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)
class DeviceTrackerEntity(Entity):
class BaseTrackerEntity(Entity):
"""Represent a tracked device."""
@property
@ -48,6 +48,27 @@ class DeviceTrackerEntity(Entity):
"""
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
def location_accuracy(self):
"""Return the location accuracy of the device.
@ -71,11 +92,6 @@ class DeviceTrackerEntity(Entity):
"""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."""
@ -99,16 +115,27 @@ class DeviceTrackerEntity(Entity):
@property
def state_attributes(self):
"""Return the device state attributes."""
attr = {
ATTR_SOURCE_TYPE: self.source_type
}
attr = {}
attr.update(super().state_attributes)
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
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.components.device_tracker import SOURCE_TYPE_GPS
from homeassistant.components.device_tracker.config_entry import (
DeviceTrackerEntity
TrackerEntity
)
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.restore_state import RestoreEntity
@ -52,7 +52,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
return True
class GeofencyEntity(DeviceTrackerEntity, RestoreEntity):
class GeofencyEntity(TrackerEntity, RestoreEntity):
"""Represent a tracked device."""
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.config_entry import (
DeviceTrackerEntity
TrackerEntity
)
from homeassistant.helpers import device_registry
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@ -67,7 +67,7 @@ async def async_setup_entry(hass: HomeAssistantType, entry,
async_add_entities(entities)
class GPSLoggerEntity(DeviceTrackerEntity, RestoreEntity):
class GPSLoggerEntity(TrackerEntity, RestoreEntity):
"""Represent a tracked device."""
def __init__(

View File

@ -4,7 +4,7 @@ import logging
from homeassistant.core import callback
from homeassistant.components.device_tracker import SOURCE_TYPE_GPS
from homeassistant.components.device_tracker.config_entry import (
DeviceTrackerEntity
TrackerEntity
)
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@ -33,7 +33,7 @@ async def async_setup_entry(hass, entry, async_add_entities):
return True
class LocativeEntity(DeviceTrackerEntity):
class LocativeEntity(TrackerEntity):
"""Represent a tracked device."""
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.config_entry import (
DeviceTrackerEntity
TrackerEntity
)
from homeassistant.helpers.restore_state import RestoreEntity
from .const import (
@ -44,7 +44,7 @@ async def async_setup_entry(hass, entry, async_add_entities):
return True
class MobileAppEntity(DeviceTrackerEntity, RestoreEntity):
class MobileAppEntity(TrackerEntity, RestoreEntity):
"""Represent a tracked device."""
def __init__(self, entry, data=None):

View File

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

View File

@ -3,6 +3,7 @@ import enum
import logging
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.light import DOMAIN as LIGHT
from homeassistant.components.lock import DOMAIN as LOCK
@ -25,6 +26,7 @@ ZHA_DISCOVERY_NEW = 'zha_discovery_new_{}'
COMPONENTS = (
BINARY_SENSOR,
DEVICE_TRACKER,
FAN,
LIGHT,
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.device_tracker import DOMAIN as DEVICE_TRACKER
from homeassistant.components.fan import DOMAIN as FAN
from homeassistant.components.light import DOMAIN as LIGHT
from homeassistant.components.lock import DOMAIN as LOCK
@ -21,8 +22,9 @@ from .const import (
CONTROLLER, BATTERY
)
SMARTTHINGS_HUMIDITY_CLUSTER = 64581
SMARTTHINGS_ACCELERATION_CLUSTER = 64514
SMARTTHINGS_HUMIDITY_CLUSTER = 0xFC45
SMARTTHINGS_ACCELERATION_CLUSTER = 0xFC02
SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE = 0x8000
DEVICE_CLASS = {}
SINGLE_INPUT_CLUSTER_DEVICE_CLASS = {}
@ -39,12 +41,14 @@ OUTPUT_CHANNEL_ONLY_CLUSTERS = []
BINDABLE_CLUSTERS = []
INPUT_BIND_ONLY_CLUSTERS = []
BINARY_SENSOR_CLUSTERS = set()
DEVICE_TRACKER_CLUSTERS = set()
LIGHT_CLUSTERS = set()
SWITCH_CLUSTERS = set()
COMPONENT_CLUSTERS = {
BINARY_SENSOR: BINARY_SENSOR_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.DIMMABLE_PLUG_IN_UNIT: 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({
@ -323,6 +328,9 @@ def establish_device_mappings():
zcl.clusters.measurement.OccupancySensing.cluster_id)
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.LevelControl.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."""
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):
@ -8,6 +10,43 @@ def get_scanner(hass, config):
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):
"""Mock device scanner."""