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 testpull/24117/head
parent
144b530045
commit
e6d7f6ed71
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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) \
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
|
@ -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] = {}
|
||||
|
|
|
@ -3,3 +3,5 @@
|
|||
CONF_PASSIVE = 'passive'
|
||||
DOMAIN = 'zone'
|
||||
HOME_ZONE = 'home'
|
||||
ATTR_PASSIVE = 'passive'
|
||||
ATTR_RADIUS = 'radius'
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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):
|
||||
|
|
Loading…
Reference in New Issue