From 122581da7f2dbbe83271a2a7b41b20fbc78edb62 Mon Sep 17 00:00:00 2001 From: Teagan Glenn Date: Sun, 31 Jul 2016 11:20:56 -0600 Subject: [PATCH] Proximity unit of measure (#2659) * Allow multiple proximities * Distance conversion * Add unit of measurement and conversion to proximity * Shorten attribute name * Fix get unit of measurement * Fix the km <-> m conversion * Add type check and errors * first path unit test around distance utility * Fix numeric type check * Fix conversion type-os * Actually set the exception thrown flag * Test for exact conversion * More descriptive variable names * Update method invocation to match change in method name * Missed a couple variables * Line continuation * Fix linting too many return issue * Break out proximity setup for list of proximity and for single proximity device * Pass hass to setup function * Check if setup succeeded for each proximity component * Change variable name * Break out branches in convert to avoid too many branches linting error * Remove disable lint line * Variables for default properties * Combine logic * Test loading multiple proximities for 100% code coverage on proximity component * Unit test to reach 100% Fail to configure proximities missing devices * Fail first before processing * Combine return statements * lstrip = bad Teagan * Utilize string formating instead of concatenation * Fix variable reference * Typeo * Clean up conversion to reduce complexity * Update unit tests to match code changes on distance util * Test non numeric value * Private methods, value type has already been checked. --- homeassistant/components/proximity.py | 81 +++++++++++++++++--------- homeassistant/util/distance.py | 84 +++++++++++++++++++++++++++ tests/components/test_proximity.py | 62 ++++++++++++++++++++ tests/util/test_distance.py | 68 ++++++++++++++++++++++ 4 files changed, 269 insertions(+), 26 deletions(-) create mode 100644 homeassistant/util/distance.py create mode 100644 tests/util/test_distance.py diff --git a/homeassistant/components/proximity.py b/homeassistant/components/proximity.py index 5880df639bd..ced3c9a947e 100644 --- a/homeassistant/components/proximity.py +++ b/homeassistant/components/proximity.py @@ -12,17 +12,33 @@ import logging from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_state_change from homeassistant.util.location import distance +from homeassistant.util.distance import convert +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT DEPENDENCIES = ['zone', 'device_tracker'] DOMAIN = 'proximity' +NOT_SET = 'not set' + # Default tolerance DEFAULT_TOLERANCE = 1 # Default zone DEFAULT_PROXIMITY_ZONE = 'home' +# Default unit of measure +DEFAULT_UNIT_OF_MEASUREMENT = 'km' + +# Default distance to zone +DEFAULT_DIST_TO_ZONE = NOT_SET + +# Default direction of travel +DEFAULT_DIR_OF_TRAVEL = NOT_SET + +# Default nearest device +DEFAULT_NEAREST = NOT_SET + # Entity attributes ATTR_DIST_FROM = 'dist_to_zone' ATTR_DIR_OF_TRAVEL = 'dir_of_travel' @@ -31,43 +47,41 @@ ATTR_NEAREST = 'nearest' _LOGGER = logging.getLogger(__name__) -def setup(hass, config): # pylint: disable=too-many-locals,too-many-statements - """Get the zones and offsets from configuration.yaml.""" - ignored_zones = [] - if 'ignored_zones' in config[DOMAIN]: - for variable in config[DOMAIN]['ignored_zones']: - ignored_zones.append(variable) - +def setup_proximity_component(hass, config): + """Set up individual proximity component.""" # Get the devices from configuration.yaml. - if 'devices' not in config[DOMAIN]: + if 'devices' not in config: _LOGGER.error('devices not found in config') return False + ignored_zones = [] + if 'ignored_zones' in config: + for variable in config['ignored_zones']: + ignored_zones.append(variable) + proximity_devices = [] - for variable in config[DOMAIN]['devices']: + for variable in config['devices']: proximity_devices.append(variable) # Get the direction of travel tolerance from configuration.yaml. - tolerance = config[DOMAIN].get('tolerance', DEFAULT_TOLERANCE) + tolerance = config.get('tolerance', DEFAULT_TOLERANCE) # Get the zone to monitor proximity to from configuration.yaml. - proximity_zone = config[DOMAIN].get('zone', DEFAULT_PROXIMITY_ZONE) + proximity_zone = config.get('zone', DEFAULT_PROXIMITY_ZONE) - entity_id = DOMAIN + '.' + proximity_zone - proximity_zone = 'zone.' + proximity_zone + # Get the unit of measurement from configuration.yaml. + unit_of_measure = config.get(ATTR_UNIT_OF_MEASUREMENT, + DEFAULT_UNIT_OF_MEASUREMENT) - state = hass.states.get(proximity_zone) + zone_id = 'zone.{}'.format(proximity_zone) + state = hass.states.get(zone_id) zone_friendly_name = (state.name).lower() - # Set the default values. - dist_to_zone = 'not set' - dir_of_travel = 'not set' - nearest = 'not set' - - proximity = Proximity(hass, zone_friendly_name, dist_to_zone, - dir_of_travel, nearest, ignored_zones, - proximity_devices, tolerance, proximity_zone) - proximity.entity_id = entity_id + proximity = Proximity(hass, zone_friendly_name, DEFAULT_DIST_TO_ZONE, + DEFAULT_DIR_OF_TRAVEL, DEFAULT_NEAREST, + ignored_zones, proximity_devices, tolerance, + zone_id, unit_of_measure) + proximity.entity_id = '{}.{}'.format(DOMAIN, proximity_zone) proximity.update_ha_state() @@ -78,13 +92,26 @@ def setup(hass, config): # pylint: disable=too-many-locals,too-many-statements return True +def setup(hass, config): + """Get the zones and offsets from configuration.yaml.""" + result = True + if isinstance(config[DOMAIN], list): + for proximity_config in config[DOMAIN]: + if not setup_proximity_component(hass, proximity_config): + result = False + elif not setup_proximity_component(hass, config[DOMAIN]): + result = False + + return result + + class Proximity(Entity): # pylint: disable=too-many-instance-attributes """Representation of a Proximity.""" # pylint: disable=too-many-arguments def __init__(self, hass, zone_friendly_name, dist_to, dir_of_travel, nearest, ignored_zones, proximity_devices, tolerance, - proximity_zone): + proximity_zone, unit_of_measure): """Initialize the proximity.""" self.hass = hass self.friendly_name = zone_friendly_name @@ -95,6 +122,7 @@ class Proximity(Entity): # pylint: disable=too-many-instance-attributes self.proximity_devices = proximity_devices self.tolerance = tolerance self.proximity_zone = proximity_zone + self.unit_of_measure = unit_of_measure @property def name(self): @@ -109,7 +137,7 @@ class Proximity(Entity): # pylint: disable=too-many-instance-attributes @property def unit_of_measurement(self): """Return the unit of measurement of this entity.""" - return "km" + return self.unit_of_measure @property def state_attributes(self): @@ -183,7 +211,8 @@ class Proximity(Entity): # pylint: disable=too-many-instance-attributes device_state.attributes['longitude']) # Add the device and distance to a dictionary. - distances_to_zone[device] = round(dist_to_zone / 1000, 1) + distances_to_zone[device] = round( + convert(dist_to_zone, 'm', self.unit_of_measure), 1) # Loop through each of the distances collected and work out the # closest. diff --git a/homeassistant/util/distance.py b/homeassistant/util/distance.py new file mode 100644 index 00000000000..69478313df1 --- /dev/null +++ b/homeassistant/util/distance.py @@ -0,0 +1,84 @@ +"""Distance util functions.""" + +import logging +from numbers import Number + +_LOGGER = logging.getLogger(__name__) + +KILOMETERS_SYMBOL = 'km' +METERS_SYMBOL = 'm' +FEET_SYMBOL = 'ft' +MILES_SYMBOL = 'mi' + +VALID_UNITS = [ + KILOMETERS_SYMBOL, + METERS_SYMBOL, + FEET_SYMBOL, + MILES_SYMBOL, +] + + +def convert(value, unit_1, unit_2): + """Convert one unit of measurement to another.""" + if not isinstance(value, Number): + raise TypeError(str(value) + ' is not of numeric type') + + if unit_1 == unit_2: + return value + + if unit_1 not in VALID_UNITS: + _LOGGER.error('Unknown unit of measure: ' + str(unit_1)) + raise ValueError('Unknown unit of measure: ' + str(unit_1)) + elif unit_2 not in VALID_UNITS: + _LOGGER.error('Unknown unit of measure: ' + str(unit_2)) + raise ValueError('Unknown unit of measure: ' + str(unit_2)) + + meters = value + + if unit_1 == MILES_SYMBOL: + meters = __miles_to_meters(value) + elif unit_1 == FEET_SYMBOL: + meters = __feet_to_meters(value) + elif unit_1 == KILOMETERS_SYMBOL: + meters = __kilometers_to_meters(value) + + result = meters + + if unit_2 == MILES_SYMBOL: + result = __meters_to_miles(meters) + elif unit_2 == FEET_SYMBOL: + result = __meters_to_feet(meters) + elif unit_2 == KILOMETERS_SYMBOL: + result = __meters_to_kilometers(meters) + + return result + + +def __miles_to_meters(miles): + """Convert miles to meters.""" + return miles * 1609.344 + + +def __feet_to_meters(feet): + """Convert feet to meters.""" + return feet * 0.3048 + + +def __kilometers_to_meters(kilometers): + """Convert kilometers to meters.""" + return kilometers * 1000 + + +def __meters_to_miles(meters): + """Convert meters to miles.""" + return meters * 0.000621371 + + +def __meters_to_feet(meters): + """Convert meters to feet.""" + return meters * 3.28084 + + +def __meters_to_kilometers(meters): + """Convert meters to kilometers.""" + return meters * 0.001 diff --git a/tests/components/test_proximity.py b/tests/components/test_proximity.py index 0bfa403ad44..479b9459f03 100644 --- a/tests/components/test_proximity.py +++ b/tests/components/test_proximity.py @@ -18,11 +18,73 @@ class TestProximity: 'longitude': 1.1, 'radius': 10 }) + self.hass.states.set( + 'zone.work', 'zoning', + { + 'name': 'work', + 'latitude': 2.3, + 'longitude': 1.3, + 'radius': 10 + }) def teardown_method(self, method): """Stop everything that was started.""" self.hass.stop() + def test_proximities(self): + """Test a list of proximities.""" + assert proximity.setup(self.hass, { + 'proximity': [{ + 'zone': 'home', + 'ignored_zones': { + 'work' + }, + 'devices': { + 'device_tracker.test1', + 'device_tracker.test2' + }, + 'tolerance': '1' + }, { + 'zone': 'work', + 'devices': { + 'device_tracker.test1' + }, + 'tolerance': '1' + }] + }) + + proximities = ['home', 'work'] + + for prox in proximities: + state = self.hass.states.get('proximity.' + prox) + assert state.state == 'not set' + assert state.attributes.get('nearest') == 'not set' + assert state.attributes.get('dir_of_travel') == 'not set' + + self.hass.states.set('proximity.' + prox, '0') + self.hass.pool.block_till_done() + state = self.hass.states.get('proximity.' + prox) + assert state.state == '0' + + def test_proximities_missing_devices(self): + """Test a list of proximities with one missing devices.""" + assert not proximity.setup(self.hass, { + 'proximity': [{ + 'zone': 'home', + 'ignored_zones': { + 'work' + }, + 'devices': { + 'device_tracker.test1', + 'device_tracker.test2' + }, + 'tolerance': '1' + }, { + 'zone': 'work', + 'tolerance': '1' + }] + }) + def test_proximity(self): """Test the proximity.""" assert proximity.setup(self.hass, { diff --git a/tests/util/test_distance.py b/tests/util/test_distance.py new file mode 100644 index 00000000000..498416bc8eb --- /dev/null +++ b/tests/util/test_distance.py @@ -0,0 +1,68 @@ +"""Test homeasssitant distance utility functions.""" + +import unittest +import homeassistant.util.distance as distance_util + +KILOMETERS = distance_util.KILOMETERS_SYMBOL +METERS = distance_util.METERS_SYMBOL +FEET = distance_util.FEET_SYMBOL +MILES = distance_util.MILES_SYMBOL + +INVALID_SYMBOL = 'bob' +VALID_SYMBOL = KILOMETERS + + +class TestDistanceUtil(unittest.TestCase): + """Test the distance utility functions.""" + + def test_convert_same_unit(self): + """Test conversion from any unit to same unit.""" + self.assertEqual(5, distance_util.convert(5, KILOMETERS, KILOMETERS)) + self.assertEqual(2, distance_util.convert(2, METERS, METERS)) + self.assertEqual(10, distance_util.convert(10, MILES, MILES)) + self.assertEqual(9, distance_util.convert(9, FEET, FEET)) + + def test_convert_invalid_unit(self): + """Test exception is thrown for invalid units.""" + with self.assertRaises(ValueError): + distance_util.convert(5, INVALID_SYMBOL, VALID_SYMBOL) + + with self.assertRaises(ValueError): + distance_util.convert(5, VALID_SYMBOL, INVALID_SYMBOL) + + def test_convert_nonnumeric_value(self): + """Test exception is thrown for nonnumeric type.""" + with self.assertRaises(TypeError): + distance_util.convert('a', KILOMETERS, METERS) + + def test_convert_from_miles(self): + """Test conversion from miles to other units.""" + miles = 5 + self.assertEqual(distance_util.convert(miles, MILES, KILOMETERS), + 8.04672) + self.assertEqual(distance_util.convert(miles, MILES, METERS), 8046.72) + self.assertEqual(distance_util.convert(miles, MILES, FEET), + 26400.0008448) + + def test_convert_from_feet(self): + """Test conversion from feet to other units.""" + feet = 5000 + self.assertEqual(distance_util.convert(feet, FEET, KILOMETERS), 1.524) + self.assertEqual(distance_util.convert(feet, FEET, METERS), 1524) + self.assertEqual(distance_util.convert(feet, FEET, MILES), + 0.9469694040000001) + + def test_convert_from_kilometers(self): + """Test conversion from kilometers to other units.""" + km = 5 + self.assertEqual(distance_util.convert(km, KILOMETERS, FEET), 16404.2) + self.assertEqual(distance_util.convert(km, KILOMETERS, METERS), 5000) + self.assertEqual(distance_util.convert(km, KILOMETERS, MILES), + 3.106855) + + def test_convert_from_meters(self): + """Test conversion from meters to other units.""" + m = 5000 + self.assertEqual(distance_util.convert(m, METERS, FEET), 16404.2) + self.assertEqual(distance_util.convert(m, METERS, KILOMETERS), 5) + self.assertEqual(distance_util.convert(m, METERS, MILES), 3.106855)