From cd0cef640391211637368cc66ed4954872c3dae9 Mon Sep 17 00:00:00 2001 From: Nick Waring Date: Sun, 7 Feb 2016 08:52:32 +0000 Subject: [PATCH] Component to track the proximity of devices to a zone --- homeassistant/components/proximity.py | 282 ++++++++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 homeassistant/components/proximity.py diff --git a/homeassistant/components/proximity.py b/homeassistant/components/proximity.py new file mode 100644 index 00000000000..fd2483a2e83 --- /dev/null +++ b/homeassistant/components/proximity.py @@ -0,0 +1,282 @@ +""" +custom_components.proximity +~~~~~~~~~~~~~~~~~~~~~~~~~ +Component to monitor the proximity of devices to a particular zone and the +direction of travel. The result is an entity created in HA which maintains +the proximity data + +This component is useful to reduce the number of automation rules required +when wanting to perform automations based on locations outside a particular +zone. The standard HA zone and state based triggers allow similar control +but the number of rules grows exponentially when factors such as direction +of travel need to be taken into account. Some examples of its use include: +- Increase thermostat temperature as you near home +- Decrease temperature the further away from home you travel + +The Proximity entity which is created has the following values: +state = distance from the monitored zone (in km) +dir_of_travel = direction of the closest device to the monitoed zone. Values + are: + 'not set' + 'arrived' + 'towards' + 'away_from' + 'unknown' + 'stationary' +dist_to_zone = distance from the monitored zone (in km) + +Use configuration.yaml to enable the user to easily tune a number of settings: +- Zone: the zone to which this component is measuring the distance to. Default + is the home zone +- Ignored Zones: where proximity is not calculated for a device (either the + device being monitored or ones being compared (e.g. work or school) +- Devices: a list of devices to compare location against to check closeness to + the configured zone +- Tolerance: the tolerance used to calculate the direction of travel in metres + (to filter out small GPS co-ordinate changes + +Logging levels debug, info and error are in use + +Example configuration.yaml entry: +proximity: + zone: home + ignored_zones: + - twork + - elschool + devices: + - device_tracker.nwaring_nickmobile + - device_tracker.eleanorsiphone + - device_tracker.tsiphone + tolerance: 50 +""" + +import logging +from homeassistant.helpers.event import track_state_change +from homeassistant.helpers.entity import Entity +from homeassistant.util.location import distance + +DEPENDENCIES = ['zone', 'device_tracker'] + +# domain for the component +DOMAIN = 'proximity' + +# default tolerance +DEFAULT_TOLERANCE = 1 + +# default zone +DEFAULT_PROXIMITY_ZONE = 'home' + +# entity attributes +ATTR_DIST_FROM = 'dist_to_zone' +ATTR_DIR_OF_TRAVEL = 'dir_of_travel' +ATTR_NEAREST = 'nearest' +ATTR_FRIENDLY_NAME = 'friendly_name' + +# Shortcut for the logger +_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) + + # get the devices from configuration.yaml + if 'devices' not in config[DOMAIN]: + _LOGGER.error('devices not found in config') + return False + + proximity_devices = [] + for variable in config[DOMAIN]['devices']: + proximity_devices.append(variable) + + # get the direction of travel tolerance from configuration.yaml + tolerance = config[DOMAIN].get('tolerance', DEFAULT_TOLERANCE) + + # get the zone to monitor proximity to from configuration.yaml + proximity_zone = config[DOMAIN].get('zone', DEFAULT_PROXIMITY_ZONE) + + entity_id = DOMAIN + '.' + proximity_zone + proximity_zone = 'zone.' + proximity_zone + + state = hass.states.get(proximity_zone) + 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.update_ha_state() + + # main command to monitor proximity of devices + track_state_change(hass, proximity_devices, + proximity.check_proximity_state_change) + + # Tells the bootstrapper that the component was successfully initialized + return True + + +class Proximity(Entity): # pylint: disable=too-many-instance-attributes + """ Represents a Proximity in Home Assistant. """ + def __init__(self, hass, zone_friendly_name, dist_to, dir_of_travel, + nearest, ignored_zones, proximity_devices, tolerance, + proximity_zone): + # pylint: disable=too-many-arguments + self.hass = hass + self.friendly_name = zone_friendly_name + self.dist_to = dist_to + self.dir_of_travel = dir_of_travel + self.nearest = nearest + self.ignored_zones = ignored_zones + self.proximity_devices = proximity_devices + self.tolerance = tolerance + self.proximity_zone = proximity_zone + + @property + def state(self): + return self.dist_to + + @property + def unit_of_measurement(self): + """ Unit of measurement of this entity """ + return "km" + + @property + def state_attributes(self): + return { + ATTR_DIR_OF_TRAVEL: self.dir_of_travel, + ATTR_NEAREST: self.nearest, + ATTR_FRIENDLY_NAME: self.friendly_name + } + + def check_proximity_state_change(self, entity, old_state, new_state): + # pylint: disable=too-many-branches,too-many-statements,too-many-locals + """ Function to perform the proximity checking """ + entity_name = new_state.name + devices_to_calculate = False + devices_in_zone = '' + + zone_state = self.hass.states.get(self.proximity_zone) + proximity_latitude = zone_state.attributes.get('latitude') + proximity_longitude = zone_state.attributes.get('longitude') + + # check for devices in the monitored zone + for device in self.proximity_devices: + device_state = self.hass.states.get(device) + + if device_state.state not in self.ignored_zones: + devices_to_calculate = True + + # check the location of all devices + if (device_state.state).lower() == (self.friendly_name).lower(): + device_friendly = device_state.name + if devices_in_zone != '': + devices_in_zone = devices_in_zone + ', ' + devices_in_zone = devices_in_zone + device_friendly + + # no-one to track so reset the entity + if not devices_to_calculate: + self.dist_to = 'not set' + self.dir_of_travel = 'not set' + self.nearest = 'not set' + self.update_ha_state() + return + + # at least one device is in the monitored zone so update the entity + if devices_in_zone != '': + self.dist_to = 0 + self.dir_of_travel = 'arrived' + self.nearest = devices_in_zone + self.update_ha_state() + return + + # we can't check proximity because latitude and longitude don't exist + if 'latitude' not in new_state.attributes: + return + + # collect distances to the zone for all devices + distances_to_zone = {} + for device in self.proximity_devices: + # ignore devices in an ignored zone + device_state = self.hass.states.get(device) + if device_state.state in self.ignored_zones: + continue + + # ignore devices if proximity cannot be calculated + if 'latitude' not in device_state.attributes: + continue + + # calculate the distance to the proximity zone + dist_to_zone = distance(proximity_latitude, + proximity_longitude, + device_state.attributes['latitude'], + device_state.attributes['longitude']) + + # add the device and distance to a dictionary + distances_to_zone[device] = round(dist_to_zone / 1000, 1) + + # loop through each of the distances collected and work out the closest + closest_device = '' + dist_to_zone = 1000000 + + for device in distances_to_zone: + if distances_to_zone[device] < dist_to_zone: + closest_device = device + dist_to_zone = distances_to_zone[device] + + # if the closest device is one of the other devices + if closest_device != entity: + self.dist_to = round(distances_to_zone[closest_device]) + self.dir_of_travel = 'unknown' + device_state = self.hass.states.get(closest_device) + self.nearest = device_state.name + self.update_ha_state() + return + + # stop if we cannot calculate the direction of travel (i.e. we don't + # have a previous state and a current LAT and LONG) + if old_state is None or 'latitude' not in old_state.attributes: + self.dist_to = round(distances_to_zone[entity]) + self.dir_of_travel = 'unknown' + self.nearest = entity_name + self.update_ha_state() + return + + # reset the variables + distance_travelled = 0 + + # calculate the distance travelled + old_distance = distance(proximity_latitude, proximity_longitude, + old_state.attributes['latitude'], + old_state.attributes['longitude']) + new_distance = distance(proximity_latitude, proximity_longitude, + new_state.attributes['latitude'], + new_state.attributes['longitude']) + distance_travelled = round(new_distance - old_distance, 1) + + # check for tolerance + if distance_travelled < self.tolerance * -1: + direction_of_travel = 'towards' + elif distance_travelled > self.tolerance: + direction_of_travel = 'away_from' + else: + direction_of_travel = 'stationary' + + # update the proximity entity + self.dist_to = round(dist_to_zone) + self.dir_of_travel = direction_of_travel + self.nearest = entity_name + self.update_ha_state() + _LOGGER.debug('proximity.%s update entity: distance=%s: direction=%s: ' + 'device=%s', self.friendly_name, round(dist_to_zone), + direction_of_travel, entity_name) + + _LOGGER.info('%s: proximity calculation complete', entity_name)