New scanner device tracker and ZHA device tracker support (#24584)

* initial implementation for zha device trackers

* constant

* review comments

* Revert "review comments"

This reverts commit 2130823566820dfc114dbeda08fcdf76ed47a4e7.

* rename device tracker entity

* update trackers

* raise when not implemented

* Update homeassistant/components/device_tracker/config_entry.py

Review comment

Co-Authored-By: Martin Hjelmare <marhje52@kth.se>

* move source type to base state attrs

* review comments

* review comments

* review comments

* fix super call

* fix battery and use last seen from device

* add test

* cleanup and add more to test

* cleanup post zha entity removal PR

* add tests for base entities

* rework entity tests
This commit is contained in:
David F. Mulcahey 2019-07-04 06:44:40 -04:00 committed by Martin Hjelmare
parent e824c553ca
commit 3c487928d4
12 changed files with 359 additions and 27 deletions

View File

@ -37,7 +37,7 @@ async def async_unload_entry(hass, entry):
return await hass.data[DOMAIN].async_unload_entry(entry) return await hass.data[DOMAIN].async_unload_entry(entry)
class DeviceTrackerEntity(Entity): class BaseTrackerEntity(Entity):
"""Represent a tracked device.""" """Represent a tracked device."""
@property @property
@ -48,6 +48,27 @@ class DeviceTrackerEntity(Entity):
""" """
return None return None
@property
def source_type(self):
"""Return the source type, eg gps or router, of the device."""
raise NotImplementedError
@property
def state_attributes(self):
"""Return the device state attributes."""
attr = {
ATTR_SOURCE_TYPE: self.source_type
}
if self.battery_level:
attr[ATTR_BATTERY_LEVEL] = self.battery_level
return attr
class TrackerEntity(BaseTrackerEntity):
"""Represent a tracked device."""
@property @property
def location_accuracy(self): def location_accuracy(self):
"""Return the location accuracy of the device. """Return the location accuracy of the device.
@ -71,11 +92,6 @@ class DeviceTrackerEntity(Entity):
"""Return longitude value of the device.""" """Return longitude value of the device."""
return NotImplementedError return NotImplementedError
@property
def source_type(self):
"""Return the source type, eg gps or router, of the device."""
raise NotImplementedError
@property @property
def state(self): def state(self):
"""Return the state of the device.""" """Return the state of the device."""
@ -99,16 +115,27 @@ class DeviceTrackerEntity(Entity):
@property @property
def state_attributes(self): def state_attributes(self):
"""Return the device state attributes.""" """Return the device state attributes."""
attr = { attr = {}
ATTR_SOURCE_TYPE: self.source_type attr.update(super().state_attributes)
}
if self.latitude is not None: if self.latitude is not None:
attr[ATTR_LATITUDE] = self.latitude attr[ATTR_LATITUDE] = self.latitude
attr[ATTR_LONGITUDE] = self.longitude attr[ATTR_LONGITUDE] = self.longitude
attr[ATTR_GPS_ACCURACY] = self.location_accuracy attr[ATTR_GPS_ACCURACY] = self.location_accuracy
if self.battery_level:
attr[ATTR_BATTERY_LEVEL] = self.battery_level
return attr return attr
class ScannerEntity(BaseTrackerEntity):
"""Represent a tracked device that is on a scanned network."""
@property
def state(self):
"""Return the state of the device."""
if self.is_connected:
return STATE_HOME
return STATE_NOT_HOME
@property
def is_connected(self):
"""Return true if the device is connected to the network."""
raise NotImplementedError

View File

@ -8,7 +8,7 @@ from homeassistant.const import (
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.components.device_tracker import SOURCE_TYPE_GPS from homeassistant.components.device_tracker import SOURCE_TYPE_GPS
from homeassistant.components.device_tracker.config_entry import ( from homeassistant.components.device_tracker.config_entry import (
DeviceTrackerEntity TrackerEntity
) )
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.restore_state import RestoreEntity
@ -52,7 +52,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
return True return True
class GeofencyEntity(DeviceTrackerEntity, RestoreEntity): class GeofencyEntity(TrackerEntity, RestoreEntity):
"""Represent a tracked device.""" """Represent a tracked device."""
def __init__(self, device, gps=None, location_name=None, attributes=None): def __init__(self, device, gps=None, location_name=None, attributes=None):

View File

@ -10,7 +10,7 @@ from homeassistant.const import (
) )
from homeassistant.components.device_tracker import SOURCE_TYPE_GPS from homeassistant.components.device_tracker import SOURCE_TYPE_GPS
from homeassistant.components.device_tracker.config_entry import ( from homeassistant.components.device_tracker.config_entry import (
DeviceTrackerEntity TrackerEntity
) )
from homeassistant.helpers import device_registry from homeassistant.helpers import device_registry
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
@ -67,7 +67,7 @@ async def async_setup_entry(hass: HomeAssistantType, entry,
async_add_entities(entities) async_add_entities(entities)
class GPSLoggerEntity(DeviceTrackerEntity, RestoreEntity): class GPSLoggerEntity(TrackerEntity, RestoreEntity):
"""Represent a tracked device.""" """Represent a tracked device."""
def __init__( def __init__(

View File

@ -4,7 +4,7 @@ import logging
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.components.device_tracker import SOURCE_TYPE_GPS from homeassistant.components.device_tracker import SOURCE_TYPE_GPS
from homeassistant.components.device_tracker.config_entry import ( from homeassistant.components.device_tracker.config_entry import (
DeviceTrackerEntity TrackerEntity
) )
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
@ -33,7 +33,7 @@ async def async_setup_entry(hass, entry, async_add_entities):
return True return True
class LocativeEntity(DeviceTrackerEntity): class LocativeEntity(TrackerEntity):
"""Represent a tracked device.""" """Represent a tracked device."""
def __init__(self, device, location, location_name): def __init__(self, device, location, location_name):

View File

@ -9,7 +9,7 @@ from homeassistant.const import (
) )
from homeassistant.components.device_tracker.const import SOURCE_TYPE_GPS from homeassistant.components.device_tracker.const import SOURCE_TYPE_GPS
from homeassistant.components.device_tracker.config_entry import ( from homeassistant.components.device_tracker.config_entry import (
DeviceTrackerEntity TrackerEntity
) )
from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.restore_state import RestoreEntity
from .const import ( from .const import (
@ -44,7 +44,7 @@ async def async_setup_entry(hass, entry, async_add_entities):
return True return True
class MobileAppEntity(DeviceTrackerEntity, RestoreEntity): class MobileAppEntity(TrackerEntity, RestoreEntity):
"""Represent a tracked device.""" """Represent a tracked device."""
def __init__(self, entry, data=None): def __init__(self, entry, data=None):

View File

@ -11,7 +11,7 @@ from homeassistant.const import (
from homeassistant.components.device_tracker.const import ( from homeassistant.components.device_tracker.const import (
ENTITY_ID_FORMAT, ATTR_SOURCE_TYPE, SOURCE_TYPE_GPS) ENTITY_ID_FORMAT, ATTR_SOURCE_TYPE, SOURCE_TYPE_GPS)
from homeassistant.components.device_tracker.config_entry import ( from homeassistant.components.device_tracker.config_entry import (
DeviceTrackerEntity TrackerEntity
) )
from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers import device_registry from homeassistant.helpers import device_registry
@ -62,7 +62,7 @@ async def async_setup_entry(hass, entry, async_add_entities):
return True return True
class OwnTracksEntity(DeviceTrackerEntity, RestoreEntity): class OwnTracksEntity(TrackerEntity, RestoreEntity):
"""Represent a tracked device.""" """Represent a tracked device."""
def __init__(self, dev_id, data=None): def __init__(self, dev_id, data=None):

View File

@ -3,6 +3,7 @@ import enum
import logging import logging
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR
from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER
from homeassistant.components.fan import DOMAIN as FAN from homeassistant.components.fan import DOMAIN as FAN
from homeassistant.components.light import DOMAIN as LIGHT from homeassistant.components.light import DOMAIN as LIGHT
from homeassistant.components.lock import DOMAIN as LOCK from homeassistant.components.lock import DOMAIN as LOCK
@ -25,6 +26,7 @@ ZHA_DISCOVERY_NEW = 'zha_discovery_new_{}'
COMPONENTS = ( COMPONENTS = (
BINARY_SENSOR, BINARY_SENSOR,
DEVICE_TRACKER,
FAN, FAN,
LIGHT, LIGHT,
LOCK, LOCK,

View File

@ -6,6 +6,7 @@ https://home-assistant.io/components/zha/
""" """
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR
from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER
from homeassistant.components.fan import DOMAIN as FAN from homeassistant.components.fan import DOMAIN as FAN
from homeassistant.components.light import DOMAIN as LIGHT from homeassistant.components.light import DOMAIN as LIGHT
from homeassistant.components.lock import DOMAIN as LOCK from homeassistant.components.lock import DOMAIN as LOCK
@ -21,8 +22,9 @@ from .const import (
CONTROLLER, BATTERY CONTROLLER, BATTERY
) )
SMARTTHINGS_HUMIDITY_CLUSTER = 64581 SMARTTHINGS_HUMIDITY_CLUSTER = 0xFC45
SMARTTHINGS_ACCELERATION_CLUSTER = 64514 SMARTTHINGS_ACCELERATION_CLUSTER = 0xFC02
SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE = 0x8000
DEVICE_CLASS = {} DEVICE_CLASS = {}
SINGLE_INPUT_CLUSTER_DEVICE_CLASS = {} SINGLE_INPUT_CLUSTER_DEVICE_CLASS = {}
@ -39,12 +41,14 @@ OUTPUT_CHANNEL_ONLY_CLUSTERS = []
BINDABLE_CLUSTERS = [] BINDABLE_CLUSTERS = []
INPUT_BIND_ONLY_CLUSTERS = [] INPUT_BIND_ONLY_CLUSTERS = []
BINARY_SENSOR_CLUSTERS = set() BINARY_SENSOR_CLUSTERS = set()
DEVICE_TRACKER_CLUSTERS = set()
LIGHT_CLUSTERS = set() LIGHT_CLUSTERS = set()
SWITCH_CLUSTERS = set() SWITCH_CLUSTERS = set()
COMPONENT_CLUSTERS = { COMPONENT_CLUSTERS = {
BINARY_SENSOR: BINARY_SENSOR_CLUSTERS, BINARY_SENSOR: BINARY_SENSOR_CLUSTERS,
LIGHT: LIGHT_CLUSTERS, LIGHT: LIGHT_CLUSTERS,
SWITCH: SWITCH_CLUSTERS SWITCH: SWITCH_CLUSTERS,
DEVICE_TRACKER: DEVICE_TRACKER_CLUSTERS
} }
@ -134,7 +138,8 @@ def establish_device_mappings():
zha.DeviceType.ON_OFF_PLUG_IN_UNIT: SWITCH, zha.DeviceType.ON_OFF_PLUG_IN_UNIT: SWITCH,
zha.DeviceType.DIMMABLE_PLUG_IN_UNIT: LIGHT, zha.DeviceType.DIMMABLE_PLUG_IN_UNIT: LIGHT,
zha.DeviceType.COLOR_TEMPERATURE_LIGHT: LIGHT, zha.DeviceType.COLOR_TEMPERATURE_LIGHT: LIGHT,
zha.DeviceType.EXTENDED_COLOR_LIGHT: LIGHT zha.DeviceType.EXTENDED_COLOR_LIGHT: LIGHT,
SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE: DEVICE_TRACKER
}) })
DEVICE_CLASS[zll.PROFILE_ID].update({ DEVICE_CLASS[zll.PROFILE_ID].update({
@ -323,6 +328,9 @@ def establish_device_mappings():
zcl.clusters.measurement.OccupancySensing.cluster_id) zcl.clusters.measurement.OccupancySensing.cluster_id)
BINARY_SENSOR_CLUSTERS.add(SMARTTHINGS_ACCELERATION_CLUSTER) BINARY_SENSOR_CLUSTERS.add(SMARTTHINGS_ACCELERATION_CLUSTER)
DEVICE_TRACKER_CLUSTERS.add(
zcl.clusters.general.PowerConfiguration.cluster_id)
LIGHT_CLUSTERS.add(zcl.clusters.general.OnOff.cluster_id) LIGHT_CLUSTERS.add(zcl.clusters.general.OnOff.cluster_id)
LIGHT_CLUSTERS.add(zcl.clusters.general.LevelControl.cluster_id) LIGHT_CLUSTERS.add(zcl.clusters.general.LevelControl.cluster_id)
LIGHT_CLUSTERS.add(zcl.clusters.lighting.Color.cluster_id) LIGHT_CLUSTERS.add(zcl.clusters.lighting.Color.cluster_id)

View File

@ -0,0 +1,105 @@
"""Support for the ZHA platform."""
import logging
import time
from homeassistant.components.device_tracker import (
SOURCE_TYPE_ROUTER, DOMAIN
)
from homeassistant.components.device_tracker.config_entry import (
ScannerEntity
)
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .core.const import (
DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW,
POWER_CONFIGURATION_CHANNEL, SIGNAL_ATTR_UPDATED
)
from .entity import ZhaEntity
from .sensor import battery_percentage_remaining_formatter
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Zigbee Home Automation device tracker from config entry."""
async def async_discover(discovery_info):
await _async_setup_entities(hass, config_entry, async_add_entities,
[discovery_info])
unsub = async_dispatcher_connect(
hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover)
hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub)
device_trackers = hass.data.get(DATA_ZHA, {}).get(DOMAIN)
if device_trackers is not None:
await _async_setup_entities(hass, config_entry, async_add_entities,
device_trackers.values())
del hass.data[DATA_ZHA][DOMAIN]
async def _async_setup_entities(hass, config_entry, async_add_entities,
discovery_infos):
"""Set up the ZHA device trackers."""
entities = []
for discovery_info in discovery_infos:
entities.append(ZHADeviceScannerEntity(**discovery_info))
async_add_entities(entities, update_before_add=True)
class ZHADeviceScannerEntity(ScannerEntity, ZhaEntity):
"""Represent a tracked device."""
def __init__(self, **kwargs):
"""Initialize the ZHA device tracker."""
super().__init__(**kwargs)
self._battery_channel = self.cluster_channels.get(
POWER_CONFIGURATION_CHANNEL)
self._connected = False
self._keepalive_interval = 60
self._should_poll = True
self._battery_level = None
async def async_added_to_hass(self):
"""Run when about to be added to hass."""
await super().async_added_to_hass()
if self._battery_channel:
await self.async_accept_signal(
self._battery_channel, SIGNAL_ATTR_UPDATED,
self.async_battery_percentage_remaining_updated)
async def async_update(self):
"""Handle polling."""
if self.zha_device.last_seen is None:
self._connected = False
else:
difference = time.time() - self.zha_device.last_seen
if difference > self._keepalive_interval:
self._connected = False
else:
self._connected = True
@property
def is_connected(self):
"""Return true if the device is connected to the network."""
return self._connected
@property
def source_type(self):
"""Return the source type, eg gps or router, of the device."""
return SOURCE_TYPE_ROUTER
@callback
def async_battery_percentage_remaining_updated(self, value):
"""Handle tracking."""
_LOGGER.debug('battery_percentage_remaining updated: %s', value)
self._connected = True
self._battery_level = battery_percentage_remaining_formatter(value)
self.async_schedule_update_ha_state()
@property
def battery_level(self):
"""Return the battery level of the device.
Percentage from 0-100.
"""
return self._battery_level

View File

@ -0,0 +1,62 @@
"""Tests for device tracker entities."""
import pytest
from homeassistant.components.device_tracker.config_entry import (
BaseTrackerEntity, ScannerEntity
)
from homeassistant.components.device_tracker.const import (
SOURCE_TYPE_ROUTER, ATTR_SOURCE_TYPE, DOMAIN
)
from homeassistant.const import (
STATE_HOME,
STATE_NOT_HOME,
ATTR_BATTERY_LEVEL
)
from tests.common import MockConfigEntry
async def test_scanner_entity_device_tracker(hass):
"""Test ScannerEntity based device tracker."""
config_entry = MockConfigEntry(domain='test')
config_entry.add_to_hass(hass)
await hass.config_entries.async_forward_entry_setup(
config_entry, DOMAIN)
await hass.async_block_till_done()
entity_id = 'device_tracker.unnamed_device'
entity_state = hass.states.get(entity_id)
assert entity_state.attributes == {
ATTR_SOURCE_TYPE: SOURCE_TYPE_ROUTER,
ATTR_BATTERY_LEVEL: 100
}
assert entity_state.state == STATE_NOT_HOME
entity = hass.data[DOMAIN].get_entity(entity_id)
entity.set_connected()
await hass.async_block_till_done()
entity_state = hass.states.get(entity_id)
assert entity_state.state == STATE_HOME
def test_scanner_entity():
"""Test coverage for base ScannerEntity entity class."""
entity = ScannerEntity()
with pytest.raises(NotImplementedError):
assert entity.source_type is None
with pytest.raises(NotImplementedError):
assert entity.is_connected is None
with pytest.raises(NotImplementedError):
assert entity.state == STATE_NOT_HOME
assert entity.battery_level is None
def test_base_tracker_entity():
"""Test coverage for base BaseTrackerEntity entity class."""
entity = BaseTrackerEntity()
with pytest.raises(NotImplementedError):
assert entity.source_type is None
assert entity.battery_level is None
with pytest.raises(NotImplementedError):
assert entity.state_attributes is None

View File

@ -0,0 +1,89 @@
"""Test ZHA Device Tracker."""
from datetime import timedelta
import time
from homeassistant.components.device_tracker import DOMAIN, SOURCE_TYPE_ROUTER
from homeassistant.const import (
STATE_HOME,
STATE_NOT_HOME,
STATE_UNAVAILABLE
)
from homeassistant.components.zha.core.registries import \
SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE
import homeassistant.util.dt as dt_util
from .common import (
async_init_zigpy_device, make_attribute, make_entity_id,
async_test_device_join, async_enable_traffic
)
from tests.common import async_fire_time_changed
async def test_device_tracker(hass, config_entry, zha_gateway):
"""Test zha device tracker platform."""
from zigpy.zcl.clusters.general import (
Basic, PowerConfiguration, BinaryInput, Identify, Ota, PollControl)
# create zigpy device
zigpy_device = await async_init_zigpy_device(
hass,
[
Basic.cluster_id,
PowerConfiguration.cluster_id,
Identify.cluster_id,
PollControl.cluster_id,
BinaryInput.cluster_id
],
[
Identify.cluster_id,
Ota.cluster_id
],
SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE,
zha_gateway
)
# load up device tracker domain
await hass.config_entries.async_forward_entry_setup(
config_entry, DOMAIN)
await hass.async_block_till_done()
cluster = zigpy_device.endpoints.get(1).power
entity_id = make_entity_id(DOMAIN, zigpy_device, cluster, use_suffix=False)
zha_device = zha_gateway.get_device(zigpy_device.ieee)
# test that the device tracker was created and that it is unavailable
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
zigpy_device.last_seen = time.time() - 120
next_update = dt_util.utcnow() + timedelta(seconds=30)
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
# allow traffic to flow through the gateway and device
await async_enable_traffic(hass, zha_gateway, [zha_device])
# test that the state has changed from unavailable to not home
assert hass.states.get(entity_id).state == STATE_NOT_HOME
# turn state flip
attr = make_attribute(0x0020, 23)
cluster.handle_message(False, 1, 0x0a, [[attr]])
attr = make_attribute(0x0021, 200)
cluster.handle_message(False, 1, 0x0a, [[attr]])
zigpy_device.last_seen = time.time() + 10
next_update = dt_util.utcnow() + timedelta(seconds=30)
async_fire_time_changed(hass, next_update)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_HOME
entity = hass.data[DOMAIN].get_entity(entity_id)
assert entity.is_connected is True
assert entity.source_type == SOURCE_TYPE_ROUTER
assert entity.battery_level == 100
# test adding device tracker to the network and HA
await async_test_device_join(
hass, zha_gateway, PowerConfiguration.cluster_id, DOMAIN,
SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE)

View File

@ -1,6 +1,8 @@
"""Provide a mock device scanner.""" """Provide a mock device scanner."""
from homeassistant.components.device_tracker import DeviceScanner from homeassistant.components.device_tracker import DeviceScanner
from homeassistant.components.device_tracker.config_entry import ScannerEntity
from homeassistant.components.device_tracker.const import SOURCE_TYPE_ROUTER
def get_scanner(hass, config): def get_scanner(hass, config):
@ -8,6 +10,43 @@ def get_scanner(hass, config):
return SCANNER return SCANNER
class MockScannerEntity(ScannerEntity):
"""Test implementation of a ScannerEntity."""
def __init__(self):
"""Init."""
self.connected = False
@property
def source_type(self):
"""Return the source type, eg gps or router, of the device."""
return SOURCE_TYPE_ROUTER
@property
def battery_level(self):
"""Return the battery level of the device.
Percentage from 0-100.
"""
return 100
@property
def is_connected(self):
"""Return true if the device is connected to the network."""
return self.connected
def set_connected(self):
"""Set connected to True."""
self.connected = True
self.async_schedule_update_ha_state()
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the config entry."""
entity = MockScannerEntity()
async_add_entities([entity])
class MockScanner(DeviceScanner): class MockScanner(DeviceScanner):
"""Mock device scanner.""" """Mock device scanner."""