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
This commit is contained in:
Paulus Schoutsen 2019-05-25 13:34:53 -07:00 committed by GitHub
parent 144b530045
commit e6d7f6ed71
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 946 additions and 517 deletions

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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) \

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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()

View File

@ -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)

View File

@ -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] = {}

View File

@ -3,3 +3,5 @@
CONF_PASSIVE = 'passive'
DOMAIN = 'zone'
HOME_ZONE = 'home'
ATTR_PASSIVE = 'passive'
ATTR_RADIUS = 'radius'

View File

@ -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.

View File

@ -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

View File

@ -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."""

View File

@ -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):