From e6d7f6ed712f6c286e44ca5466e974652c86027b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 25 May 2019 13:34:53 -0700 Subject: [PATCH] Config entry device tracker (#24040) * Move zone helpers to zone root * Add config entry support to device tracker * Convert Geofency * Convert GPSLogger * Track unsub per entry * Convert locative * Migrate OwnTracks * Lint * location -> latitude, longitude props * Lint * lint * Fix test --- .../components/device_tracker/__init__.py | 37 +- .../components/device_tracker/config_entry.py | 114 +++++ .../components/device_tracker/legacy.py | 2 +- .../components/device_tracker/setup.py | 19 +- homeassistant/components/geofency/__init__.py | 10 +- .../components/geofency/device_tracker.py | 107 ++++- .../components/gpslogger/__init__.py | 6 +- .../components/gpslogger/device_tracker.py | 118 ++++- .../components/icloud/device_tracker.py | 15 +- homeassistant/components/locative/__init__.py | 5 + .../components/locative/device_tracker.py | 97 +++- .../components/owntracks/__init__.py | 28 +- .../components/owntracks/device_tracker.py | 433 +++++------------- .../components/owntracks/messages.py | 348 ++++++++++++++ homeassistant/components/zone/__init__.py | 40 +- homeassistant/components/zone/const.py | 2 + homeassistant/components/zone/zone.py | 49 +- tests/components/gpslogger/test_init.py | 2 +- .../owntracks/test_device_tracker.py | 23 +- tests/components/zone/test_init.py | 8 +- 20 files changed, 946 insertions(+), 517 deletions(-) create mode 100644 homeassistant/components/device_tracker/config_entry.py create mode 100644 homeassistant/components/owntracks/messages.py diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 618ed163b9d..4c67e6fa65d 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -3,10 +3,8 @@ import asyncio import voluptuous as vol -from homeassistant.helpers.entity_component import EntityComponent from homeassistant.loader import bind_hass from homeassistant.components import group -from homeassistant.config import config_without_domain from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import GPSType, ConfigType, HomeAssistantType @@ -15,6 +13,9 @@ from homeassistant.helpers.event import async_track_utc_time_change from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME from . import legacy, setup +from .config_entry import ( # noqa # pylint: disable=unused-import + async_setup_entry, async_unload_entry +) from .legacy import DeviceScanner # noqa # pylint: disable=unused-import from .const import ( ATTR_ATTRIBUTES, @@ -35,9 +36,7 @@ from .const import ( DEFAULT_CONSIDER_HOME, DEFAULT_TRACK_NEW, DOMAIN, - LOGGER, PLATFORM_TYPE_LEGACY, - SCAN_INTERVAL, SOURCE_TYPE_BLUETOOTH_LE, SOURCE_TYPE_BLUETOOTH, SOURCE_TYPE_GPS, @@ -113,36 +112,13 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): """Set up the device tracker.""" tracker = await legacy.get_tracker(hass, config) - async def setup_entry_helper(entry): - """Set up a config entry.""" - platform = await setup.async_create_platform_type( - hass, config, entry.domain, entry) - - if platform is None: - return False - - await platform.async_setup_legacy(hass, tracker) - - return True - - hass.data[DOMAIN] = setup_entry_helper - component = EntityComponent( - LOGGER, DOMAIN, hass, SCAN_INTERVAL) - - legacy_platforms, entity_platforms = \ - await setup.async_extract_config(hass, config) + legacy_platforms = await setup.async_extract_config(hass, config) setup_tasks = [ legacy_platform.async_setup_legacy(hass, tracker) for legacy_platform in legacy_platforms ] - if entity_platforms: - setup_tasks.append(component.async_setup({ - **config_without_domain(config, DOMAIN), - DOMAIN: [platform.config for platform in entity_platforms] - })) - if setup_tasks: await asyncio.wait(setup_tasks) @@ -178,8 +154,3 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): # restore await tracker.async_setup_tracked_device() return True - - -async def async_setup_entry(hass, entry): - """Set up an entry.""" - return await hass.data[DOMAIN](entry) diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py new file mode 100644 index 00000000000..59f6c0c49c1 --- /dev/null +++ b/homeassistant/components/device_tracker/config_entry.py @@ -0,0 +1,114 @@ +"""Code to set up a device tracker platform using a config entry.""" +from typing import Optional + +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.const import ( + STATE_NOT_HOME, + STATE_HOME, + ATTR_GPS_ACCURACY, + ATTR_LATITUDE, + ATTR_LONGITUDE, + ATTR_BATTERY_LEVEL, +) +from homeassistant.components import zone + +from .const import ( + ATTR_SOURCE_TYPE, + DOMAIN, + LOGGER, +) + + +async def async_setup_entry(hass, entry): + """Set up an entry.""" + component = hass.data.get(DOMAIN) # type: Optional[EntityComponent] + + if component is None: + component = hass.data[DOMAIN] = EntityComponent( + LOGGER, DOMAIN, hass + ) + + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass, entry): + """Unload an entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + +class DeviceTrackerEntity(Entity): + """Represent a tracked device.""" + + @property + def battery_level(self): + """Return the battery level of the device. + + Percentage from 0-100. + """ + return None + + @property + def location_accuracy(self): + """Return the location accuracy of the device. + + Value in meters. + """ + return 0 + + @property + def location_name(self) -> str: + """Return a location name for the current location of the device.""" + return None + + @property + def latitude(self) -> float: + """Return latitude value of the device.""" + return NotImplementedError + + @property + def longitude(self) -> float: + """Return longitude value of the device.""" + return NotImplementedError + + @property + def source_type(self): + """Return the source type, eg gps or router, of the device.""" + raise NotImplementedError + + @property + def state(self): + """Return the state of the device.""" + if self.location_name: + return self.location_name + + if self.latitude is not None: + zone_state = zone.async_active_zone( + self.hass, self.latitude, self.longitude, + self.location_accuracy) + if zone_state is None: + state = STATE_NOT_HOME + elif zone_state.entity_id == zone.ENTITY_ID_HOME: + state = STATE_HOME + else: + state = zone_state.name + return state + + return None + + @property + def state_attributes(self): + """Return the device state attributes.""" + attr = { + ATTR_SOURCE_TYPE: self.source_type + } + + if self.latitude is not None: + attr[ATTR_LATITUDE] = self.latitude + attr[ATTR_LONGITUDE] = self.longitude + attr[ATTR_GPS_ACCURACY] = self.location_accuracy + + if self.battery_level: + attr[ATTR_BATTERY_LEVEL] = self.battery_level + + return attr diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index b27b5e20acf..1fdd8077728 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -10,7 +10,7 @@ from homeassistant.components import zone from homeassistant.components.group import ( ATTR_ADD_ENTITIES, ATTR_ENTITIES, ATTR_OBJECT_ID, ATTR_VISIBLE, DOMAIN as DOMAIN_GROUP, SERVICE_SET) -from homeassistant.components.zone.zone import async_active_zone +from homeassistant.components.zone import async_active_zone from homeassistant.config import load_yaml_config_file, async_log_exception from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv diff --git a/homeassistant/components/device_tracker/setup.py b/homeassistant/components/device_tracker/setup.py index b2a3b66a27c..a74f51c6638 100644 --- a/homeassistant/components/device_tracker/setup.py +++ b/homeassistant/components/device_tracker/setup.py @@ -20,7 +20,6 @@ from homeassistant.const import ( from .const import ( DOMAIN, - PLATFORM_TYPE_ENTITY, PLATFORM_TYPE_LEGACY, CONF_SCAN_INTERVAL, SCAN_INTERVAL, @@ -38,14 +37,7 @@ class DeviceTrackerPlatform: 'get_scanner', 'async_setup_scanner', 'setup_scanner', - # Small steps, initially just legacy setup supported. - 'async_setup_entry' ) - # ENTITY_PLATFORM_SETUP = ( - # 'setup_platform', - # 'async_setup_platform', - # 'async_setup_entry' - # ) name = attr.ib(type=str) platform = attr.ib(type=ModuleType) @@ -56,7 +48,6 @@ class DeviceTrackerPlatform: """Return platform type.""" for methods, platform_type in ( (self.LEGACY_SETUP, PLATFORM_TYPE_LEGACY), - # (self.ENTITY_PLATFORM_SETUP, PLATFORM_TYPE_ENTITY), ): for meth in methods: if hasattr(self.platform, meth): @@ -83,9 +74,6 @@ class DeviceTrackerPlatform: setup = await hass.async_add_job( self.platform.setup_scanner, hass, self.config, tracker.see, discovery_info) - elif hasattr(self.platform, 'async_setup_entry'): - setup = await self.platform.async_setup_entry( - hass, self.config, tracker.async_see) else: raise HomeAssistantError( "Invalid legacy device_tracker platform.") @@ -106,7 +94,6 @@ class DeviceTrackerPlatform: async def async_extract_config(hass, config): """Extract device tracker config and split between legacy and modern.""" legacy = [] - entity_platform = [] for platform in await asyncio.gather(*[ async_create_platform_type(hass, config, p_type, p_config) @@ -115,15 +102,13 @@ async def async_extract_config(hass, config): if platform is None: continue - if platform.type == PLATFORM_TYPE_ENTITY: - entity_platform.append(platform) - elif platform.type == PLATFORM_TYPE_LEGACY: + if platform.type == PLATFORM_TYPE_LEGACY: legacy.append(platform) else: raise ValueError("Unable to determine type for {}: {}".format( platform.name, platform.type)) - return (legacy, entity_platform) + return legacy async def async_create_platform_type(hass, config, p_type, p_config) \ diff --git a/homeassistant/components/geofency/__init__.py b/homeassistant/components/geofency/__init__.py index e5698b997a4..944879788de 100644 --- a/homeassistant/components/geofency/__init__.py +++ b/homeassistant/components/geofency/__init__.py @@ -63,7 +63,11 @@ async def async_setup(hass, hass_config): """Set up the Geofency component.""" config = hass_config.get(DOMAIN, {}) mobile_beacons = config.get(CONF_MOBILE_BEACONS, []) - hass.data[DOMAIN] = [slugify(beacon) for beacon in mobile_beacons] + hass.data[DOMAIN] = { + 'beacons': [slugify(beacon) for beacon in mobile_beacons], + 'devices': set(), + 'unsub_device_tracker': {} + } return True @@ -77,7 +81,7 @@ async def handle_webhook(hass, webhook_id, request): status=HTTP_UNPROCESSABLE_ENTITY ) - if _is_mobile_beacon(data, hass.data[DOMAIN]): + if _is_mobile_beacon(data, hass.data[DOMAIN]['beacons']): return _set_location(hass, data, None) if data['entry'] == LOCATION_ENTRY: location_name = data['name'] @@ -128,7 +132,7 @@ async def async_setup_entry(hass, entry): async def async_unload_entry(hass, entry): """Unload a config entry.""" hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) - + hass.data[DOMAIN]['unsub_device_tracker'].pop(entry.entry_id)() await hass.config_entries.async_forward_entry_unload(entry, DEVICE_TRACKER) return True diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index abccf610f5e..e340272c966 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -1,35 +1,100 @@ """Support for the Geofency device tracker platform.""" import logging -from homeassistant.components.device_tracker import ( - DOMAIN as DEVICE_TRACKER_DOMAIN) +from homeassistant.core import callback +from homeassistant.components.device_tracker import SOURCE_TYPE_GPS +from homeassistant.components.device_tracker.config_entry import ( + DeviceTrackerEntity +) from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DOMAIN as GEOFENCY_DOMAIN, TRACKER_UPDATE +from . import DOMAIN as GF_DOMAIN, TRACKER_UPDATE _LOGGER = logging.getLogger(__name__) -DATA_KEY = '{}.{}'.format(GEOFENCY_DOMAIN, DEVICE_TRACKER_DOMAIN) - -async def async_setup_entry(hass, entry, async_see): - """Configure a dispatcher connection based on a config entry.""" - async def _set_location(device, gps, location_name, attributes): +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Geofency config entry.""" + @callback + def _receive_data(device, gps, location_name, attributes): """Fire HA event to set location.""" - await async_see( - dev_id=device, - gps=gps, - location_name=location_name, - attributes=attributes - ) + if device in hass.data[GF_DOMAIN]['devices']: + return + + hass.data[GF_DOMAIN]['devices'].add(device) + + async_add_entities([GeofencyEntity( + device, gps, location_name, attributes + )]) + + hass.data[GF_DOMAIN]['unsub_device_tracker'][config_entry.entry_id] = \ + async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) - hass.data[DATA_KEY] = async_dispatcher_connect( - hass, TRACKER_UPDATE, _set_location - ) return True -async def async_unload_entry(hass, entry): - """Unload the config entry and remove the dispatcher connection.""" - hass.data[DATA_KEY]() - return True +class GeofencyEntity(DeviceTrackerEntity): + """Represent a tracked device.""" + + def __init__(self, device, gps, location_name, attributes): + """Set up Geofency entity.""" + self._attributes = attributes + self._name = device + self._location_name = location_name + self._gps = gps + self._unsub_dispatcher = None + + @property + def device_state_attributes(self): + """Return device specific attributes.""" + return self._attributes + + @property + def latitude(self): + """Return latitude value of the device.""" + return self._gps[0] + + @property + def longitude(self): + """Return longitude value of the device.""" + return self._gps[1] + + @property + def location_name(self): + """Return a location name for the current location of the device.""" + return self._location_name + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def source_type(self): + """Return the source type, eg gps or router, of the device.""" + return SOURCE_TYPE_GPS + + async def async_added_to_hass(self): + """Register state update callback.""" + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, TRACKER_UPDATE, self._async_receive_data) + + async def async_will_remove_from_hass(self): + """Clean up after entity before removal.""" + self._unsub_dispatcher() + + @callback + def _async_receive_data(self, device, gps, location_name, attributes): + """Mark the device as seen.""" + if device != self.name: + return + + self._attributes.update(attributes) + self._location_name = location_name + self._gps = gps + self.async_write_ha_state() diff --git a/homeassistant/components/gpslogger/__init__.py b/homeassistant/components/gpslogger/__init__.py index 016de66e9fd..2123421334a 100644 --- a/homeassistant/components/gpslogger/__init__.py +++ b/homeassistant/components/gpslogger/__init__.py @@ -50,6 +50,10 @@ WEBHOOK_SCHEMA = vol.Schema({ async def async_setup(hass, hass_config): """Set up the GPSLogger component.""" + hass.data[DOMAIN] = { + 'devices': set(), + 'unsub_device_tracker': {}, + } return True @@ -98,7 +102,7 @@ async def async_setup_entry(hass, entry): async def async_unload_entry(hass, entry): """Unload a config entry.""" hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) - + hass.data[DOMAIN]['unsub_device_tracker'].pop(entry.entry_id)() await hass.config_entries.async_forward_entry_unload(entry, DEVICE_TRACKER) return True diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py index 67967821083..81a4fb3e7f8 100644 --- a/homeassistant/components/gpslogger/device_tracker.py +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -1,37 +1,109 @@ """Support for the GPSLogger device tracking.""" import logging -from homeassistant.components.device_tracker import ( - DOMAIN as DEVICE_TRACKER_DOMAIN) +from homeassistant.core import callback +from homeassistant.components.device_tracker import SOURCE_TYPE_GPS +from homeassistant.components.device_tracker.config_entry import ( + DeviceTrackerEntity +) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import HomeAssistantType -from . import DOMAIN as GPSLOGGER_DOMAIN, TRACKER_UPDATE +from . import DOMAIN as GPL_DOMAIN, TRACKER_UPDATE _LOGGER = logging.getLogger(__name__) -DATA_KEY = '{}.{}'.format(GPSLOGGER_DOMAIN, DEVICE_TRACKER_DOMAIN) - -async def async_setup_entry(hass: HomeAssistantType, entry, async_see): +async def async_setup_entry(hass: HomeAssistantType, entry, + async_add_entities): """Configure a dispatcher connection based on a config entry.""" - async def _set_location(device, gps_location, battery, accuracy, attrs): - """Fire HA event to set location.""" - await async_see( - dev_id=device, - gps=gps_location, - battery=battery, - gps_accuracy=accuracy, - attributes=attrs - ) + @callback + def _receive_data(device, gps, battery, accuracy, attrs): + """Receive set location.""" + if device in hass.data[GPL_DOMAIN]['devices']: + return - hass.data[DATA_KEY] = async_dispatcher_connect( - hass, TRACKER_UPDATE, _set_location - ) - return True + hass.data[GPL_DOMAIN]['devices'].add(device) + + async_add_entities([GPSLoggerEntity( + device, gps, battery, accuracy, attrs + )]) + + hass.data[GPL_DOMAIN]['unsub_device_tracker'][entry.entry_id] = \ + async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) -async def async_unload_entry(hass: HomeAssistantType, entry): - """Unload the config entry and remove the dispatcher connection.""" - hass.data[DATA_KEY]() - return True +class GPSLoggerEntity(DeviceTrackerEntity): + """Represent a tracked device.""" + + def __init__( + self, device, location, battery, accuracy, attributes): + """Set up Geofency entity.""" + self._accuracy = accuracy + self._attributes = attributes + self._name = device + self._battery = battery + self._location = location + self._unsub_dispatcher = None + + @property + def battery_level(self): + """Return battery value of the device.""" + return self._battery + + @property + def device_state_attributes(self): + """Return device specific attributes.""" + return self._attributes + + @property + def latitude(self): + """Return latitude value of the device.""" + return self._location[0] + + @property + def longitude(self): + """Return longitude value of the device.""" + return self._location[1] + + @property + def location_accuracy(self): + """Return the gps accuracy of the device.""" + return self._accuracy + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def source_type(self): + """Return the source type, eg gps or router, of the device.""" + return SOURCE_TYPE_GPS + + async def async_added_to_hass(self): + """Register state update callback.""" + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, TRACKER_UPDATE, self._async_receive_data) + + async def async_will_remove_from_hass(self): + """Clean up after entity before removal.""" + self._unsub_dispatcher() + + @callback + def _async_receive_data(self, device, location, battery, accuracy, + attributes): + """Mark the device as seen.""" + if device != self.name: + return + + self._location = location + self._battery = battery + self._accuracy = accuracy + self._attributes.update(attributes) + self.async_write_ha_state() diff --git a/homeassistant/components/icloud/device_tracker.py b/homeassistant/components/icloud/device_tracker.py index 573da5fce63..89de6e57f6e 100644 --- a/homeassistant/components/icloud/device_tracker.py +++ b/homeassistant/components/icloud/device_tracker.py @@ -10,12 +10,13 @@ from homeassistant.components.device_tracker import PLATFORM_SCHEMA from homeassistant.components.device_tracker.const import ( DOMAIN, ATTR_ATTRIBUTES, ENTITY_ID_FORMAT) from homeassistant.components.device_tracker.legacy import DeviceScanner -from homeassistant.components.zone.zone import active_zone +from homeassistant.components.zone import async_active_zone from homeassistant.helpers.event import track_utc_time_change import homeassistant.helpers.config_validation as cv from homeassistant.util import slugify import homeassistant.util.dt as dt_util from homeassistant.util.location import distance +from homeassistant.util.async_ import run_callback_threadsafe _LOGGER = logging.getLogger(__name__) @@ -330,7 +331,10 @@ class Icloud(DeviceScanner): def determine_interval(self, devicename, latitude, longitude, battery): """Calculate new interval.""" - currentzone = active_zone(self.hass, latitude, longitude) + currentzone = run_callback_threadsafe( + self.hass.loop, + async_active_zone, self.hass, latitude, longitude + ).result() if ((currentzone is not None and currentzone == self._overridestates.get(devicename)) or @@ -472,10 +476,13 @@ class Icloud(DeviceScanner): devicestate = self.hass.states.get(devid) if interval is not None: if devicestate is not None: - self._overridestates[device] = active_zone( + self._overridestates[device] = run_callback_threadsafe( + self.hass.loop, + async_active_zone, self.hass, float(devicestate.attributes.get('latitude', 0)), - float(devicestate.attributes.get('longitude', 0))) + float(devicestate.attributes.get('longitude', 0)) + ).result() if self._overridestates[device] is None: self._overridestates[device] = 'away' self._intervals[device] = interval diff --git a/homeassistant/components/locative/__init__.py b/homeassistant/components/locative/__init__.py index 66f917e5729..49502186d8e 100644 --- a/homeassistant/components/locative/__init__.py +++ b/homeassistant/components/locative/__init__.py @@ -49,6 +49,10 @@ WEBHOOK_SCHEMA = vol.All( async def async_setup(hass, hass_config): """Set up the Locative component.""" + hass.data[DOMAIN] = { + 'devices': set(), + 'unsub_device_tracker': {}, + } return True @@ -139,6 +143,7 @@ async def async_setup_entry(hass, entry): async def async_unload_entry(hass, entry): """Unload a config entry.""" hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) + hass.data[DOMAIN]['unsub_device_tracker'].pop(entry.entry_id)() await hass.config_entries.async_forward_entry_unload(entry, DEVICE_TRACKER) return True diff --git a/homeassistant/components/locative/device_tracker.py b/homeassistant/components/locative/device_tracker.py index 1e16bde58ad..6f86519c47c 100644 --- a/homeassistant/components/locative/device_tracker.py +++ b/homeassistant/components/locative/device_tracker.py @@ -1,35 +1,90 @@ """Support for the Locative platform.""" import logging -from homeassistant.components.device_tracker import ( - DOMAIN as DEVICE_TRACKER_DOMAIN) +from homeassistant.core import callback +from homeassistant.components.device_tracker import SOURCE_TYPE_GPS +from homeassistant.components.device_tracker.config_entry import ( + DeviceTrackerEntity +) from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.util import slugify -from . import DOMAIN as LOCATIVE_DOMAIN, TRACKER_UPDATE +from . import DOMAIN as LT_DOMAIN, TRACKER_UPDATE _LOGGER = logging.getLogger(__name__) -DATA_KEY = '{}.{}'.format(LOCATIVE_DOMAIN, DEVICE_TRACKER_DOMAIN) - -async def async_setup_entry(hass, entry, async_see): +async def async_setup_entry(hass, entry, async_add_entities): """Configure a dispatcher connection based on a config entry.""" - async def _set_location(device, gps_location, location_name): - """Fire HA event to set location.""" - await async_see( - dev_id=slugify(device), - gps=gps_location, - location_name=location_name - ) + @callback + def _receive_data(device, location, location_name): + """Receive set location.""" + if device in hass.data[LT_DOMAIN]['devices']: + return + + hass.data[LT_DOMAIN]['devices'].add(device) + + async_add_entities([LocativeEntity( + device, location, location_name + )]) + + hass.data[LT_DOMAIN]['unsub_device_tracker'][entry.entry_id] = \ + async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data) - hass.data[DATA_KEY] = async_dispatcher_connect( - hass, TRACKER_UPDATE, _set_location - ) return True -async def async_unload_entry(hass, entry): - """Unload the config entry and remove the dispatcher connection.""" - hass.data[DATA_KEY]() - return True +class LocativeEntity(DeviceTrackerEntity): + """Represent a tracked device.""" + + def __init__(self, device, location, location_name): + """Set up Locative entity.""" + self._name = device + self._location = location + self._location_name = location_name + self._unsub_dispatcher = None + + @property + def latitude(self): + """Return latitude value of the device.""" + return self._location[0] + + @property + def longitude(self): + """Return longitude value of the device.""" + return self._location[1] + + @property + def location_name(self): + """Return a location name for the current location of the device.""" + return self._location_name + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def source_type(self): + """Return the source type, eg gps or router, of the device.""" + return SOURCE_TYPE_GPS + + async def async_added_to_hass(self): + """Register state update callback.""" + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, TRACKER_UPDATE, self._async_receive_data) + + async def async_will_remove_from_hass(self): + """Clean up after entity before removal.""" + self._unsub_dispatcher() + + @callback + def _async_receive_data(self, device, location, location_name): + """Update device data.""" + self._location_name = location_name + self._location = location + self.async_write_ha_state() diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index 979f3829454..a4df4303fa8 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -15,6 +15,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.setup import async_when_setup from .config_flow import CONF_SECRET +from .messages import async_handle_message _LOGGER = logging.getLogger(__name__) @@ -50,7 +51,9 @@ CONFIG_SCHEMA = vol.Schema({ async def async_setup(hass, config): """Initialize OwnTracks component.""" hass.data[DOMAIN] = { - 'config': config[DOMAIN] + 'config': config[DOMAIN], + 'devices': {}, + 'unsub': None, } if not hass.config_entries.async_entries(DOMAIN): hass.async_create_task(hass.config_entries.flow.async_init( @@ -88,6 +91,10 @@ async def async_setup_entry(hass, entry): hass.async_create_task(hass.config_entries.async_forward_entry_setup( entry, 'device_tracker')) + hass.data[DOMAIN]['unsub'] = \ + hass.helpers.dispatcher.async_dispatcher_connect( + DOMAIN, async_handle_message) + return True @@ -96,6 +103,8 @@ async def async_unload_entry(hass, entry): hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) await hass.config_entries.async_forward_entry_unload( entry, 'device_tracker') + hass.data[DOMAIN]['unsub']() + return True @@ -213,11 +222,13 @@ class OwnTracksContext: return True - async def async_see(self, **data): + @callback + def async_see(self, **data): """Send a see message to the device tracker.""" raise NotImplementedError - async def async_see_beacons(self, hass, dev_id, kwargs_param): + @callback + def async_see_beacons(self, hass, dev_id, kwargs_param): """Set active beacons to the current location.""" kwargs = kwargs_param.copy() @@ -231,8 +242,13 @@ class OwnTracksContext: acc = device_tracker_state.attributes.get("gps_accuracy") lat = device_tracker_state.attributes.get("latitude") lon = device_tracker_state.attributes.get("longitude") - kwargs['gps_accuracy'] = acc - kwargs['gps'] = (lat, lon) + + if lat is not None and lon is not None: + kwargs['gps'] = (lat, lon) + kwargs['gps_accuracy'] = acc + else: + kwargs['gps'] = None + kwargs['gps_accuracy'] = None # the battery state applies to the tracking device, not the beacon # kwargs location is the beacon's configured lat/lon @@ -240,4 +256,4 @@ class OwnTracksContext: for beacon in self.mobile_beacons_active[dev_id]: kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) kwargs['host_name'] = beacon - await self.async_see(**kwargs) + self.async_see(**kwargs) diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index 999e883be19..fb9fedf26fa 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -1,351 +1,142 @@ """Device tracker platform that adds support for OwnTracks over MQTT.""" -import json import logging -from homeassistant.components import zone as zone_comp -from homeassistant.components.device_tracker import ( - ATTR_SOURCE_TYPE, SOURCE_TYPE_BLUETOOTH_LE, SOURCE_TYPE_GPS) -from homeassistant.const import STATE_HOME -from homeassistant.util import decorator, slugify - +from homeassistant.core import callback +from homeassistant.components.device_tracker.const import ENTITY_ID_FORMAT +from homeassistant.components.device_tracker.config_entry import ( + DeviceTrackerEntity +) from . import DOMAIN as OT_DOMAIN _LOGGER = logging.getLogger(__name__) -HANDLERS = decorator.Registry() - -async def async_setup_entry(hass, entry, async_see): +async def async_setup_entry(hass, entry, async_add_entities): """Set up OwnTracks based off an entry.""" - hass.data[OT_DOMAIN]['context'].async_see = async_see - hass.helpers.dispatcher.async_dispatcher_connect( - OT_DOMAIN, async_handle_message) + @callback + def _receive_data(dev_id, host_name, gps, attributes, gps_accuracy=None, + battery=None, source_type=None, location_name=None): + """Receive set location.""" + device = hass.data[OT_DOMAIN]['devices'].get(dev_id) + + if device is not None: + device.update_data( + host_name=host_name, + gps=gps, + attributes=attributes, + gps_accuracy=gps_accuracy, + battery=battery, + source_type=source_type, + location_name=location_name, + ) + return + + device = hass.data[OT_DOMAIN]['devices'][dev_id] = OwnTracksEntity( + dev_id=dev_id, + host_name=host_name, + gps=gps, + attributes=attributes, + gps_accuracy=gps_accuracy, + battery=battery, + source_type=source_type, + location_name=location_name, + ) + async_add_entities([device]) + + hass.data[OT_DOMAIN]['context'].async_see = _receive_data return True -def get_cipher(): - """Return decryption function and length of key. +class OwnTracksEntity(DeviceTrackerEntity): + """Represent a tracked device.""" - Async friendly. - """ - from nacl.secret import SecretBox - from nacl.encoding import Base64Encoder + def __init__(self, dev_id, host_name, gps, attributes, gps_accuracy, + battery, source_type, location_name): + """Set up OwnTracks entity.""" + self._dev_id = dev_id + self._host_name = host_name + self._gps = gps + self._gps_accuracy = gps_accuracy + self._location_name = location_name + self._attributes = attributes + self._battery = battery + self._source_type = source_type + self.entity_id = ENTITY_ID_FORMAT.format(dev_id) - def decrypt(ciphertext, key): - """Decrypt ciphertext using key.""" - return SecretBox(key).decrypt(ciphertext, encoder=Base64Encoder) - return (SecretBox.KEY_SIZE, decrypt) + @property + def unique_id(self): + """Return the unique ID.""" + return self._dev_id + @property + def battery_level(self): + """Return the battery level of the device.""" + return self._battery -def _parse_topic(topic, subscribe_topic): - """Parse an MQTT topic {sub_topic}/user/dev, return (user, dev) tuple. + @property + def device_state_attributes(self): + """Return device specific attributes.""" + return self._attributes - Async friendly. - """ - subscription = subscribe_topic.split('/') - try: - user_index = subscription.index('#') - except ValueError: - _LOGGER.error("Can't parse subscription topic: '%s'", subscribe_topic) - raise + @property + def location_accuracy(self): + """Return the gps accuracy of the device.""" + return self._gps_accuracy - topic_list = topic.split('/') - try: - user, device = topic_list[user_index], topic_list[user_index + 1] - except IndexError: - _LOGGER.error("Can't parse topic: '%s'", topic) - raise + @property + def latitude(self): + """Return latitude value of the device.""" + if self._gps is not None: + return self._gps[0] - return user, device - - -def _parse_see_args(message, subscribe_topic): - """Parse the OwnTracks location parameters, into the format see expects. - - Async friendly. - """ - user, device = _parse_topic(message['topic'], subscribe_topic) - dev_id = slugify('{}_{}'.format(user, device)) - kwargs = { - 'dev_id': dev_id, - 'host_name': user, - 'gps': (message['lat'], message['lon']), - 'attributes': {} - } - if 'acc' in message: - kwargs['gps_accuracy'] = message['acc'] - if 'batt' in message: - kwargs['battery'] = message['batt'] - if 'vel' in message: - kwargs['attributes']['velocity'] = message['vel'] - if 'tid' in message: - kwargs['attributes']['tid'] = message['tid'] - if 'addr' in message: - kwargs['attributes']['address'] = message['addr'] - if 'cog' in message: - kwargs['attributes']['course'] = message['cog'] - if 't' in message: - if message['t'] == 'c': - kwargs['attributes'][ATTR_SOURCE_TYPE] = SOURCE_TYPE_GPS - if message['t'] == 'b': - kwargs['attributes'][ATTR_SOURCE_TYPE] = SOURCE_TYPE_BLUETOOTH_LE - - return dev_id, kwargs - - -def _set_gps_from_zone(kwargs, location, zone): - """Set the see parameters from the zone parameters. - - Async friendly. - """ - if zone is not None: - kwargs['gps'] = ( - zone.attributes['latitude'], - zone.attributes['longitude']) - kwargs['gps_accuracy'] = zone.attributes['radius'] - kwargs['location_name'] = location - return kwargs - - -def _decrypt_payload(secret, topic, ciphertext): - """Decrypt encrypted payload.""" - try: - keylen, decrypt = get_cipher() - except OSError: - _LOGGER.warning( - "Ignoring encrypted payload because libsodium not installed") return None - if isinstance(secret, dict): - key = secret.get(topic) - else: - key = secret + @property + def longitude(self): + """Return longitude value of the device.""" + if self._gps is not None: + return self._gps[1] - if key is None: - _LOGGER.warning( - "Ignoring encrypted payload because no decryption key known " - "for topic %s", topic) return None - key = key.encode("utf-8") - key = key[:keylen] - key = key.ljust(keylen, b'\0') + @property + def location_name(self): + """Return a location name for the current location of the device.""" + return self._location_name - try: - message = decrypt(ciphertext, key) - message = message.decode("utf-8") - _LOGGER.debug("Decrypted payload: %s", message) - return message - except ValueError: - _LOGGER.warning( - "Ignoring encrypted payload because unable to decrypt using " - "key for topic %s", topic) - return None + @property + def name(self): + """Return the name of the device.""" + return self._host_name + @property + def should_poll(self): + """No polling needed.""" + return False -@HANDLERS.register('location') -async def async_handle_location_message(hass, context, message): - """Handle a location message.""" - if not context.async_valid_accuracy(message): - return + @property + def source_type(self): + """Return the source type, eg gps or router, of the device.""" + return self._source_type - if context.events_only: - _LOGGER.debug("Location update ignored due to events_only setting") - return + @property + def device_info(self): + """Return the device info.""" + return { + 'name': self._host_name, + 'identifiers': {(OT_DOMAIN, self._dev_id)}, + } - dev_id, kwargs = _parse_see_args(message, context.mqtt_topic) + @callback + def update_data(self, host_name, gps, attributes, gps_accuracy, + battery, source_type, location_name): + """Mark the device as seen.""" + self._host_name = host_name + self._gps = gps + self._gps_accuracy = gps_accuracy + self._location_name = location_name + self._attributes = attributes + self._battery = battery + self._source_type = source_type - if context.regions_entered[dev_id]: - _LOGGER.debug( - "Location update ignored, inside region %s", - context.regions_entered[-1]) - return - - await context.async_see(**kwargs) - await context.async_see_beacons(hass, dev_id, kwargs) - - -async def _async_transition_message_enter(hass, context, message, location): - """Execute enter event.""" - zone = hass.states.get("zone.{}".format(slugify(location))) - dev_id, kwargs = _parse_see_args(message, context.mqtt_topic) - - if zone is None and message.get('t') == 'b': - # Not a HA zone, and a beacon so mobile beacon. - # kwargs will contain the lat/lon of the beacon - # which is not where the beacon actually is - # and is probably set to 0/0 - beacons = context.mobile_beacons_active[dev_id] - if location not in beacons: - beacons.add(location) - _LOGGER.info("Added beacon %s", location) - await context.async_see_beacons(hass, dev_id, kwargs) - else: - # Normal region - regions = context.regions_entered[dev_id] - if location not in regions: - regions.append(location) - _LOGGER.info("Enter region %s", location) - _set_gps_from_zone(kwargs, location, zone) - await context.async_see(**kwargs) - await context.async_see_beacons(hass, dev_id, kwargs) - - -async def _async_transition_message_leave(hass, context, message, location): - """Execute leave event.""" - dev_id, kwargs = _parse_see_args(message, context.mqtt_topic) - regions = context.regions_entered[dev_id] - - if location in regions: - regions.remove(location) - - beacons = context.mobile_beacons_active[dev_id] - if location in beacons: - beacons.remove(location) - _LOGGER.info("Remove beacon %s", location) - await context.async_see_beacons(hass, dev_id, kwargs) - else: - new_region = regions[-1] if regions else None - if new_region: - # Exit to previous region - zone = hass.states.get( - "zone.{}".format(slugify(new_region))) - _set_gps_from_zone(kwargs, new_region, zone) - _LOGGER.info("Exit to %s", new_region) - await context.async_see(**kwargs) - await context.async_see_beacons(hass, dev_id, kwargs) - return - - _LOGGER.info("Exit to GPS") - - # Check for GPS accuracy - if context.async_valid_accuracy(message): - await context.async_see(**kwargs) - await context.async_see_beacons(hass, dev_id, kwargs) - - -@HANDLERS.register('transition') -async def async_handle_transition_message(hass, context, message): - """Handle a transition message.""" - if message.get('desc') is None: - _LOGGER.error( - "Location missing from `Entering/Leaving` message - " - "please turn `Share` on in OwnTracks app") - return - # OwnTracks uses - at the start of a beacon zone - # to switch on 'hold mode' - ignore this - location = message['desc'].lstrip("-") - - # Create a layer of indirection for Owntracks instances that may name - # regions differently than their HA names - if location in context.region_mapping: - location = context.region_mapping[location] - - if location.lower() == 'home': - location = STATE_HOME - - if message['event'] == 'enter': - await _async_transition_message_enter( - hass, context, message, location) - elif message['event'] == 'leave': - await _async_transition_message_leave( - hass, context, message, location) - else: - _LOGGER.error( - "Misformatted mqtt msgs, _type=transition, event=%s", - message['event']) - - -async def async_handle_waypoint(hass, name_base, waypoint): - """Handle a waypoint.""" - name = waypoint['desc'] - pretty_name = '{} - {}'.format(name_base, name) - lat = waypoint['lat'] - lon = waypoint['lon'] - rad = waypoint['rad'] - - # check zone exists - entity_id = zone_comp.ENTITY_ID_FORMAT.format(slugify(pretty_name)) - - # Check if state already exists - if hass.states.get(entity_id) is not None: - return - - zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad, - zone_comp.ICON_IMPORT, False) - zone.entity_id = entity_id - await zone.async_update_ha_state() - - -@HANDLERS.register('waypoint') -@HANDLERS.register('waypoints') -async def async_handle_waypoints_message(hass, context, message): - """Handle a waypoints message.""" - if not context.import_waypoints: - return - - if context.waypoint_whitelist is not None: - user = _parse_topic(message['topic'], context.mqtt_topic)[0] - - if user not in context.waypoint_whitelist: - return - - if 'waypoints' in message: - wayps = message['waypoints'] - else: - wayps = [message] - - _LOGGER.info("Got %d waypoints from %s", len(wayps), message['topic']) - - name_base = ' '.join(_parse_topic(message['topic'], context.mqtt_topic)) - - for wayp in wayps: - await async_handle_waypoint(hass, name_base, wayp) - - -@HANDLERS.register('encrypted') -async def async_handle_encrypted_message(hass, context, message): - """Handle an encrypted message.""" - if 'topic' not in message and isinstance(context.secret, dict): - _LOGGER.error("You cannot set per topic secrets when using HTTP") - return - - plaintext_payload = _decrypt_payload(context.secret, message.get('topic'), - message['data']) - - if plaintext_payload is None: - return - - decrypted = json.loads(plaintext_payload) - if 'topic' in message and 'topic' not in decrypted: - decrypted['topic'] = message['topic'] - - await async_handle_message(hass, context, decrypted) - - -@HANDLERS.register('lwt') -@HANDLERS.register('configuration') -@HANDLERS.register('beacon') -@HANDLERS.register('cmd') -@HANDLERS.register('steps') -@HANDLERS.register('card') -async def async_handle_not_impl_msg(hass, context, message): - """Handle valid but not implemented message types.""" - _LOGGER.debug('Not handling %s message: %s', message.get("_type"), message) - - -async def async_handle_unsupported_msg(hass, context, message): - """Handle an unsupported or invalid message type.""" - _LOGGER.warning('Received unsupported message type: %s.', - message.get('_type')) - - -async def async_handle_message(hass, context, message): - """Handle an OwnTracks message.""" - msgtype = message.get('_type') - - _LOGGER.debug("Received %s", message) - - handler = HANDLERS.get(msgtype, async_handle_unsupported_msg) - - await handler(hass, context, message) + self.async_write_ha_state() diff --git a/homeassistant/components/owntracks/messages.py b/homeassistant/components/owntracks/messages.py new file mode 100644 index 00000000000..7eac2148013 --- /dev/null +++ b/homeassistant/components/owntracks/messages.py @@ -0,0 +1,348 @@ +"""OwnTracks Message handlers.""" +import json +import logging + +from homeassistant.components import zone as zone_comp +from homeassistant.components.device_tracker import ( + SOURCE_TYPE_GPS, SOURCE_TYPE_BLUETOOTH_LE +) + +from homeassistant.const import STATE_HOME +from homeassistant.util import decorator, slugify + + +_LOGGER = logging.getLogger(__name__) + +HANDLERS = decorator.Registry() + + +def get_cipher(): + """Return decryption function and length of key. + + Async friendly. + """ + from nacl.secret import SecretBox + from nacl.encoding import Base64Encoder + + def decrypt(ciphertext, key): + """Decrypt ciphertext using key.""" + return SecretBox(key).decrypt(ciphertext, encoder=Base64Encoder) + return (SecretBox.KEY_SIZE, decrypt) + + +def _parse_topic(topic, subscribe_topic): + """Parse an MQTT topic {sub_topic}/user/dev, return (user, dev) tuple. + + Async friendly. + """ + subscription = subscribe_topic.split('/') + try: + user_index = subscription.index('#') + except ValueError: + _LOGGER.error("Can't parse subscription topic: '%s'", subscribe_topic) + raise + + topic_list = topic.split('/') + try: + user, device = topic_list[user_index], topic_list[user_index + 1] + except IndexError: + _LOGGER.error("Can't parse topic: '%s'", topic) + raise + + return user, device + + +def _parse_see_args(message, subscribe_topic): + """Parse the OwnTracks location parameters, into the format see expects. + + Async friendly. + """ + user, device = _parse_topic(message['topic'], subscribe_topic) + dev_id = slugify('{}_{}'.format(user, device)) + kwargs = { + 'dev_id': dev_id, + 'host_name': user, + 'attributes': {} + } + if message['lat'] is not None and message['lon'] is not None: + kwargs['gps'] = (message['lat'], message['lon']) + else: + kwargs['gps'] = None + + if 'acc' in message: + kwargs['gps_accuracy'] = message['acc'] + if 'batt' in message: + kwargs['battery'] = message['batt'] + if 'vel' in message: + kwargs['attributes']['velocity'] = message['vel'] + if 'tid' in message: + kwargs['attributes']['tid'] = message['tid'] + if 'addr' in message: + kwargs['attributes']['address'] = message['addr'] + if 'cog' in message: + kwargs['attributes']['course'] = message['cog'] + if 't' in message: + if message['t'] in ('c', 'u'): + kwargs['source_type'] = SOURCE_TYPE_GPS + if message['t'] == 'b': + kwargs['source_type'] = SOURCE_TYPE_BLUETOOTH_LE + + return dev_id, kwargs + + +def _set_gps_from_zone(kwargs, location, zone): + """Set the see parameters from the zone parameters. + + Async friendly. + """ + if zone is not None: + kwargs['gps'] = ( + zone.attributes['latitude'], + zone.attributes['longitude']) + kwargs['gps_accuracy'] = zone.attributes['radius'] + kwargs['location_name'] = location + return kwargs + + +def _decrypt_payload(secret, topic, ciphertext): + """Decrypt encrypted payload.""" + try: + keylen, decrypt = get_cipher() + except OSError: + _LOGGER.warning( + "Ignoring encrypted payload because libsodium not installed") + return None + + if isinstance(secret, dict): + key = secret.get(topic) + else: + key = secret + + if key is None: + _LOGGER.warning( + "Ignoring encrypted payload because no decryption key known " + "for topic %s", topic) + return None + + key = key.encode("utf-8") + key = key[:keylen] + key = key.ljust(keylen, b'\0') + + try: + message = decrypt(ciphertext, key) + message = message.decode("utf-8") + _LOGGER.debug("Decrypted payload: %s", message) + return message + except ValueError: + _LOGGER.warning( + "Ignoring encrypted payload because unable to decrypt using " + "key for topic %s", topic) + return None + + +@HANDLERS.register('location') +async def async_handle_location_message(hass, context, message): + """Handle a location message.""" + if not context.async_valid_accuracy(message): + return + + if context.events_only: + _LOGGER.debug("Location update ignored due to events_only setting") + return + + dev_id, kwargs = _parse_see_args(message, context.mqtt_topic) + + if context.regions_entered[dev_id]: + _LOGGER.debug( + "Location update ignored, inside region %s", + context.regions_entered[-1]) + return + + context.async_see(**kwargs) + context.async_see_beacons(hass, dev_id, kwargs) + + +async def _async_transition_message_enter(hass, context, message, location): + """Execute enter event.""" + zone = hass.states.get("zone.{}".format(slugify(location))) + dev_id, kwargs = _parse_see_args(message, context.mqtt_topic) + + if zone is None and message.get('t') == 'b': + # Not a HA zone, and a beacon so mobile beacon. + # kwargs will contain the lat/lon of the beacon + # which is not where the beacon actually is + # and is probably set to 0/0 + beacons = context.mobile_beacons_active[dev_id] + if location not in beacons: + beacons.add(location) + _LOGGER.info("Added beacon %s", location) + context.async_see_beacons(hass, dev_id, kwargs) + else: + # Normal region + regions = context.regions_entered[dev_id] + if location not in regions: + regions.append(location) + _LOGGER.info("Enter region %s", location) + _set_gps_from_zone(kwargs, location, zone) + context.async_see(**kwargs) + context.async_see_beacons(hass, dev_id, kwargs) + + +async def _async_transition_message_leave(hass, context, message, location): + """Execute leave event.""" + dev_id, kwargs = _parse_see_args(message, context.mqtt_topic) + regions = context.regions_entered[dev_id] + + if location in regions: + regions.remove(location) + + beacons = context.mobile_beacons_active[dev_id] + if location in beacons: + beacons.remove(location) + _LOGGER.info("Remove beacon %s", location) + context.async_see_beacons(hass, dev_id, kwargs) + else: + new_region = regions[-1] if regions else None + if new_region: + # Exit to previous region + zone = hass.states.get( + "zone.{}".format(slugify(new_region))) + _set_gps_from_zone(kwargs, new_region, zone) + _LOGGER.info("Exit to %s", new_region) + context.async_see(**kwargs) + context.async_see_beacons(hass, dev_id, kwargs) + return + + _LOGGER.info("Exit to GPS") + + # Check for GPS accuracy + if context.async_valid_accuracy(message): + context.async_see(**kwargs) + context.async_see_beacons(hass, dev_id, kwargs) + + +@HANDLERS.register('transition') +async def async_handle_transition_message(hass, context, message): + """Handle a transition message.""" + if message.get('desc') is None: + _LOGGER.error( + "Location missing from `Entering/Leaving` message - " + "please turn `Share` on in OwnTracks app") + return + # OwnTracks uses - at the start of a beacon zone + # to switch on 'hold mode' - ignore this + location = message['desc'].lstrip("-") + + # Create a layer of indirection for Owntracks instances that may name + # regions differently than their HA names + if location in context.region_mapping: + location = context.region_mapping[location] + + if location.lower() == 'home': + location = STATE_HOME + + if message['event'] == 'enter': + await _async_transition_message_enter( + hass, context, message, location) + elif message['event'] == 'leave': + await _async_transition_message_leave( + hass, context, message, location) + else: + _LOGGER.error( + "Misformatted mqtt msgs, _type=transition, event=%s", + message['event']) + + +async def async_handle_waypoint(hass, name_base, waypoint): + """Handle a waypoint.""" + name = waypoint['desc'] + pretty_name = '{} - {}'.format(name_base, name) + lat = waypoint['lat'] + lon = waypoint['lon'] + rad = waypoint['rad'] + + # check zone exists + entity_id = zone_comp.ENTITY_ID_FORMAT.format(slugify(pretty_name)) + + # Check if state already exists + if hass.states.get(entity_id) is not None: + return + + zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad, + zone_comp.ICON_IMPORT, False) + zone.entity_id = entity_id + await zone.async_update_ha_state() + + +@HANDLERS.register('waypoint') +@HANDLERS.register('waypoints') +async def async_handle_waypoints_message(hass, context, message): + """Handle a waypoints message.""" + if not context.import_waypoints: + return + + if context.waypoint_whitelist is not None: + user = _parse_topic(message['topic'], context.mqtt_topic)[0] + + if user not in context.waypoint_whitelist: + return + + if 'waypoints' in message: + wayps = message['waypoints'] + else: + wayps = [message] + + _LOGGER.info("Got %d waypoints from %s", len(wayps), message['topic']) + + name_base = ' '.join(_parse_topic(message['topic'], context.mqtt_topic)) + + for wayp in wayps: + await async_handle_waypoint(hass, name_base, wayp) + + +@HANDLERS.register('encrypted') +async def async_handle_encrypted_message(hass, context, message): + """Handle an encrypted message.""" + if 'topic' not in message and isinstance(context.secret, dict): + _LOGGER.error("You cannot set per topic secrets when using HTTP") + return + + plaintext_payload = _decrypt_payload(context.secret, message.get('topic'), + message['data']) + + if plaintext_payload is None: + return + + decrypted = json.loads(plaintext_payload) + if 'topic' in message and 'topic' not in decrypted: + decrypted['topic'] = message['topic'] + + await async_handle_message(hass, context, decrypted) + + +@HANDLERS.register('lwt') +@HANDLERS.register('configuration') +@HANDLERS.register('beacon') +@HANDLERS.register('cmd') +@HANDLERS.register('steps') +@HANDLERS.register('card') +async def async_handle_not_impl_msg(hass, context, message): + """Handle valid but not implemented message types.""" + _LOGGER.debug('Not handling %s message: %s', message.get("_type"), message) + + +async def async_handle_unsupported_msg(hass, context, message): + """Handle an unsupported or invalid message type.""" + _LOGGER.warning('Received unsupported message type: %s.', + message.get('_type')) + + +async def async_handle_message(hass, context, message): + """Handle an OwnTracks message.""" + msgtype = message.get('_type') + + _LOGGER.debug("Received %s", message) + + handler = HANDLERS.get(msgtype, async_handle_unsupported_msg) + + await handler(hass, context, message) diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 242f0362088..0340964561c 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -3,15 +3,19 @@ import logging import voluptuous as vol +from homeassistant.loader import bind_hass import homeassistant.helpers.config_validation as cv from homeassistant.const import ( CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_ICON, CONF_RADIUS) from homeassistant.helpers import config_per_platform from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.util import slugify +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.util.location import distance + from .config_flow import configured_zones -from .const import CONF_PASSIVE, DOMAIN, HOME_ZONE +from .const import CONF_PASSIVE, DOMAIN, HOME_ZONE, ATTR_PASSIVE, ATTR_RADIUS from .zone import Zone _LOGGER = logging.getLogger(__name__) @@ -37,6 +41,40 @@ PLATFORM_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) +@bind_hass +def async_active_zone(hass, latitude, longitude, radius=0): + """Find the active zone for given latitude, longitude. + + This method must be run in the event loop. + """ + # Sort entity IDs so that we are deterministic if equal distance to 2 zones + zones = (hass.states.get(entity_id) for entity_id + in sorted(hass.states.async_entity_ids(DOMAIN))) + + min_dist = None + closest = None + + for zone in zones: + if zone.attributes.get(ATTR_PASSIVE): + continue + + zone_dist = distance( + latitude, longitude, + zone.attributes[ATTR_LATITUDE], zone.attributes[ATTR_LONGITUDE]) + + within_zone = zone_dist - radius < zone.attributes[ATTR_RADIUS] + closer_zone = closest is None or zone_dist < min_dist + smaller_zone = (zone_dist == min_dist and + zone.attributes[ATTR_RADIUS] < + closest.attributes[ATTR_RADIUS]) + + if within_zone and (closer_zone or smaller_zone): + min_dist = zone_dist + closest = zone + + return closest + + async def async_setup(hass, config): """Set up configured zones as well as home assistant zone if necessary.""" hass.data[DOMAIN] = {} diff --git a/homeassistant/components/zone/const.py b/homeassistant/components/zone/const.py index b69ba67302a..676104b6943 100644 --- a/homeassistant/components/zone/const.py +++ b/homeassistant/components/zone/const.py @@ -3,3 +3,5 @@ CONF_PASSIVE = 'passive' DOMAIN = 'zone' HOME_ZONE = 'home' +ATTR_PASSIVE = 'passive' +ATTR_RADIUS = 'radius' diff --git a/homeassistant/components/zone/zone.py b/homeassistant/components/zone/zone.py index 21084e18f06..20155e06311 100644 --- a/homeassistant/components/zone/zone.py +++ b/homeassistant/components/zone/zone.py @@ -1,60 +1,13 @@ """Zone entity and functionality.""" from homeassistant.const import ATTR_HIDDEN, ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.helpers.entity import Entity -from homeassistant.loader import bind_hass -from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.location import distance -from .const import DOMAIN - -ATTR_PASSIVE = 'passive' -ATTR_RADIUS = 'radius' +from .const import ATTR_PASSIVE, ATTR_RADIUS STATE = 'zoning' -@bind_hass -def active_zone(hass, latitude, longitude, radius=0): - """Find the active zone for given latitude, longitude.""" - return run_callback_threadsafe( - hass.loop, async_active_zone, hass, latitude, longitude, radius - ).result() - - -@bind_hass -def async_active_zone(hass, latitude, longitude, radius=0): - """Find the active zone for given latitude, longitude. - - This method must be run in the event loop. - """ - # Sort entity IDs so that we are deterministic if equal distance to 2 zones - zones = (hass.states.get(entity_id) for entity_id - in sorted(hass.states.async_entity_ids(DOMAIN))) - - min_dist = None - closest = None - - for zone in zones: - if zone.attributes.get(ATTR_PASSIVE): - continue - - zone_dist = distance( - latitude, longitude, - zone.attributes[ATTR_LATITUDE], zone.attributes[ATTR_LONGITUDE]) - - within_zone = zone_dist - radius < zone.attributes[ATTR_RADIUS] - closer_zone = closest is None or zone_dist < min_dist - smaller_zone = (zone_dist == min_dist and - zone.attributes[ATTR_RADIUS] < - closest.attributes[ATTR_RADIUS]) - - if within_zone and (closer_zone or smaller_zone): - min_dist = zone_dist - closest = zone - - return closest - - def in_zone(zone, latitude, longitude, radius=0) -> bool: """Test if given latitude, longitude is in given zone. diff --git a/tests/components/gpslogger/test_init.py b/tests/components/gpslogger/test_init.py index 608456d44db..2cffa86f393 100644 --- a/tests/components/gpslogger/test_init.py +++ b/tests/components/gpslogger/test_init.py @@ -165,7 +165,7 @@ async def test_enter_with_attrs(hass, gpslogger_client, webhook_id): data['device'])) assert state.state == STATE_NOT_HOME assert state.attributes['gps_accuracy'] == 10.5 - assert state.attributes['battery'] == 10.0 + assert state.attributes['battery_level'] == 10.0 assert state.attributes['speed'] == 100.0 assert state.attributes['direction'] == 105.32 assert state.attributes['altitude'] == 102.0 diff --git a/tests/components/owntracks/test_device_tracker.py b/tests/components/owntracks/test_device_tracker.py index 8e868296703..b81f434a2c1 100644 --- a/tests/components/owntracks/test_device_tracker.py +++ b/tests/components/owntracks/test_device_tracker.py @@ -861,10 +861,9 @@ async def test_event_beacon_unknown_zone_no_location(hass, context): # the Device during test case setup. assert_location_state(hass, 'None') - # home is the state of a Device constructed through - # the normal code path on it's first observation with - # the conditions I pass along. - assert_mobile_tracker_state(hass, 'home', 'unknown') + # We have had no location yet, so the beacon status + # set to unknown. + assert_mobile_tracker_state(hass, 'unknown', 'unknown') async def test_event_beacon_unknown_zone(hass, context): @@ -1276,7 +1275,7 @@ async def test_single_waypoint_import(hass, context): async def test_not_implemented_message(hass, context): """Handle not implemented message type.""" patch_handler = patch('homeassistant.components.owntracks.' - 'device_tracker.async_handle_not_impl_msg', + 'messages.async_handle_not_impl_msg', return_value=mock_coro(False)) patch_handler.start() assert not await send_message(hass, LWT_TOPIC, LWT_MESSAGE) @@ -1286,7 +1285,7 @@ async def test_not_implemented_message(hass, context): async def test_unsupported_message(hass, context): """Handle not implemented message type.""" patch_handler = patch('homeassistant.components.owntracks.' - 'device_tracker.async_handle_unsupported_msg', + 'messages.async_handle_unsupported_msg', return_value=mock_coro(False)) patch_handler.start() assert not await send_message(hass, BAD_TOPIC, BAD_MESSAGE) @@ -1374,7 +1373,7 @@ def config_context(hass, setup_comp): patch_save.stop() -@patch('homeassistant.components.owntracks.device_tracker.get_cipher', +@patch('homeassistant.components.owntracks.messages.get_cipher', mock_cipher) async def test_encrypted_payload(hass, setup_comp): """Test encrypted payload.""" @@ -1385,7 +1384,7 @@ async def test_encrypted_payload(hass, setup_comp): assert_location_latitude(hass, LOCATION_MESSAGE['lat']) -@patch('homeassistant.components.owntracks.device_tracker.get_cipher', +@patch('homeassistant.components.owntracks.messages.get_cipher', mock_cipher) async def test_encrypted_payload_topic_key(hass, setup_comp): """Test encrypted payload with a topic key.""" @@ -1398,7 +1397,7 @@ async def test_encrypted_payload_topic_key(hass, setup_comp): assert_location_latitude(hass, LOCATION_MESSAGE['lat']) -@patch('homeassistant.components.owntracks.device_tracker.get_cipher', +@patch('homeassistant.components.owntracks.messages.get_cipher', mock_cipher) async def test_encrypted_payload_no_key(hass, setup_comp): """Test encrypted payload with no key, .""" @@ -1411,7 +1410,7 @@ async def test_encrypted_payload_no_key(hass, setup_comp): assert hass.states.get(DEVICE_TRACKER_STATE) is None -@patch('homeassistant.components.owntracks.device_tracker.get_cipher', +@patch('homeassistant.components.owntracks.messages.get_cipher', mock_cipher) async def test_encrypted_payload_wrong_key(hass, setup_comp): """Test encrypted payload with wrong key.""" @@ -1422,7 +1421,7 @@ async def test_encrypted_payload_wrong_key(hass, setup_comp): assert hass.states.get(DEVICE_TRACKER_STATE) is None -@patch('homeassistant.components.owntracks.device_tracker.get_cipher', +@patch('homeassistant.components.owntracks.messages.get_cipher', mock_cipher) async def test_encrypted_payload_wrong_topic_key(hass, setup_comp): """Test encrypted payload with wrong topic key.""" @@ -1435,7 +1434,7 @@ async def test_encrypted_payload_wrong_topic_key(hass, setup_comp): assert hass.states.get(DEVICE_TRACKER_STATE) is None -@patch('homeassistant.components.owntracks.device_tracker.get_cipher', +@patch('homeassistant.components.owntracks.messages.get_cipher', mock_cipher) async def test_encrypted_payload_no_topic_key(hass, setup_comp): """Test encrypted payload with no topic key.""" diff --git a/tests/components/zone/test_init.py b/tests/components/zone/test_init.py index ba98915e777..576be0ce03c 100644 --- a/tests/components/zone/test_init.py +++ b/tests/components/zone/test_init.py @@ -142,7 +142,7 @@ class TestComponentZone(unittest.TestCase): ] }) self.hass.block_till_done() - active = zone.zone.active_zone(self.hass, 32.880600, -117.237561) + active = zone.async_active_zone(self.hass, 32.880600, -117.237561) assert active is None def test_active_zone_skips_passive_zones_2(self): @@ -158,7 +158,7 @@ class TestComponentZone(unittest.TestCase): ] }) self.hass.block_till_done() - active = zone.zone.active_zone(self.hass, 32.880700, -117.237561) + active = zone.async_active_zone(self.hass, 32.880700, -117.237561) assert 'zone.active_zone' == active.entity_id def test_active_zone_prefers_smaller_zone_if_same_distance(self): @@ -182,7 +182,7 @@ class TestComponentZone(unittest.TestCase): ] }) - active = zone.zone.active_zone(self.hass, latitude, longitude) + active = zone.async_active_zone(self.hass, latitude, longitude) assert 'zone.small_zone' == active.entity_id def test_active_zone_prefers_smaller_zone_if_same_distance_2(self): @@ -200,7 +200,7 @@ class TestComponentZone(unittest.TestCase): ] }) - active = zone.zone.active_zone(self.hass, latitude, longitude) + active = zone.async_active_zone(self.hass, latitude, longitude) assert 'zone.smallest_zone' == active.entity_id def test_in_zone_works_for_passive_zones(self):