diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index 59f6c0c49c1766..872d982618c1bf 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -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 @@ def battery_level(self): """ 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 @@ 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.""" @@ -99,16 +115,27 @@ def state(self): @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 diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index f9a7df638eb80f..3400e7ea35d123 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -8,7 +8,7 @@ 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 @@ def _receive_data(device, gps, location_name, attributes): 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): diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py index d4b6b3c53cc8f8..254c9d2b391e97 100644 --- a/homeassistant/components/gpslogger/device_tracker.py +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -10,7 +10,7 @@ ) 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 @@ def _receive_data(device, gps, battery, accuracy, attrs): async_add_entities(entities) -class GPSLoggerEntity(DeviceTrackerEntity, RestoreEntity): +class GPSLoggerEntity(TrackerEntity, RestoreEntity): """Represent a tracked device.""" def __init__( diff --git a/homeassistant/components/locative/device_tracker.py b/homeassistant/components/locative/device_tracker.py index 38efab7e8c015b..aa6d056c78666f 100644 --- a/homeassistant/components/locative/device_tracker.py +++ b/homeassistant/components/locative/device_tracker.py @@ -4,7 +4,7 @@ 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 @@ def _receive_data(device, location, location_name): return True -class LocativeEntity(DeviceTrackerEntity): +class LocativeEntity(TrackerEntity): """Represent a tracked device.""" def __init__(self, device, location, location_name): diff --git a/homeassistant/components/mobile_app/device_tracker.py b/homeassistant/components/mobile_app/device_tracker.py index 7fb76f3af413b8..62eb575fcb8c96 100644 --- a/homeassistant/components/mobile_app/device_tracker.py +++ b/homeassistant/components/mobile_app/device_tracker.py @@ -9,7 +9,7 @@ ) 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): diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index b573e390a12fa4..4ef0cb8d699dc7 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -11,7 +11,7 @@ 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 @@ def _receive_data(dev_id, **data): return True -class OwnTracksEntity(DeviceTrackerEntity, RestoreEntity): +class OwnTracksEntity(TrackerEntity, RestoreEntity): """Represent a tracked device.""" def __init__(self, dev_id, data=None): diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index b40f1cf5ff4d63..f86a5ee9f45263 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -3,6 +3,7 @@ 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 @@ COMPONENTS = ( BINARY_SENSOR, + DEVICE_TRACKER, FAN, LIGHT, LOCK, diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index e710b0cc856dcf..1997f130278ada 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -6,6 +6,7 @@ """ 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 @@ 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 @@ 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 get_deconz_radio(): 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 get_deconz_radio(): 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) diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py new file mode 100644 index 00000000000000..677b1bc1f2759d --- /dev/null +++ b/homeassistant/components/zha/device_tracker.py @@ -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 diff --git a/tests/components/device_tracker/test_entities.py b/tests/components/device_tracker/test_entities.py new file mode 100644 index 00000000000000..a338a03f208195 --- /dev/null +++ b/tests/components/device_tracker/test_entities.py @@ -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 diff --git a/tests/components/zha/test_device_tracker.py b/tests/components/zha/test_device_tracker.py new file mode 100644 index 00000000000000..3fbad7fd6d4230 --- /dev/null +++ b/tests/components/zha/test_device_tracker.py @@ -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) diff --git a/tests/testing_config/custom_components/test/device_tracker.py b/tests/testing_config/custom_components/test/device_tracker.py index 6f4314b767dd48..960f9eb47da9a1 100644 --- a/tests/testing_config/custom_components/test/device_tracker.py +++ b/tests/testing_config/custom_components/test/device_tracker.py @@ -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."""