From f20a3313b09129b725fec0ac4064bafe940db233 Mon Sep 17 00:00:00 2001 From: Malte Franken <exxamalte@users.noreply.github.com> Date: Thu, 30 Aug 2018 21:58:23 +1000 Subject: [PATCH] Geo Location component (#15953) * initial working version of a geo location component and georss platform * ensure that custom attributes don't override built-in ones * bugfixes and tests * fixing tests because of introduction of new component using same fixture * improving test cases * removing potentially unavailable attribute from debug message output * completing test suite * cleaning up debug messages; sorting entries in group view by distance * ability to define the desired state attribute and corresponding unit of measurement; sort devices in group by configured state; find centroid for map if event is defined by polygon; updated tests * sort entries in group; code clean-ups * fixing indentation * added requirements of new component and platform * fixed various lint issues * fixed more lint issues * introducing demo geo location platform; refactored geo location component and geo rss platform to fit * removing geo rss events platform; added unit tests for geo location platform and demo platform * reverting change in debug message for feedreader to avoid confusion with new geo location component * updated requirements after removing georss platform * removed unused imports * fixing a lint issue and a test case * simplifying component code; moving code into demo platform; fixing tests * removed grouping from demo platform; small refactorings * automating the entity id generation (the use of an entity namespace achieves the same thing) * undoing changes made for the georss platform * simplified test cases * small tweaks to test case * rounding all state attribute values * fixing lint; removing distance from state attributes * fixed test * renamed add_devices to add_entities; tweaked test to gain more control over the timed update in the demo platform * reusing utcnow variable instead of patched method * fixed test by avoiding to make assumptions about order of list of entity ids * adding test for the geo location event class --- .../components/geo_location/__init__.py | 68 +++++++++ homeassistant/components/geo_location/demo.py | 132 ++++++++++++++++++ tests/components/geo_location/__init__.py | 1 + tests/components/geo_location/test_demo.py | 63 +++++++++ tests/components/geo_location/test_init.py | 20 +++ 5 files changed, 284 insertions(+) create mode 100644 homeassistant/components/geo_location/__init__.py create mode 100644 homeassistant/components/geo_location/demo.py create mode 100644 tests/components/geo_location/__init__.py create mode 100644 tests/components/geo_location/test_demo.py create mode 100644 tests/components/geo_location/test_init.py diff --git a/homeassistant/components/geo_location/__init__.py b/homeassistant/components/geo_location/__init__.py new file mode 100644 index 00000000000..67ed9520fa4 --- /dev/null +++ b/homeassistant/components/geo_location/__init__.py @@ -0,0 +1,68 @@ +""" +Geo Location component. + +This component covers platforms that deal with external events that contain +a geo location related to the installed HA instance. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/geo_location/ +""" +import logging +from datetime import timedelta +from typing import Optional + +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent + +_LOGGER = logging.getLogger(__name__) + +ATTR_DISTANCE = 'distance' +DOMAIN = 'geo_location' +ENTITY_ID_FORMAT = DOMAIN + '.{}' +GROUP_NAME_ALL_EVENTS = 'All Geo Location Events' +SCAN_INTERVAL = timedelta(seconds=60) + + +async def async_setup(hass, config): + """Set up this component.""" + component = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_EVENTS) + await component.async_setup(config) + return True + + +class GeoLocationEvent(Entity): + """This represents an external event with an associated geo location.""" + + @property + def state(self): + """Return the state of the sensor.""" + if self.distance is not None: + return round(self.distance, 1) + return None + + @property + def distance(self) -> Optional[float]: + """Return distance value of this external event.""" + return None + + @property + def latitude(self) -> Optional[float]: + """Return latitude value of this external event.""" + return None + + @property + def longitude(self) -> Optional[float]: + """Return longitude value of this external event.""" + return None + + @property + def state_attributes(self): + """Return the state attributes of this external event.""" + data = {} + if self.latitude is not None: + data[ATTR_LATITUDE] = round(self.latitude, 5) + if self.longitude is not None: + data[ATTR_LONGITUDE] = round(self.longitude, 5) + return data diff --git a/homeassistant/components/geo_location/demo.py b/homeassistant/components/geo_location/demo.py new file mode 100644 index 00000000000..8e8d8211086 --- /dev/null +++ b/homeassistant/components/geo_location/demo.py @@ -0,0 +1,132 @@ +""" +Demo platform for the geo location component. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/demo/ +""" +import logging +import random +from datetime import timedelta +from math import pi, cos, sin, radians + +from typing import Optional + +from homeassistant.components.geo_location import GeoLocationEvent +from homeassistant.helpers.event import track_time_interval + +_LOGGER = logging.getLogger(__name__) + +AVG_KM_PER_DEGREE = 111.0 +DEFAULT_UNIT_OF_MEASUREMENT = "km" +DEFAULT_UPDATE_INTERVAL = timedelta(minutes=1) +MAX_RADIUS_IN_KM = 50 +NUMBER_OF_DEMO_DEVICES = 5 + +EVENT_NAMES = ["Bushfire", "Hazard Reduction", "Grass Fire", "Burn off", + "Structure Fire", "Fire Alarm", "Thunderstorm", "Tornado", + "Cyclone", "Waterspout", "Dust Storm", "Blizzard", "Ice Storm", + "Earthquake", "Tsunami"] + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Demo geo locations.""" + DemoManager(hass, add_entities) + + +class DemoManager: + """Device manager for demo geo location events.""" + + def __init__(self, hass, add_entities): + """Initialise the demo geo location event manager.""" + self._hass = hass + self._add_entities = add_entities + self._managed_devices = [] + self._update(count=NUMBER_OF_DEMO_DEVICES) + self._init_regular_updates() + + def _generate_random_event(self): + """Generate a random event in vicinity of this HA instance.""" + home_latitude = self._hass.config.latitude + home_longitude = self._hass.config.longitude + + # Approx. 111km per degree (north-south). + radius_in_degrees = random.random() * MAX_RADIUS_IN_KM / \ + AVG_KM_PER_DEGREE + radius_in_km = radius_in_degrees * AVG_KM_PER_DEGREE + angle = random.random() * 2 * pi + # Compute coordinates based on radius and angle. Adjust longitude value + # based on HA's latitude. + latitude = home_latitude + radius_in_degrees * sin(angle) + longitude = home_longitude + radius_in_degrees * cos(angle) / \ + cos(radians(home_latitude)) + + event_name = random.choice(EVENT_NAMES) + return DemoGeoLocationEvent(event_name, radius_in_km, latitude, + longitude, DEFAULT_UNIT_OF_MEASUREMENT) + + def _init_regular_updates(self): + """Schedule regular updates based on configured time interval.""" + track_time_interval(self._hass, lambda now: self._update(), + DEFAULT_UPDATE_INTERVAL) + + def _update(self, count=1): + """Remove events and add new random events.""" + # Remove devices. + for _ in range(1, count + 1): + if self._managed_devices: + device = random.choice(self._managed_devices) + if device: + _LOGGER.debug("Removing %s", device) + self._managed_devices.remove(device) + self._hass.add_job(device.async_remove()) + # Generate new devices from events. + new_devices = [] + for _ in range(1, count + 1): + new_device = self._generate_random_event() + _LOGGER.debug("Adding %s", new_device) + new_devices.append(new_device) + self._managed_devices.append(new_device) + self._add_entities(new_devices) + + +class DemoGeoLocationEvent(GeoLocationEvent): + """This represents a demo geo location event.""" + + def __init__(self, name, distance, latitude, longitude, + unit_of_measurement): + """Initialize entity with data provided.""" + self._name = name + self._distance = distance + self._latitude = latitude + self._longitude = longitude + self._unit_of_measurement = unit_of_measurement + + @property + def name(self) -> Optional[str]: + """Return the name of the event.""" + return self._name + + @property + def should_poll(self): + """No polling needed for a demo geo location event.""" + return False + + @property + def distance(self) -> Optional[float]: + """Return distance value of this external event.""" + return self._distance + + @property + def latitude(self) -> Optional[float]: + """Return latitude value of this external event.""" + return self._latitude + + @property + def longitude(self) -> Optional[float]: + """Return longitude value of this external event.""" + return self._longitude + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement diff --git a/tests/components/geo_location/__init__.py b/tests/components/geo_location/__init__.py new file mode 100644 index 00000000000..56fc7d9fc92 --- /dev/null +++ b/tests/components/geo_location/__init__.py @@ -0,0 +1 @@ +"""The tests for Geo Location platforms.""" diff --git a/tests/components/geo_location/test_demo.py b/tests/components/geo_location/test_demo.py new file mode 100644 index 00000000000..158e5d61968 --- /dev/null +++ b/tests/components/geo_location/test_demo.py @@ -0,0 +1,63 @@ +"""The tests for the demo platform.""" +import unittest +from unittest.mock import patch + +from homeassistant.components import geo_location +from homeassistant.components.geo_location.demo import \ + NUMBER_OF_DEMO_DEVICES, DEFAULT_UNIT_OF_MEASUREMENT, \ + DEFAULT_UPDATE_INTERVAL +from homeassistant.setup import setup_component +from tests.common import get_test_home_assistant, assert_setup_component, \ + fire_time_changed +import homeassistant.util.dt as dt_util + +CONFIG = { + geo_location.DOMAIN: [ + { + 'platform': 'demo' + } + ] +} + + +class TestDemoPlatform(unittest.TestCase): + """Test the demo platform.""" + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_setup_platform(self): + """Test setup of demo platform via configuration.""" + utcnow = dt_util.utcnow() + # Patching 'utcnow' to gain more control over the timed update. + with patch('homeassistant.util.dt.utcnow', return_value=utcnow): + with assert_setup_component(1, geo_location.DOMAIN): + self.assertTrue(setup_component(self.hass, geo_location.DOMAIN, + CONFIG)) + + # In this test, only entities of the geo location domain have been + # generated. + all_states = self.hass.states.all() + assert len(all_states) == NUMBER_OF_DEMO_DEVICES + + # Check a single device's attributes. + state_first_entry = all_states[0] + self.assertAlmostEqual(state_first_entry.attributes['latitude'], + self.hass.config.latitude, delta=1.0) + self.assertAlmostEqual(state_first_entry.attributes['longitude'], + self.hass.config.longitude, delta=1.0) + assert state_first_entry.attributes['unit_of_measurement'] == \ + DEFAULT_UNIT_OF_MEASUREMENT + # Update (replaces 1 device). + fire_time_changed(self.hass, utcnow + DEFAULT_UPDATE_INTERVAL) + self.hass.block_till_done() + # Get all states again, ensure that the number of states is still + # the same, but the lists are different. + all_states_updated = self.hass.states.all() + assert len(all_states_updated) == NUMBER_OF_DEMO_DEVICES + self.assertNotEqual(all_states, all_states_updated) diff --git a/tests/components/geo_location/test_init.py b/tests/components/geo_location/test_init.py new file mode 100644 index 00000000000..54efe977bf9 --- /dev/null +++ b/tests/components/geo_location/test_init.py @@ -0,0 +1,20 @@ +"""The tests for the geo location component.""" +from homeassistant.components import geo_location +from homeassistant.components.geo_location import GeoLocationEvent +from homeassistant.setup import async_setup_component + + +async def test_setup_component(hass): + """Simple test setup of component.""" + result = await async_setup_component(hass, geo_location.DOMAIN) + assert result + + +async def test_event(hass): + """Simple test of the geo location event class.""" + entity = GeoLocationEvent() + + assert entity.state is None + assert entity.distance is None + assert entity.latitude is None + assert entity.longitude is None -- GitLab