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
pull/24117/head
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 import voluptuous as vol
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.loader import bind_hass from homeassistant.loader import bind_hass
from homeassistant.components import group from homeassistant.components import group
from homeassistant.config import config_without_domain
from homeassistant.helpers import discovery from homeassistant.helpers import discovery
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import GPSType, ConfigType, HomeAssistantType 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 homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME
from . import legacy, setup 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 .legacy import DeviceScanner # noqa # pylint: disable=unused-import
from .const import ( from .const import (
ATTR_ATTRIBUTES, ATTR_ATTRIBUTES,
@ -35,9 +36,7 @@ from .const import (
DEFAULT_CONSIDER_HOME, DEFAULT_CONSIDER_HOME,
DEFAULT_TRACK_NEW, DEFAULT_TRACK_NEW,
DOMAIN, DOMAIN,
LOGGER,
PLATFORM_TYPE_LEGACY, PLATFORM_TYPE_LEGACY,
SCAN_INTERVAL,
SOURCE_TYPE_BLUETOOTH_LE, SOURCE_TYPE_BLUETOOTH_LE,
SOURCE_TYPE_BLUETOOTH, SOURCE_TYPE_BLUETOOTH,
SOURCE_TYPE_GPS, SOURCE_TYPE_GPS,
@ -113,36 +112,13 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType):
"""Set up the device tracker.""" """Set up the device tracker."""
tracker = await legacy.get_tracker(hass, config) tracker = await legacy.get_tracker(hass, config)
async def setup_entry_helper(entry): legacy_platforms = await setup.async_extract_config(hass, config)
"""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)
setup_tasks = [ setup_tasks = [
legacy_platform.async_setup_legacy(hass, tracker) legacy_platform.async_setup_legacy(hass, tracker)
for legacy_platform in legacy_platforms 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: if setup_tasks:
await asyncio.wait(setup_tasks) await asyncio.wait(setup_tasks)
@ -178,8 +154,3 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType):
# restore # restore
await tracker.async_setup_tracked_device() await tracker.async_setup_tracked_device()
return True 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 ( from homeassistant.components.group import (
ATTR_ADD_ENTITIES, ATTR_ENTITIES, ATTR_OBJECT_ID, ATTR_VISIBLE, ATTR_ADD_ENTITIES, ATTR_ENTITIES, ATTR_OBJECT_ID, ATTR_VISIBLE,
DOMAIN as DOMAIN_GROUP, SERVICE_SET) 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.config import load_yaml_config_file, async_log_exception
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv

View File

@ -20,7 +20,6 @@ from homeassistant.const import (
from .const import ( from .const import (
DOMAIN, DOMAIN,
PLATFORM_TYPE_ENTITY,
PLATFORM_TYPE_LEGACY, PLATFORM_TYPE_LEGACY,
CONF_SCAN_INTERVAL, CONF_SCAN_INTERVAL,
SCAN_INTERVAL, SCAN_INTERVAL,
@ -38,14 +37,7 @@ class DeviceTrackerPlatform:
'get_scanner', 'get_scanner',
'async_setup_scanner', 'async_setup_scanner',
'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) name = attr.ib(type=str)
platform = attr.ib(type=ModuleType) platform = attr.ib(type=ModuleType)
@ -56,7 +48,6 @@ class DeviceTrackerPlatform:
"""Return platform type.""" """Return platform type."""
for methods, platform_type in ( for methods, platform_type in (
(self.LEGACY_SETUP, PLATFORM_TYPE_LEGACY), (self.LEGACY_SETUP, PLATFORM_TYPE_LEGACY),
# (self.ENTITY_PLATFORM_SETUP, PLATFORM_TYPE_ENTITY),
): ):
for meth in methods: for meth in methods:
if hasattr(self.platform, meth): if hasattr(self.platform, meth):
@ -83,9 +74,6 @@ class DeviceTrackerPlatform:
setup = await hass.async_add_job( setup = await hass.async_add_job(
self.platform.setup_scanner, hass, self.config, self.platform.setup_scanner, hass, self.config,
tracker.see, discovery_info) 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: else:
raise HomeAssistantError( raise HomeAssistantError(
"Invalid legacy device_tracker platform.") "Invalid legacy device_tracker platform.")
@ -106,7 +94,6 @@ class DeviceTrackerPlatform:
async def async_extract_config(hass, config): async def async_extract_config(hass, config):
"""Extract device tracker config and split between legacy and modern.""" """Extract device tracker config and split between legacy and modern."""
legacy = [] legacy = []
entity_platform = []
for platform in await asyncio.gather(*[ for platform in await asyncio.gather(*[
async_create_platform_type(hass, config, p_type, p_config) 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: if platform is None:
continue continue
if platform.type == PLATFORM_TYPE_ENTITY: if platform.type == PLATFORM_TYPE_LEGACY:
entity_platform.append(platform)
elif platform.type == PLATFORM_TYPE_LEGACY:
legacy.append(platform) legacy.append(platform)
else: else:
raise ValueError("Unable to determine type for {}: {}".format( raise ValueError("Unable to determine type for {}: {}".format(
platform.name, platform.type)) platform.name, platform.type))
return (legacy, entity_platform) return legacy
async def async_create_platform_type(hass, config, p_type, p_config) \ 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.""" """Set up the Geofency component."""
config = hass_config.get(DOMAIN, {}) config = hass_config.get(DOMAIN, {})
mobile_beacons = config.get(CONF_MOBILE_BEACONS, []) 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 return True
@ -77,7 +81,7 @@ async def handle_webhook(hass, webhook_id, request):
status=HTTP_UNPROCESSABLE_ENTITY 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) return _set_location(hass, data, None)
if data['entry'] == LOCATION_ENTRY: if data['entry'] == LOCATION_ENTRY:
location_name = data['name'] location_name = data['name']
@ -128,7 +132,7 @@ async def async_setup_entry(hass, entry):
async def async_unload_entry(hass, entry): async def async_unload_entry(hass, entry):
"""Unload a config entry.""" """Unload a config entry."""
hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) 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) await hass.config_entries.async_forward_entry_unload(entry, DEVICE_TRACKER)
return True return True

View File

@ -1,35 +1,100 @@
"""Support for the Geofency device tracker platform.""" """Support for the Geofency device tracker platform."""
import logging import logging
from homeassistant.components.device_tracker import ( from homeassistant.core import callback
DOMAIN as DEVICE_TRACKER_DOMAIN) 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.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__) _LOGGER = logging.getLogger(__name__)
DATA_KEY = '{}.{}'.format(GEOFENCY_DOMAIN, DEVICE_TRACKER_DOMAIN)
async def async_setup_entry(hass, config_entry, async_add_entities):
async def async_setup_entry(hass, entry, async_see): """Set up Geofency config entry."""
"""Configure a dispatcher connection based on a config entry.""" @callback
async def _set_location(device, gps, location_name, attributes): def _receive_data(device, gps, location_name, attributes):
"""Fire HA event to set location.""" """Fire HA event to set location."""
await async_see( if device in hass.data[GF_DOMAIN]['devices']:
dev_id=device, return
gps=gps,
location_name=location_name, hass.data[GF_DOMAIN]['devices'].add(device)
attributes=attributes
) 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 return True
async def async_unload_entry(hass, entry): class GeofencyEntity(DeviceTrackerEntity):
"""Unload the config entry and remove the dispatcher connection.""" """Represent a tracked device."""
hass.data[DATA_KEY]()
return True 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): async def async_setup(hass, hass_config):
"""Set up the GPSLogger component.""" """Set up the GPSLogger component."""
hass.data[DOMAIN] = {
'devices': set(),
'unsub_device_tracker': {},
}
return True return True
@ -98,7 +102,7 @@ async def async_setup_entry(hass, entry):
async def async_unload_entry(hass, entry): async def async_unload_entry(hass, entry):
"""Unload a config entry.""" """Unload a config entry."""
hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) 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) await hass.config_entries.async_forward_entry_unload(entry, DEVICE_TRACKER)
return True return True

View File

@ -1,37 +1,109 @@
"""Support for the GPSLogger device tracking.""" """Support for the GPSLogger device tracking."""
import logging import logging
from homeassistant.components.device_tracker import ( from homeassistant.core import callback
DOMAIN as DEVICE_TRACKER_DOMAIN) 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.dispatcher import async_dispatcher_connect
from homeassistant.helpers.typing import HomeAssistantType 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__) _LOGGER = logging.getLogger(__name__)
DATA_KEY = '{}.{}'.format(GPSLOGGER_DOMAIN, DEVICE_TRACKER_DOMAIN)
async def async_setup_entry(hass: HomeAssistantType, entry,
async def async_setup_entry(hass: HomeAssistantType, entry, async_see): async_add_entities):
"""Configure a dispatcher connection based on a config entry.""" """Configure a dispatcher connection based on a config entry."""
async def _set_location(device, gps_location, battery, accuracy, attrs): @callback
"""Fire HA event to set location.""" def _receive_data(device, gps, battery, accuracy, attrs):
await async_see( """Receive set location."""
dev_id=device, if device in hass.data[GPL_DOMAIN]['devices']:
gps=gps_location, return
battery=battery,
gps_accuracy=accuracy,
attributes=attrs
)
hass.data[DATA_KEY] = async_dispatcher_connect( hass.data[GPL_DOMAIN]['devices'].add(device)
hass, TRACKER_UPDATE, _set_location
) async_add_entities([GPSLoggerEntity(
return True 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): class GPSLoggerEntity(DeviceTrackerEntity):
"""Unload the config entry and remove the dispatcher connection.""" """Represent a tracked device."""
hass.data[DATA_KEY]()
return True 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 ( from homeassistant.components.device_tracker.const import (
DOMAIN, ATTR_ATTRIBUTES, ENTITY_ID_FORMAT) DOMAIN, ATTR_ATTRIBUTES, ENTITY_ID_FORMAT)
from homeassistant.components.device_tracker.legacy import DeviceScanner 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 from homeassistant.helpers.event import track_utc_time_change
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.util import slugify from homeassistant.util import slugify
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from homeassistant.util.location import distance from homeassistant.util.location import distance
from homeassistant.util.async_ import run_callback_threadsafe
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -330,7 +331,10 @@ class Icloud(DeviceScanner):
def determine_interval(self, devicename, latitude, longitude, battery): def determine_interval(self, devicename, latitude, longitude, battery):
"""Calculate new interval.""" """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 if ((currentzone is not None and
currentzone == self._overridestates.get(devicename)) or currentzone == self._overridestates.get(devicename)) or
@ -472,10 +476,13 @@ class Icloud(DeviceScanner):
devicestate = self.hass.states.get(devid) devicestate = self.hass.states.get(devid)
if interval is not None: if interval is not None:
if devicestate 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, self.hass,
float(devicestate.attributes.get('latitude', 0)), 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: if self._overridestates[device] is None:
self._overridestates[device] = 'away' self._overridestates[device] = 'away'
self._intervals[device] = interval self._intervals[device] = interval

View File

@ -49,6 +49,10 @@ WEBHOOK_SCHEMA = vol.All(
async def async_setup(hass, hass_config): async def async_setup(hass, hass_config):
"""Set up the Locative component.""" """Set up the Locative component."""
hass.data[DOMAIN] = {
'devices': set(),
'unsub_device_tracker': {},
}
return True return True
@ -139,6 +143,7 @@ async def async_setup_entry(hass, entry):
async def async_unload_entry(hass, entry): async def async_unload_entry(hass, entry):
"""Unload a config entry.""" """Unload a config entry."""
hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) 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) await hass.config_entries.async_forward_entry_unload(entry, DEVICE_TRACKER)
return True return True

View File

@ -1,35 +1,90 @@
"""Support for the Locative platform.""" """Support for the Locative platform."""
import logging import logging
from homeassistant.components.device_tracker import ( from homeassistant.core import callback
DOMAIN as DEVICE_TRACKER_DOMAIN) 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.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__) _LOGGER = logging.getLogger(__name__)
DATA_KEY = '{}.{}'.format(LOCATIVE_DOMAIN, DEVICE_TRACKER_DOMAIN)
async def async_setup_entry(hass, entry, async_add_entities):
async def async_setup_entry(hass, entry, async_see):
"""Configure a dispatcher connection based on a config entry.""" """Configure a dispatcher connection based on a config entry."""
async def _set_location(device, gps_location, location_name): @callback
"""Fire HA event to set location.""" def _receive_data(device, location, location_name):
await async_see( """Receive set location."""
dev_id=slugify(device), if device in hass.data[LT_DOMAIN]['devices']:
gps=gps_location, return
location_name=location_name
) 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 return True
async def async_unload_entry(hass, entry): class LocativeEntity(DeviceTrackerEntity):
"""Unload the config entry and remove the dispatcher connection.""" """Represent a tracked device."""
hass.data[DATA_KEY]()
return True 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 homeassistant.setup import async_when_setup
from .config_flow import CONF_SECRET from .config_flow import CONF_SECRET
from .messages import async_handle_message
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -50,7 +51,9 @@ CONFIG_SCHEMA = vol.Schema({
async def async_setup(hass, config): async def async_setup(hass, config):
"""Initialize OwnTracks component.""" """Initialize OwnTracks component."""
hass.data[DOMAIN] = { hass.data[DOMAIN] = {
'config': config[DOMAIN] 'config': config[DOMAIN],
'devices': {},
'unsub': None,
} }
if not hass.config_entries.async_entries(DOMAIN): if not hass.config_entries.async_entries(DOMAIN):
hass.async_create_task(hass.config_entries.flow.async_init( 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( hass.async_create_task(hass.config_entries.async_forward_entry_setup(
entry, 'device_tracker')) entry, 'device_tracker'))
hass.data[DOMAIN]['unsub'] = \
hass.helpers.dispatcher.async_dispatcher_connect(
DOMAIN, async_handle_message)
return True return True
@ -96,6 +103,8 @@ async def async_unload_entry(hass, entry):
hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID])
await hass.config_entries.async_forward_entry_unload( await hass.config_entries.async_forward_entry_unload(
entry, 'device_tracker') entry, 'device_tracker')
hass.data[DOMAIN]['unsub']()
return True return True
@ -213,11 +222,13 @@ class OwnTracksContext:
return True return True
async def async_see(self, **data): @callback
def async_see(self, **data):
"""Send a see message to the device tracker.""" """Send a see message to the device tracker."""
raise NotImplementedError 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.""" """Set active beacons to the current location."""
kwargs = kwargs_param.copy() kwargs = kwargs_param.copy()
@ -231,8 +242,13 @@ class OwnTracksContext:
acc = device_tracker_state.attributes.get("gps_accuracy") acc = device_tracker_state.attributes.get("gps_accuracy")
lat = device_tracker_state.attributes.get("latitude") lat = device_tracker_state.attributes.get("latitude")
lon = device_tracker_state.attributes.get("longitude") 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 # the battery state applies to the tracking device, not the beacon
# kwargs location is the beacon's configured lat/lon # kwargs location is the beacon's configured lat/lon
@ -240,4 +256,4 @@ class OwnTracksContext:
for beacon in self.mobile_beacons_active[dev_id]: for beacon in self.mobile_beacons_active[dev_id]:
kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon)
kwargs['host_name'] = 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.""" """Device tracker platform that adds support for OwnTracks over MQTT."""
import json
import logging import logging
from homeassistant.components import zone as zone_comp from homeassistant.core import callback
from homeassistant.components.device_tracker import ( from homeassistant.components.device_tracker.const import ENTITY_ID_FORMAT
ATTR_SOURCE_TYPE, SOURCE_TYPE_BLUETOOTH_LE, SOURCE_TYPE_GPS) from homeassistant.components.device_tracker.config_entry import (
from homeassistant.const import STATE_HOME DeviceTrackerEntity
from homeassistant.util import decorator, slugify )
from . import DOMAIN as OT_DOMAIN from . import DOMAIN as OT_DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
HANDLERS = decorator.Registry()
async def async_setup_entry(hass, entry, async_add_entities):
async def async_setup_entry(hass, entry, async_see):
"""Set up OwnTracks based off an entry.""" """Set up OwnTracks based off an entry."""
hass.data[OT_DOMAIN]['context'].async_see = async_see @callback
hass.helpers.dispatcher.async_dispatcher_connect( def _receive_data(dev_id, host_name, gps, attributes, gps_accuracy=None,
OT_DOMAIN, async_handle_message) 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 return True
def get_cipher(): class OwnTracksEntity(DeviceTrackerEntity):
"""Return decryption function and length of key. """Represent a tracked device."""
Async friendly. def __init__(self, dev_id, host_name, gps, attributes, gps_accuracy,
""" battery, source_type, location_name):
from nacl.secret import SecretBox """Set up OwnTracks entity."""
from nacl.encoding import Base64Encoder 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): @property
"""Decrypt ciphertext using key.""" def unique_id(self):
return SecretBox(key).decrypt(ciphertext, encoder=Base64Encoder) """Return the unique ID."""
return (SecretBox.KEY_SIZE, decrypt) 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): @property
"""Parse an MQTT topic {sub_topic}/user/dev, return (user, dev) tuple. def device_state_attributes(self):
"""Return device specific attributes."""
return self._attributes
Async friendly. @property
""" def location_accuracy(self):
subscription = subscribe_topic.split('/') """Return the gps accuracy of the device."""
try: return self._gps_accuracy
user_index = subscription.index('#')
except ValueError:
_LOGGER.error("Can't parse subscription topic: '%s'", subscribe_topic)
raise
topic_list = topic.split('/') @property
try: def latitude(self):
user, device = topic_list[user_index], topic_list[user_index + 1] """Return latitude value of the device."""
except IndexError: if self._gps is not None:
_LOGGER.error("Can't parse topic: '%s'", topic) return self._gps[0]
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,
'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 return None
if isinstance(secret, dict): @property
key = secret.get(topic) def longitude(self):
else: """Return longitude value of the device."""
key = secret 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 return None
key = key.encode("utf-8") @property
key = key[:keylen] def location_name(self):
key = key.ljust(keylen, b'\0') """Return a location name for the current location of the device."""
return self._location_name
try: @property
message = decrypt(ciphertext, key) def name(self):
message = message.decode("utf-8") """Return the name of the device."""
_LOGGER.debug("Decrypted payload: %s", message) return self._host_name
return message
except ValueError:
_LOGGER.warning(
"Ignoring encrypted payload because unable to decrypt using "
"key for topic %s", topic)
return None
@property
def should_poll(self):
"""No polling needed."""
return False
@HANDLERS.register('location') @property
async def async_handle_location_message(hass, context, message): def source_type(self):
"""Handle a location message.""" """Return the source type, eg gps or router, of the device."""
if not context.async_valid_accuracy(message): return self._source_type
return
if context.events_only: @property
_LOGGER.debug("Location update ignored due to events_only setting") def device_info(self):
return """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]: self.async_write_ha_state()
_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)

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 import voluptuous as vol
from homeassistant.loader import bind_hass
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.const import ( from homeassistant.const import (
CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_ICON, CONF_RADIUS) CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_ICON, CONF_RADIUS)
from homeassistant.helpers import config_per_platform from homeassistant.helpers import config_per_platform
from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.util import slugify 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 .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 from .zone import Zone
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -37,6 +41,40 @@ PLATFORM_SCHEMA = vol.Schema({
}, extra=vol.ALLOW_EXTRA) }, 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): async def async_setup(hass, config):
"""Set up configured zones as well as home assistant zone if necessary.""" """Set up configured zones as well as home assistant zone if necessary."""
hass.data[DOMAIN] = {} hass.data[DOMAIN] = {}

View File

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

View File

@ -1,60 +1,13 @@
"""Zone entity and functionality.""" """Zone entity and functionality."""
from homeassistant.const import ATTR_HIDDEN, ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.const import ATTR_HIDDEN, ATTR_LATITUDE, ATTR_LONGITUDE
from homeassistant.helpers.entity import Entity 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 homeassistant.util.location import distance
from .const import DOMAIN from .const import ATTR_PASSIVE, ATTR_RADIUS
ATTR_PASSIVE = 'passive'
ATTR_RADIUS = 'radius'
STATE = 'zoning' 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: def in_zone(zone, latitude, longitude, radius=0) -> bool:
"""Test if given latitude, longitude is in given zone. """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'])) data['device']))
assert state.state == STATE_NOT_HOME assert state.state == STATE_NOT_HOME
assert state.attributes['gps_accuracy'] == 10.5 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['speed'] == 100.0
assert state.attributes['direction'] == 105.32 assert state.attributes['direction'] == 105.32
assert state.attributes['altitude'] == 102.0 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. # the Device during test case setup.
assert_location_state(hass, 'None') assert_location_state(hass, 'None')
# home is the state of a Device constructed through # We have had no location yet, so the beacon status
# the normal code path on it's first observation with # set to unknown.
# the conditions I pass along. assert_mobile_tracker_state(hass, 'unknown', 'unknown')
assert_mobile_tracker_state(hass, 'home', 'unknown')
async def test_event_beacon_unknown_zone(hass, context): 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): async def test_not_implemented_message(hass, context):
"""Handle not implemented message type.""" """Handle not implemented message type."""
patch_handler = patch('homeassistant.components.owntracks.' patch_handler = patch('homeassistant.components.owntracks.'
'device_tracker.async_handle_not_impl_msg', 'messages.async_handle_not_impl_msg',
return_value=mock_coro(False)) return_value=mock_coro(False))
patch_handler.start() patch_handler.start()
assert not await send_message(hass, LWT_TOPIC, LWT_MESSAGE) 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): async def test_unsupported_message(hass, context):
"""Handle not implemented message type.""" """Handle not implemented message type."""
patch_handler = patch('homeassistant.components.owntracks.' patch_handler = patch('homeassistant.components.owntracks.'
'device_tracker.async_handle_unsupported_msg', 'messages.async_handle_unsupported_msg',
return_value=mock_coro(False)) return_value=mock_coro(False))
patch_handler.start() patch_handler.start()
assert not await send_message(hass, BAD_TOPIC, BAD_MESSAGE) assert not await send_message(hass, BAD_TOPIC, BAD_MESSAGE)
@ -1374,7 +1373,7 @@ def config_context(hass, setup_comp):
patch_save.stop() patch_save.stop()
@patch('homeassistant.components.owntracks.device_tracker.get_cipher', @patch('homeassistant.components.owntracks.messages.get_cipher',
mock_cipher) mock_cipher)
async def test_encrypted_payload(hass, setup_comp): async def test_encrypted_payload(hass, setup_comp):
"""Test encrypted payload.""" """Test encrypted payload."""
@ -1385,7 +1384,7 @@ async def test_encrypted_payload(hass, setup_comp):
assert_location_latitude(hass, LOCATION_MESSAGE['lat']) assert_location_latitude(hass, LOCATION_MESSAGE['lat'])
@patch('homeassistant.components.owntracks.device_tracker.get_cipher', @patch('homeassistant.components.owntracks.messages.get_cipher',
mock_cipher) mock_cipher)
async def test_encrypted_payload_topic_key(hass, setup_comp): async def test_encrypted_payload_topic_key(hass, setup_comp):
"""Test encrypted payload with a topic key.""" """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']) assert_location_latitude(hass, LOCATION_MESSAGE['lat'])
@patch('homeassistant.components.owntracks.device_tracker.get_cipher', @patch('homeassistant.components.owntracks.messages.get_cipher',
mock_cipher) mock_cipher)
async def test_encrypted_payload_no_key(hass, setup_comp): async def test_encrypted_payload_no_key(hass, setup_comp):
"""Test encrypted payload with no key, .""" """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 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) mock_cipher)
async def test_encrypted_payload_wrong_key(hass, setup_comp): async def test_encrypted_payload_wrong_key(hass, setup_comp):
"""Test encrypted payload with wrong key.""" """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 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) mock_cipher)
async def test_encrypted_payload_wrong_topic_key(hass, setup_comp): async def test_encrypted_payload_wrong_topic_key(hass, setup_comp):
"""Test encrypted payload with wrong topic key.""" """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 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) mock_cipher)
async def test_encrypted_payload_no_topic_key(hass, setup_comp): async def test_encrypted_payload_no_topic_key(hass, setup_comp):
"""Test encrypted payload with no topic key.""" """Test encrypted payload with no topic key."""

View File

@ -142,7 +142,7 @@ class TestComponentZone(unittest.TestCase):
] ]
}) })
self.hass.block_till_done() 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 assert active is None
def test_active_zone_skips_passive_zones_2(self): def test_active_zone_skips_passive_zones_2(self):
@ -158,7 +158,7 @@ class TestComponentZone(unittest.TestCase):
] ]
}) })
self.hass.block_till_done() 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 assert 'zone.active_zone' == active.entity_id
def test_active_zone_prefers_smaller_zone_if_same_distance(self): 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 assert 'zone.small_zone' == active.entity_id
def test_active_zone_prefers_smaller_zone_if_same_distance_2(self): 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 assert 'zone.smallest_zone' == active.entity_id
def test_in_zone_works_for_passive_zones(self): def test_in_zone_works_for_passive_zones(self):