From 90007a04d384e9004a42d529f92e553b9ffa76c7 Mon Sep 17 00:00:00 2001 From: Daren Lord Date: Thu, 12 Nov 2015 23:37:15 -0700 Subject: [PATCH 01/69] Adding iCloud device_tracker component. Allow to track devices registered with iCloud --- .../components/device_tracker/icloud.py | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 homeassistant/components/device_tracker/icloud.py diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py new file mode 100644 index 00000000000..f193e68bc22 --- /dev/null +++ b/homeassistant/components/device_tracker/icloud.py @@ -0,0 +1,131 @@ +""" +homeassistant.components.device_tracker.icloud +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Device tracker platform that supports scanning a iCloud devices. + +It does require that your device has registered with Find My iPhone. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.icloud/ +""" +import logging +from datetime import timedelta +import threading + +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +from homeassistant.helpers import validate_config +from homeassistant.util import Throttle +from homeassistant.components.device_tracker import DOMAIN + +import re + +# Return cached results if last scan was less then this time ago +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['https://github.com/picklepete/pyicloud/archive/' + '80f6cd6decc950514b8dc43b30c5bded81b34d5f.zip' + '#pyicloud==0.8.0', + 'certifi'] + + +#pylint: disable=unused-argument +def get_scanner(hass, config): + """ Validates config and returns a iPhone Scanner. """ + if not validate_config(config, + {DOMAIN: [CONF_USERNAME, CONF_PASSWORD]}, + _LOGGER): + return None + + scanner = ICloudDeviceScanner(config[DOMAIN]) + + return scanner if scanner.success_init else None + + +class ICloudDeviceScanner(object): + """ + This class looks up devices from your iCloud account + and can report on their lat and long if registered. + """ + + def __init__(self, config): + from pyicloud import PyiCloudService + from pyicloud.exceptions import PyiCloudFailedLoginException + from pyicloud.exceptions import PyiCloudNoDevicesException + + self.username = config[CONF_USERNAME] + self.password = config[CONF_PASSWORD] + + self.lock = threading.Lock() + + self.last_results = {} + + # Get the data from iCloud + try: + _LOGGER.info('Logging into iCloud Services') + self._api = PyiCloudService(self.username, self.password, verify=True) + except PyiCloudFailedLoginException: + _LOGGER.exception("Failed login to iCloud Service." + + "Verify Username and Password") + return + + try: + devices = self.get_devices() + except PyiCloudNoDevicesException: + _LOGGER.exception("No iCloud Devices found.") + return + + self.success_init = devices is not None + + if self.success_init: + self.last_results = devices + else: + _LOGGER.error('Issues getting iCloud results') + + def scan_devices(self): + """ + Scans for new devices and return a list containing found devices id's + """ + + self._update_info() + + return [device for device in self.last_results] + + def get_device_name(self, mac): + """ Returns the name of the given device or None if we don't know """ + try: + return next(device for device in self.last_results + if device == mac) + except StopIteration: + return None + + @Throttle(MIN_TIME_BETWEEN_SCANS) + def _update_info(self): + """ Retrieve the latest information from iCloud + Returns a bool if scanning is successful + """ + + if not self.success_init: + return + + with self.lock: + _LOGGER.info('Scanning iCloud Devices') + + self.last_results = self.get_devices() or {} + + def get_devices(self): + devices = {} + for device in self._api.devices: + try: + devices[device.status()['name']] = { + 'device_id': re.sub(r'(\s*|\W*)', device.status()['name'], ''), + 'host_name': device.status()['name'], + 'gps': (device.location()['latitude'], + device.location()['longitude']), + 'battery': device.status()['batteryLevel']*100 + } + except TypeError: + # Device is not tracked. + continue + return devices \ No newline at end of file From c60bb35d4ae7a277117b490ad37e7f000479ca67 Mon Sep 17 00:00:00 2001 From: Daren Lord Date: Thu, 12 Nov 2015 23:40:30 -0700 Subject: [PATCH 02/69] Fixed lint errors --- homeassistant/components/device_tracker/icloud.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py index f193e68bc22..f4b3998ab40 100644 --- a/homeassistant/components/device_tracker/icloud.py +++ b/homeassistant/components/device_tracker/icloud.py @@ -30,7 +30,6 @@ REQUIREMENTS = ['https://github.com/picklepete/pyicloud/archive/' 'certifi'] -#pylint: disable=unused-argument def get_scanner(hass, config): """ Validates config and returns a iPhone Scanner. """ if not validate_config(config, @@ -64,7 +63,9 @@ class ICloudDeviceScanner(object): # Get the data from iCloud try: _LOGGER.info('Logging into iCloud Services') - self._api = PyiCloudService(self.username, self.password, verify=True) + self._api = PyiCloudService(self.username, + self.password, + verify=True) except PyiCloudFailedLoginException: _LOGGER.exception("Failed login to iCloud Service." + "Verify Username and Password") @@ -119,7 +120,9 @@ class ICloudDeviceScanner(object): for device in self._api.devices: try: devices[device.status()['name']] = { - 'device_id': re.sub(r'(\s*|\W*)', device.status()['name'], ''), + 'device_id': re.sub(r'(\s*|\W*)', + device.status()['name'], + ''), 'host_name': device.status()['name'], 'gps': (device.location()['latitude'], device.location()['longitude']), @@ -128,4 +131,4 @@ class ICloudDeviceScanner(object): except TypeError: # Device is not tracked. continue - return devices \ No newline at end of file + return devices From fff6b24449d8ea77aefb66eb603b9f5ad5309061 Mon Sep 17 00:00:00 2001 From: Daren Lord Date: Sat, 21 Nov 2015 21:04:28 -0700 Subject: [PATCH 03/69] Switching to new device scanner setup. --- .../components/device_tracker/icloud.py | 163 ++++++------------ 1 file changed, 54 insertions(+), 109 deletions(-) diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py index f4b3998ab40..e99d5fc0e30 100644 --- a/homeassistant/components/device_tracker/icloud.py +++ b/homeassistant/components/device_tracker/icloud.py @@ -1,134 +1,79 @@ """ homeassistant.components.device_tracker.icloud ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Device tracker platform that supports scanning a iCloud devices. +Device tracker platform that supports scanning iCloud devices. -It does require that your device has registered with Find My iPhone. +It does require that your device has beend registered with Find My iPhone. + +Note: that this may cause battery drainage as it wakes up your device to +get the current location. + +Note: You may receive an email from Apple stating that someone has logged +into your account. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.icloud/ """ import logging -from datetime import timedelta -import threading from homeassistant.const import CONF_USERNAME, CONF_PASSWORD -from homeassistant.helpers import validate_config -from homeassistant.util import Throttle -from homeassistant.components.device_tracker import DOMAIN - +from homeassistant.helpers.event import track_utc_time_change +from pyicloud import PyiCloudService +from pyicloud.exceptions import PyiCloudFailedLoginException +from pyicloud.exceptions import PyiCloudNoDevicesException import re -# Return cached results if last scan was less then this time ago -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=60) +SCAN_INTERVAL = 60 _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['https://github.com/picklepete/pyicloud/archive/' '80f6cd6decc950514b8dc43b30c5bded81b34d5f.zip' - '#pyicloud==0.8.0', - 'certifi'] + '#pyicloud==0.8.0'] -def get_scanner(hass, config): - """ Validates config and returns a iPhone Scanner. """ - if not validate_config(config, - {DOMAIN: [CONF_USERNAME, CONF_PASSWORD]}, - _LOGGER): - return None +def setup_scanner(hass, config, see): - scanner = ICloudDeviceScanner(config[DOMAIN]) + # Get the username and password from the configuration + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] - return scanner if scanner.success_init else None + try: + _LOGGER.info('Logging into iCloud Account') + # Attempt the login to iCloud + api = PyiCloudService(username, + password, + verify=True) + except PyiCloudFailedLoginException as e: + _LOGGER.exception('Error logging into iCloud Service: {0}'.format(str(e))) - -class ICloudDeviceScanner(object): - """ - This class looks up devices from your iCloud account - and can report on their lat and long if registered. - """ - - def __init__(self, config): - from pyicloud import PyiCloudService - from pyicloud.exceptions import PyiCloudFailedLoginException - from pyicloud.exceptions import PyiCloudNoDevicesException - - self.username = config[CONF_USERNAME] - self.password = config[CONF_PASSWORD] - - self.lock = threading.Lock() - - self.last_results = {} - - # Get the data from iCloud + def update_icloud(now): try: - _LOGGER.info('Logging into iCloud Services') - self._api = PyiCloudService(self.username, - self.password, - verify=True) - except PyiCloudFailedLoginException: - _LOGGER.exception("Failed login to iCloud Service." + - "Verify Username and Password") - return + # The session timeouts if we are not using it so we have to re-authenticate. This will send an email. + api.authenticate() + # Loop through every device registered with the iCloud account + for device in api.devices: + status = device.status() + location = device.location() + # If the device has a location add it. If not do nothing + if location: + see( + dev_id=re.sub(r"(\s|\W|')", + '', + status['name']), + host_name=status['name'], + gps=(location['latitude'], location['longitude']), + battery=status['batteryLevel']*100, + gps_accuracy=location['horizontalAccuracy'] + ) + else: + # No location found for the device so continue + continue + except PyiCloudNoDevicesException as e: + _LOGGER.exception('No iCloud Devices found!') - try: - devices = self.get_devices() - except PyiCloudNoDevicesException: - _LOGGER.exception("No iCloud Devices found.") - return - - self.success_init = devices is not None - - if self.success_init: - self.last_results = devices - else: - _LOGGER.error('Issues getting iCloud results') - - def scan_devices(self): - """ - Scans for new devices and return a list containing found devices id's - """ - - self._update_info() - - return [device for device in self.last_results] - - def get_device_name(self, mac): - """ Returns the name of the given device or None if we don't know """ - try: - return next(device for device in self.last_results - if device == mac) - except StopIteration: - return None - - @Throttle(MIN_TIME_BETWEEN_SCANS) - def _update_info(self): - """ Retrieve the latest information from iCloud - Returns a bool if scanning is successful - """ - - if not self.success_init: - return - - with self.lock: - _LOGGER.info('Scanning iCloud Devices') - - self.last_results = self.get_devices() or {} - - def get_devices(self): - devices = {} - for device in self._api.devices: - try: - devices[device.status()['name']] = { - 'device_id': re.sub(r'(\s*|\W*)', - device.status()['name'], - ''), - 'host_name': device.status()['name'], - 'gps': (device.location()['latitude'], - device.location()['longitude']), - 'battery': device.status()['batteryLevel']*100 - } - except TypeError: - # Device is not tracked. - continue - return devices + track_utc_time_change( + hass, + update_icloud, + second=range(0, 60, SCAN_INTERVAL) + ) \ No newline at end of file From 807485473163e659a35d5bd16622d8d86db19a13 Mon Sep 17 00:00:00 2001 From: Daren Lord Date: Sat, 21 Nov 2015 21:12:41 -0700 Subject: [PATCH 04/69] Fixing formatting --- .../components/device_tracker/icloud.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py index e99d5fc0e30..5968d7d8ac8 100644 --- a/homeassistant/components/device_tracker/icloud.py +++ b/homeassistant/components/device_tracker/icloud.py @@ -33,6 +33,9 @@ REQUIREMENTS = ['https://github.com/picklepete/pyicloud/archive/' def setup_scanner(hass, config, see): + """ + Set up the iCloud Scanner + """ # Get the username and password from the configuration username = config[CONF_USERNAME] @@ -45,11 +48,17 @@ def setup_scanner(hass, config, see): password, verify=True) except PyiCloudFailedLoginException as e: - _LOGGER.exception('Error logging into iCloud Service: {0}'.format(str(e))) + _LOGGER.exception( + 'Error logging into iCloud Service: {0}'.format(str(e)) + ) def update_icloud(now): + """ + Authenticate against iCloud and scan for devices. + """ try: - # The session timeouts if we are not using it so we have to re-authenticate. This will send an email. + # The session timeouts if we are not using it so we + # have to re-authenticate. This will send an email. api.authenticate() # Loop through every device registered with the iCloud account for device in api.devices: @@ -69,11 +78,11 @@ def setup_scanner(hass, config, see): else: # No location found for the device so continue continue - except PyiCloudNoDevicesException as e: + except PyiCloudNoDevicesException: _LOGGER.exception('No iCloud Devices found!') track_utc_time_change( hass, update_icloud, second=range(0, 60, SCAN_INTERVAL) - ) \ No newline at end of file + ) From e3d4e3ad4db6ad32ee6ff16a08e8be656343e1e4 Mon Sep 17 00:00:00 2001 From: Daren Lord Date: Fri, 4 Dec 2015 09:08:46 -0700 Subject: [PATCH 05/69] Increasing scan interval. Moved imports. --- homeassistant/components/device_tracker/icloud.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py index 5968d7d8ac8..2986bbec595 100644 --- a/homeassistant/components/device_tracker/icloud.py +++ b/homeassistant/components/device_tracker/icloud.py @@ -18,12 +18,9 @@ import logging from homeassistant.const import CONF_USERNAME, CONF_PASSWORD from homeassistant.helpers.event import track_utc_time_change -from pyicloud import PyiCloudService -from pyicloud.exceptions import PyiCloudFailedLoginException -from pyicloud.exceptions import PyiCloudNoDevicesException import re -SCAN_INTERVAL = 60 +SCAN_INTERVAL = 1800 _LOGGER = logging.getLogger(__name__) @@ -36,6 +33,9 @@ def setup_scanner(hass, config, see): """ Set up the iCloud Scanner """ + from pyicloud import PyiCloudService + from pyicloud.exceptions import PyiCloudFailedLoginException + from pyicloud.exceptions import PyiCloudNoDevicesException # Get the username and password from the configuration username = config[CONF_USERNAME] @@ -47,10 +47,11 @@ def setup_scanner(hass, config, see): api = PyiCloudService(username, password, verify=True) - except PyiCloudFailedLoginException as e: + except PyiCloudFailedLoginException as error: _LOGGER.exception( - 'Error logging into iCloud Service: {0}'.format(str(e)) + 'Error logging into iCloud Service: {0}'.format(error) ) + return def update_icloud(now): """ @@ -79,7 +80,7 @@ def setup_scanner(hass, config, see): # No location found for the device so continue continue except PyiCloudNoDevicesException: - _LOGGER.exception('No iCloud Devices found!') + _LOGGER.info('No iCloud Devices found!') track_utc_time_change( hass, From 9ecc08c0c8c97d655610f80f3c9090b68704401f Mon Sep 17 00:00:00 2001 From: Daren Lord Date: Fri, 4 Dec 2015 09:19:16 -0700 Subject: [PATCH 06/69] Adding in pyicloud to requirements_all.txt --- homeassistant/components/device_tracker/icloud.py | 4 ++-- requirements_all.txt | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py index 2986bbec595..d196cc40107 100644 --- a/homeassistant/components/device_tracker/icloud.py +++ b/homeassistant/components/device_tracker/icloud.py @@ -16,9 +16,9 @@ https://home-assistant.io/components/device_tracker.icloud/ """ import logging +import re from homeassistant.const import CONF_USERNAME, CONF_PASSWORD from homeassistant.helpers.event import track_utc_time_change -import re SCAN_INTERVAL = 1800 @@ -49,7 +49,7 @@ def setup_scanner(hass, config, see): verify=True) except PyiCloudFailedLoginException as error: _LOGGER.exception( - 'Error logging into iCloud Service: {0}'.format(error) + 'Error logging into iCloud Service: {}'.format(error) ) return diff --git a/requirements_all.txt b/requirements_all.txt index 2715ca3288d..2f5d27d0b7d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -170,3 +170,6 @@ https://github.com/persandstrom/python-verisure/archive/9873c4527f01b1ba1f72ae60 # homeassistant.components.zwave pydispatcher==2.0.5 + +# homeassistant.sensor.icloud +https://github.com/picklepete/pyicloud/archive/80f6cd6decc950514b8dc43b30c5bded81b34d5f.zip#pyicloud==0.8.0 From 254889e3fd6655b312f9f6fd95d0cfe6eed31d9d Mon Sep 17 00:00:00 2001 From: Daren Lord Date: Fri, 4 Dec 2015 09:23:05 -0700 Subject: [PATCH 07/69] Fixing logging for pylint --- homeassistant/components/device_tracker/icloud.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py index d196cc40107..a4adaa547bc 100644 --- a/homeassistant/components/device_tracker/icloud.py +++ b/homeassistant/components/device_tracker/icloud.py @@ -49,7 +49,7 @@ def setup_scanner(hass, config, see): verify=True) except PyiCloudFailedLoginException as error: _LOGGER.exception( - 'Error logging into iCloud Service: {}'.format(error) + 'Error logging into iCloud Service: %s' % error ) return From d3a21bee82bef1c11c926c28298590cba7df82c9 Mon Sep 17 00:00:00 2001 From: happyleaves Date: Thu, 10 Dec 2015 20:52:36 -0500 Subject: [PATCH 08/69] twitch media player --- .../components/media_player/twitch.py | 87 +++++++++++++++++++ requirements_all.txt | 3 + 2 files changed, 90 insertions(+) create mode 100644 homeassistant/components/media_player/twitch.py diff --git a/homeassistant/components/media_player/twitch.py b/homeassistant/components/media_player/twitch.py new file mode 100644 index 00000000000..4b6705c58fd --- /dev/null +++ b/homeassistant/components/media_player/twitch.py @@ -0,0 +1,87 @@ +""" +homeassistant.components.media_player.twitch +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Twitch stream status. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.twitch/ +""" + +from homeassistant.const import STATE_PLAYING, STATE_OFF + +from homeassistant.components.media_player import ( + MediaPlayerDevice, MEDIA_TYPE_CHANNEL) + +REQUIREMENTS = ['python-twitch==1.2.0'] +DOMAIN = 'twitch' + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Sets up the Twitch platform. """ + add_devices( + [TwitchDevice(channel) for channel in config.get('channels', [])]) + + +class TwitchDevice(MediaPlayerDevice): + """ Represents an Twitch channel. """ + + # pylint: disable=abstract-method + def __init__(self, channel): + self._channel = channel + self._state = STATE_OFF + self._preview = None + self._game = None + self._title = None + + @property + def should_poll(self): + """ Device should be polled. """ + return True + + @property + def state(self): + """ State of the player. """ + return self._state + + # pylint: disable=no-member + def update(self): + """ Update device state. """ + from twitch.api import v3 as twitch + stream = twitch.streams.by_channel(self._channel).get('stream') + if stream: + self._game = stream.get('channel').get('game') + self._title = stream.get('channel').get('status') + self._preview = stream.get('preview').get('small') + self._state = STATE_PLAYING + else: + self._state = STATE_OFF + + @property + def name(self): + """ Channel name. """ + return self._channel + + @property + def media_title(self): + """ Channel title. """ + return self._title + + @property + def app_name(self): + """ Game name. """ + return self._game + + @property + def media_image_url(self): + """ Image preview url of the live stream. """ + return self._preview + + @property + def media_content_type(self): + """ Media type (channel). """ + return MEDIA_TYPE_CHANNEL + + def media_pause(self): + """ Must implement because UI can pause. """ + pass diff --git a/requirements_all.txt b/requirements_all.txt index 15bdeb60a3c..b57e27e8fbd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -76,6 +76,9 @@ plexapi==1.1.0 # homeassistant.components.media_player.sonos SoCo==0.11.1 +# homeassistant.components.media_player.twitch +python-twitch==1.2.0 + # homeassistant.components.modbus https://github.com/bashwork/pymodbus/archive/d7fc4f1cc975631e0a9011390e8017f64b612661.zip#pymodbus==1.2.0 From 1c5411101878c886cf9cced53b9b0c9d6652e465 Mon Sep 17 00:00:00 2001 From: Philip Lundrigan Date: Fri, 11 Dec 2015 15:19:49 -0700 Subject: [PATCH 09/69] Add template support to rest sensor --- homeassistant/components/sensor/rest.py | 74 +++++++++---------------- 1 file changed, 26 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/sensor/rest.py b/homeassistant/components/sensor/rest.py index f50113350da..041a656a5ba 100644 --- a/homeassistant/components/sensor/rest.py +++ b/homeassistant/components/sensor/rest.py @@ -7,12 +7,12 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.rest/ """ from datetime import timedelta -from json import loads import logging - import requests +import jinja2.exceptions -from homeassistant.util import Throttle +from homeassistant.const import CONF_VALUE_TEMPLATE +from homeassistant.util import template, Throttle from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) @@ -59,57 +59,43 @@ def setup_platform(hass, config, add_devices, discovery_info=None): 'Please check the URL in the configuration file.') return False - try: - data = loads(response.text) - except ValueError: - _LOGGER.error('No valid JSON in the response in: %s', data) - return False - - try: - RestSensor.extract_value(data, config.get('variable')) - except KeyError: - _LOGGER.error('Variable "%s" not found in response: "%s"', - config.get('variable'), data) - return False + value_template = config.get(CONF_VALUE_TEMPLATE) + data = response.text + if value_template is not None: + try: + template.render_with_possible_json_value(hass, + value_template, + data) + except jinja2.exceptions.UndefinedError: + _LOGGER.error('Template "%s" not found in response: "%s"', + value_template, data) + return False if use_get: rest = RestDataGet(resource, verify_ssl) elif use_post: rest = RestDataPost(resource, payload, verify_ssl) - add_devices([RestSensor(rest, + add_devices([RestSensor(hass, + rest, config.get('name', DEFAULT_NAME), - config.get('variable'), config.get('unit_of_measurement'), - config.get('correction_factor', None), - config.get('decimal_places', None))]) + config.get(CONF_VALUE_TEMPLATE))]) # pylint: disable=too-many-arguments class RestSensor(Entity): """ Implements a REST sensor. """ - def __init__(self, rest, name, variable, unit_of_measurement, corr_factor, - decimal_places): + def __init__(self, hass, rest, name, unit_of_measurement, value_template): + self._hass = hass self.rest = rest self._name = name - self._variable = variable self._state = 'n/a' self._unit_of_measurement = unit_of_measurement - self._corr_factor = corr_factor - self._decimal_places = decimal_places + self._value_template = value_template self.update() - @classmethod - def extract_value(cls, data, variable): - """ Extracts the value using a key name or a path. """ - if isinstance(variable, list): - for variable_item in variable: - data = data[variable_item] - return data - else: - return data[variable] - @property def name(self): """ The name of the sensor. """ @@ -133,18 +119,10 @@ class RestSensor(Entity): if 'error' in value: self._state = value['error'] else: - try: - if value is not None: - value = RestSensor.extract_value(value, self._variable) - if self._corr_factor is not None: - value = float(value) * float(self._corr_factor) - if self._decimal_places is not None: - value = round(value, self._decimal_places) - if self._decimal_places == 0: - value = int(value) - self._state = value - except ValueError: - self._state = RestSensor.extract_value(value, self._variable) + if self._value_template is not None: + value = template.render_with_possible_json_value( + self._hass, self._value_template, value) + self._state = value # pylint: disable=too-few-public-methods @@ -164,7 +142,7 @@ class RestDataGet(object): verify=self._verify_ssl) if 'error' in self.data: del self.data['error'] - self.data = response.json() + self.data = response.text except requests.exceptions.ConnectionError: _LOGGER.error("No route to resource/endpoint.") self.data['error'] = 'N/A' @@ -188,7 +166,7 @@ class RestDataPost(object): timeout=10, verify=self._verify_ssl) if 'error' in self.data: del self.data['error'] - self.data = response.json() + self.data = response.text except requests.exceptions.ConnectionError: _LOGGER.error("No route to resource/endpoint.") self.data['error'] = 'N/A' From 0b325b2b7de2a6841d4548730ff812dc404121ac Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 11 Dec 2015 18:43:00 -0800 Subject: [PATCH 10/69] API.stream - catch more errors --- homeassistant/components/api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index 6a66a2a110e..6d2f9e52a7a 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -126,7 +126,9 @@ def _handle_get_api_stream(handler, path_match, data): wfile.write(msg.encode("UTF-8")) wfile.flush() handler.server.sessions.extend_validation(session_id) - except IOError: + except (IOError, ValueError): + # IOError: socket errors + # ValueError: raised when 'I/O operation on closed file' block.set() def forward_events(event): From 5c63862054078224c01409fbb8b1f2df7de6ecb1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 11 Dec 2015 18:45:53 -0800 Subject: [PATCH 11/69] Fix template rounding --- homeassistant/util/template.py | 4 ++-- tests/util/test_template.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/util/template.py b/homeassistant/util/template.py index 107532db776..bc89d053e60 100644 --- a/homeassistant/util/template.py +++ b/homeassistant/util/template.py @@ -66,8 +66,8 @@ class DomainStates(object): def forgiving_round(value, precision=0): """ Rounding method that accepts strings. """ try: - return int(float(value)) if precision == 0 else round(float(value), - precision) + value = round(float(value), precision) + return int(value) if precision == 0 else value except ValueError: # If value can't be converted to float return value diff --git a/tests/util/test_template.py b/tests/util/test_template.py index 5c1dfff1f85..bbb4de31626 100644 --- a/tests/util/test_template.py +++ b/tests/util/test_template.py @@ -57,10 +57,10 @@ class TestUtilTemplate(unittest.TestCase): '{{ states.sensor.temperature.state | round(1) }}')) def test_rounding_value2(self): - self.hass.states.set('sensor.temperature', 12.72) + self.hass.states.set('sensor.temperature', 12.78) self.assertEqual( - '127', + '128', template.render( self.hass, '{{ states.sensor.temperature.state | multiply(10) | round }}')) From b1bf6a609e4e187543ac72d592cb3ecdbabff532 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 11 Dec 2015 19:07:03 -0800 Subject: [PATCH 12/69] Catch exceptions when error rendering templates --- homeassistant/exceptions.py | 7 +++++++ homeassistant/util/template.py | 20 ++++++++++++++++---- tests/util/test_template.py | 6 +++++- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index bd32d356670..510bc9b4e54 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -14,3 +14,10 @@ class InvalidEntityFormatError(HomeAssistantError): class NoEntitySpecifiedError(HomeAssistantError): """ When no entity is specified. """ pass + + +class TemplateError(HomeAssistantError): + """ Error during template rendering. """ + def __init__(self, exception): + super().__init__('{}: {}'.format(exception.__class__.__name__, + exception)) diff --git a/homeassistant/util/template.py b/homeassistant/util/template.py index bc89d053e60..b7fc3197e08 100644 --- a/homeassistant/util/template.py +++ b/homeassistant/util/template.py @@ -6,7 +6,12 @@ Template utility methods for rendering strings with HA data. """ # pylint: disable=too-few-public-methods import json +import logging +import jinja2 from jinja2.sandbox import ImmutableSandboxedEnvironment +from homeassistant.exceptions import TemplateError + +_LOGGER = logging.getLogger(__name__) def render_with_possible_json_value(hass, template, value): @@ -20,7 +25,11 @@ def render_with_possible_json_value(hass, template, value): except ValueError: pass - return render(hass, template, variables) + try: + return render(hass, template, variables) + except TemplateError: + _LOGGER.exception('Error parsing value') + return value def render(hass, template, variables=None, **kwargs): @@ -28,9 +37,12 @@ def render(hass, template, variables=None, **kwargs): if variables is not None: kwargs.update(variables) - return ENV.from_string(template, { - 'states': AllStates(hass) - }).render(kwargs) + try: + return ENV.from_string(template, { + 'states': AllStates(hass) + }).render(kwargs) + except jinja2.TemplateError as err: + raise TemplateError(err) class AllStates(object): diff --git a/tests/util/test_template.py b/tests/util/test_template.py index bbb4de31626..16e1f8b6a04 100644 --- a/tests/util/test_template.py +++ b/tests/util/test_template.py @@ -7,7 +7,7 @@ Tests Home Assistant util methods. # pylint: disable=too-many-public-methods import unittest import homeassistant.core as ha - +from homeassistant.exceptions import TemplateError from homeassistant.util import template @@ -84,3 +84,7 @@ class TestUtilTemplate(unittest.TestCase): '', template.render_with_possible_json_value( self.hass, '{{ value_json }}', '{ I AM NOT JSON }')) + + def test_raise_exception_on_error(self): + with self.assertRaises(TemplateError): + template.render(self.hass, '{{ invalid_syntax') From 9eea7a6cde126dbf7c2f76d9708cb7cc93eb34df Mon Sep 17 00:00:00 2001 From: Richard Arends Date: Sat, 12 Dec 2015 16:25:56 +0100 Subject: [PATCH 13/69] Added support for MPD to start playing the current song/playlist --- homeassistant/components/media_player/mpd.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index 8134ce5e743..b6c89cbfc3f 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -21,14 +21,14 @@ from homeassistant.const import ( from homeassistant.components.media_player import ( MediaPlayerDevice, SUPPORT_PAUSE, SUPPORT_VOLUME_SET, SUPPORT_TURN_OFF, - SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, + SUPPORT_TURN_ON, SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, MEDIA_TYPE_MUSIC) _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['python-mpd2==0.5.4'] SUPPORT_MPD = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_TURN_OFF | \ - SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK + SUPPORT_TURN_ON | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK # pylint: disable=unused-argument @@ -163,9 +163,13 @@ class MpdDevice(MediaPlayerDevice): return SUPPORT_MPD def turn_off(self): - """ Service to exit the running MPD. """ + """ Service to send the MPD the command to stop playing. """ self.client.stop() + def turn_on(self): + """ Service to send the MPD the command to start playing. """ + self.client.play() + def set_volume_level(self, volume): """ Sets volume """ self.client.setvol(int(volume * 100)) From 2b975c8620e0e420df30327d08b6d654ced1dbda Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 12 Dec 2015 10:35:15 -0800 Subject: [PATCH 14/69] Add flexible error value for value template parsing --- homeassistant/util/template.py | 6 ++++-- tests/util/test_template.py | 12 ++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/homeassistant/util/template.py b/homeassistant/util/template.py index b7fc3197e08..ad0fabdab53 100644 --- a/homeassistant/util/template.py +++ b/homeassistant/util/template.py @@ -12,9 +12,11 @@ from jinja2.sandbox import ImmutableSandboxedEnvironment from homeassistant.exceptions import TemplateError _LOGGER = logging.getLogger(__name__) +_SENTINEL = object() -def render_with_possible_json_value(hass, template, value): +def render_with_possible_json_value(hass, template, value, + error_value=_SENTINEL): """ Renders template with value exposed. If valid JSON will expose value_json too. """ variables = { @@ -29,7 +31,7 @@ def render_with_possible_json_value(hass, template, value): return render(hass, template, variables) except TemplateError: _LOGGER.exception('Error parsing value') - return value + return value if error_value is _SENTINEL else error_value def render(hass, template, variables=None, **kwargs): diff --git a/tests/util/test_template.py b/tests/util/test_template.py index 16e1f8b6a04..ba354f3e7be 100644 --- a/tests/util/test_template.py +++ b/tests/util/test_template.py @@ -85,6 +85,18 @@ class TestUtilTemplate(unittest.TestCase): template.render_with_possible_json_value( self.hass, '{{ value_json }}', '{ I AM NOT JSON }')) + def test_render_with_possible_json_value_with_template_error(self): + self.assertEqual( + 'hello', + template.render_with_possible_json_value( + self.hass, '{{ value_json', 'hello')) + + def test_render_with_possible_json_value_with_template_error_error_value(self): + self.assertEqual( + '-', + template.render_with_possible_json_value( + self.hass, '{{ value_json', 'hello', '-')) + def test_raise_exception_on_error(self): with self.assertRaises(TemplateError): template.render(self.hass, '{{ invalid_syntax') From a84ff14b00b69230d8e2ba9a115c8e621db4cc74 Mon Sep 17 00:00:00 2001 From: Philip Lundrigan Date: Sat, 12 Dec 2015 13:34:21 -0700 Subject: [PATCH 15/69] Remove exception handling for templates It is now handled in the layers below --- homeassistant/components/sensor/rest.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/homeassistant/components/sensor/rest.py b/homeassistant/components/sensor/rest.py index 041a656a5ba..f824f022966 100644 --- a/homeassistant/components/sensor/rest.py +++ b/homeassistant/components/sensor/rest.py @@ -9,7 +9,6 @@ https://home-assistant.io/components/sensor.rest/ from datetime import timedelta import logging import requests -import jinja2.exceptions from homeassistant.const import CONF_VALUE_TEMPLATE from homeassistant.util import template, Throttle @@ -59,18 +58,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): 'Please check the URL in the configuration file.') return False - value_template = config.get(CONF_VALUE_TEMPLATE) - data = response.text - if value_template is not None: - try: - template.render_with_possible_json_value(hass, - value_template, - data) - except jinja2.exceptions.UndefinedError: - _LOGGER.error('Template "%s" not found in response: "%s"', - value_template, data) - return False - if use_get: rest = RestDataGet(resource, verify_ssl) elif use_post: @@ -121,7 +108,7 @@ class RestSensor(Entity): else: if self._value_template is not None: value = template.render_with_possible_json_value( - self._hass, self._value_template, value) + self._hass, self._value_template, value, 'N/A') self._state = value From 27e3e72211805fd44a3af2d255c10a75aa7bc60b Mon Sep 17 00:00:00 2001 From: Philip Lundrigan Date: Sat, 12 Dec 2015 14:19:00 -0700 Subject: [PATCH 16/69] Change description of rest sensor --- homeassistant/components/sensor/rest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/rest.py b/homeassistant/components/sensor/rest.py index f824f022966..4dcd036df5e 100644 --- a/homeassistant/components/sensor/rest.py +++ b/homeassistant/components/sensor/rest.py @@ -1,7 +1,7 @@ """ homeassistant.components.sensor.rest ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The rest sensor will consume JSON responses sent by an exposed REST API. +The rest sensor will consume responses sent by an exposed REST API. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.rest/ From f45848477043d933119180e1229b841372b86240 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 10 Dec 2015 11:11:35 +0100 Subject: [PATCH 17/69] Add sensor for Dweet.io --- homeassistant/components/sensor/dweet.py | 119 +++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 homeassistant/components/sensor/dweet.py diff --git a/homeassistant/components/sensor/dweet.py b/homeassistant/components/sensor/dweet.py new file mode 100644 index 00000000000..346ecaf6457 --- /dev/null +++ b/homeassistant/components/sensor/dweet.py @@ -0,0 +1,119 @@ +""" +homeassistant.components.sensor.dweet +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Displays values from Dweet.io.. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.dweet/ +""" +from datetime import timedelta +import logging + +import homeassistant.util as util +from homeassistant.util import Throttle +from homeassistant.helpers.entity import Entity +from homeassistant.const import STATE_UNKNOWN + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Dweet.io Sensor' +CONF_DEVICE = 'device' + +# Return cached results if last scan was less then this time ago +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + + +# pylint: disable=unused-variable +def setup_platform(hass, config, add_devices, discovery_info=None): + """ Setup the Dweet sensor. """ + + import dweepy + + device = config.get('device') + + if device is None: + _LOGGER.error('Not all required config keys present: %s', + ', '.join(CONF_DEVICE)) + return False + + try: + dweepy.get_latest_dweet_for(device) + except dweepy.DweepyError: + _LOGGER.error("Device/thing '%s' could not be found", device) + return False + + dweet = DweetData(device) + + add_devices([DweetSensor(dweet, + config.get('name', DEFAULT_NAME), + config.get('variable'), + config.get('unit_of_measurement'), + config.get('correction_factor', None), + config.get('decimal_places', None))]) + + +class DweetSensor(Entity): + """ Implements a Dweet sensor. """ + + def __init__(self, dweet, name, variable, unit_of_measurement, corr_factor, + decimal_places): + self.dweet = dweet + self._name = name + self._variable = variable + self._state = STATE_UNKNOWN + self._unit_of_measurement = unit_of_measurement + self._corr_factor = corr_factor + self._decimal_places = decimal_places + self.update() + + @property + def name(self): + """ The name of the sensor. """ + return self._name + + @property + def unit_of_measurement(self): + """ Unit the value is expressed in. """ + return self._unit_of_measurement + + @property + def state(self): + """ Returns the state. """ + values = self.dweet.data + + if values is not None: + value = util.extract_value_json(values[0]['content'], + self._variable) + if self._corr_factor is not None: + value = float(value) * float(self._corr_factor) + if self._decimal_places is not None: + value = round(value, self._decimal_places) + if self._decimal_places == 0: + value = int(value) + return value + else: + return STATE_UNKNOWN + + def update(self): + """ Gets the latest data from REST API. """ + self.dweet.update() + + +# pylint: disable=too-few-public-methods +class DweetData(object): + """ Class for handling the data retrieval. """ + + def __init__(self, device): + self._device = device + self.data = dict() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """ Gets the latest data from Dweet.io. """ + import dweepy + + try: + self.data = dweepy.get_latest_dweet_for(self._device) + except dweepy.DweepyError: + _LOGGER.error("Device '%s' could not be found", self._device) + self.data = None From 64e3db2444d6c3822deb98666920f14c206eed2f Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 10 Dec 2015 11:12:06 +0100 Subject: [PATCH 18/69] Add dweet sensor --- .coveragerc | 1 + 1 file changed, 1 insertion(+) diff --git a/.coveragerc b/.coveragerc index 993f8c9533b..44093131cfd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -84,6 +84,7 @@ omit = homeassistant/components/sensor/command_sensor.py homeassistant/components/sensor/cpuspeed.py homeassistant/components/sensor/dht.py + homeassistant/components/sensor/dweet.py homeassistant/components/sensor/efergy.py homeassistant/components/sensor/forecast.py homeassistant/components/sensor/glances.py From 4286e116f3d661bcc86c3c8041e216a5d35af733 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 10 Dec 2015 11:13:59 +0100 Subject: [PATCH 19/69] Add requirement --- homeassistant/components/sensor/dweet.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/sensor/dweet.py b/homeassistant/components/sensor/dweet.py index 346ecaf6457..124036d298e 100644 --- a/homeassistant/components/sensor/dweet.py +++ b/homeassistant/components/sensor/dweet.py @@ -15,6 +15,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.const import STATE_UNKNOWN _LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ['dweepy==0.2.0'] DEFAULT_NAME = 'Dweet.io Sensor' CONF_DEVICE = 'device' From 1d017beb934bf584780a29c71784dcb658a5c3fa Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 12 Dec 2015 21:56:47 +0100 Subject: [PATCH 20/69] Use templating --- homeassistant/components/sensor/dweet.py | 54 ++++++++++++------------ 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/sensor/dweet.py b/homeassistant/components/sensor/dweet.py index 124036d298e..422012bec2c 100644 --- a/homeassistant/components/sensor/dweet.py +++ b/homeassistant/components/sensor/dweet.py @@ -1,18 +1,19 @@ """ homeassistant.components.sensor.dweet ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Displays values from Dweet.io.. +Displays values from Dweet.io. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.dweet/ """ from datetime import timedelta import logging +import json -import homeassistant.util as util from homeassistant.util import Throttle +from homeassistant.util import template from homeassistant.helpers.entity import Entity -from homeassistant.const import STATE_UNKNOWN +from homeassistant.const import (STATE_UNKNOWN, CONF_VALUE_TEMPLATE) _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['dweepy==0.2.0'] @@ -24,47 +25,51 @@ CONF_DEVICE = 'device' MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -# pylint: disable=unused-variable +# pylint: disable=unused-variable, too-many-function-args def setup_platform(hass, config, add_devices, discovery_info=None): """ Setup the Dweet sensor. """ - import dweepy device = config.get('device') + value_template = config.get(CONF_VALUE_TEMPLATE) - if device is None: + if None in (device, value_template): _LOGGER.error('Not all required config keys present: %s', - ', '.join(CONF_DEVICE)) + ', '.join(CONF_DEVICE, CONF_VALUE_TEMPLATE)) return False try: - dweepy.get_latest_dweet_for(device) + content = json.dumps(dweepy.get_latest_dweet_for(device)[0]['content']) except dweepy.DweepyError: _LOGGER.error("Device/thing '%s' could not be found", device) return False + if template.render_with_possible_json_value(hass, + value_template, + content) is '': + _LOGGER.error("'%s' was not found", value_template) + return False + dweet = DweetData(device) - add_devices([DweetSensor(dweet, + add_devices([DweetSensor(hass, + dweet, config.get('name', DEFAULT_NAME), - config.get('variable'), - config.get('unit_of_measurement'), - config.get('correction_factor', None), - config.get('decimal_places', None))]) + value_template, + config.get('unit_of_measurement'))]) +# pylint: disable=too-many-arguments class DweetSensor(Entity): """ Implements a Dweet sensor. """ - def __init__(self, dweet, name, variable, unit_of_measurement, corr_factor, - decimal_places): + def __init__(self, hass, dweet, name, value_template, unit_of_measurement): + self.hass = hass self.dweet = dweet self._name = name - self._variable = variable + self._value_template = value_template self._state = STATE_UNKNOWN self._unit_of_measurement = unit_of_measurement - self._corr_factor = corr_factor - self._decimal_places = decimal_places self.update() @property @@ -80,17 +85,10 @@ class DweetSensor(Entity): @property def state(self): """ Returns the state. """ - values = self.dweet.data - + values = json.dumps(self.dweet.data[0]['content']) if values is not None: - value = util.extract_value_json(values[0]['content'], - self._variable) - if self._corr_factor is not None: - value = float(value) * float(self._corr_factor) - if self._decimal_places is not None: - value = round(value, self._decimal_places) - if self._decimal_places == 0: - value = int(value) + value = template.render_with_possible_json_value( + self.hass, self._value_template, values) return value else: return STATE_UNKNOWN From 27c5c1cb9f59d40c339fb30037172f8d9079d03f Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 12 Dec 2015 22:58:08 +0100 Subject: [PATCH 21/69] Add dweepy --- requirements_all.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/requirements_all.txt b/requirements_all.txt index 5db0a85cee4..78234e8fb52 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -177,3 +177,6 @@ pydispatcher==2.0.5 # homeassistant.components.heatmiser heatmiserV3==0.9.1 + +# homeassistant.components.seansor.dweet +dweepy==0.2.0 From ed9b75756aa950296048a76d633b40922537ee58 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 13 Dec 2015 01:00:12 +0100 Subject: [PATCH 22/69] Catch error state --- homeassistant/components/sensor/dweet.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sensor/dweet.py b/homeassistant/components/sensor/dweet.py index 422012bec2c..8aa55c847cf 100644 --- a/homeassistant/components/sensor/dweet.py +++ b/homeassistant/components/sensor/dweet.py @@ -85,13 +85,13 @@ class DweetSensor(Entity): @property def state(self): """ Returns the state. """ - values = json.dumps(self.dweet.data[0]['content']) - if values is not None: + if self.dweet.data is None: + return STATE_UNKNOWN + else: + values = json.dumps(self.dweet.data[0]['content']) value = template.render_with_possible_json_value( self.hass, self._value_template, values) return value - else: - return STATE_UNKNOWN def update(self): """ Gets the latest data from REST API. """ @@ -104,7 +104,7 @@ class DweetData(object): def __init__(self, device): self._device = device - self.data = dict() + self.data = None @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): From 649ce7befbade134db3c56b93eccc4ad9d02ab24 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 13 Dec 2015 01:00:34 +0100 Subject: [PATCH 23/69] Fix typo --- requirements_all.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_all.txt b/requirements_all.txt index 78234e8fb52..d26029d909f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -178,5 +178,5 @@ pydispatcher==2.0.5 # homeassistant.components.heatmiser heatmiserV3==0.9.1 -# homeassistant.components.seansor.dweet +# homeassistant.components.sensor.dweet dweepy==0.2.0 From 931f7e86152aef707fbd57ffdefe57ee39293151 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 12 Dec 2015 22:18:38 -0800 Subject: [PATCH 24/69] Simplify http component --- homeassistant/components/http.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/http.py b/homeassistant/components/http.py index 2b260b0e841..81e26aeae5a 100644 --- a/homeassistant/components/http.py +++ b/homeassistant/components/http.py @@ -178,12 +178,8 @@ class RequestHandler(SimpleHTTPRequestHandler): """ Does some common checks and calls appropriate method. """ url = urlparse(self.path) - # Read query input - data = parse_qs(url.query) - - # parse_qs gives a list for each value, take the latest element - for key in data: - data[key] = data[key][-1] + # Read query input. parse_qs gives a list for each value, we want last + data = {key: data[-1] for key, data in parse_qs(url.query).items()} # Did we get post input ? content_length = int(self.headers.get(HTTP_HEADER_CONTENT_LENGTH, 0)) From 360b99be59068a04504b8417d6877a782537cd6e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 12 Dec 2015 22:19:12 -0800 Subject: [PATCH 25/69] Add template is_state method --- homeassistant/util/template.py | 5 +++-- tests/util/test_template.py | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/util/template.py b/homeassistant/util/template.py index ad0fabdab53..f8fa2c70f8b 100644 --- a/homeassistant/util/template.py +++ b/homeassistant/util/template.py @@ -41,8 +41,9 @@ def render(hass, template, variables=None, **kwargs): try: return ENV.from_string(template, { - 'states': AllStates(hass) - }).render(kwargs) + 'states': AllStates(hass), + 'is_state': hass.states.is_state + }).render(kwargs).strip() except jinja2.TemplateError as err: raise TemplateError(err) diff --git a/tests/util/test_template.py b/tests/util/test_template.py index ba354f3e7be..1e34d999fa7 100644 --- a/tests/util/test_template.py +++ b/tests/util/test_template.py @@ -100,3 +100,11 @@ class TestUtilTemplate(unittest.TestCase): def test_raise_exception_on_error(self): with self.assertRaises(TemplateError): template.render(self.hass, '{{ invalid_syntax') + + def test_is_state(self): + self.hass.states.set('test.object', 'available') + self.assertEqual( + 'yes', + template.render( + self.hass, + '{% if is_state("test.object", "available") %}yes{% else %}no{% endif %}')) From f73f824e0e5df55bb9b86c6771b26c0d262e1d95 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 12 Dec 2015 22:19:37 -0800 Subject: [PATCH 26/69] Make template states var callable --- homeassistant/util/template.py | 14 +++++++++++++- tests/util/test_template.py | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/homeassistant/util/template.py b/homeassistant/util/template.py index f8fa2c70f8b..d0a07507bdf 100644 --- a/homeassistant/util/template.py +++ b/homeassistant/util/template.py @@ -9,6 +9,7 @@ import json import logging import jinja2 from jinja2.sandbox import ImmutableSandboxedEnvironment +from homeassistant.const import STATE_UNKNOWN from homeassistant.exceptions import TemplateError _LOGGER = logging.getLogger(__name__) @@ -60,6 +61,10 @@ class AllStates(object): return iter(sorted(self._hass.states.all(), key=lambda state: state.entity_id)) + def __call__(self, entity_id): + state = self._hass.states.get(entity_id) + return STATE_UNKNOWN if state is None else state.state + class DomainStates(object): """ Class to expose a specific HA domain as attributes. """ @@ -96,6 +101,13 @@ def multiply(value, amount): # If value can't be converted to float return value -ENV = ImmutableSandboxedEnvironment() + +class TemplateEnvironment(ImmutableSandboxedEnvironment): + """ Home Assistant template environment. """ + + def is_safe_callable(self, obj): + return isinstance(obj, AllStates) or super().is_safe_callable(obj) + +ENV = TemplateEnvironment() ENV.filters['round'] = forgiving_round ENV.filters['multiply'] = multiply diff --git a/tests/util/test_template.py b/tests/util/test_template.py index 1e34d999fa7..1ecd7d5b894 100644 --- a/tests/util/test_template.py +++ b/tests/util/test_template.py @@ -101,6 +101,14 @@ class TestUtilTemplate(unittest.TestCase): with self.assertRaises(TemplateError): template.render(self.hass, '{{ invalid_syntax') + def test_if_state_exists(self): + self.hass.states.set('test.object', 'available') + self.assertEqual( + 'exists', + template.render( + self.hass, + '{% if states.test.object %}exists{% else %}not exists{% endif %}')) + def test_is_state(self): self.hass.states.set('test.object', 'available') self.assertEqual( @@ -108,3 +116,12 @@ class TestUtilTemplate(unittest.TestCase): template.render( self.hass, '{% if is_state("test.object", "available") %}yes{% else %}no{% endif %}')) + + def test_states_function(self): + self.hass.states.set('test.object', 'available') + self.assertEqual( + 'available', + template.render(self.hass, '{{ states("test.object") }}')) + self.assertEqual( + 'unknown', + template.render(self.hass, '{{ states("test.object2") }}')) From 729c24d59bada00f0f35a3ae7273a8044a7bb796 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 12 Dec 2015 22:29:02 -0800 Subject: [PATCH 27/69] Add Alexa component --- homeassistant/components/alexa.py | 186 ++++++++++++++++++++++++ tests/components/test_alexa.py | 225 ++++++++++++++++++++++++++++++ 2 files changed, 411 insertions(+) create mode 100644 homeassistant/components/alexa.py create mode 100644 tests/components/test_alexa.py diff --git a/homeassistant/components/alexa.py b/homeassistant/components/alexa.py new file mode 100644 index 00000000000..c261cfd3f6a --- /dev/null +++ b/homeassistant/components/alexa.py @@ -0,0 +1,186 @@ +""" +components.alexa +~~~~~~~~~~~~~~~~ +Component to offer a service end point for an Alexa skill. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/alexa/ +""" +import enum +import logging + +from homeassistant.const import HTTP_OK, HTTP_UNPROCESSABLE_ENTITY +from homeassistant.util import template + +DOMAIN = 'alexa' +DEPENDENCIES = ['http'] + +_LOGGER = logging.getLogger(__name__) +_CONFIG = {} + +API_ENDPOINT = '/api/alexa' + +CONF_INTENTS = 'intents' +CONF_CARD = 'card' +CONF_SPEECH = 'speech' + + +def setup(hass, config): + """ Activate Alexa component. """ + _CONFIG.update(config[DOMAIN].get(CONF_INTENTS, {})) + + hass.http.register_path('POST', API_ENDPOINT, _handle_alexa, True) + + return True + + +def _handle_alexa(handler, path_match, data): + """ Handle Alexa. """ + _LOGGER.debug('Received Alexa request: %s', data) + + req = data.get('request') + + if req is None: + _LOGGER.error('Received invalid data from Alexa: %s', data) + handler.write_json_message( + "Invalid value received for port", HTTP_UNPROCESSABLE_ENTITY) + return + + req_type = req['type'] + + if req_type == 'SessionEndedRequest': + handler.send_response(HTTP_OK) + handler.end_headers() + return + + intent = req.get('intent') + response = AlexaResponse(handler.server.hass, intent) + + if req_type == 'LaunchRequest': + response.add_speech( + SpeechType.plaintext, + "Hello, and welcome to the future. How may I help?") + handler.write_json(response.as_dict()) + return + + if req_type != 'IntentRequest': + _LOGGER.warning('Received unsupported request: %s', req_type) + return + + intent_name = intent['name'] + config = _CONFIG.get(intent_name) + + if config is None: + _LOGGER.warning('Received unknown intent %s', intent_name) + response.add_speech( + SpeechType.plaintext, + "This intent is not yet configured within Home Assistant.") + handler.write_json(response.as_dict()) + return + + speech = config.get(CONF_SPEECH) + card = config.get(CONF_CARD) + + # pylint: disable=unsubscriptable-object + if speech is not None: + response.add_speech(SpeechType[speech['type']], speech['text']) + + if card is not None: + response.add_card(CardType[card['type']], card['title'], + card['content']) + + handler.write_json(response.as_dict()) + + +class SpeechType(enum.Enum): + """ Alexa speech types. """ + plaintext = "PlainText" + ssml = "SSML" + + +class CardType(enum.Enum): + """ Alexa card types. """ + simple = "Simple" + link_account = "LinkAccount" + + +class AlexaResponse(object): + """ Helps generating the response for Alexa. """ + + def __init__(self, hass, intent=None): + self.hass = hass + self.speech = None + self.card = None + self.reprompt = None + self.session_attributes = {} + self.should_end_session = True + if intent is not None and 'slots' in intent: + self.variables = {key: value['value'] for key, value + in intent['slots'].items()} + else: + self.variables = {} + + def add_card(self, card_type, title, content): + """ Add a card to the response. """ + assert self.card is None + + card = { + "type": card_type.value + } + + if card_type == CardType.link_account: + self.card = card + return + + card["title"] = self._render(title), + card["content"] = self._render(content) + self.card = card + + def add_speech(self, speech_type, text): + """ Add speech to the response. """ + assert self.speech is None + + key = 'ssml' if speech_type == SpeechType.ssml else 'text' + + self.speech = { + 'type': speech_type.value, + key: self._render(text) + } + + def add_reprompt(self, speech_type, text): + """ Add repromopt if user does not answer. """ + assert self.reprompt is None + + key = 'ssml' if speech_type == SpeechType.ssml else 'text' + + self.reprompt = { + 'type': speech_type.value, + key: self._render(text) + } + + def as_dict(self): + """ Returns response in an Alexa valid dict. """ + response = { + 'shouldEndSession': self.should_end_session + } + + if self.card is not None: + response['card'] = self.card + + if self.speech is not None: + response['outputSpeech'] = self.speech + + if self.reprompt is not None: + response['reprompt'] = { + 'outputSpeech': self.reprompt + } + + return { + 'version': '1.0', + 'sessionAttributes': self.session_attributes, + 'response': response, + } + + def _render(self, template_string): + """ Render a response, adding data from intent if available. """ + return template.render(self.hass, template_string, self.variables) diff --git a/tests/components/test_alexa.py b/tests/components/test_alexa.py new file mode 100644 index 00000000000..75aec2b087c --- /dev/null +++ b/tests/components/test_alexa.py @@ -0,0 +1,225 @@ +""" +tests.test_component_alexa +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests Home Assistant Alexa component does what it should do. +""" +# pylint: disable=protected-access,too-many-public-methods +import unittest +import json +from unittest.mock import patch + +import requests + +from homeassistant import bootstrap, const +import homeassistant.core as ha +from homeassistant.components import alexa, http + +API_PASSWORD = "test1234" + +# Somehow the socket that holds the default port does not get released +# when we close down HA in a different test case. Until I have figured +# out what is going on, let's run this test on a different port. +SERVER_PORT = 8119 + +API_URL = "http://127.0.0.1:{}{}".format(SERVER_PORT, alexa.API_ENDPOINT) + +HA_HEADERS = {const.HTTP_HEADER_HA_AUTH: API_PASSWORD} + +hass = None + + +@patch('homeassistant.components.http.util.get_local_ip', + return_value='127.0.0.1') +def setUpModule(mock_get_local_ip): # pylint: disable=invalid-name + """ Initalizes a Home Assistant server. """ + global hass + + hass = ha.HomeAssistant() + + bootstrap.setup_component( + hass, http.DOMAIN, + {http.DOMAIN: {http.CONF_API_PASSWORD: API_PASSWORD, + http.CONF_SERVER_PORT: SERVER_PORT}}) + + bootstrap.setup_component(hass, alexa.DOMAIN, { + 'alexa': { + 'intents': { + 'WhereAreWeIntent': { + 'speech': { + 'type': 'plaintext', + 'text': + """ + {%- if is_state('device_tracker.paulus', 'home') and is_state('device_tracker.anne_therese', 'home') -%} + You are both home, you silly + {%- else -%} + Anne Therese is at {{ states("device_tracker.anne_therese") }} and Paulus is at {{ states("device_tracker.paulus") }} + {% endif %} + """, + } + }, + 'GetZodiacHoroscopeIntent': { + 'speech': { + 'type': 'plaintext', + 'text': 'You told us your sign is {{ ZodiacSign }}.' + } + } + } + } + }) + + hass.start() + + +def tearDownModule(): # pylint: disable=invalid-name + """ Stops the Home Assistant server. """ + hass.stop() + + +def _req(data={}): + return requests.post(API_URL, data=json.dumps(data), timeout=5, + headers=HA_HEADERS) + + +class TestAlexa(unittest.TestCase): + """ Test Alexa. """ + + def test_launch_request(self): + data = { + 'version': '1.0', + 'session': { + 'new': True, + 'sessionId': 'amzn1.echo-api.session.0000000-0000-0000-0000-00000000000', + 'application': { + 'applicationId': 'amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe' + }, + 'attributes': {}, + 'user': { + 'userId': 'amzn1.account.AM3B00000000000000000000000' + } + }, + 'request': { + 'type': 'LaunchRequest', + 'requestId': 'amzn1.echo-api.request.0000000-0000-0000-0000-00000000000', + 'timestamp': '2015-05-13T12:34:56Z' + } + } + req = _req(data) + self.assertEqual(200, req.status_code) + resp = req.json() + self.assertIn('outputSpeech', resp['response']) + + def test_intent_request_with_slots(self): + data = { + 'version': '1.0', + 'session': { + 'new': False, + 'sessionId': 'amzn1.echo-api.session.0000000-0000-0000-0000-00000000000', + 'application': { + 'applicationId': 'amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe' + }, + 'attributes': { + 'supportedHoroscopePeriods': { + 'daily': True, + 'weekly': False, + 'monthly': False + } + }, + 'user': { + 'userId': 'amzn1.account.AM3B00000000000000000000000' + } + }, + 'request': { + 'type': 'IntentRequest', + 'requestId': ' amzn1.echo-api.request.0000000-0000-0000-0000-00000000000', + 'timestamp': '2015-05-13T12:34:56Z', + 'intent': { + 'name': 'GetZodiacHoroscopeIntent', + 'slots': { + 'ZodiacSign': { + 'name': 'ZodiacSign', + 'value': 'virgo' + } + } + } + } + } + req = _req(data) + self.assertEqual(200, req.status_code) + text = req.json().get('response', {}).get('outputSpeech', {}).get('text') + self.assertEqual('You told us your sign is virgo.', text) + + def test_intent_request_without_slots(self): + data = { + 'version': '1.0', + 'session': { + 'new': False, + 'sessionId': 'amzn1.echo-api.session.0000000-0000-0000-0000-00000000000', + 'application': { + 'applicationId': 'amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe' + }, + 'attributes': { + 'supportedHoroscopePeriods': { + 'daily': True, + 'weekly': False, + 'monthly': False + } + }, + 'user': { + 'userId': 'amzn1.account.AM3B00000000000000000000000' + } + }, + 'request': { + 'type': 'IntentRequest', + 'requestId': ' amzn1.echo-api.request.0000000-0000-0000-0000-00000000000', + 'timestamp': '2015-05-13T12:34:56Z', + 'intent': { + 'name': 'WhereAreWeIntent', + } + } + } + req = _req(data) + self.assertEqual(200, req.status_code) + text = req.json().get('response', {}).get('outputSpeech', {}).get('text') + + self.assertEqual('Anne Therese is at unknown and Paulus is at unknown', text) + + hass.states.set('device_tracker.paulus', 'home') + hass.states.set('device_tracker.anne_therese', 'home') + + req = _req(data) + self.assertEqual(200, req.status_code) + text = req.json().get('response', {}).get('outputSpeech', {}).get('text') + self.assertEqual('You are both home, you silly', text) + + def test_session_ended_request(self): + data = { + 'version': '1.0', + 'session': { + 'new': False, + 'sessionId': 'amzn1.echo-api.session.0000000-0000-0000-0000-00000000000', + 'application': { + 'applicationId': 'amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe' + }, + 'attributes': { + 'supportedHoroscopePeriods': { + 'daily': True, + 'weekly': False, + 'monthly': False + } + }, + 'user': { + 'userId': 'amzn1.account.AM3B00000000000000000000000' + } + }, + 'request': { + 'type': 'SessionEndedRequest', + 'requestId': 'amzn1.echo-api.request.0000000-0000-0000-0000-00000000000', + 'timestamp': '2015-05-13T12:34:56Z', + 'reason': 'USER_INITIATED' + } + } + + req = _req(data) + self.assertEqual(200, req.status_code) + self.assertEqual('', req.text) From 46f742f82f6d0fda347e2ffade32b4b49d30879c Mon Sep 17 00:00:00 2001 From: Richard Arends Date: Sun, 13 Dec 2015 18:42:53 +0100 Subject: [PATCH 28/69] Added support for MPD to display the name before the title if it is available When using a radio stream, the name of the station is often available in currentsong['name']. Just like the 'mpc' CLI client, this change displays the name of the station before the current song title. For example: "Mick Jagger - Let's Work" becomes "Radio 10: Mick Jagger - Let's Work" --- homeassistant/components/media_player/mpd.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index b6c89cbfc3f..e1cdadd9ae2 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -141,7 +141,15 @@ class MpdDevice(MediaPlayerDevice): @property def media_title(self): """ Title of current playing media. """ - return self.currentsong['title'] + name = self.currentsong['name'] + title = self.currentsong['title'] + + if name: + separator = ': ' + nameandtitle = (name, title) + return separator.join(nameandtitle) + else: + return title @property def media_artist(self): From 85b62a20c8e7326d4110a22078fa61af8083ed8e Mon Sep 17 00:00:00 2001 From: Richard Arends Date: Sun, 13 Dec 2015 19:46:17 +0100 Subject: [PATCH 29/69] Use format instead of str.join to join name and title --- homeassistant/components/media_player/mpd.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index e1cdadd9ae2..f84013fd573 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -145,9 +145,7 @@ class MpdDevice(MediaPlayerDevice): title = self.currentsong['title'] if name: - separator = ': ' - nameandtitle = (name, title) - return separator.join(nameandtitle) + return '{}: {}'.format(name, title) else: return title From 035d518cb686d314f586ed6a2acc94e3b59183fe Mon Sep 17 00:00:00 2001 From: Richard Arends Date: Sun, 13 Dec 2015 19:54:10 +0100 Subject: [PATCH 30/69] Expect the case where currentsong['name'] can be absent. Use the .get funtion with a default value set to None --- homeassistant/components/media_player/mpd.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index f84013fd573..a61dac88150 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -141,13 +141,13 @@ class MpdDevice(MediaPlayerDevice): @property def media_title(self): """ Title of current playing media. """ - name = self.currentsong['name'] + name = self.currentsong.get('name', None) title = self.currentsong['title'] - if name: - return '{}: {}'.format(name, title) - else: + if name is None: return title + else: + return '{}: {}'.format(name, title) @property def media_artist(self): From b3171c7cde16c08a0d7da7f3c4c869211cfc2022 Mon Sep 17 00:00:00 2001 From: caius Date: Wed, 9 Dec 2015 20:12:23 +0100 Subject: [PATCH 31/69] Add fritzconnection library to requirements --- requirements_all.txt | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/requirements_all.txt b/requirements_all.txt index d26029d909f..0712569561a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -83,9 +83,6 @@ https://github.com/bashwork/pymodbus/archive/d7fc4f1cc975631e0a9011390e8017f64b6 # homeassistant.components.mqtt paho-mqtt==1.1 -# homeassistant.components.mqtt -jsonpath-rw==1.4.0 - # homeassistant.components.notify.pushbullet pushbullet.py==0.9.0 @@ -119,6 +116,9 @@ py-cpuinfo==0.1.6 # homeassistant.components.sensor.dht # http://github.com/mala-zaba/Adafruit_Python_DHT/archive/4101340de8d2457dd194bca1e8d11cbfc237e919.zip#Adafruit_DHT==1.1.0 +# homeassistant.components.sensor.dweet +dweepy==0.2.0 + # homeassistant.components.sensor.forecast python-forecastio==1.3.3 @@ -160,6 +160,9 @@ orvibo==1.0.1 # homeassistant.components.switch.wemo pywemo==0.3.3 +# homeassistant.components.thermostat.heatmiser +heatmiserV3==0.9.1 + # homeassistant.components.thermostat.honeywell evohomeclient==0.2.4 @@ -174,9 +177,3 @@ https://github.com/persandstrom/python-verisure/archive/9873c4527f01b1ba1f72ae60 # homeassistant.components.zwave pydispatcher==2.0.5 - -# homeassistant.components.heatmiser -heatmiserV3==0.9.1 - -# homeassistant.components.sensor.dweet -dweepy==0.2.0 From 9833b4b66337f6116147ed9b11f60ca8db2b7b04 Mon Sep 17 00:00:00 2001 From: caius Date: Wed, 9 Dec 2015 20:22:40 +0100 Subject: [PATCH 32/69] Add the fritz device tracker to track established connections to FritzBox routers --- .../components/device_tracker/fritz.py | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 homeassistant/components/device_tracker/fritz.py diff --git a/homeassistant/components/device_tracker/fritz.py b/homeassistant/components/device_tracker/fritz.py new file mode 100644 index 00000000000..99c405d479f --- /dev/null +++ b/homeassistant/components/device_tracker/fritz.py @@ -0,0 +1,165 @@ +""" +homeassistant.components.device_tracker.fritz +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Unfortunately, you have to execute the following command by hand: +sudo apt-get install libxslt-dev libxml2-dev + +Device tracker platform that supports scanning a FitzBox router for device +presence. + +Configuration: + +To use the fritz tracker you have to adapt your configuration.yaml by +using the following template: + +device_tracker: + platform: fritz + host: YOUR_ROUTER_IP + username: YOUR_ADMIN_USERNAME + password: YOUR_ADMIN_PASSWORD + + +Description: + +host +*Optional +The IP address of your router, e.g. 192.168.0.1. +It is optional since every fritzbox is also reachable by using +the 169.254.1.1 IP. + +username +*Optional +The username of an user with administrative privileges, usually 'admin'. +However, it seems that it is not necessary to use it in +current generation fritzbox routers because the necessary data +can be retrieved anonymously. + +password +*Optional +The password for your given admin account. +However, it seems that it is not necessary to use it in current +generation fritzbox routers because the necessary data can +be retrieved anonymously. +""" + +import logging +from datetime import timedelta + +from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD +from homeassistant.helpers import validate_config +from homeassistant.util import Throttle +from homeassistant.components.device_tracker import DOMAIN + +# Return cached results if last scan was less then this time ago +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) + +_LOGGER = logging.getLogger(__name__) + + +# noinspection PyUnusedLocal +def get_scanner(hass, config): + """ + Validates config and returns FritzBoxScanner + @param hass: + @param config: + @return: + """ + if not validate_config(config, + {DOMAIN: []}, + _LOGGER): + return None + scanner = FritzBoxScanner(config[DOMAIN]) + return scanner if scanner.success_init else None + + +# pylint: disable=too-many-instance-attributes +class FritzBoxScanner(object): + """ + This class queries a FritzBox router. It is using the + fritzconnection library for communication with the router. + + The API description can be found under: + https://pypi.python.org/pypi/fritzconnection/0.4.6 + + This scanner retrieves the list of known hosts and checks + their corresponding states (on, or off). + + Due to a bug of the fritzbox api (router side) it is not possible + to track more than 16 hosts. + """ + def __init__(self, config): + self.last_results = [] + self.host = '169.254.1.1' # This IP is valid for all fritzboxes + self.username = 'admin' + self.password = '' + self.success_init = True + + # Try to import the fritzconnection library + try: + # noinspection PyPackageRequirements,PyUnresolvedReferences + import fritzconnection as fc + except ImportError: + _LOGGER.exception("""Failed to import Python library + fritzconnection. Please run + /setup to install it.""") + self.success_init = False + return + + # Check for user specific configuration + if CONF_HOST in config.keys(): + self.host = config[CONF_HOST] + if CONF_USERNAME in config.keys(): + self.username = config[CONF_USERNAME] + if CONF_PASSWORD in config.keys(): + self.password = config[CONF_PASSWORD] + + # Establish a connection to the fritzbox + # noinspection PyBroadException + try: + self.fritz_box = fc.FritzHosts(address=self.host, + user=self.username, + password=self.password) + except Exception: + self.fritz_box = None + + # At this point it is difficult to tell if a connection is established. + # So just check for null objects ... + if self.fritz_box is None or not self.fritz_box.modelname: + self.success_init = False + + if self.success_init: + _LOGGER.info("Successfully connected to {0}" + .format(self.fritz_box.modelname)) + self._update_info() + else: + _LOGGER.error("Failed to establish connection to FritzBox " + "with IP: {0}".format(self.host)) + + def scan_devices(self): + """ Scan for new devices and return a list of found device ids. """ + self._update_info() + active_hosts = [] + for known_host in self.last_results: + if known_host["status"] == "1": + active_hosts.append(known_host["mac"]) + return active_hosts + + def get_device_name(self, mac): + """ Returns the name of the given device or None if is not known. """ + ret = self.fritz_box.get_specific_host_entry(mac)["NewHostName"] + if ret == {}: + return None + return ret + + @Throttle(MIN_TIME_BETWEEN_SCANS) + def _update_info(self): + """ + Retrieves latest information from the FritzBox. + Returns boolean if scanning successful. + """ + if not self.success_init: + return False + + _LOGGER.info("Scanning") + self.last_results = self.fritz_box.get_hosts_info() + return True From cc962c6bb2f582636543138352eebe0b36be4048 Mon Sep 17 00:00:00 2001 From: caius Date: Wed, 9 Dec 2015 20:31:40 +0100 Subject: [PATCH 33/69] Add some lint suggested modifications --- homeassistant/components/device_tracker/fritz.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/device_tracker/fritz.py b/homeassistant/components/device_tracker/fritz.py index 99c405d479f..df439a4fb24 100644 --- a/homeassistant/components/device_tracker/fritz.py +++ b/homeassistant/components/device_tracker/fritz.py @@ -114,12 +114,11 @@ class FritzBoxScanner(object): self.password = config[CONF_PASSWORD] # Establish a connection to the fritzbox - # noinspection PyBroadException try: self.fritz_box = fc.FritzHosts(address=self.host, user=self.username, password=self.password) - except Exception: + except (ValueError, TypeError): self.fritz_box = None # At this point it is difficult to tell if a connection is established. @@ -128,12 +127,12 @@ class FritzBoxScanner(object): self.success_init = False if self.success_init: - _LOGGER.info("Successfully connected to {0}" - .format(self.fritz_box.modelname)) + _LOGGER.info("Successfully connected to %s", + self.fritz_box.modelname) self._update_info() else: _LOGGER.error("Failed to establish connection to FritzBox " - "with IP: {0}".format(self.host)) + "with IP: %s", self.host) def scan_devices(self): """ Scan for new devices and return a list of found device ids. """ From 0f777ecd4c665733e1c646841f1bad4bcf954e93 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 13 Dec 2015 13:21:38 -0800 Subject: [PATCH 34/69] Update coveragerc --- .coveragerc | 1 + 1 file changed, 1 insertion(+) diff --git a/.coveragerc b/.coveragerc index 44093131cfd..7192883b655 100644 --- a/.coveragerc +++ b/.coveragerc @@ -39,6 +39,7 @@ omit = homeassistant/components/device_tracker/aruba.py homeassistant/components/device_tracker/asuswrt.py homeassistant/components/device_tracker/ddwrt.py + homeassistant/components/device_tracker/fritz.py homeassistant/components/device_tracker/geofancy.py homeassistant/components/device_tracker/luci.py homeassistant/components/device_tracker/ubus.py From f93d6a1a1159016f4de8f2a8b3eeaabfcaf7e37c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 13 Dec 2015 13:40:42 -0800 Subject: [PATCH 35/69] Fix local dir config path --- homeassistant/components/frontend/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index fc955e30d44..3de4fdc99f8 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -109,8 +109,6 @@ def _handle_get_local(handler, path_match, data): """ req_file = util.sanitize_path(path_match.group('file')) - path = os.path.join(get_default_config_dir(), 'www', req_file) - if not os.path.isfile(path): - return False + path = handler.server.hass.config.path('www', req_file) handler.write_file(path) From 3faa1a4393c188855e9179f1e693b2783f3e83d3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 13 Dec 2015 13:47:05 -0800 Subject: [PATCH 36/69] Fix lint issues --- homeassistant/components/frontend/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 3de4fdc99f8..37e9b39079e 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -11,7 +11,6 @@ import logging from . import version, mdi_version import homeassistant.util as util from homeassistant.const import URL_ROOT, HTTP_OK -from homeassistant.config import get_default_config_dir DOMAIN = 'frontend' DEPENDENCIES = ['api'] From 90f0632a6986f851c069cab969744cf4b7368d21 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 13 Dec 2015 23:54:15 -0800 Subject: [PATCH 37/69] Install bower dependencies on frontend bootstrap --- script/bootstrap_frontend | 1 + 1 file changed, 1 insertion(+) diff --git a/script/bootstrap_frontend b/script/bootstrap_frontend index 6fc94f95725..5bc3baf38c9 100755 --- a/script/bootstrap_frontend +++ b/script/bootstrap_frontend @@ -1,5 +1,6 @@ echo "Bootstrapping frontend..." cd homeassistant/components/frontend/www_static/home-assistant-polymer npm install +bower install npm run setup_js_dev cd ../../../../.. From df24a1bfa7afb3a7c1595694e056d145637102e5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 13 Dec 2015 23:59:40 -0800 Subject: [PATCH 38/69] Update frontend (babel 6) --- homeassistant/components/frontend/version.py | 2 +- .../frontend/www_static/frontend.html | 17 ++++++++--------- .../frontend/www_static/home-assistant-polymer | 2 +- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 83383baf11d..13335959c9f 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -1,2 +1,2 @@ """ DO NOT MODIFY. Auto-generated by build_frontend script """ -VERSION = "aac488c33cd4291cd0924e60a55bd309" +VERSION = "026761064bba4614a0441b7b4fc4eeea" diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 907722b09ee..dc839ee6f9f 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -6080,12 +6080,11 @@ case"touchend":return this.addPointerListenerEnd(t,e,i,n);case"touchmove":return font-weight: 300; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; - } \ No newline at end of file + } \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer index e51b8add369..eff286e7425 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit e51b8add369f9e81d22b25b4be2400675361afdb +Subproject commit eff286e7425f53750048a68506767c5bc2f3a985 From 87d40f667361c0925963e4ed87feecce30eac9d4 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 14 Dec 2015 18:18:56 +0100 Subject: [PATCH 39/69] Move config details to doc --- .../components/device_tracker/fritz.py | 66 ++++--------------- 1 file changed, 12 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/device_tracker/fritz.py b/homeassistant/components/device_tracker/fritz.py index df439a4fb24..73c2e98792f 100644 --- a/homeassistant/components/device_tracker/fritz.py +++ b/homeassistant/components/device_tracker/fritz.py @@ -1,47 +1,12 @@ """ homeassistant.components.device_tracker.fritz -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Unfortunately, you have to execute the following command by hand: -sudo apt-get install libxslt-dev libxml2-dev - -Device tracker platform that supports scanning a FitzBox router for device +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Device tracker platform that supports scanning a FRITZ!Box router for device presence. -Configuration: - -To use the fritz tracker you have to adapt your configuration.yaml by -using the following template: - -device_tracker: - platform: fritz - host: YOUR_ROUTER_IP - username: YOUR_ADMIN_USERNAME - password: YOUR_ADMIN_PASSWORD - - -Description: - -host -*Optional -The IP address of your router, e.g. 192.168.0.1. -It is optional since every fritzbox is also reachable by using -the 169.254.1.1 IP. - -username -*Optional -The username of an user with administrative privileges, usually 'admin'. -However, it seems that it is not necessary to use it in -current generation fritzbox routers because the necessary data -can be retrieved anonymously. - -password -*Optional -The password for your given admin account. -However, it seems that it is not necessary to use it in current -generation fritzbox routers because the necessary data can -be retrieved anonymously. +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.fritz/ """ - import logging from datetime import timedelta @@ -58,16 +23,12 @@ _LOGGER = logging.getLogger(__name__) # noinspection PyUnusedLocal def get_scanner(hass, config): - """ - Validates config and returns FritzBoxScanner - @param hass: - @param config: - @return: - """ + """ Validates config and returns FritzBoxScanner. """ if not validate_config(config, {DOMAIN: []}, _LOGGER): return None + scanner = FritzBoxScanner(config[DOMAIN]) return scanner if scanner.success_init else None @@ -75,14 +36,14 @@ def get_scanner(hass, config): # pylint: disable=too-many-instance-attributes class FritzBoxScanner(object): """ - This class queries a FritzBox router. It is using the + This class queries a FRITZ!Box router. It is using the fritzconnection library for communication with the router. The API description can be found under: https://pypi.python.org/pypi/fritzconnection/0.4.6 - This scanner retrieves the list of known hosts and checks - their corresponding states (on, or off). + This scanner retrieves the list of known hosts and checks their + corresponding states (on, or off). Due to a bug of the fritzbox api (router side) it is not possible to track more than 16 hosts. @@ -113,7 +74,7 @@ class FritzBoxScanner(object): if CONF_PASSWORD in config.keys(): self.password = config[CONF_PASSWORD] - # Establish a connection to the fritzbox + # Establish a connection to the FRITZ!Box try: self.fritz_box = fc.FritzHosts(address=self.host, user=self.username, @@ -131,7 +92,7 @@ class FritzBoxScanner(object): self.fritz_box.modelname) self._update_info() else: - _LOGGER.error("Failed to establish connection to FritzBox " + _LOGGER.error("Failed to establish connection to FRITZ!Box " "with IP: %s", self.host) def scan_devices(self): @@ -152,10 +113,7 @@ class FritzBoxScanner(object): @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): - """ - Retrieves latest information from the FritzBox. - Returns boolean if scanning successful. - """ + """ Retrieves latest information from the FRITZ!Box. """ if not self.success_init: return False From 600831cff5a6da5fdfc873dc72f78e4ea7bf717c Mon Sep 17 00:00:00 2001 From: Philip Lundrigan Date: Mon, 14 Dec 2015 10:29:27 -0700 Subject: [PATCH 40/69] Add support for template --- .../components/sensor/command_sensor.py | 27 +++++++------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/sensor/command_sensor.py b/homeassistant/components/sensor/command_sensor.py index e60723f6bfa..1fd30a8bdb5 100644 --- a/homeassistant/components/sensor/command_sensor.py +++ b/homeassistant/components/sensor/command_sensor.py @@ -10,8 +10,9 @@ import logging import subprocess from datetime import timedelta +from homeassistant.const import CONF_VALUE_TEMPLATE from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle +from homeassistant.util import template, Throttle _LOGGER = logging.getLogger(__name__) @@ -32,25 +33,24 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): data = CommandSensorData(config.get('command')) add_devices_callback([CommandSensor( + hass, data, config.get('name', DEFAULT_NAME), config.get('unit_of_measurement'), - config.get('correction_factor', None), - config.get('decimal_places', None) + config.get(CONF_VALUE_TEMPLATE) )]) # pylint: disable=too-many-arguments class CommandSensor(Entity): """ Represents a sensor that is returning a value of a shell commands. """ - def __init__(self, data, name, unit_of_measurement, corr_factor, - decimal_places): + def __init__(self, hass, data, name, unit_of_measurement, value_template): + self._hass = hass self.data = data self._name = name self._state = False self._unit_of_measurement = unit_of_measurement - self._corr_factor = corr_factor - self._decimal_places = decimal_places + self._value_template = value_template self.update() @property @@ -73,17 +73,8 @@ class CommandSensor(Entity): self.data.update() value = self.data.value - try: - if value is not None: - if self._corr_factor is not None: - value = float(value) * float(self._corr_factor) - if self._decimal_places is not None: - value = round(value, self._decimal_places) - if self._decimal_places == 0: - value = int(value) - self._state = value - except ValueError: - self._state = value + self._state = template.render_with_possible_json_value( + self._hass, self._value_template, value, 'N/A') # pylint: disable=too-few-public-methods From e821b546d5666f5a51d26738e50d9b0f2a359b9a Mon Sep 17 00:00:00 2001 From: Philip Lundrigan Date: Mon, 14 Dec 2015 10:49:54 -0700 Subject: [PATCH 41/69] Ignore template if it isn't set --- homeassistant/components/sensor/command_sensor.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/command_sensor.py b/homeassistant/components/sensor/command_sensor.py index 1fd30a8bdb5..95751e73645 100644 --- a/homeassistant/components/sensor/command_sensor.py +++ b/homeassistant/components/sensor/command_sensor.py @@ -73,8 +73,11 @@ class CommandSensor(Entity): self.data.update() value = self.data.value - self._state = template.render_with_possible_json_value( - self._hass, self._value_template, value, 'N/A') + if self._value_template is not None: + self._state = template.render_with_possible_json_value( + self._hass, self._value_template, value, 'N/A') + else: + self._state = value # pylint: disable=too-few-public-methods From a1009d9138c2733cbda9e10dafd0d61eb5446b96 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 14 Dec 2015 21:38:56 +0100 Subject: [PATCH 42/69] Add template support --- homeassistant/components/binary_sensor/mqtt.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index cac991d4eb2..916f1226c82 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -7,7 +7,10 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.mqtt/ """ import logging + +from homeassistant.const import CONF_VALUE_TEMPLATE from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.util import template import homeassistant.components.mqtt as mqtt _LOGGER = logging.getLogger(__name__) @@ -34,13 +37,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): config.get('state_topic', None), config.get('qos', DEFAULT_QOS), config.get('payload_on', DEFAULT_PAYLOAD_ON), - config.get('payload_off', DEFAULT_PAYLOAD_OFF))]) + config.get('payload_off', DEFAULT_PAYLOAD_OFF), + config.get(CONF_VALUE_TEMPLATE))]) # pylint: disable=too-many-arguments, too-many-instance-attributes class MqttBinarySensor(BinarySensorDevice): """ Represents a binary sensor that is updated by MQTT. """ - def __init__(self, hass, name, state_topic, qos, payload_on, payload_off): + def __init__(self, hass, name, state_topic, qos, payload_on, payload_off, + value_template): self._hass = hass self._name = name self._state = False @@ -51,6 +56,9 @@ class MqttBinarySensor(BinarySensorDevice): def message_received(topic, payload, qos): """ A new MQTT message has been received. """ + if value_template is not None: + payload = template.render_with_possible_json_value( + hass, value_template, payload) if payload == self._payload_on: self._state = True self.update_ha_state() From 53239387e066d4744666f5c8123216c50b1169da Mon Sep 17 00:00:00 2001 From: Philip Lundrigan Date: Mon, 14 Dec 2015 11:08:31 -0700 Subject: [PATCH 43/69] Add support for template --- .../components/automation/numeric_state.py | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index 9f099100084..93fedcc8e3b 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -8,13 +8,14 @@ at https://home-assistant.io/components/automation/#numeric-state-trigger """ import logging +from homeassistant.const import CONF_VALUE_TEMPLATE from homeassistant.helpers.event import track_state_change +from homeassistant.util import template CONF_ENTITY_ID = "entity_id" CONF_BELOW = "below" CONF_ABOVE = "above" -CONF_ATTRIBUTE = "attribute" _LOGGER = logging.getLogger(__name__) @@ -29,7 +30,7 @@ def trigger(hass, config, action): below = config.get(CONF_BELOW) above = config.get(CONF_ABOVE) - attribute = config.get(CONF_ATTRIBUTE) + value_template = config.get(CONF_VALUE_TEMPLATE) if below is None and above is None: _LOGGER.error("Missing configuration key." @@ -37,13 +38,18 @@ def trigger(hass, config, action): CONF_BELOW, CONF_ABOVE) return False + if value_template is not None: + renderer = lambda value: template.render(hass, value_template, value) + else: + renderer = None + # pylint: disable=unused-argument def state_automation_listener(entity, from_s, to_s): """ Listens for state changes and calls action. """ # Fire action if we go from outside range into range - if _in_range(to_s, above, below, attribute) and \ - (from_s is None or not _in_range(from_s, above, below, attribute)): + if _in_range(to_s, above, below, renderer) and \ + (from_s is None or not _in_range(from_s, above, below, renderer)): action() track_state_change( @@ -63,7 +69,7 @@ def if_action(hass, config): below = config.get(CONF_BELOW) above = config.get(CONF_ABOVE) - attribute = config.get(CONF_ATTRIBUTE) + value_template = config.get(CONF_VALUE_TEMPLATE) if below is None and above is None: _LOGGER.error("Missing configuration key." @@ -71,18 +77,29 @@ def if_action(hass, config): CONF_BELOW, CONF_ABOVE) return None + if value_template is not None: + renderer = lambda value: template.render(hass, value_template, value) + else: + renderer = None + def if_numeric_state(): """ Test numeric state condition. """ state = hass.states.get(entity_id) - return state is not None and _in_range(state, above, below, attribute) + return state is not None and _in_range(state, above, below, + renderer) return if_numeric_state -def _in_range(state, range_start, range_end, attribute): +def _in_range(state, range_start, range_end, renderer): """ Checks if value is inside the range """ - value = (state.state if attribute is None - else state.attributes.get(attribute)) + + if renderer is not None: + value = renderer({'value': state}) + else: + # If no renderer is provided, just assume they want the state + value = state.state + try: value = float(value) except ValueError: From cec62bdf874d3963096b4ce469b679487e3d08f0 Mon Sep 17 00:00:00 2001 From: Philip Lundrigan Date: Mon, 14 Dec 2015 14:47:32 -0700 Subject: [PATCH 44/69] Add tests --- .../automation/test_numeric_state.py | 50 ++++++++++++++++--- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/automation/test_numeric_state.py index 8280f396f93..9e0301168f2 100644 --- a/tests/components/automation/test_numeric_state.py +++ b/tests/components/automation/test_numeric_state.py @@ -295,7 +295,7 @@ class TestAutomationNumericState(unittest.TestCase): 'trigger': { 'platform': 'numeric_state', 'entity_id': 'test.entity', - 'attribute': 'test_attribute', + 'value_template': '{{ value.attributes.test_attribute }}', 'below': 10, }, 'action': { @@ -314,7 +314,7 @@ class TestAutomationNumericState(unittest.TestCase): 'trigger': { 'platform': 'numeric_state', 'entity_id': 'test.entity', - 'attribute': 'test_attribute', + 'value_template': '{{ value.attributes.test_attribute }}', 'below': 10, }, 'action': { @@ -333,7 +333,7 @@ class TestAutomationNumericState(unittest.TestCase): 'trigger': { 'platform': 'numeric_state', 'entity_id': 'test.entity', - 'attribute': 'test_attribute', + 'value_template': '{{ value.attributes.test_attribute }}', 'below': 10, }, 'action': { @@ -352,7 +352,7 @@ class TestAutomationNumericState(unittest.TestCase): 'trigger': { 'platform': 'numeric_state', 'entity_id': 'test.entity', - 'attribute': 'test_attribute', + 'value_template': '{{ value.attributes.test_attribute }}', 'below': 10, }, 'action': { @@ -371,7 +371,7 @@ class TestAutomationNumericState(unittest.TestCase): 'trigger': { 'platform': 'numeric_state', 'entity_id': 'test.entity', - 'attribute': 'test_attribute', + 'value_template': '{{ value.attributes.test_attribute }}', 'below': 10, }, 'action': { @@ -384,13 +384,51 @@ class TestAutomationNumericState(unittest.TestCase): self.hass.pool.block_till_done() self.assertEqual(1, len(self.calls)) + def test_template_list(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'value_template': '{{ value.attributes.test_attribute[2] }}', + 'below': 10, + }, + 'action': { + 'service': 'test.automation' + } + } + })) + # 3 is below 10 + self.hass.states.set('test.entity', 'entity', { 'test_attribute': [11, 15, 3] }) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_template_string(self): + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'value_template': '{{ value.attributes.test_attribute | multiply(10) }}', + 'below': 10, + }, + 'action': { + 'service': 'test.automation' + } + } + })) + # 9 is below 10 + self.hass.states.set('test.entity', 'entity', { 'test_attribute': '0.9' }) + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls)) + def test_if_not_fires_on_attribute_change_with_attribute_not_below_multiple_attributes(self): self.assertTrue(automation.setup(self.hass, { automation.DOMAIN: { 'trigger': { 'platform': 'numeric_state', 'entity_id': 'test.entity', - 'attribute': 'test_attribute', + 'value_template': '{{ value.attributes.test_attribute }}', 'below': 10, }, 'action': { From 91a945f4c73d4c539e14c73baec0c153eb3bd61d Mon Sep 17 00:00:00 2001 From: Philip Lundrigan Date: Mon, 14 Dec 2015 15:01:38 -0700 Subject: [PATCH 45/69] Slight style change --- homeassistant/components/automation/numeric_state.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index 93fedcc8e3b..40249372b43 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -85,8 +85,7 @@ def if_action(hass, config): def if_numeric_state(): """ Test numeric state condition. """ state = hass.states.get(entity_id) - return state is not None and _in_range(state, above, below, - renderer) + return state is not None and _in_range(state, above, below, renderer) return if_numeric_state From a517784c9e6d4d67e51652f58ade87430376e1f0 Mon Sep 17 00:00:00 2001 From: Philip Lundrigan Date: Mon, 14 Dec 2015 15:07:35 -0700 Subject: [PATCH 46/69] Condense in_range template logic --- homeassistant/components/automation/numeric_state.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index 40249372b43..d73b7061eb8 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -92,12 +92,7 @@ def if_action(hass, config): def _in_range(state, range_start, range_end, renderer): """ Checks if value is inside the range """ - - if renderer is not None: - value = renderer({'value': state}) - else: - # If no renderer is provided, just assume they want the state - value = state.state + value = state.state if renderer is None else renderer({'value': state}) try: value = float(value) From d16526739c549afd0c8e3facf1f3f5562cb256d1 Mon Sep 17 00:00:00 2001 From: David Edmundson Date: Mon, 14 Dec 2015 23:53:35 +0000 Subject: [PATCH 47/69] Add PlayMedia support to Chromecast --- homeassistant/components/media_player/cast.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 2b343129861..5ad9607d9a3 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -15,7 +15,7 @@ from homeassistant.const import ( from homeassistant.components.media_player import ( MediaPlayerDevice, SUPPORT_PAUSE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_MUTE, - SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_YOUTUBE, + SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_YOUTUBE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO) @@ -24,7 +24,7 @@ CONF_IGNORE_CEC = 'ignore_cec' CAST_SPLASH = 'https://home-assistant.io/images/cast/splash.png' SUPPORT_CAST = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ - SUPPORT_NEXT_TRACK | SUPPORT_YOUTUBE + SUPPORT_NEXT_TRACK | SUPPORT_YOUTUBE | SUPPORT_PLAY_MEDIA KNOWN_HOSTS = [] # pylint: disable=invalid-name @@ -261,6 +261,10 @@ class CastDevice(MediaPlayerDevice): """ Seek the media to a specific location. """ self.cast.media_controller.seek(position) + def play_media(self, media_type, media_id): + """ Plays media from a URL """ + self.cast.media_controller.play_media(media_id, media_type) + def play_youtube(self, media_id): """ Plays a YouTube media. """ self.youtube.play_video(media_id) From 2e0042adb017f9bbc7322809aba522f121f66c04 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 14 Dec 2015 21:39:48 -0800 Subject: [PATCH 48/69] Tweak iCloud device tracker --- .coveragerc | 1 + .../components/device_tracker/icloud.py | 32 +++++++++++++------ requirements_all.txt | 6 ++-- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/.coveragerc b/.coveragerc index 7192883b655..94f4352ef89 100644 --- a/.coveragerc +++ b/.coveragerc @@ -41,6 +41,7 @@ omit = homeassistant/components/device_tracker/ddwrt.py homeassistant/components/device_tracker/fritz.py homeassistant/components/device_tracker/geofancy.py + homeassistant/components/device_tracker/icloud.py homeassistant/components/device_tracker/luci.py homeassistant/components/device_tracker/ubus.py homeassistant/components/device_tracker/netgear.py diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py index a4adaa547bc..a617fda9411 100644 --- a/homeassistant/components/device_tracker/icloud.py +++ b/homeassistant/components/device_tracker/icloud.py @@ -20,14 +20,15 @@ import re from homeassistant.const import CONF_USERNAME, CONF_PASSWORD from homeassistant.helpers.event import track_utc_time_change -SCAN_INTERVAL = 1800 - _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['https://github.com/picklepete/pyicloud/archive/' '80f6cd6decc950514b8dc43b30c5bded81b34d5f.zip' '#pyicloud==0.8.0'] +CONF_INTERVAL = 'interval' +DEFAULT_INTERVAL = 8 + def setup_scanner(hass, config, see): """ @@ -38,8 +39,12 @@ def setup_scanner(hass, config, see): from pyicloud.exceptions import PyiCloudNoDevicesException # Get the username and password from the configuration - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + + if username is None or password is None: + _LOGGER.error('Must specify a username and password') + return try: _LOGGER.info('Logging into iCloud Account') @@ -48,11 +53,18 @@ def setup_scanner(hass, config, see): password, verify=True) except PyiCloudFailedLoginException as error: - _LOGGER.exception( - 'Error logging into iCloud Service: %s' % error - ) + _LOGGER.exception('Error logging into iCloud Service: %s', error) return + def keep_alive(now): + """ + Keeps authenticating icloud connection + """ + api.authenticate() + _LOGGER.info("Authenticate against iCloud.") + + track_utc_time_change(hass, keep_alive, second=0) + def update_icloud(now): """ Authenticate against iCloud and scan for devices. @@ -83,7 +95,7 @@ def setup_scanner(hass, config, see): _LOGGER.info('No iCloud Devices found!') track_utc_time_change( - hass, - update_icloud, - second=range(0, 60, SCAN_INTERVAL) + hass, update_icloud, + minute=range(0, 60, config.get(CONF_INTERVAL, DEFAULT_INTERVAL)), + second=0 ) diff --git a/requirements_all.txt b/requirements_all.txt index c9835294a20..48a127b0f30 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -9,6 +9,9 @@ jinja2>=2.8 # homeassistant.components.arduino PyMata==2.07a +# homeassistant.components.device_tracker.icloud +https://github.com/picklepete/pyicloud/archive/80f6cd6decc950514b8dc43b30c5bded81b34d5f.zip#pyicloud==0.8.0 + # homeassistant.components.device_tracker.netgear pynetgear==0.3 @@ -177,6 +180,3 @@ https://github.com/persandstrom/python-verisure/archive/9873c4527f01b1ba1f72ae60 # homeassistant.components.zwave pydispatcher==2.0.5 - -# homeassistant.sensor.icloud -https://github.com/picklepete/pyicloud/archive/80f6cd6decc950514b8dc43b30c5bded81b34d5f.zip#pyicloud==0.8.0 From 027b891052889325cd97587fc3fadff3c06cc303 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 14 Dec 2015 23:20:43 -0800 Subject: [PATCH 49/69] Add tests for API.stream --- homeassistant/components/api.py | 17 ++++++--- homeassistant/components/http.py | 18 +++++---- tests/components/test_api.py | 64 +++++++++++++++++++++++++++++++- 3 files changed, 84 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index 6d2f9e52a7a..18b743d06ca 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -125,7 +125,6 @@ def _handle_get_api_stream(handler, path_match, data): try: wfile.write(msg.encode("UTF-8")) wfile.flush() - handler.server.sessions.extend_validation(session_id) except (IOError, ValueError): # IOError: socket errors # ValueError: raised when 'I/O operation on closed file' @@ -135,14 +134,14 @@ def _handle_get_api_stream(handler, path_match, data): """ Forwards events to the open request. """ nonlocal gracefully_closed - if block.is_set() or event.event_type == EVENT_TIME_CHANGED or \ - restrict and event.event_type not in restrict: + if block.is_set() or event.event_type == EVENT_TIME_CHANGED: return elif event.event_type == EVENT_HOMEASSISTANT_STOP: gracefully_closed = True block.set() return + handler.server.sessions.extend_validation(session_id) write_message(json.dumps(event, cls=rem.JSONEncoder)) handler.send_response(HTTP_OK) @@ -150,7 +149,11 @@ def _handle_get_api_stream(handler, path_match, data): session_id = handler.set_session_cookie_header() handler.end_headers() - hass.bus.listen(MATCH_ALL, forward_events) + if restrict: + for event in restrict: + hass.bus.listen(event, forward_events) + else: + hass.bus.listen(MATCH_ALL, forward_events) while True: write_message(STREAM_PING_PAYLOAD) @@ -164,7 +167,11 @@ def _handle_get_api_stream(handler, path_match, data): _LOGGER.info("Found broken event stream to %s, cleaning up", handler.client_address[0]) - hass.bus.remove_listener(MATCH_ALL, forward_events) + if restrict: + for event in restrict: + hass.bus.remove_listener(event, forward_events) + else: + hass.bus.remove_listener(MATCH_ALL, forward_events) def _handle_get_api_config(handler, path_match, data): diff --git a/homeassistant/components/http.py b/homeassistant/components/http.py index 81e26aeae5a..7a4e87de5a8 100644 --- a/homeassistant/components/http.py +++ b/homeassistant/components/http.py @@ -359,13 +359,13 @@ class RequestHandler(SimpleHTTPRequestHandler): def set_session_cookie_header(self): """ Add the header for the session cookie and return session id. """ if not self.authenticated: - return + return None session_id = self.get_cookie_session_id() if session_id is not None: self.server.sessions.extend_validation(session_id) - return + return session_id self.send_header( 'Set-Cookie', @@ -422,10 +422,10 @@ def session_valid_time(): class SessionStore(object): """ Responsible for storing and retrieving http sessions """ - def __init__(self, enabled=True): + def __init__(self): """ Set up the session store """ self._sessions = {} - self.lock = threading.RLock() + self._lock = threading.RLock() @util.Throttle(SESSION_CLEAR_INTERVAL) def _remove_expired(self): @@ -437,7 +437,7 @@ class SessionStore(object): def is_valid(self, key): """ Return True if a valid session is given. """ - with self.lock: + with self._lock: self._remove_expired() return (key in self._sessions and @@ -445,17 +445,19 @@ class SessionStore(object): def extend_validation(self, key): """ Extend a session validation time. """ - with self.lock: + with self._lock: + if key not in self._sessions: + return self._sessions[key] = session_valid_time() def destroy(self, key): """ Destroy a session by key. """ - with self.lock: + with self._lock: self._sessions.pop(key, None) def create(self): """ Creates a new session. """ - with self.lock: + with self._lock: session_id = util.get_random_string(20) while session_id in self._sessions: diff --git a/tests/components/test_api.py b/tests/components/test_api.py index cf530c1f301..c8d8fa50ae1 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -5,10 +5,11 @@ tests.test_component_http Tests Home Assistant HTTP component does what it should do. """ # pylint: disable=protected-access,too-many-public-methods -import unittest +from contextlib import closing import json -from unittest.mock import patch import tempfile +import unittest +from unittest.mock import patch import requests @@ -415,3 +416,62 @@ class TestAPI(unittest.TestCase): }), headers=HA_HEADERS) self.assertEqual(200, req.status_code) + + def test_stream(self): + listen_count = self._listen_count() + with closing(requests.get(_url(const.URL_API_STREAM), + stream=True, headers=HA_HEADERS)) as req: + + self.assertEqual(listen_count + 1, self._listen_count()) + data = self._stream_next_event(req) + + self.assertEqual('ping', data) + + hass.bus.fire('test_event') + hass.pool.block_till_done() + + data = self._stream_next_event(req) + + self.assertEqual('test_event', data['event_type']) + + def test_stream_with_restricted(self): + listen_count = self._listen_count() + with closing(requests.get(_url(const.URL_API_STREAM), + data=json.dumps({ + 'restrict': 'test_event1,test_event3'}), + stream=True, headers=HA_HEADERS)) as req: + + self.assertEqual(listen_count + 2, self._listen_count()) + + data = self._stream_next_event(req) + + self.assertEqual('ping', data) + + hass.bus.fire('test_event1') + hass.pool.block_till_done() + hass.bus.fire('test_event2') + hass.pool.block_till_done() + hass.bus.fire('test_event3') + hass.pool.block_till_done() + + data = self._stream_next_event(req) + self.assertEqual('test_event1', data['event_type']) + data = self._stream_next_event(req) + self.assertEqual('test_event3', data['event_type']) + + def _stream_next_event(self, stream): + data = b'' + last_new_line = False + for dat in stream.iter_content(1): + if dat == b'\n' and last_new_line: + break + data += dat + last_new_line = dat == b'\n' + + conv = data.decode('utf-8').strip()[6:] + + return conv if conv == 'ping' else json.loads(conv) + + def _listen_count(self): + """ Return number of event listeners. """ + return sum(hass.bus.listeners.values()) From 4029d149fbbe661a5c726c703c08885e7787f979 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 14 Dec 2015 23:27:22 -0800 Subject: [PATCH 50/69] Weird travis fix for api --- tests/components/test_api.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/components/test_api.py b/tests/components/test_api.py index c8d8fa50ae1..644982e18fc 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -422,11 +422,11 @@ class TestAPI(unittest.TestCase): with closing(requests.get(_url(const.URL_API_STREAM), stream=True, headers=HA_HEADERS)) as req: - self.assertEqual(listen_count + 1, self._listen_count()) data = self._stream_next_event(req) - self.assertEqual('ping', data) + self.assertEqual(listen_count + 1, self._listen_count()) + hass.bus.fire('test_event') hass.pool.block_till_done() @@ -441,12 +441,11 @@ class TestAPI(unittest.TestCase): 'restrict': 'test_event1,test_event3'}), stream=True, headers=HA_HEADERS)) as req: - self.assertEqual(listen_count + 2, self._listen_count()) - data = self._stream_next_event(req) - self.assertEqual('ping', data) + self.assertEqual(listen_count + 2, self._listen_count()) + hass.bus.fire('test_event1') hass.pool.block_till_done() hass.bus.fire('test_event2') From a35dcf860e234d0d1c641c27914c509c2112cca0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 14 Dec 2015 23:53:32 -0800 Subject: [PATCH 51/69] Update frontend with layout bugfix --- homeassistant/components/frontend/version.py | 2 +- homeassistant/components/frontend/www_static/frontend.html | 7 +++++++ .../components/frontend/www_static/home-assistant-polymer | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 13335959c9f..c0bafc4f8f3 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -1,2 +1,2 @@ """ DO NOT MODIFY. Auto-generated by build_frontend script """ -VERSION = "026761064bba4614a0441b7b4fc4eeea" +VERSION = "0d8516cd9a13ee2ae3f27c702777e028" diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index dc839ee6f9f..7d20e4bb667 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -3775,6 +3775,7 @@ case"touchend":return this.addPointerListenerEnd(t,e,i,n);case"touchmove":return .column { max-width: 500px; + overflow-x: hidden; } .zone-card { @@ -3790,6 +3791,12 @@ case"touchend":return this.addPointerListenerEnd(t,e,i,n);case"touchmove":return .zone-card { margin-left: 0; } + } + + @media (max-width: 599px) { + .column { + max-width: 600px; + } }