diff --git a/homeassistant/components/igd/.translations/en.json b/homeassistant/components/igd/.translations/en.json new file mode 100644 index 00000000000..bd0d2a9b7c0 --- /dev/null +++ b/homeassistant/components/igd/.translations/en.json @@ -0,0 +1,29 @@ +{ + "config": { + "title": "IGD", + "step": { + "init": { + "title": "IGD" + }, + "user": { + "title": "Configuration options for the IGD", + "data":{ + "sensors": "Add traffic in/out sensors", + "port_forward": "Enable port forward for Home Assistant\nOnly enable this when your Home Assistant is password protected!", + "ssdp_url": "SSDP URL", + "udn": "UDN", + "name": "Name" + } + } + }, + "error": { + }, + "abort": { + "no_devices_discovered": "No IGDs discovered", + "already_configured": "IGD is already configured", + "no_sensors_or_port_forward": "Enable at least sensors or Port forward", + "no_igds": "No IGDs discovered", + "todo": "TODO" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/igd/.translations/nl.json b/homeassistant/components/igd/.translations/nl.json new file mode 100644 index 00000000000..06f9122678c --- /dev/null +++ b/homeassistant/components/igd/.translations/nl.json @@ -0,0 +1,29 @@ +{ + "config": { + "title": "IGD", + "step": { + "init": { + "title": "IGD" + }, + "user": { + "title": "Extra configuratie options voor IGD", + "data":{ + "sensors": "Verkeer in/out sensors", + "port_forward": "Maak port-forward voor Home Assistant\nZet dit alleen aan wanneer uw Home Assistant een wachtwoord heeft!", + "ssdp_url": "SSDP URL", + "udn": "UDN", + "name": "Naam" + } + } + }, + "error": { + }, + "abort": { + "no_devices_discovered": "Geen IGDs gevonden", + "already_configured": "IGD is reeds geconfigureerd", + "no_sensors_or_port_forward": "Kies ten minste sensors of Port forward", + "no_igds": "Geen IGDs gevonden", + "todo": "TODO" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/igd/__init__.py b/homeassistant/components/igd/__init__.py index 18aa10e391d..b932a4f8055 100644 --- a/homeassistant/components/igd/__init__.py +++ b/homeassistant/components/igd/__init__.py @@ -4,30 +4,49 @@ 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/ """ +# XXX TODO: +# + flow: +# + discovery +# + adding device +# + removing device +# - configured: +# - adding +# - sensors: +# + adding +# + handle overflow +# - removing +# - port forward: +# - adding +# - removing +# - shutdown + + +from ipaddress import IPv4Address from ipaddress import ip_address import aiohttp import asyncio import voluptuous as vol -from homeassistant import config_entries, data_entry_flow -from homeassistant.const import ( - CONF_URL, -) +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_URL from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util import get_local_ip -from .config_flow import configured_hosts +from .config_flow import configured_udns +from .const import CONF_PORT_FORWARD, CONF_SENSORS from .const import DOMAIN from .const import LOGGER as _LOGGER -_LOGGER.warning('Loading IGD') - -REQUIREMENTS = ['async-upnp-client==0.12.3'] -DEPENDENCIES = ['http', 'api'] +REQUIREMENTS = ['async-upnp-client==0.12.4'] +DEPENDENCIES = ['http'] CONF_LOCAL_IP = 'local_ip' CONF_ENABLE_PORT_MAPPING = 'port_mapping' @@ -38,8 +57,6 @@ CONF_HASS = 'hass' NOTIFICATION_ID = 'igd_notification' NOTIFICATION_TITLE = 'UPnP/IGD Setup' -IP_SERVICE = 'urn:schemas-upnp-org:service:WANIPConnection:1' # XXX TODO: remove this - UNITS = { "Bytes": 1, "KBytes": 1024, @@ -59,129 +76,157 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -async def async_setup(hass, config, *args, **kwargs): - """Register a port mapping for Home Assistant via UPnP.""" - conf = config.get(DOMAIN) - if conf is None: - conf = {} +async def _async_create_igd_device(hass: HomeAssistantType, ssdp_description: str): + """.""" + # build requester + from async_upnp_client.aiohttp import AiohttpSessionRequester + session = async_get_clientsession(hass) + requester = AiohttpSessionRequester(session, True) - hass.data[DOMAIN] = {} - configured = configured_hosts(hass) - _LOGGER.debug('Config: %s', config) + # create upnp device + from async_upnp_client import UpnpFactory + factory = UpnpFactory(requester, disable_state_variable_validation=True) + try: + upnp_device = await factory.async_create_device(ssdp_description) + except (asyncio.TimeoutError, aiohttp.ClientError): + raise PlatformNotReady() + + # wrap with IgdDevice + from async_upnp_client.igd import IgdDevice + igd_device = IgdDevice(upnp_device, None) + return igd_device + + +def _store_device(hass: HomeAssistantType, udn, igd_device): + """Store an igd_device by udn.""" + hass.data[DOMAIN] = hass.data.get(DOMAIN, {}) + hass.data[DOMAIN]['devices'] = hass.data[DOMAIN].get('devices', {}) + hass.data[DOMAIN]['devices'][udn] = igd_device + + +def _get_device(hass: HomeAssistantType, udn): + """Get an igd_device by udn.""" + hass.data[DOMAIN] = hass.data.get(DOMAIN, {}) + hass.data[DOMAIN]['devices'] = hass.data[DOMAIN].get('devices', {}) + return hass.data[DOMAIN]['devices'][udn] + + +async def _async_create_port_forward(hass: HomeAssistantType, igd_device): + """Create a port forward.""" + _LOGGER.debug('Creating port forward: %s', igd_device) + + # determine local ip, ensure sane IP + local_ip = get_local_ip() + if local_ip == '127.0.0.1': + _LOGGER.warning('Could not create port forward, our IP is 127.0.0.1') + return False + local_ip = IPv4Address(local_ip) + + # create port mapping + port = hass.http.server_port + await igd_device.async_add_port_mapping(remote_host=None, + external_port=port, + protocol='TCP', + internal_port=port, + internal_client=local_ip, + enabled=True, + description="Home Assistant", + lease_duration=None) + + return True + + +async def _async_remove_port_forward(hass: HomeAssistantType, igd_device): + """Remove a port forward.""" + _LOGGER.debug('Removing port forward: %s', igd_device) + + # remove port mapping + port = hass.http.server_port + await igd_device.async_remove_port_mapping(remote_host=None, + external_port=port, + protocol='TCP') + + +# config +async def async_setup(hass: HomeAssistantType, config): + """Register a port mapping for Home Assistant via UPnP.""" + _LOGGER.debug('async_setup: config: %s', config) + conf = config.get(DOMAIN, {}) + + hass.data[DOMAIN] = hass.data.get(DOMAIN, {}) + configured = configured_udns(hass) _LOGGER.debug('configured: %s', configured) - igds = [] - if not igds: - return True + # if no ssdp given: take any discovered - by flow - IGD entry + # if none discovered, raise PlatformNotReady + # if ssdp given: use the SSDP + igds = [] # XXX TODO for igd_conf in igds: hass.async_add_job(hass.config_entries.flow.async_init( DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, data={ - 'ssdp_url': igd_conf['ssdp_url'], + 'ssdp_description': igd_conf['ssdp_description'], } )) return True - # 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 - # - # url = config.get(CONF_URL) - # - # # build requester - # from async_upnp_client.aiohttp import AiohttpSessionRequester - # session = async_get_clientsession(hass) - # requester = AiohttpSessionRequester(session, True) - # - # # create upnp device - # from async_upnp_client import UpnpFactory - # factory = UpnpFactory(requester, disable_state_variable_validation=True) - # try: - # upnp_device = await factory.async_create_device(url) - # except (asyncio.TimeoutError, aiohttp.ClientError): - # raise PlatformNotReady() - # - # # wrap with IgdDevice - # from async_upnp_client.igd import IgdDevice - # igd_device = IgdDevice(upnp_device, None) - # hass.data[DATA_IGD]['device'] = igd_device - # - # # sensors - # unit = config.get(CONF_UNITS) - # hass.async_create_task(discovery.async_load_platform( - # hass, 'sensor', DOMAIN, {'unit': unit}, config)) - # - # # port mapping - # port_mapping = config.get(CONF_ENABLE_PORT_MAPPING) - # if not port_mapping: - # return True - # - # # determine ports - # internal_port = hass.http.server_port - # ports = config.get(CONF_PORTS) - # if ports is None: - # ports = {CONF_HASS: internal_port} - # - # registered = [] - # async def register_port_mappings(event): - # """(Re-)register the port mapping.""" - # from async_upnp_client import UpnpError - # for internal, external in ports.items(): - # if internal == CONF_HASS: - # internal = internal_port - # try: - # await igd_device.async_add_port_mapping(remote_host=None, - # external_port=external, - # protocol='TCP', - # internal_port=internal, - # internal_client=ip_address(host), - # enabled=True, - # description='Home Assistant', - # lease_duration=None) - # registered.append(external) - # _LOGGER.debug("external %s -> %s @ %s", external, internal, host) - # except UpnpError as error: - # _LOGGER.error(error) - # hass.components.persistent_notification.create( - # 'ERROR: TCP port {} is already mapped in your router.' - # '
Please disable port_mapping in the upnp ' - # 'configuration section.
' - # 'You will need to restart hass after fixing.' - # ''.format(external), - # title=NOTIFICATION_TITLE, - # notification_id=NOTIFICATION_ID) - # - # async def deregister_port_mappings(event): - # """De-register the port mapping.""" - # tasks = [igd_device.async_delete_port_mapping(remote_host=None, - # external_port=external, - # protocol='TCP') - # for external in registered] - # if tasks: - # await asyncio.wait(tasks) - # - # await register_port_mappings(None) - # hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, deregister_port_mappings) - # - # return True - -async def async_setup_entry(hass, entry): +# config flow +async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry): """Set up a bridge from a config entry.""" - _LOGGER.debug('async_setup_entry, title: %s, data: %s', entry.title, entry.data) + _LOGGER.debug('async_setup_entry: title: %s, data: %s', config_entry.title, config_entry.data) + + data = config_entry.data + ssdp_description = data['ssdp_description'] + + # build IGD device + try: + igd_device = await _async_create_igd_device(hass, ssdp_description) + except (asyncio.TimeoutError, aiohttp.ClientError): + raise PlatformNotReady() + + _store_device(hass, igd_device.udn, igd_device) + + # port forward + if data.get(CONF_PORT_FORWARD): + await _async_create_port_forward(hass, igd_device) - # port mapping? # sensors + if data.get(CONF_SENSORS): + discovery_info = { + 'unit': 'MBytes', + 'udn': data['udn'], + } + hass_config = config_entry.data + hass.async_create_task(discovery.async_load_platform( + hass, 'sensor', DOMAIN, discovery_info, hass_config)) + + 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, entry): + +async def async_unload_entry(hass: HomeAssistantType, config_entry: ConfigEntry): """Unload a config entry.""" + _LOGGER.debug('async_unload_entry: title: %s, data: %s', config_entry.title, config_entry.data) + data = config_entry.data + udn = data['udn'] + igd_device = _get_device(hass, udn) + # port forward + if data.get(CONF_PORT_FORWARD): + _LOGGER.debug('Removing port forward for: %s', igd_device) + _async_remove_port_forward(hass, igd_device) + # sensors + if data.get(CONF_SENSORS): + # XXX TODO: remove sensors + pass + + return True diff --git a/homeassistant/components/igd/config_flow.py b/homeassistant/components/igd/config_flow.py index 2eb1aae5b80..9ccbe79a35f 100644 --- a/homeassistant/components/igd/config_flow.py +++ b/homeassistant/components/igd/config_flow.py @@ -1,40 +1,19 @@ """Config flow for IGD.""" from homeassistant import config_entries, data_entry_flow from homeassistant.core import callback -from homeassistant.helpers import config_validation as cv import voluptuous as vol from .const import DOMAIN from .const import LOGGER as _LOGGER - @callback -def configured_hosts(hass): - """Return a set of the configured hosts.""" - return set(entry.data['ssdp_url'] - for entry in hass.config_entries.async_entries(DOMAIN)) - - -async def _get_igd_device(hass, ssdp_url): - """.""" - # build requester - from async_upnp_client.aiohttp import AiohttpSessionRequester - session = async_get_clientsession(hass) - requester = AiohttpSessionRequester(session, True) - - # create upnp device - from async_upnp_client import UpnpFactory - factory = UpnpFactory(requester, disable_state_variable_validation=True) - try: - upnp_device = await factory.async_create_device(ssdp_url) - except (asyncio.TimeoutError, aiohttp.ClientError): - raise PlatformNotReady() - - # wrap with IgdDevice - from async_upnp_client.igd import IgdDevice - igd_device = IgdDevice(upnp_device, None) - return igd_device +def configured_udns(hass): + """Get all configured UDNs.""" + return [ + entry.data['udn'] + for entry in hass.config_entries.async_entries(DOMAIN) + ] @config_entries.HANDLERS.register(DOMAIN) @@ -43,61 +22,114 @@ class IgdFlowHandler(data_entry_flow.FlowHandler): VERSION = 1 - # def __init__(self): - # """Initializer.""" - # self.host = None + def __init__(self): + """Initializer.""" + pass - # flow: 1. detection/user adding - # 2. question: port forward? sensors? - # 3. add it! + @property + def _discovereds(self): + """Get all discovered entries.""" + if DOMAIN not in self.hass.data: + _LOGGER.debug('DOMAIN not in hass.data') + if 'discovered' not in self.hass.data.get(DOMAIN, {}): + _LOGGER.debug('discovered not in hass.data[DOMAIN]') - async def async_step_user(self, user_input=None): - _LOGGER.debug('async_step_user: %s', user_input) - return await self.async_abort(reason='todo') + return self.hass.data.get(DOMAIN, {}).get('discovered', {}) + + def _store_discovery_info(self, discovery_info): + """Add discovery info.""" + udn = discovery_info['udn'] + if DOMAIN not in self.hass.data: + _LOGGER.debug('DOMAIN not in hass.data') + self.hass.data[DOMAIN] = self.hass.data.get(DOMAIN, {}) + if 'discovered' not in self.hass.data[DOMAIN]: + _LOGGER.debug('Creating new discovered: %s', self.hass.data[DOMAIN]) + self.hass.data[DOMAIN]['discovered'] = self.hass.data[DOMAIN].get('discovered', {}) + self.hass.data[DOMAIN]['discovered'][udn] = discovery_info async def async_step_discovery(self, discovery_info): - """Handle a discovered IGD. + """ + Handle a discovered 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. """ - _LOGGER.debug('async_step_discovery: %s', discovery_info) + _LOGGER.debug('async_step_discovery %s: %s', id(self), discovery_info) - ssdp_url = discovery_info['ssdp_description'] - return await self.async_step_options({ - 'ssdp_url': ssdp_url, - }) + # ensure not already discovered/configured + udn = discovery_info['udn'] + if udn in configured_udns(self.hass): + _LOGGER.debug('Already configured: %s', discovery_info) + return self.async_abort(reason='already_configured') - async def async_step_options(self, user_options): - """.""" - _LOGGER.debug('async_step_options: %s', user_options) - if user_options and \ - 'sensors' in user_options and \ - 'port_forward' in user_options: - return await self.async_step_import(user_options) + # store discovered device + self._store_discovery_info(discovery_info) + + # abort --> not showing up in discovered things + # return self.async_abort(reason='user_input_required') + + # user -> showing up in discovered things + return await self.async_step_user() + + async def async_step_user(self, user_input=None): + """Manual set up.""" + _LOGGER.debug('async_step_user %s: %s', id(self), user_input) + + # if user input given, handle it + user_input = user_input or {} + if 'igd_host' in user_input: + if not user_input['sensors'] and not user_input['port_forward']: + _LOGGER.debug('Aborting, no sensors and no portforward') + return self.async_abort(reason='no_sensors_or_port_forward') + + configured_hosts = [ + entry['host'] + for entry in self._discovereds.values() + if entry['udn'] in configured_udns(self.hass) + ] + if user_input['igd_host'] in configured_hosts: + return self.async_abort(reason='already_configured') + + return await self._async_save(user_input) + + # let user choose from all discovered IGDs + _LOGGER.debug('Discovered devices: %s', self._discovereds) + igd_hosts = [ + entry['host'] + for entry in self._discovereds.values() + if entry['udn'] not in configured_udns(self.hass) + ] + if not igd_hosts: + return self.async_abort(reason='no_devices_discovered') return self.async_show_form( - step_id='options', + step_id='user', data_schema=vol.Schema({ - vol.Required('sensors'): cv.boolean, - vol.Required('port_forward'): cv.boolean, - # vol.Optional('ssdp_url', default=user_options['ssdp_url']): cv.url, + vol.Required('igd_host'): vol.In(igd_hosts), + vol.Required('sensors'): bool, + vol.Required('port_forward'): bool, }) ) - async def async_step_import(self, import_info): - """Import a IGD as new entry.""" - _LOGGER.debug('async_step_import: %s', import_info) + async def _async_save(self, import_info): + """Store IGD as new entry.""" + _LOGGER.debug('async_step_import %s: %s', id(self), import_info) + + # ensure we know the host + igd_host = import_info['igd_host'] + discovery_infos = [info + for info in self._discovereds.values() + if info['host'] == igd_host] + if not discovery_infos: + return self.async_abort(reason='host_not_found') + discovery_info = discovery_infos[0] - ssdp_url = import_info['ssdp_url'] - try: - igd_device = await _get_igd_device(self.hass, ssdp_url) # try it to see if it works - except: - pass return self.async_create_entry( - title=igd_device.name, + title=discovery_info['name'], data={ - 'ssdp_url': ssdp_url, - 'udn': igd_device.udn, - } - ) \ No newline at end of file + 'ssdp_description': discovery_info['ssdp_description'], + 'udn': discovery_info['udn'], + 'sensors': import_info['sensors'], + 'port_forward': import_info['port_forward'], + }, + ) diff --git a/homeassistant/components/igd/const.py b/homeassistant/components/igd/const.py index a933497bd3e..d1d92f7ccb5 100644 --- a/homeassistant/components/igd/const.py +++ b/homeassistant/components/igd/const.py @@ -3,3 +3,5 @@ import logging DOMAIN = 'igd' LOGGER = logging.getLogger('homeassistant.components.igd') +CONF_PORT_FORWARD = 'port_forward' +CONF_SENSORS = 'sensors' diff --git a/homeassistant/components/igd/strings.json b/homeassistant/components/igd/strings.json index b88ed5c7968..bd0d2a9b7c0 100644 --- a/homeassistant/components/igd/strings.json +++ b/homeassistant/components/igd/strings.json @@ -2,23 +2,26 @@ "config": { "title": "IGD", "step": { - "options": { - "title": "Extra configuration options for the IGD", + "init": { + "title": "IGD" + }, + "user": { + "title": "Configuration options for the IGD", "data":{ "sensors": "Add traffic in/out sensors", "port_forward": "Enable port forward for Home Assistant\nOnly enable this when your Home Assistant is password protected!", - "ssdp_ur": "SSDP URL", + "ssdp_url": "SSDP URL", + "udn": "UDN", + "name": "Name" } - }, - "import": { - "title": "Link with IGD", - "description": "Setup the IGD" } }, "error": { }, "abort": { + "no_devices_discovered": "No IGDs discovered", "already_configured": "IGD is already configured", + "no_sensors_or_port_forward": "Enable at least sensors or Port forward", "no_igds": "No IGDs discovered", "todo": "TODO" } diff --git a/homeassistant/components/sensor/igd.py b/homeassistant/components/sensor/igd.py index 53692a3432f..1d0402520df 100644 --- a/homeassistant/components/sensor/igd.py +++ b/homeassistant/components/sensor/igd.py @@ -6,26 +6,29 @@ https://home-assistant.io/components/sensor.upnp/ """ import logging -from homeassistant.components.igd import DATA_IGD, UNITS +from homeassistant.components import history +from homeassistant.components.igd import DOMAIN, UNITS from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -DEPENDENCIES = ['igd'] +DEPENDENCIES = ['igd', 'history'] -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: ['bytes received', True, 'mdi:server-network', float], + BYTES_SENT: ['bytes sent', True, 'mdi:server-network', float], + PACKETS_RECEIVED: ['packets received', False, 'mdi:server-network', int], + PACKETS_SENT: ['packets sent', False, 'mdi:server-network', int], } +OVERFLOW_AT = 2**32 + async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): @@ -33,11 +36,12 @@ async def async_setup_platform(hass, config, async_add_devices, if discovery_info is None: return - device = hass.data[DATA_IGD]['device'] + udn = discovery_info['udn'] + device = hass.data[DOMAIN]['devices'][udn] unit = discovery_info['unit'] async_add_devices([ IGDSensor(device, t, unit if SENSOR_TYPES[t][1] else '#') - for t in SENSOR_TYPES], True) + for t in SENSOR_TYPES]) class IGDSensor(Entity): @@ -51,6 +55,7 @@ class IGDSensor(Entity): self.unit_factor = UNITS[unit] if unit in UNITS else 1 self._name = 'IGD {}'.format(SENSOR_TYPES[sensor_type][0]) self._state = None + self._last_value = None @property def name(self): @@ -60,9 +65,14 @@ class IGDSensor(Entity): @property def state(self): """Return the state of the device.""" - if self._state: - return format(float(self._state) / self.unit_factor, '.1f') - return self._state + if self._state is None: + return None + + coercer = SENSOR_TYPES[self.type][3] + if coercer == int: + return format(self._state) + + return format(self._state / self.unit_factor, '.1f') @property def icon(self): @@ -76,11 +86,85 @@ class IGDSensor(Entity): async def async_update(self): """Get the latest information from the IGD.""" + new_value = 0 if self.type == BYTES_RECEIVED: - self._state = await self._device.async_get_total_bytes_received() + new_value = await self._device.async_get_total_bytes_received() elif self.type == BYTES_SENT: - self._state = await self._device.async_get_total_bytes_sent() + new_value = await self._device.async_get_total_bytes_sent() elif self.type == PACKETS_RECEIVED: - self._state = await self._device.async_get_total_packets_received() + new_value = await self._device.async_get_total_packets_received() elif self.type == PACKETS_SENT: - self._state = await self._device.async_get_total_packets_sent() + new_value = await self._device.async_get_total_packets_sent() + + self._handle_new_value(new_value) + + # _LOGGER.debug('Removing self: %s', self) + # await self.async_remove() # XXX TODO: does not remove from the UI + + @property + def _last_state(self): + """Get the last state reported to hass.""" + states = history.get_last_state_changes(self.hass, 2, self.entity_id) + entity_states = [ + state for state in states[self.entity_id] + if state.state != 'unknown'] + _LOGGER.debug('%s: entity_states: %s', self.entity_id, entity_states) + if not entity_states: + return None + + return entity_states[0] + + @property + def _last_value_from_state(self): + """Get the last value reported to hass.""" + last_state = self._last_state + if not last_state: + _LOGGER.debug('%s: No last state', self.entity_id) + return None + + coercer = SENSOR_TYPES[self.type][3] + try: + state = coercer(float(last_state.state)) * self.unit_factor + except ValueError: + _LOGGER.debug('%s: value error, coercer: %s, state: %s', self.entity_id, coercer, last_state.state) + raise + state = coercer(0.0) + + return state + + def _handle_new_value(self, new_value): + _LOGGER.debug('%s: handle_new_value: state: %s, new_value: %s, last_value: %s', + self.entity_id, self._state, new_value, self._last_value) + + # ❯❯❯ upnp-client --debug --pprint --device http://192.168.178.1/RootDevice.xml call-action WANCIFC/GetTotalBytesReceived + if self.entity_id is None: + # don't know our entity ID yet, do nothing but store value + self._last_value = new_value + return + + if self._last_value is None: + self._last_value = new_value + + if self._state is None: + # try to get the state from history + self._state = self._last_value_from_state or 0 + + _LOGGER.debug('%s: state: %s, last_value: %s', + self.entity_id, self._state, self._last_value) + + # calculate new state + if self._last_value <= new_value: + diff = new_value - self._last_value + else: + # handle overflow + diff = OVERFLOW_AT - self._last_value + if new_value >= 0: + diff += new_value + else: + # some devices don't overflow and start at 0, but somewhere to -2**32 + diff += new_value - -OVERFLOW_AT + + self._state += diff + self._last_value = new_value + _LOGGER.debug('%s: diff: %s, state: %s, last_value: %s', + self.entity_id, diff, self._state, self._last_value) diff --git a/tests/components/igd/__init__.py b/tests/components/igd/__init__.py new file mode 100644 index 00000000000..d6985e0544b --- /dev/null +++ b/tests/components/igd/__init__.py @@ -0,0 +1 @@ +"""Tests for the IGD component.""" \ No newline at end of file diff --git a/tests/components/igd/test_config_flow.py b/tests/components/igd/test_config_flow.py new file mode 100644 index 00000000000..2fe2c60b19d --- /dev/null +++ b/tests/components/igd/test_config_flow.py @@ -0,0 +1,165 @@ +"""Tests for IGD config flow.""" + +from homeassistant.components import igd + +from tests.common import MockConfigEntry + + +async def test_flow_none_discovered(hass): + """Test no device discovered flow.""" + flow = igd.config_flow.IgdFlowHandler() + flow.hass = hass + + 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 = igd.config_flow.IgdFlowHandler() + flow.hass = hass + + # discovered device + udn = 'uuid:device_1' + hass.data[igd.DOMAIN] = { + 'discovered': { + udn: { + 'host': '192.168.1.1', + 'udn': udn, + }, + }, + } + + # configured entry + MockConfigEntry(domain=igd.DOMAIN, data={ + 'udn': udn, + 'host': '192.168.1.1', + }).add_to_hass(hass) + + result = await flow.async_step_user({ + 'igd_host': '192.168.1.1', + 'sensors': True, + 'port_forward': False, + }) + assert result['type'] == 'abort' + assert result['reason'] == 'already_configured' + + +async def test_flow_no_sensors_no_port_forward(hass): + """Test single device, no sensors, no port_forward.""" + flow = igd.config_flow.IgdFlowHandler() + flow.hass = hass + + # discovered device + udn = 'uuid:device_1' + hass.data[igd.DOMAIN] = { + 'discovered': { + udn: { + 'host': '192.168.1.1', + 'udn': udn, + }, + }, + } + + # configured entry + MockConfigEntry(domain=igd.DOMAIN, data={ + 'udn': udn, + 'host': '192.168.1.1', + }).add_to_hass(hass) + + result = await flow.async_step_user({ + 'igd_host': '192.168.1.1', + 'sensors': False, + 'port_forward': False, + }) + assert result['type'] == 'abort' + assert result['reason'] == 'no_sensors_or_port_forward' + + +async def test_flow_discovered_form(hass): + """Test single device discovered, show form flow.""" + flow = igd.config_flow.IgdFlowHandler() + flow.hass = hass + + # discovered device + udn = 'uuid:device_1' + hass.data[igd.DOMAIN] = { + 'discovered': { + udn: { + '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 single device discovered, show form flow.""" + flow = igd.config_flow.IgdFlowHandler() + flow.hass = hass + + # discovered device + udn_1 = 'uuid:device_1' + udn_2 = 'uuid:device_2' + hass.data[igd.DOMAIN] = { + 'discovered': { + udn_1: { + 'host': '192.168.1.1', + 'udn': udn_1, + }, + udn_2: { + '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']({ + 'igd_host': '192.168.1.1', + 'sensors': True, + 'port_forward': False, + }) + assert result['data_schema']({ + 'igd_host': '192.168.2.1', + 'sensors': True, + 'port_forward': False, + }) + + +async def test_config_entry_created(hass): + flow = igd.config_flow.IgdFlowHandler() + flow.hass = hass + + # discovered device + udn = 'uuid:device_1' + hass.data[igd.DOMAIN] = { + 'discovered': { + udn: { + 'name': 'Test device 1', + 'host': '192.168.1.1', + 'ssdp_description': 'http://192.168.1.1/desc.xml', + 'udn': udn, + }, + }, + } + + result = await flow.async_step_user({ + 'igd_host': '192.168.1.1', + 'sensors': True, + 'port_forward': False, + }) + assert result['data'] == { + 'port_forward': False, + 'sensors': True, + 'ssdp_description': 'http://192.168.1.1/desc.xml', + 'udn': 'uuid:device_1', + } + assert result['title'] == 'Test device 1' diff --git a/tests/components/igd/test_init.py b/tests/components/igd/test_init.py new file mode 100644 index 00000000000..4405cc10999 --- /dev/null +++ b/tests/components/igd/test_init.py @@ -0,0 +1,41 @@ +"""Test IGD setup process.""" + +from unittest.mock import patch, MagicMock + +from homeassistant.setup import async_setup_component +from homeassistant.components import igd +from homeassistant.const import EVENT_HOMEASSISTANT_STOP + +from tests.common import MockConfigEntry +from tests.common import mock_coro + + +async def test_async_setup_entry_port_forward_created(hass): + """Test async_setup_entry.""" + + udn = 'uuid:device_1' + entry = MockConfigEntry(domain=igd.DOMAIN, data={ + 'ssdp_description': 'http://192.168.1.1/desc.xml', + 'udn': udn, + 'sensors': False, + 'port_forward': True, + }) + + # ensure hass.http is available + await async_setup_component(hass, 'igd') + + mock_igd_device = MagicMock() + mock_igd_device.udn = udn + mock_igd_device.async_add_port_mapping.return_value = mock_coro() + mock_igd_device.async_remove_port_mapping.return_value = mock_coro() + with patch.object(igd, '_async_create_igd_device') as mock_create_device: + mock_create_device.return_value = mock_coro(return_value=mock_igd_device) + with patch('homeassistant.components.igd.get_local_ip', return_value='192.168.1.10'): + assert await igd.async_setup_entry(hass, entry) is True + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + assert hass.data[igd.DOMAIN]['devices'][udn] == mock_igd_device + assert len(mock_igd_device.async_add_port_mapping.mock_calls) > 0 + assert len(mock_igd_device.async_delete_port_mapping.mock_calls) > 0 diff --git a/tests/components/test_igd.py b/tests/components/test_igd.py deleted file mode 100644 index eb42b3127f6..00000000000 --- a/tests/components/test_igd.py +++ /dev/null @@ -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.igd import IP_SERVICE, DATA_IGD - - -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_IGD].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_IGD].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_IGD].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_IGD].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')