diff --git a/ha_test/config/custom_components/device_tracker/test.py b/ha_test/config/custom_components/device_tracker/test.py new file mode 100644 index 00000000000..50cc2bff9d4 --- /dev/null +++ b/ha_test/config/custom_components/device_tracker/test.py @@ -0,0 +1,41 @@ +""" +custom_components.device_tracker.test +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Provides a mock device scanner. +""" + + +def get_scanner(hass, config): + """ Returns a mock scanner. """ + return SCANNER + + +class MockScanner(object): + """ Mock device scanner. """ + + def __init__(self): + """ Initialize the MockScanner. """ + self.devices_home = [] + + def come_home(self, device): + """ Make a device come home. """ + self.devices_home.append(device) + + def leave_home(self, device): + """ Make a device leave the house. """ + self.devices_home.remove(device) + + def scan_devices(self): + """ Returns a list of fake devices. """ + + return list(self.devices_home) + + def get_device_name(self, device): + """ + Returns a name for a mock device. + Returns None for dev1 for testing. + """ + return None if device == 'dev1' else device.upper() + +SCANNER = MockScanner() diff --git a/ha_test/test_component_device_scanner.py b/ha_test/test_component_device_scanner.py new file mode 100644 index 00000000000..26a7ddb5590 --- /dev/null +++ b/ha_test/test_component_device_scanner.py @@ -0,0 +1,190 @@ +""" +ha_test.test_component_group +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests the group compoments. +""" +# pylint: disable=protected-access,too-many-public-methods +import unittest +from datetime import datetime, timedelta +import logging +import os + +import homeassistant as ha +import homeassistant.loader as loader +from homeassistant.components import ( + STATE_HOME, STATE_NOT_HOME, ATTR_ENTITY_PICTURE) +import homeassistant.components.device_tracker as device_tracker + +from helper import get_test_home_assistant + + +def setUpModule(): # pylint: disable=invalid-name + """ Setup to ignore group errors. """ + logging.disable(logging.CRITICAL) + + +class TestComponentsDeviceTracker(unittest.TestCase): + """ Tests homeassistant.components.device_tracker module. """ + + def setUp(self): # pylint: disable=invalid-name + """ Init needed objects. """ + self.hass = get_test_home_assistant() + loader.prepare(self.hass) + + self.known_dev_path = self.hass.get_config_path( + device_tracker.KNOWN_DEVICES_FILE) + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + self.hass.stop() + + if os.path.isfile(self.known_dev_path): + os.remove(self.known_dev_path) + + def test_is_on(self): + """ Test is_on method. """ + entity_id = device_tracker.ENTITY_ID_FORMAT.format('test') + + self.hass.states.set(entity_id, STATE_HOME) + + self.assertTrue(device_tracker.is_on(self.hass, entity_id)) + + self.hass.states.set(entity_id, STATE_NOT_HOME) + + self.assertFalse(device_tracker.is_on(self.hass, entity_id)) + + def test_setup(self): + """ Test setup method. """ + # Bogus config + self.assertFalse(device_tracker.setup(self.hass, {})) + + self.assertFalse( + device_tracker.setup(self.hass, {device_tracker.DOMAIN: {}})) + + # Test with non-existing component + self.assertFalse(device_tracker.setup( + self.hass, {device_tracker.DOMAIN: {ha.CONF_TYPE: 'nonexisting'}} + )) + + # Test with a bad known device file around + with open(self.known_dev_path, 'w') as fil: + fil.write("bad data\nbad data\n") + + self.assertFalse(device_tracker.setup(self.hass, { + device_tracker.DOMAIN: {ha.CONF_TYPE: 'test'} + })) + + def test_device_tracker(self): + """ Test the device tracker class. """ + scanner = loader.get_component( + 'device_tracker.test').get_scanner(None, None) + + scanner.come_home('dev1') + scanner.come_home('dev2') + + self.assertTrue(device_tracker.setup(self.hass, { + device_tracker.DOMAIN: {ha.CONF_TYPE: 'test'} + })) + + # Ensure a new known devices file has been created. + # Since the device_tracker uses a set internally we cannot + # know what the order of the devices in the known devices file is. + # To ensure all the three expected lines are there, we sort the file + with open(self.known_dev_path) as fil: + self.assertEqual( + ['dev1,unknown_device,0,\n', 'dev2,DEV2,0,\n', + 'device,name,track,picture\n'], + sorted(fil)) + + # Write one where we track dev1, dev2 + with open(self.known_dev_path, 'w') as fil: + fil.write('device,name,track,picture\n') + fil.write('dev1,Device 1,1,http://example.com/dev1.jpg\n') + fil.write('dev2,Device 2,1,http://example.com/dev2.jpg\n') + + scanner.leave_home('dev1') + scanner.come_home('dev3') + + self.hass.services.call( + device_tracker.DOMAIN, + device_tracker.SERVICE_DEVICE_TRACKER_RELOAD) + + self.hass._pool.block_till_done() + + dev1 = device_tracker.ENTITY_ID_FORMAT.format('Device_1') + dev2 = device_tracker.ENTITY_ID_FORMAT.format('Device_2') + dev3 = device_tracker.ENTITY_ID_FORMAT.format('DEV3') + + now = datetime.now() + nowNext = now + timedelta(seconds=ha.TIMER_INTERVAL) + nowAlmostMinGone = (now + device_tracker.TIME_DEVICE_NOT_FOUND - + timedelta(seconds=1)) + nowMinGone = nowAlmostMinGone + timedelta(seconds=2) + + # Test initial is correct + self.assertTrue(device_tracker.is_on(self.hass)) + self.assertFalse(device_tracker.is_on(self.hass, dev1)) + self.assertTrue(device_tracker.is_on(self.hass, dev2)) + self.assertIsNone(self.hass.states.get(dev3)) + + self.assertEqual( + 'http://example.com/dev1.jpg', + self.hass.states.get(dev1).attributes.get(ATTR_ENTITY_PICTURE)) + self.assertEqual( + 'http://example.com/dev2.jpg', + self.hass.states.get(dev2).attributes.get(ATTR_ENTITY_PICTURE)) + + # Test if dev3 got added to known dev file + with open(self.known_dev_path) as fil: + self.assertEqual('dev3,DEV3,0,\n', list(fil)[-1]) + + # Change dev3 to track + with open(self.known_dev_path, 'w') as fil: + fil.write("device,name,track,picture\n") + fil.write('dev1,Device 1,1,http://example.com/picture.jpg\n') + fil.write('dev2,Device 2,1,http://example.com/picture.jpg\n') + fil.write('dev3,DEV3,1,\n') + + # reload dev file + scanner.come_home('dev1') + scanner.leave_home('dev2') + + self.hass.services.call( + device_tracker.DOMAIN, + device_tracker.SERVICE_DEVICE_TRACKER_RELOAD) + + self.hass._pool.block_till_done() + + # Test what happens if a device comes home and another leaves + self.assertTrue(device_tracker.is_on(self.hass)) + self.assertTrue(device_tracker.is_on(self.hass, dev1)) + # Dev2 will still be home because of the error margin on time + self.assertTrue(device_tracker.is_on(self.hass, dev2)) + # dev3 should be tracked now after we reload the known devices + self.assertTrue(device_tracker.is_on(self.hass, dev3)) + + self.assertIsNone( + self.hass.states.get(dev3).attributes.get(ATTR_ENTITY_PICTURE)) + + # Test if device leaves what happens, test the time span + self.hass.bus.fire( + ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: nowAlmostMinGone}) + + self.hass._pool.block_till_done() + + self.assertTrue(device_tracker.is_on(self.hass)) + self.assertTrue(device_tracker.is_on(self.hass, dev1)) + # Dev2 will still be home because of the error time + self.assertTrue(device_tracker.is_on(self.hass, dev2)) + self.assertTrue(device_tracker.is_on(self.hass, dev3)) + + # Now test if gone for longer then error margin + self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: nowMinGone}) + + self.hass._pool.block_till_done() + + self.assertTrue(device_tracker.is_on(self.hass)) + self.assertTrue(device_tracker.is_on(self.hass, dev1)) + self.assertFalse(device_tracker.is_on(self.hass, dev2)) + self.assertTrue(device_tracker.is_on(self.hass, dev3)) diff --git a/homeassistant/__init__.py b/homeassistant/__init__.py index 3ee3e0d4539..6fbe1a2c3e3 100644 --- a/homeassistant/__init__.py +++ b/homeassistant/__init__.py @@ -557,6 +557,9 @@ class StateMachine(object): Track specific state changes. entity_ids, from_state and to_state can be string or list. Use list to match multiple. + + Returns the listener that listens on the bus for EVENT_STATE_CHANGED. + Pass the return value into hass.bus.remove_listener to remove it. """ from_state = _process_match_param(from_state) to_state = _process_match_param(to_state) @@ -579,6 +582,8 @@ class StateMachine(object): self._bus.listen(EVENT_STATE_CHANGED, state_listener) + return state_listener + # pylint: disable=too-few-public-methods class ServiceCall(object): diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 85e7d206e3e..08f1abd9ff1 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -1,6 +1,6 @@ """ homeassistant.components.tracker -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Provides functionality to keep track of devices. """ @@ -13,9 +13,9 @@ from datetime import datetime, timedelta import homeassistant as ha from homeassistant.loader import get_component import homeassistant.util as util -import homeassistant.components as components -from homeassistant.components import group +from homeassistant.components import ( + group, STATE_HOME, STATE_NOT_HOME, ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME) DOMAIN = "device_tracker" DEPENDENCIES = [] @@ -30,7 +30,7 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' # After how much time do we consider a device not home if # it does not show up on scans -TIME_SPAN_FOR_ERROR_IN_SCANNING = timedelta(minutes=3) +TIME_DEVICE_NOT_FOUND = timedelta(minutes=3) # Filename to save known devices to KNOWN_DEVICES_FILE = "known_devices.csv" @@ -43,7 +43,7 @@ def is_on(hass, entity_id=None): """ Returns if any or specified device is home. """ entity = entity_id or ENTITY_ID_ALL_DEVICES - return hass.states.is_state(entity, components.STATE_HOME) + return hass.states.is_state(entity, STATE_HOME) def setup(hass, config): @@ -70,223 +70,231 @@ def setup(hass, config): return False - DeviceTracker(hass, device_scanner) + tracker = DeviceTracker(hass, device_scanner) - return True + # We only succeeded if we got to parse the known devices file + return not tracker.invalid_known_devices_file -# pylint: disable=too-many-instance-attributes class DeviceTracker(object): """ Class that tracks which devices are home and which are not. """ def __init__(self, hass, device_scanner): - self.states = hass.states + self.hass = hass self.device_scanner = device_scanner - self.error_scanning = TIME_SPAN_FOR_ERROR_IN_SCANNING - self.lock = threading.Lock() - self.path_known_devices_file = hass.get_config_path(KNOWN_DEVICES_FILE) - # Dictionary to keep track of known devices and devices we track - self.known_devices = {} + self.tracked = {} + self.untracked_devices = set() # Did we encounter an invalid known devices file self.invalid_known_devices_file = False self._read_known_devices_file() + if self.invalid_known_devices_file: + return + # Wrap it in a func instead of lambda so it can be identified in # the bus by its __name__ attribute. - def update_device_state(time): # pylint: disable=unused-argument + def update_device_state(now): """ Triggers update of the device states. """ - self.update_devices() + self.update_devices(now) + + # pylint: disable=unused-argument + def reload_known_devices_service(service): + """ Reload known devices file. """ + group.remove_group(self.hass, GROUP_NAME_ALL_DEVICES) + + self._read_known_devices_file() + + self.update_devices(datetime.now()) + + if self.tracked: + group.setup_group( + self.hass, GROUP_NAME_ALL_DEVICES, + self.device_entity_ids, False) hass.track_time_change(update_device_state) hass.services.register(DOMAIN, SERVICE_DEVICE_TRACKER_RELOAD, - lambda service: self._read_known_devices_file()) + reload_known_devices_service) - self.update_devices() - - group.setup_group( - hass, GROUP_NAME_ALL_DEVICES, self.device_entity_ids, False) + reload_known_devices_service(None) @property def device_entity_ids(self): """ Returns a set containing all device entity ids that are being tracked. """ - return set([self.known_devices[device]['entity_id'] for device - in self.known_devices - if self.known_devices[device]['track']]) + return set(device['entity_id'] for device in self.tracked.values()) - def update_devices(self, found_devices=None): + def _update_state(self, now, device, is_home): + """ Update the state of a device. """ + dev_info = self.tracked[device] + + if is_home: + # Update last seen if at home + dev_info['last_seen'] = now + else: + # State remains at home if it has been seen in the last + # TIME_DEVICE_NOT_FOUND + is_home = now - dev_info['last_seen'] < TIME_DEVICE_NOT_FOUND + + state = STATE_HOME if is_home else STATE_NOT_HOME + + self.hass.states.set( + dev_info['entity_id'], state, + dev_info['state_attr']) + + def update_devices(self, now): """ Update device states based on the found devices. """ self.lock.acquire() - found_devices = found_devices or self.device_scanner.scan_devices() + found_devices = set(self.device_scanner.scan_devices()) - now = datetime.now() + for device in self.tracked: + is_home = device in found_devices - known_dev = self.known_devices + self._update_state(now, device, is_home) - temp_tracking_devices = [device for device in known_dev - if known_dev[device]['track']] + if is_home: + found_devices.remove(device) - for device in found_devices: - # Are we tracking this device? - if device in temp_tracking_devices: - temp_tracking_devices.remove(device) + # Did we find any devices that we didn't know about yet? + new_devices = found_devices - self.untracked_devices - known_dev[device]['last_seen'] = now + # Write new devices to known devices file + if not self.invalid_known_devices_file and new_devices: - self.states.set( - known_dev[device]['entity_id'], components.STATE_HOME, - known_dev[device]['default_state_attr']) + known_dev_path = self.hass.get_config_path(KNOWN_DEVICES_FILE) - # For all devices we did not find, set state to NH - # But only if they have been gone for longer then the error time span - # Because we do not want to have stuff happening when the device does - # not show up for 1 scan beacuse of reboot etc - for device in temp_tracking_devices: - if now - known_dev[device]['last_seen'] > self.error_scanning: + try: + # If file does not exist we will write the header too + is_new_file = not os.path.isfile(known_dev_path) - self.states.set(known_dev[device]['entity_id'], - components.STATE_NOT_HOME, - known_dev[device]['default_state_attr']) + with open(known_dev_path, 'a') as outp: + _LOGGER.info( + "Found %d new devices, updating %s", + len(new_devices), known_dev_path) - # If we come along any unknown devices we will write them to the - # known devices file but only if we did not encounter an invalid - # known devices file - if not self.invalid_known_devices_file: + writer = csv.writer(outp) - known_dev_path = self.path_known_devices_file + if is_new_file: + writer.writerow(( + "device", "name", "track", "picture")) - unknown_devices = [device for device in found_devices - if device not in known_dev] + for device in new_devices: + # See if the device scanner knows the name + # else defaults to unknown device + name = (self.device_scanner.get_device_name(device) + or "unknown_device") - if unknown_devices: - try: - # If file does not exist we will write the header too - is_new_file = not os.path.isfile(known_dev_path) + writer.writerow((device, name, 0, "")) - with open(known_dev_path, 'a') as outp: - _LOGGER.info( - "Found %d new devices, updating %s", - len(unknown_devices), known_dev_path) - - writer = csv.writer(outp) - - if is_new_file: - writer.writerow(( - "device", "name", "track", "picture")) - - for device in unknown_devices: - # See if the device scanner knows the name - # else defaults to unknown device - name = (self.device_scanner.get_device_name(device) - or "unknown_device") - - writer.writerow((device, name, 0, "")) - known_dev[device] = {'name': name, - 'track': False, - 'picture': ""} - - except IOError: - _LOGGER.exception( - "Error updating %s with %d new devices", - known_dev_path, len(unknown_devices)) + except IOError: + _LOGGER.exception( + "Error updating %s with %d new devices", + known_dev_path, len(new_devices)) self.lock.release() + # pylint: disable=too-many-branches def _read_known_devices_file(self): """ Parse and process the known devices file. """ + known_dev_path = self.hass.get_config_path(KNOWN_DEVICES_FILE) - # Read known devices if file exists - if os.path.isfile(self.path_known_devices_file): - self.lock.acquire() + # Return if no known devices file exists + if not os.path.isfile(known_dev_path): + return - known_devices = {} + self.lock.acquire() - with open(self.path_known_devices_file) as inp: - default_last_seen = datetime(1990, 1, 1) + self.untracked_devices.clear() - # Temp variable to keep track of which entity ids we use - # so we can ensure we have unique entity ids. - used_entity_ids = [] + with open(known_dev_path) as inp: + default_last_seen = datetime(1990, 1, 1) - try: - for row in csv.DictReader(inp): - device = row['device'] + # To track which devices need an entity_id assigned + need_entity_id = [] - row['track'] = True if row['track'] == '1' else False + # All devices that are still in this set after we read the CSV file + # have been removed from the file and thus need to be cleaned up. + removed_devices = set(self.tracked.keys()) + + try: + for row in csv.DictReader(inp): + device = row['device'] + + if row['track'] == '1': + if device in self.tracked: + # Device exists + removed_devices.remove(device) + else: + # We found a new device + need_entity_id.append(device) + + self.tracked[device] = { + 'name': row['name'], + 'last_seen': default_last_seen + } + + # Update state_attr with latest from file + state_attr = { + ATTR_FRIENDLY_NAME: row['name'] + } if row['picture']: - row['default_state_attr'] = { - components.ATTR_ENTITY_PICTURE: row['picture']} + state_attr[ATTR_ENTITY_PICTURE] = row['picture'] - else: - row['default_state_attr'] = None + self.tracked[device]['state_attr'] = state_attr - # If we track this device setup tracking variables - if row['track']: - row['last_seen'] = default_last_seen + else: + self.untracked_devices.add(device) - # Make sure that each device is mapped - # to a unique entity_id name - name = util.slugify(row['name']) if row['name'] \ - else "unnamed_device" + # Remove existing devices that we no longer track + for device in removed_devices: + entity_id = self.tracked[device]['entity_id'] - entity_id = ENTITY_ID_FORMAT.format(name) - tries = 1 + _LOGGER.info("Removing entity %s", entity_id) - while entity_id in used_entity_ids: - tries += 1 + self.hass.states.remove(entity_id) - suffix = "_{}".format(tries) + self.tracked.pop(device) - entity_id = ENTITY_ID_FORMAT.format( - name + suffix) + # Setup entity_ids for the new devices + used_entity_ids = [info['entity_id'] for device, info + in self.tracked.items() + if device not in need_entity_id] - row['entity_id'] = entity_id - used_entity_ids.append(entity_id) + for device in need_entity_id: + name = self.tracked[device]['name'] - row['picture'] = row['picture'] + entity_id = util.ensure_unique_string( + ENTITY_ID_FORMAT.format(util.slugify(name)), + used_entity_ids) - known_devices[device] = row + used_entity_ids.append(entity_id) - if not known_devices: - _LOGGER.warning( - "No devices to track. Please update %s.", - self.path_known_devices_file) + self.tracked[device]['entity_id'] = entity_id - # Remove entities that are no longer maintained - new_entity_ids = set([known_devices[dev]['entity_id'] - for dev in known_devices - if known_devices[dev]['track']]) - - for entity_id in \ - self.device_entity_ids - new_entity_ids: - - _LOGGER.info("Removing entity %s", entity_id) - self.states.remove(entity_id) - - # File parsed, warnings given if necessary - # entities cleaned up, make it available - self.known_devices = known_devices - - _LOGGER.info("Loaded devices from %s", - self.path_known_devices_file) - - except KeyError: - self.invalid_known_devices_file = True + if not self.tracked: _LOGGER.warning( - ("Invalid known devices file: %s. " - "We won't update it with new found devices."), - self.path_known_devices_file) + "No devices to track. Please update %s.", + known_dev_path) - finally: - self.lock.release() + _LOGGER.info("Loaded devices from %s", known_dev_path) + + except KeyError: + self.invalid_known_devices_file = True + + _LOGGER.warning( + ("Invalid known devices file: %s. " + "We won't update it with new found devices."), + known_dev_path) + + finally: + self.lock.release() diff --git a/homeassistant/components/group.py b/homeassistant/components/group.py index 5376b65131a..f0ce5cc0f94 100644 --- a/homeassistant/components/group.py +++ b/homeassistant/components/group.py @@ -7,6 +7,7 @@ Provides functionality to group devices that can be turned on or off. import logging +import homeassistant as ha import homeassistant.util as util from homeassistant.components import (STATE_ON, STATE_OFF, STATE_HOME, STATE_NOT_HOME, @@ -24,6 +25,8 @@ _GROUP_TYPES = { "home_not_home": (STATE_HOME, STATE_NOT_HOME) } +_GROUPS = {} + def _get_group_type(state): """ Determine the group type based on the given group type. """ @@ -105,7 +108,6 @@ def setup(hass, config): def setup_group(hass, name, entity_ids, user_defined=True): """ Sets up a group state that is the combined state of several states. Supports ON/OFF and DEVICE_HOME/DEVICE_NOT_HOME. """ - # In case an iterable is passed in entity_ids = list(entity_ids) @@ -159,35 +161,47 @@ def setup_group(hass, name, entity_ids, user_defined=True): return False - else: - group_entity_id = ENTITY_ID_FORMAT.format(name) - state_attr = {ATTR_ENTITY_ID: entity_ids, ATTR_AUTO: not user_defined} + group_entity_id = ENTITY_ID_FORMAT.format(util.slugify(name)) + state_attr = {ATTR_ENTITY_ID: entity_ids, ATTR_AUTO: not user_defined} - # pylint: disable=unused-argument - def update_group_state(entity_id, old_state, new_state): - """ Updates the group state based on a state change by - a tracked entity. """ + # pylint: disable=unused-argument + def update_group_state(entity_id, old_state, new_state): + """ Updates the group state based on a state change by + a tracked entity. """ - cur_gr_state = hass.states.get(group_entity_id).state + cur_gr_state = hass.states.get(group_entity_id).state - # if cur_gr_state = OFF and new_state = ON: set ON - # if cur_gr_state = ON and new_state = OFF: research - # else: ignore + # if cur_gr_state = OFF and new_state = ON: set ON + # if cur_gr_state = ON and new_state = OFF: research + # else: ignore - if cur_gr_state == group_off and new_state.state == group_on: + if cur_gr_state == group_off and new_state.state == group_on: - hass.states.set(group_entity_id, group_on, state_attr) + hass.states.set(group_entity_id, group_on, state_attr) - elif cur_gr_state == group_on and new_state.state == group_off: + elif cur_gr_state == group_on and new_state.state == group_off: - # Check if any of the other states is still on - if not any([hass.states.is_state(ent_id, group_on) - for ent_id in entity_ids - if entity_id != ent_id]): - hass.states.set(group_entity_id, group_off, state_attr) + # Check if any of the other states is still on + if not any([hass.states.is_state(ent_id, group_on) + for ent_id in entity_ids + if entity_id != ent_id]): + hass.states.set(group_entity_id, group_off, state_attr) - hass.states.track_change(entity_ids, update_group_state) + _GROUPS[group_entity_id] = hass.states.track_change( + entity_ids, update_group_state) - hass.states.set(group_entity_id, group_state, state_attr) + hass.states.set(group_entity_id, group_state, state_attr) - return True + return True + + +def remove_group(hass, name): + """ Remove a group and its state listener from Home Assistant. """ + group_entity_id = ENTITY_ID_FORMAT.format(util.slugify(name)) + + if hass.states.get(group_entity_id) is not None: + hass.states.remove(group_entity_id) + + if group_entity_id in _GROUPS: + hass.bus.remove_listener( + ha.EVENT_STATE_CHANGED, _GROUPS.pop(group_entity_id))