From 9ccb85d959c550bb7e66080b814722aec8de7fb5 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 10 Jul 2019 16:40:11 -0600 Subject: [PATCH] Add support for World Wide Lightning Location Network (#25001) * Add support for World Wide Lightning Location Network * Updated .coveragerc * Added test * Updated requirements * Fixed tests * Use local time for nearest strike * Base geo location in place * Finished geolocation work * Fixed tests * Cleanup * Removed no-longer-needed method * Updated requirements * Add support for window and attrs * Add strike ID to entity name * Member comments --- .coveragerc | 2 + CODEOWNERS | 1 + .../components/wwlln/.translations/en.json | 18 ++ homeassistant/components/wwlln/__init__.py | 87 +++++++ homeassistant/components/wwlln/config_flow.py | 70 ++++++ homeassistant/components/wwlln/const.py | 11 + .../components/wwlln/geo_location.py | 216 ++++++++++++++++++ homeassistant/components/wwlln/manifest.json | 13 ++ homeassistant/components/wwlln/strings.json | 18 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/wwlln/__init__.py | 1 + tests/components/wwlln/test_config_flow.py | 106 +++++++++ 15 files changed, 551 insertions(+) create mode 100644 homeassistant/components/wwlln/.translations/en.json create mode 100644 homeassistant/components/wwlln/__init__.py create mode 100644 homeassistant/components/wwlln/config_flow.py create mode 100644 homeassistant/components/wwlln/const.py create mode 100644 homeassistant/components/wwlln/geo_location.py create mode 100644 homeassistant/components/wwlln/manifest.json create mode 100644 homeassistant/components/wwlln/strings.json create mode 100644 tests/components/wwlln/__init__.py create mode 100644 tests/components/wwlln/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 592ac42c3de..781b5d17279 100644 --- a/.coveragerc +++ b/.coveragerc @@ -692,6 +692,8 @@ omit = homeassistant/components/worldtidesinfo/sensor.py homeassistant/components/worxlandroid/sensor.py homeassistant/components/wunderlist/* + homeassistant/components/wwlln/__init__.py + homeassistant/components/wwlln/geo_location.py homeassistant/components/x10/light.py homeassistant/components/xbox_live/sensor.py homeassistant/components/xeoma/camera.py diff --git a/CODEOWNERS b/CODEOWNERS index 663c3a125b3..8117968cf11 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -289,6 +289,7 @@ homeassistant/components/weblink/* @home-assistant/core homeassistant/components/websocket_api/* @home-assistant/core homeassistant/components/wemo/* @sqldiablo homeassistant/components/worldclock/* @fabaff +homeassistant/components/wwlln/* @bachya homeassistant/components/xfinity/* @cisasteelersfan homeassistant/components/xiaomi_aqara/* @danielhiversen @syssi homeassistant/components/xiaomi_miio/* @rytilahti @syssi diff --git a/homeassistant/components/wwlln/.translations/en.json b/homeassistant/components/wwlln/.translations/en.json new file mode 100644 index 00000000000..4200c4b4378 --- /dev/null +++ b/homeassistant/components/wwlln/.translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "identifier_exists": "Location already registered" + }, + "step": { + "user": { + "data": { + "latitude": "Latitude", + "longitude": "Longitude", + "radius": "Radius (using your base unit system)" + }, + "title": "Fill in your location information." + } + }, + "title": "World Wide Lightning Location Network (WWLLN)" + } +} \ No newline at end of file diff --git a/homeassistant/components/wwlln/__init__.py b/homeassistant/components/wwlln/__init__.py new file mode 100644 index 00000000000..676bdfcc0c1 --- /dev/null +++ b/homeassistant/components/wwlln/__init__.py @@ -0,0 +1,87 @@ +"""Support for World Wide Lightning Location Network.""" +import logging + +from aiowwlln import Client +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, CONF_UNIT_SYSTEM, + CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC) +from homeassistant.helpers import aiohttp_client, config_validation as cv + +from .config_flow import configured_instances +from .const import ( + CONF_WINDOW, DATA_CLIENT, DEFAULT_RADIUS, DEFAULT_WINDOW, DOMAIN) + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): cv.positive_int, + vol.Optional(CONF_WINDOW, default=DEFAULT_WINDOW): + vol.All(cv.time_period, cv.positive_timedelta) + }) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the WWLLN component.""" + hass.data[DOMAIN] = {} + hass.data[DOMAIN][DATA_CLIENT] = {} + + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + latitude = conf.get(CONF_LATITUDE, hass.config.latitude) + longitude = conf.get(CONF_LONGITUDE, hass.config.longitude) + + identifier = '{0}, {1}'.format(latitude, longitude) + if identifier in configured_instances(hass): + return True + + if hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: + unit_system = CONF_UNIT_SYSTEM_IMPERIAL + else: + unit_system = CONF_UNIT_SYSTEM_METRIC + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={'source': SOURCE_IMPORT}, + data={ + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + CONF_RADIUS: conf[CONF_RADIUS], + CONF_WINDOW: conf[CONF_WINDOW], + CONF_UNIT_SYSTEM: unit_system, + })) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up the WWLLN as config entry.""" + websession = aiohttp_client.async_get_clientsession(hass) + + hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = Client(websession) + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup( + config_entry, 'geo_location')) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload an WWLLN config entry.""" + hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id) + + await hass.config_entries.async_forward_entry_unload( + config_entry, 'geo_location') + + return True diff --git a/homeassistant/components/wwlln/config_flow.py b/homeassistant/components/wwlln/config_flow.py new file mode 100644 index 00000000000..35b6ce6c8a0 --- /dev/null +++ b/homeassistant/components/wwlln/config_flow.py @@ -0,0 +1,70 @@ +"""Config flow to configure the WWLLN integration.""" +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.helpers import config_validation as cv +from homeassistant.const import ( + CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, CONF_UNIT_SYSTEM, + CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC) +from homeassistant.core import callback + +from .const import CONF_WINDOW, DEFAULT_RADIUS, DEFAULT_WINDOW, DOMAIN + + +@callback +def configured_instances(hass): + """Return a set of configured WWLLN instances.""" + return set( + '{0}, {1}'.format( + entry.data[CONF_LATITUDE], entry.data[CONF_LONGITUDE]) + for entry in hass.config_entries.async_entries(DOMAIN) + ) + + +@config_entries.HANDLERS.register(DOMAIN) +class WWLLNFlowHandler(config_entries.ConfigFlow): + """Handle a WWLLN config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def _show_form(self, errors=None): + """Show the form to the user.""" + data_schema = vol.Schema({ + vol.Optional(CONF_LATITUDE, default=self.hass.config.latitude): + cv.latitude, + vol.Optional(CONF_LONGITUDE, default=self.hass.config.longitude): + cv.longitude, + vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): cv.positive_int, + }) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors or {}) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_config) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + if not user_input: + return await self._show_form() + + identifier = '{0}, {1}'.format( + user_input[CONF_LATITUDE], user_input[CONF_LONGITUDE]) + if identifier in configured_instances(self.hass): + return await self._show_form({'base': 'identifier_exists'}) + + if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: + user_input[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_IMPERIAL + else: + user_input[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_METRIC + + # To simplify things, we don't allow users of the config flow to + # input a window; instead, we make a sane assumption to use the + # default (stored as seconds, since timedelta's aren't + # JSON-serializable): + if CONF_WINDOW not in user_input: + user_input[CONF_WINDOW] = DEFAULT_WINDOW.total_seconds() + + return self.async_create_entry(title=identifier, data=user_input) diff --git a/homeassistant/components/wwlln/const.py b/homeassistant/components/wwlln/const.py new file mode 100644 index 00000000000..e712f7f68a4 --- /dev/null +++ b/homeassistant/components/wwlln/const.py @@ -0,0 +1,11 @@ +"""Define constants for the WWLLN integration.""" +from datetime import timedelta + +DOMAIN = 'wwlln' + +CONF_WINDOW = 'window' + +DATA_CLIENT = 'client' + +DEFAULT_RADIUS = 25 +DEFAULT_WINDOW = timedelta(minutes=10) diff --git a/homeassistant/components/wwlln/geo_location.py b/homeassistant/components/wwlln/geo_location.py new file mode 100644 index 00000000000..95367130aef --- /dev/null +++ b/homeassistant/components/wwlln/geo_location.py @@ -0,0 +1,216 @@ +"""Support for WWLLN geo location events.""" +from datetime import timedelta +import logging + +from aiowwlln.errors import WWLLNError + +from homeassistant.components.geo_location import GeolocationEvent +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, + CONF_UNIT_SYSTEM, CONF_UNIT_SYSTEM_IMPERIAL, LENGTH_KILOMETERS, + LENGTH_MILES) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, async_dispatcher_send) +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util.dt import utc_from_timestamp + +from .const import CONF_WINDOW, DATA_CLIENT, DEFAULT_WINDOW, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +ATTR_EXTERNAL_ID = 'external_id' +ATTR_PUBLICATION_DATE = 'publication_date' + +DEFAULT_ATTRIBUTION = 'Data provided by the WWLLN' +DEFAULT_EVENT_NAME = 'Lightning Strike: {0}' +DEFAULT_ICON = 'mdi:flash' + +SIGNAL_DELETE_ENTITY = 'delete_entity_{0}' + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up WWLLN based on a config entry.""" + client = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] + manager = WWLLNEventManager( + hass, + async_add_entities, + client, + entry.data[CONF_LATITUDE], + entry.data[CONF_LONGITUDE], + entry.data[CONF_RADIUS], + entry.data[CONF_WINDOW], + entry.data[CONF_UNIT_SYSTEM]) + await manager.async_init() + + +class WWLLNEventManager: + """Define a class to handle WWLLN events.""" + + def __init__( + self, + hass, + async_add_entities, + client, + latitude, + longitude, + radius, + window_seconds, + unit_system): + """Initialize.""" + self._async_add_entities = async_add_entities + self._client = client + self._hass = hass + self._latitude = latitude + self._longitude = longitude + self._managed_strike_ids = set() + self._radius = radius + self._strikes = {} + self._window = timedelta(seconds=window_seconds) + + self._unit_system = unit_system + if unit_system == CONF_UNIT_SYSTEM_IMPERIAL: + self._unit = LENGTH_MILES + else: + self._unit = LENGTH_KILOMETERS + + @callback + def _create_events(self, ids_to_create): + """Create new geo location events.""" + events = [] + for strike_id in ids_to_create: + strike = self._strikes[strike_id] + event = WWLLNEvent( + strike['distance'], + strike['lat'], + strike['long'], + self._unit, + strike_id, + strike['unixTime']) + events.append(event) + + self._async_add_entities(events) + + @callback + def _remove_events(self, ids_to_remove): + """Remove old geo location events.""" + for strike_id in ids_to_remove: + async_dispatcher_send( + self._hass, SIGNAL_DELETE_ENTITY.format(strike_id)) + + async def async_init(self): + """Schedule regular updates based on configured time interval.""" + async def update(event_time): + """Update.""" + await self.async_update() + + await self.async_update() + async_track_time_interval(self._hass, update, DEFAULT_WINDOW) + + async def async_update(self): + """Refresh data.""" + _LOGGER.debug('Refreshing WWLLN data') + + try: + self._strikes = await self._client.within_radius( + self._latitude, + self._longitude, + self._radius, + unit=self._unit_system, + window=self._window) + except WWLLNError as err: + _LOGGER.error('Error while updating WWLLN data: %s', err) + return + + new_strike_ids = set(self._strikes) + ids_to_remove = self._managed_strike_ids.difference(new_strike_ids) + self._remove_events(ids_to_remove) + + ids_to_create = new_strike_ids.difference(self._managed_strike_ids) + self._create_events(ids_to_create) + + +class WWLLNEvent(GeolocationEvent): + """Define a lightning strike event.""" + + def __init__( + self, + distance, + latitude, + longitude, + unit, + strike_id, + publication_date): + """Initialize entity with data provided.""" + self._distance = distance + self._latitude = latitude + self._longitude = longitude + self._publication_date = publication_date + self._remove_signal_delete = None + self._strike_id = strike_id + self._unit_of_measurement = unit + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = {} + for key, value in ( + (ATTR_EXTERNAL_ID, self._strike_id), + (ATTR_ATTRIBUTION, DEFAULT_ATTRIBUTION), + (ATTR_PUBLICATION_DATE, utc_from_timestamp( + self._publication_date)), + ): + attributes[key] = value + return attributes + + @property + def distance(self): + """Return distance value of this external event.""" + return self._distance + + @property + def icon(self): + """Return the icon to use in the front-end.""" + return DEFAULT_ICON + + @property + def latitude(self): + """Return latitude value of this external event.""" + return self._latitude + + @property + def longitude(self): + """Return longitude value of this external event.""" + return self._longitude + + @property + def name(self): + """Return the name of the event.""" + return DEFAULT_EVENT_NAME.format(self._strike_id) + + @property + def source(self) -> str: + """Return source value of this external event.""" + return DOMAIN + + @property + def should_poll(self): + """Disable polling.""" + return False + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @callback + def _delete_callback(self): + """Remove this entity.""" + self._remove_signal_delete() + self.hass.async_create_task(self.async_remove()) + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self._remove_signal_delete = async_dispatcher_connect( + self.hass, SIGNAL_DELETE_ENTITY.format(self._strike_id), + self._delete_callback) diff --git a/homeassistant/components/wwlln/manifest.json b/homeassistant/components/wwlln/manifest.json new file mode 100644 index 00000000000..ef9295341c0 --- /dev/null +++ b/homeassistant/components/wwlln/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "wwlln", + "name": "World Wide Lightning Location Network", + "config_flow": true, + "documentation": "https://www.home-assistant.io/components/wwlln", + "requirements": [ + "aiowwlln==1.0.0" + ], + "dependencies": [], + "codeowners": [ + "@bachya" + ] +} diff --git a/homeassistant/components/wwlln/strings.json b/homeassistant/components/wwlln/strings.json new file mode 100644 index 00000000000..c0d768a010c --- /dev/null +++ b/homeassistant/components/wwlln/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "title": "World Wide Lightning Location Network (WWLLN)", + "step": { + "user": { + "title": "Fill in your location information.", + "data": { + "latitude": "Latitude", + "longitude": "Longitude", + "radius": "Radius (using your base unit system)" + } + } + }, + "error": { + "identifier_exists": "Location already registered" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 521417436f9..4a2cfcf5009 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -56,6 +56,7 @@ FLOWS = [ "unifi", "upnp", "wemo", + "wwlln", "zha", "zone", "zwave" diff --git a/requirements_all.txt b/requirements_all.txt index 5ece9209d15..ea2c8b2b48c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -168,6 +168,9 @@ aioswitcher==2019.4.26 # homeassistant.components.unifi aiounifi==6 +# homeassistant.components.wwlln +aiowwlln==1.0.0 + # homeassistant.components.aladdin_connect aladdin_connect==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 828604f981c..a3458e091b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -66,6 +66,9 @@ aioswitcher==2019.4.26 # homeassistant.components.unifi aiounifi==6 +# homeassistant.components.wwlln +aiowwlln==1.0.0 + # homeassistant.components.ambiclimate ambiclimate==0.2.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 391b6605220..fc8656f0333 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -52,6 +52,7 @@ TEST_REQUIREMENTS = ( 'aionotion', 'aiounifi', 'aioswitcher', + 'aiowwlln', 'apns2', 'aprslib', 'av', diff --git a/tests/components/wwlln/__init__.py b/tests/components/wwlln/__init__.py new file mode 100644 index 00000000000..c44245e5988 --- /dev/null +++ b/tests/components/wwlln/__init__.py @@ -0,0 +1 @@ +"""Define tests for the WWLLN component.""" diff --git a/tests/components/wwlln/test_config_flow.py b/tests/components/wwlln/test_config_flow.py new file mode 100644 index 00000000000..9751f5d5c9c --- /dev/null +++ b/tests/components/wwlln/test_config_flow.py @@ -0,0 +1,106 @@ +"""Define tests for the WWLLN config flow.""" +from homeassistant import data_entry_flow +from homeassistant.components.wwlln import CONF_WINDOW, DOMAIN, config_flow +from homeassistant.const import ( + CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, CONF_UNIT_SYSTEM) + +from tests.common import MockConfigEntry + + +async def test_duplicate_error(hass): + """Test that errors are shown when duplicates are added.""" + conf = { + CONF_LATITUDE: 39.128712, + CONF_LONGITUDE: -104.9812612, + CONF_RADIUS: 25, + } + + MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass) + flow = config_flow.WWLLNFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {'base': 'identifier_exists'} + + +async def test_show_form(hass): + """Test that the form is served with no input.""" + flow = config_flow.WWLLNFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=None) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + +async def test_step_import(hass): + """Test that the import step works.""" + conf = { + CONF_LATITUDE: 39.128712, + CONF_LONGITUDE: -104.9812612, + CONF_RADIUS: 25, + CONF_UNIT_SYSTEM: 'metric', + CONF_WINDOW: 600.0, + } + + flow = config_flow.WWLLNFlowHandler() + flow.hass = hass + + result = await flow.async_step_import(import_config=conf) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == '39.128712, -104.9812612' + assert result['data'] == { + CONF_LATITUDE: 39.128712, + CONF_LONGITUDE: -104.9812612, + CONF_RADIUS: 25, + CONF_UNIT_SYSTEM: 'metric', + CONF_WINDOW: 600.0, + } + + +async def test_step_user(hass): + """Test that the user step works.""" + conf = { + CONF_LATITUDE: 39.128712, + CONF_LONGITUDE: -104.9812612, + CONF_RADIUS: 25, + } + + flow = config_flow.WWLLNFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=conf) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == '39.128712, -104.9812612' + assert result['data'] == { + CONF_LATITUDE: 39.128712, + CONF_LONGITUDE: -104.9812612, + CONF_RADIUS: 25, + CONF_UNIT_SYSTEM: 'metric', + CONF_WINDOW: 600.0, + } + + +async def test_custom_window(hass): + """Test that a custom window is stored correctly.""" + conf = { + CONF_LATITUDE: 39.128712, + CONF_LONGITUDE: -104.9812612, + CONF_RADIUS: 25, + CONF_WINDOW: 300 + } + + flow = config_flow.WWLLNFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=conf) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == '39.128712, -104.9812612' + assert result['data'] == { + CONF_LATITUDE: 39.128712, + CONF_LONGITUDE: -104.9812612, + CONF_RADIUS: 25, + CONF_UNIT_SYSTEM: 'metric', + CONF_WINDOW: 300, + }