From c89cd6a68c127dcfa15fc3f0cb176e7c825e5535 Mon Sep 17 00:00:00 2001 From: Justyn Shull Date: Fri, 1 Apr 2016 19:47:32 -0500 Subject: [PATCH 01/95] Add 'purge_days' option to the history/recorder component Issue https://github.com/balloob/home-assistant/issues/1337 When purge_days is set under the history component, recorder.py will delete all events and states that are older than purge_days days ago. Currently, this is only done once at start up. A vacuum command is also run to free up the disk space sqlite would still use after deleting records. --- homeassistant/components/recorder.py | 38 ++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder.py b/homeassistant/components/recorder.py index 05a95ee27b4..d5dd3ea1205 100644 --- a/homeassistant/components/recorder.py +++ b/homeassistant/components/recorder.py @@ -13,7 +13,7 @@ import logging import queue import sqlite3 import threading -from datetime import date, datetime +from datetime import date, datetime, timedelta import homeassistant.util.dt as dt_util from homeassistant.const import ( @@ -102,14 +102,13 @@ def setup(hass, config): """Setup the recorder.""" # pylint: disable=global-statement global _INSTANCE - - _INSTANCE = Recorder(hass) + _INSTANCE = Recorder(hass, config.get('history', {})) return True class RecorderRun(object): - """Representation of arecorder run.""" + """Representation of a recorder run.""" def __init__(self, row=None): """Initialize the recorder run.""" @@ -169,11 +168,12 @@ class Recorder(threading.Thread): """A threaded recorder class.""" # pylint: disable=too-many-instance-attributes - def __init__(self, hass): + def __init__(self, hass, config): """Initialize the recorder.""" threading.Thread.__init__(self) self.hass = hass + self.config = config self.conn = None self.queue = queue.Queue() self.quit_object = object() @@ -194,6 +194,7 @@ class Recorder(threading.Thread): """Start processing events to save.""" self._setup_connection() self._setup_run() + self._purge_old_data() while True: event = self.queue.get() @@ -475,6 +476,33 @@ class Recorder(threading.Thread): "UPDATE recorder_runs SET end=? WHERE start=?", (dt_util.utcnow(), self.recording_start)) + def _purge_old_data(self): + """Purge events and states older than purge_days ago.""" + purge_days = self.config.get('purge_days', -1) + if purge_days < 1: + _LOGGER.debug("purge_days set to %s, will not purge any old data.", + purge_days) + return + + purge_before = dt_util.utcnow() - timedelta(days=purge_days) + + _LOGGER.info("Purging events created before %s", purge_before) + deleted_rows = self.query( + sql_query="DELETE FROM events WHERE created < ?;", + data=(int(purge_before.timestamp()),), + return_value=RETURN_ROWCOUNT) + _LOGGER.debug("Deleted %s events", deleted_rows) + + _LOGGER.info("Purging states created before %s", purge_before) + deleted_rows = self.query( + sql_query="DELETE FROM states WHERE created < ?;", + data=(int(purge_before.timestamp()),), + return_value=RETURN_ROWCOUNT) + _LOGGER.debug("Deleted %s states", deleted_rows) + + # Execute sqlite vacuum command to free up space on disk + self.query("VACUUM;") + def _adapt_datetime(datetimestamp): """Turn a datetime into an integer for in the DB.""" From fd48fc5f8346010bc51326a2b5d03fb2c66819be Mon Sep 17 00:00:00 2001 From: Justyn Shull Date: Wed, 13 Apr 2016 19:37:33 -0500 Subject: [PATCH 02/95] Add CONFIG_SCHEMA to verify config. Move purge_days key name to CONF_PURGE_DAYS so it can be changed easier later. Use 'recorder' domain instead of 'history' domain. Pass purge_days config directly into Recorder object instead of passing the config object around. --- homeassistant/components/recorder.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/recorder.py b/homeassistant/components/recorder.py index d5dd3ea1205..308adb1aa44 100644 --- a/homeassistant/components/recorder.py +++ b/homeassistant/components/recorder.py @@ -13,6 +13,7 @@ import logging import queue import sqlite3 import threading +import voluptuous as vol from datetime import date, datetime, timedelta import homeassistant.util.dt as dt_util @@ -30,6 +31,14 @@ RETURN_ROWCOUNT = "rowcount" RETURN_LASTROWID = "lastrowid" RETURN_ONE_ROW = "one_row" +CONF_PURGE_DAYS = "purge_days" +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All(dict, { + CONF_PURGE_DAYS: int + }) +}, extra=vol.ALLOW_EXTRA) + + _INSTANCE = None _LOGGER = logging.getLogger(__name__) @@ -102,7 +111,8 @@ def setup(hass, config): """Setup the recorder.""" # pylint: disable=global-statement global _INSTANCE - _INSTANCE = Recorder(hass, config.get('history', {})) + purge_days = config.get(DOMAIN, {}).get(CONF_PURGE_DAYS) + _INSTANCE = Recorder(hass, purge_days=purge_days) return True @@ -168,12 +178,12 @@ class Recorder(threading.Thread): """A threaded recorder class.""" # pylint: disable=too-many-instance-attributes - def __init__(self, hass, config): + def __init__(self, hass, purge_days): """Initialize the recorder.""" threading.Thread.__init__(self) self.hass = hass - self.config = config + self.purge_days = purge_days self.conn = None self.queue = queue.Queue() self.quit_object = object() @@ -478,13 +488,12 @@ class Recorder(threading.Thread): def _purge_old_data(self): """Purge events and states older than purge_days ago.""" - purge_days = self.config.get('purge_days', -1) - if purge_days < 1: + if not self.purge_days or self.purge_days < 1: _LOGGER.debug("purge_days set to %s, will not purge any old data.", - purge_days) + self.purge_days) return - purge_before = dt_util.utcnow() - timedelta(days=purge_days) + purge_before = dt_util.utcnow() - timedelta(days=self.purge_days) _LOGGER.info("Purging events created before %s", purge_before) deleted_rows = self.query( From d5ca97b1f6c3183cc64fdc518293ac6fbd0f03e9 Mon Sep 17 00:00:00 2001 From: Justyn Shull Date: Fri, 15 Apr 2016 21:02:17 -0500 Subject: [PATCH 03/95] Add tests for purging old states and events --- homeassistant/components/recorder.py | 2 +- tests/components/test_recorder.py | 119 ++++++++++++++++++++++++++- 2 files changed, 119 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder.py b/homeassistant/components/recorder.py index 308adb1aa44..c1e4b850397 100644 --- a/homeassistant/components/recorder.py +++ b/homeassistant/components/recorder.py @@ -13,8 +13,8 @@ import logging import queue import sqlite3 import threading -import voluptuous as vol from datetime import date, datetime, timedelta +import voluptuous as vol import homeassistant.util.dt as dt_util from homeassistant.const import ( diff --git a/tests/components/test_recorder.py b/tests/components/test_recorder.py index 4326134ed84..0be5ce42182 100644 --- a/tests/components/test_recorder.py +++ b/tests/components/test_recorder.py @@ -1,6 +1,8 @@ """The tests for the Recorder component.""" # pylint: disable=too-many-public-methods,protected-access import unittest +import time +import json from unittest.mock import patch from homeassistant.const import MATCH_ALL @@ -10,7 +12,7 @@ from tests.common import get_test_home_assistant class TestRecorder(unittest.TestCase): - """Test the chromecast module.""" + """Test the recorder module.""" def setUp(self): # pylint: disable=invalid-name """Setup things to be run when tests are started.""" @@ -25,6 +27,66 @@ class TestRecorder(unittest.TestCase): self.hass.stop() recorder._INSTANCE.block_till_done() + def _add_test_states(self): + """Adds multiple states to the db for testing.""" + now = int(time.time()) + five_days_ago = now - (60*60*24*5) + attributes = {'test_attr': 5, 'test_attr_10': 'nice'} + + test_states = """ + INSERT INTO states ( + entity_id, domain, state, attributes, last_changed, last_updated, + created, utc_offset, event_id) + VALUES + ('test.recorder2', 'sensor', 'purgeme', '{attr}', {five_days_ago}, + {five_days_ago}, {five_days_ago}, -18000, 1001), + ('test.recorder2', 'sensor', 'purgeme', '{attr}', {five_days_ago}, + {five_days_ago}, {five_days_ago}, -18000, 1002), + ('test.recorder2', 'sensor', 'purgeme', '{attr}', {five_days_ago}, + {five_days_ago}, {five_days_ago}, -18000, 1002), + ('test.recorder2', 'sensor', 'dontpurgeme', '{attr}', {now}, + {now}, {now}, -18000, 1003), + ('test.recorder2', 'sensor', 'dontpurgeme', '{attr}', {now}, + {now}, {now}, -18000, 1004); + """.format( + attr=json.dumps(attributes), + five_days_ago=five_days_ago, + now=now, + ) + + # insert test states + self.hass.pool.block_till_done() + recorder._INSTANCE.block_till_done() + recorder.query(test_states) + + def _add_test_events(self): + """Adds a few events for testing.""" + now = int(time.time()) + five_days_ago = now - (60*60*24*5) + event_data = {'test_attr': 5, 'test_attr_10': 'nice'} + + test_events = """ + INSERT INTO events ( + event_type, event_data, origin, created, time_fired, utc_offset + ) VALUES + ('EVENT_TEST_PURGE', '{event_data}', 'LOCAL', {five_days_ago}, + {five_days_ago}, -18000), + ('EVENT_TEST_PURGE', '{event_data}', 'LOCAL', {five_days_ago}, + {five_days_ago}, -18000), + ('EVENT_TEST', '{event_data}', 'LOCAL', {now}, {five_days_ago}, -18000), + ('EVENT_TEST', '{event_data}', 'LOCAL', {now}, {five_days_ago}, -18000), + ('EVENT_TEST', '{event_data}', 'LOCAL', {now}, {five_days_ago}, -18000); + """.format( + event_data=json.dumps(event_data), + now=now, + five_days_ago=five_days_ago + ) + + # insert test events + self.hass.pool.block_till_done() + recorder._INSTANCE.block_till_done() + recorder.query(test_events) + def test_saving_state(self): """Test saving and restoring a state.""" entity_id = 'test.recorder' @@ -64,3 +126,58 @@ class TestRecorder(unittest.TestCase): 'SELECT * FROM events WHERE event_type = ?', (event_type, )) self.assertEqual(events, db_events) + + def test_purge_old_states(self): + """Tests deleting old states.""" + self._add_test_states() + # make sure we start with 5 states + states = recorder.query_states('SELECT * FROM states') + self.assertEqual(len(states), 5) + + # run purge_old_data() + recorder._INSTANCE.purge_days = 4 + recorder._INSTANCE._purge_old_data() + + # we should only have 2 states left after purging + states = recorder.query_states('SELECT * FROM states') + self.assertEqual(len(states), 2) + + def test_purge_old_events(self): + """Tests deleting old events.""" + self._add_test_events() + events = recorder.query_events('SELECT * FROM events WHERE ' + 'event_type LIKE "EVENT_TEST%"') + self.assertEqual(len(events), 5) + + # run purge_old_data() + recorder._INSTANCE.purge_days = 4 + recorder._INSTANCE._purge_old_data() + + # now we should only have 3 events left + events = recorder.query_events('SELECT * FROM events WHERE ' + 'event_type LIKE "EVENT_TEST%"') + self.assertEqual(len(events), 3) + + + def test_purge_disabled(self): + """Tests leaving purge_days disabled.""" + self._add_test_states() + self._add_test_events() + # make sure we start with 5 states and events + states = recorder.query_states('SELECT * FROM states') + events = recorder.query_events('SELECT * FROM events WHERE ' + 'event_type LIKE "EVENT_TEST%"') + self.assertEqual(len(states), 5) + self.assertEqual(len(events), 5) + + + # run purge_old_data() + recorder._INSTANCE.purge_days = None + recorder._INSTANCE._purge_old_data() + + # we should have all of our states still + states = recorder.query_states('SELECT * FROM states') + events = recorder.query_events('SELECT * FROM events WHERE ' + 'event_type LIKE "EVENT_TEST%"') + self.assertEqual(len(states), 5) + self.assertEqual(len(events), 5) From bf3b77e1f23a9a37554daa5e13af8abb11033659 Mon Sep 17 00:00:00 2001 From: Justyn Shull Date: Fri, 15 Apr 2016 21:18:51 -0500 Subject: [PATCH 04/95] Change sqlite queries to work with older versions of sqlite --- tests/components/test_recorder.py | 69 ++++++++++++------------------- 1 file changed, 27 insertions(+), 42 deletions(-) diff --git a/tests/components/test_recorder.py b/tests/components/test_recorder.py index 0be5ce42182..94161520709 100644 --- a/tests/components/test_recorder.py +++ b/tests/components/test_recorder.py @@ -33,31 +33,22 @@ class TestRecorder(unittest.TestCase): five_days_ago = now - (60*60*24*5) attributes = {'test_attr': 5, 'test_attr_10': 'nice'} - test_states = """ - INSERT INTO states ( - entity_id, domain, state, attributes, last_changed, last_updated, - created, utc_offset, event_id) - VALUES - ('test.recorder2', 'sensor', 'purgeme', '{attr}', {five_days_ago}, - {five_days_ago}, {five_days_ago}, -18000, 1001), - ('test.recorder2', 'sensor', 'purgeme', '{attr}', {five_days_ago}, - {five_days_ago}, {five_days_ago}, -18000, 1002), - ('test.recorder2', 'sensor', 'purgeme', '{attr}', {five_days_ago}, - {five_days_ago}, {five_days_ago}, -18000, 1002), - ('test.recorder2', 'sensor', 'dontpurgeme', '{attr}', {now}, - {now}, {now}, -18000, 1003), - ('test.recorder2', 'sensor', 'dontpurgeme', '{attr}', {now}, - {now}, {now}, -18000, 1004); - """.format( - attr=json.dumps(attributes), - five_days_ago=five_days_ago, - now=now, - ) - - # insert test states self.hass.pool.block_till_done() recorder._INSTANCE.block_till_done() - recorder.query(test_states) + for event_id in range(5): + if event_id < 3: + timestamp = five_days_ago + state = 'purgeme' + else: + timestamp = now + state = 'dontpurgeme' + recorder.query("INSERT INTO states (" + "entity_id, domain, state, attributes, last_changed," + "last_updated, created, utc_offset, event_id)" + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ('test.recorder2', 'sensor', state, + json.dumps(attributes), timestamp, timestamp, + timestamp, -18000, event_id + 1000)) def _add_test_events(self): """Adds a few events for testing.""" @@ -65,27 +56,21 @@ class TestRecorder(unittest.TestCase): five_days_ago = now - (60*60*24*5) event_data = {'test_attr': 5, 'test_attr_10': 'nice'} - test_events = """ - INSERT INTO events ( - event_type, event_data, origin, created, time_fired, utc_offset - ) VALUES - ('EVENT_TEST_PURGE', '{event_data}', 'LOCAL', {five_days_ago}, - {five_days_ago}, -18000), - ('EVENT_TEST_PURGE', '{event_data}', 'LOCAL', {five_days_ago}, - {five_days_ago}, -18000), - ('EVENT_TEST', '{event_data}', 'LOCAL', {now}, {five_days_ago}, -18000), - ('EVENT_TEST', '{event_data}', 'LOCAL', {now}, {five_days_ago}, -18000), - ('EVENT_TEST', '{event_data}', 'LOCAL', {now}, {five_days_ago}, -18000); - """.format( - event_data=json.dumps(event_data), - now=now, - five_days_ago=five_days_ago - ) - - # insert test events self.hass.pool.block_till_done() recorder._INSTANCE.block_till_done() - recorder.query(test_events) + for event_id in range(5): + if event_id < 2: + timestamp = five_days_ago + event_type = 'EVENT_TEST_PURGE' + else: + timestamp = now + event_type = 'EVENT_TEST' + recorder.query("INSERT INTO events" + "(event_type, event_data, origin, created," + "time_fired, utc_offset)" + "VALUES (?, ?, ?, ?, ?, ?)", + (event_type, json.dumps(event_data), 'LOCAL', + timestamp, timestamp, -18000)) def test_saving_state(self): """Test saving and restoring a state.""" From e67729b2f4a98e9f99079d44f0f9b8750ffe93db Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 7 May 2016 12:55:41 -0700 Subject: [PATCH 05/95] Version bump to 0.20.0.dev0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index bbd9de7585b..d9396188487 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ # coding: utf-8 """Constants used by Home Assistant components.""" -__version__ = "0.19" +__version__ = "0.20.0.dev0" REQUIRED_PYTHON_VER = (3, 4) PLATFORM_FORMAT = '{}.{}' From e40908d67cabe9962b7d06cacaed9a6b47b65721 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 7 May 2016 22:24:04 -0700 Subject: [PATCH 06/95] Improve config validation error message --- homeassistant/bootstrap.py | 14 ++++++++------ homeassistant/helpers/config_validation.py | 9 +++++++-- homeassistant/util/yaml.py | 8 +++++++- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 6b68f46d4ca..a8e85ca3bd3 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -104,7 +104,7 @@ def _setup_component(hass, domain, config): try: config = component.CONFIG_SCHEMA(config) except vol.MultipleInvalid as ex: - cv.log_exception(_LOGGER, ex, domain) + cv.log_exception(_LOGGER, ex, domain, config) return False elif hasattr(component, 'PLATFORM_SCHEMA'): @@ -114,11 +114,11 @@ def _setup_component(hass, domain, config): try: p_validated = component.PLATFORM_SCHEMA(p_config) except vol.MultipleInvalid as ex: - cv.log_exception(_LOGGER, ex, domain) + cv.log_exception(_LOGGER, ex, domain, p_config) return False # Not all platform components follow same pattern for platforms - # Sof if p_name is None we are not going to validate platform + # So if p_name is None we are not going to validate platform # (the automation component is one of them) if p_name is None: platforms.append(p_validated) @@ -136,7 +136,7 @@ def _setup_component(hass, domain, config): p_validated = platform.PLATFORM_SCHEMA(p_validated) except vol.MultipleInvalid as ex: cv.log_exception(_LOGGER, ex, '{}.{}' - .format(domain, p_name)) + .format(domain, p_name), p_validated) return False platforms.append(p_validated) @@ -228,11 +228,13 @@ def from_config_dict(config, hass=None, config_dir=None, enable_log=True, hass.config.config_dir = config_dir mount_local_lib_path(config_dir) + core_config = config.get(core.DOMAIN, {}) + try: process_ha_core_config(hass, config_util.CORE_CONFIG_SCHEMA( - config.get(core.DOMAIN, {}))) + core_config)) except vol.MultipleInvalid as ex: - cv.log_exception(_LOGGER, ex, 'homeassistant') + cv.log_exception(_LOGGER, ex, 'homeassistant', core_config) return None process_ha_config_upgrade(hass) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 0bab2674ca6..cea7e95ac5a 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -146,7 +146,7 @@ def time_period_str(value): time_period = vol.Any(time_period_str, timedelta, time_period_dict) -def log_exception(logger, ex, domain): +def log_exception(logger, ex, domain, config): """Generate log exception for config validation.""" message = 'Invalid config for [{}]: '.format(domain) if 'extra keys not allowed' in ex.error_message: @@ -154,7 +154,12 @@ def log_exception(logger, ex, domain): .format(ex.path[-1], domain, domain, '->'.join('%s' % m for m in ex.path)) else: - message += ex.error_message + message += str(ex) + + if hasattr(config, '__line__'): + message += " (See {}:{})".format(config.__config_file__, + config.__line__ or '?') + logger.error(message) diff --git a/homeassistant/util/yaml.py b/homeassistant/util/yaml.py index 8768de5d2f7..bdf0c6d5c41 100644 --- a/homeassistant/util/yaml.py +++ b/homeassistant/util/yaml.py @@ -50,8 +50,11 @@ def _ordered_dict(loader, node): nodes = loader.construct_pairs(node) seen = {} + min_line = None for (key, _), (node, _) in zip(nodes, node.value): line = getattr(node, '__line__', 'unknown') + if line != 'unknown' and (min_line is None or line < min_line): + min_line = line if key in seen: fname = getattr(loader.stream, 'name', '') first_mark = yaml.Mark(fname, 0, seen[key], -1, None, None) @@ -62,7 +65,10 @@ def _ordered_dict(loader, node): ) seen[key] = line - return OrderedDict(nodes) + processed = OrderedDict(nodes) + processed.__config_file__ = loader.name + processed.__line__ = min_line + return processed def _env_var_yaml(loader, node): From 8257e3f38413811cd71810097e33a00eca089f34 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 7 May 2016 22:24:13 -0700 Subject: [PATCH 07/95] Fix automation deprecation warning --- homeassistant/components/automation/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index ef3a9b4a41d..d99043f0c75 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -143,7 +143,7 @@ def _process_if(hass, config, p_config, action): # Deprecated since 0.19 - 5/5/2016 if cond_type != DEFAULT_CONDITION_TYPE: - _LOGGER.warning('Using condition_type: %s is deprecated. Please use ' + _LOGGER.warning('Using condition_type: "or" is deprecated. Please use ' '"condition: or" instead.') if_configs = p_config.get(CONF_CONDITION) From ab2e85840fd76930fb666f25afc9e7a3e86fca56 Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Sun, 8 May 2016 18:52:16 +0200 Subject: [PATCH 08/95] Fix for not recognizing Z-Wave thermostats (#2006) * Fix for not recognizing thermostats * Properly ignore zxt-120 * fix --- homeassistant/components/thermostat/zwave.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/thermostat/zwave.py b/homeassistant/components/thermostat/zwave.py index ef72fc55c10..a8632cde128 100644 --- a/homeassistant/components/thermostat/zwave.py +++ b/homeassistant/components/thermostat/zwave.py @@ -17,7 +17,7 @@ DEFAULT_NAME = 'ZWave Thermostat' REMOTEC = 0x5254 REMOTEC_ZXT_120 = 0x8377 -REMOTEC_ZXT_120_THERMOSTAT = (REMOTEC, REMOTEC_ZXT_120, 0) +REMOTEC_ZXT_120_THERMOSTAT = (REMOTEC, REMOTEC_ZXT_120) WORKAROUND_IGNORE = 'ignore' @@ -40,16 +40,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if (value.node.manufacturer_id.strip() and value.node.product_id.strip()): specific_sensor_key = (int(value.node.manufacturer_id, 16), - int(value.node.product_id, 16), - value.index) + int(value.node.product_id, 16)) if specific_sensor_key in DEVICE_MAPPINGS: if DEVICE_MAPPINGS[specific_sensor_key] == WORKAROUND_IGNORE: _LOGGER.debug("Remotec ZXT-120 Zwave Thermostat, ignoring") return - else: - add_devices([ZWaveThermostat(value)]) - _LOGGER.debug("discovery_info=%s and zwave.NETWORK=%s", - discovery_info, zwave.NETWORK) + + add_devices([ZWaveThermostat(value)]) + _LOGGER.debug("discovery_info=%s and zwave.NETWORK=%s", + discovery_info, zwave.NETWORK) # pylint: disable=too-many-arguments From 09483e3be44e185693e28427e7d6d948b8b5f6fb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 8 May 2016 21:23:03 -0700 Subject: [PATCH 09/95] More fault tolerant discovery --- homeassistant/components/discovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 1e3ee660bd6..0ba0c1ffb11 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -52,7 +52,7 @@ def listen(hass, service, callback): def discovery_event_listener(event): """Listen for discovery events.""" - if event.data[ATTR_SERVICE] in service: + if ATTR_SERVICE in event.data and event.data[ATTR_SERVICE] in service: callback(event.data[ATTR_SERVICE], event.data.get(ATTR_DISCOVERED)) hass.bus.listen(EVENT_PLATFORM_DISCOVERED, discovery_event_listener) From 20dad9f194f29ce1b6c2f5990a200271de4d95c5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 8 May 2016 23:21:26 -0700 Subject: [PATCH 10/95] Add HVAC to demo --- homeassistant/components/demo.py | 1 + homeassistant/components/hvac/__init__.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index 0570d20c262..81299479772 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -21,6 +21,7 @@ COMPONENTS_WITH_DEMO_PLATFORM = [ 'camera', 'device_tracker', 'garage_door', + 'hvac', 'light', 'lock', 'media_player', diff --git a/homeassistant/components/hvac/__init__.py b/homeassistant/components/hvac/__init__.py index d514db364b8..4d0f498a242 100644 --- a/homeassistant/components/hvac/__init__.py +++ b/homeassistant/components/hvac/__init__.py @@ -457,12 +457,12 @@ class HvacDevice(Entity): @property def min_temp(self): """Return the minimum temperature.""" - return self._convert_for_display(7) + return convert(7, TEMP_CELCIUS, self.unit_of_measurement) @property def max_temp(self): """Return the maximum temperature.""" - return self._convert_for_display(35) + return convert(35, TEMP_CELCIUS, self.unit_of_measurement) @property def min_humidity(self): From 685628389651eb8371f0dfd9897ec695cc6751f3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 9 May 2016 07:53:01 -0700 Subject: [PATCH 11/95] Make HVAC naming consistent (#2017) --- homeassistant/components/hvac/__init__.py | 77 +++++++++++++---------- homeassistant/components/hvac/demo.py | 4 +- homeassistant/components/hvac/zwave.py | 4 +- homeassistant/helpers/state.py | 8 +-- tests/components/hvac/test_demo.py | 8 +-- 5 files changed, 56 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/hvac/__init__.py b/homeassistant/components/hvac/__init__.py index 4d0f498a242..c57cb4e23ff 100644 --- a/homeassistant/components/hvac/__init__.py +++ b/homeassistant/components/hvac/__init__.py @@ -29,7 +29,7 @@ SERVICE_SET_AUX_HEAT = "set_aux_heat" SERVICE_SET_TEMPERATURE = "set_temperature" SERVICE_SET_FAN_MODE = "set_fan_mode" SERVICE_SET_OPERATION_MODE = "set_operation_mode" -SERVICE_SET_SWING = "set_swing_mode" +SERVICE_SET_SWING_MODE = "set_swing_mode" SERVICE_SET_HUMIDITY = "set_humidity" STATE_HEAT = "heat" @@ -40,17 +40,17 @@ STATE_DRY = "dry" STATE_FAN_ONLY = "fan_only" ATTR_CURRENT_TEMPERATURE = "current_temperature" -ATTR_CURRENT_HUMIDITY = "current_humidity" -ATTR_HUMIDITY = "humidity" -ATTR_AWAY_MODE = "away_mode" -ATTR_AUX_HEAT = "aux_heat" -ATTR_FAN = "fan" -ATTR_FAN_LIST = "fan_list" ATTR_MAX_TEMP = "max_temp" ATTR_MIN_TEMP = "min_temp" +ATTR_AWAY_MODE = "away_mode" +ATTR_AUX_HEAT = "aux_heat" +ATTR_FAN_MODE = "fan_mode" +ATTR_FAN_LIST = "fan_list" +ATTR_CURRENT_HUMIDITY = "current_humidity" +ATTR_HUMIDITY = "humidity" ATTR_MAX_HUMIDITY = "max_humidity" ATTR_MIN_HUMIDITY = "min_humidity" -ATTR_OPERATION = "operation_mode" +ATTR_OPERATION_MODE = "operation_mode" ATTR_OPERATION_LIST = "operation_list" ATTR_SWING_MODE = "swing_mode" ATTR_SWING_LIST = "swing_list" @@ -108,7 +108,7 @@ def set_humidity(hass, humidity, entity_id=None): def set_fan_mode(hass, fan, entity_id=None): """Turn all or specified hvac fan mode on.""" - data = {ATTR_FAN: fan} + data = {ATTR_FAN_MODE: fan} if entity_id: data[ATTR_ENTITY_ID] = entity_id @@ -118,7 +118,7 @@ def set_fan_mode(hass, fan, entity_id=None): def set_operation_mode(hass, operation_mode, entity_id=None): """Set new target operation mode.""" - data = {ATTR_OPERATION: operation_mode} + data = {ATTR_OPERATION_MODE: operation_mode} if entity_id is not None: data[ATTR_ENTITY_ID] = entity_id @@ -133,7 +133,7 @@ def set_swing_mode(hass, swing_mode, entity_id=None): if entity_id is not None: data[ATTR_ENTITY_ID] = entity_id - hass.services.call(DOMAIN, SERVICE_SET_SWING, data) + hass.services.call(DOMAIN, SERVICE_SET_SWING_MODE, data) # pylint: disable=too-many-branches @@ -247,12 +247,12 @@ def setup(hass, config): """Set fan mode on target hvacs.""" target_hvacs = component.extract_from_service(service) - fan = service.data.get(ATTR_FAN) + fan = service.data.get(ATTR_FAN_MODE) if fan is None: _LOGGER.error( "Received call to %s without attribute %s", - SERVICE_SET_FAN_MODE, ATTR_FAN) + SERVICE_SET_FAN_MODE, ATTR_FAN_MODE) return for hvac in target_hvacs: @@ -269,16 +269,16 @@ def setup(hass, config): """Set operating mode on the target hvacs.""" target_hvacs = component.extract_from_service(service) - operation_mode = service.data.get(ATTR_OPERATION) + operation_mode = service.data.get(ATTR_OPERATION_MODE) if operation_mode is None: _LOGGER.error( "Received call to %s without attribute %s", - SERVICE_SET_OPERATION_MODE, ATTR_OPERATION) + SERVICE_SET_OPERATION_MODE, ATTR_OPERATION_MODE) return for hvac in target_hvacs: - hvac.set_operation(operation_mode) + hvac.set_operation_mode(operation_mode) if hvac.should_poll: hvac.update_ha_state(True) @@ -296,18 +296,18 @@ def setup(hass, config): if swing_mode is None: _LOGGER.error( "Received call to %s without attribute %s", - SERVICE_SET_SWING, ATTR_SWING_MODE) + SERVICE_SET_SWING_MODE, ATTR_SWING_MODE) return for hvac in target_hvacs: - hvac.set_swing(swing_mode) + hvac.set_swing_mode(swing_mode) if hvac.should_poll: hvac.update_ha_state(True) hass.services.register( - DOMAIN, SERVICE_SET_SWING, swing_set_service, - descriptions.get(SERVICE_SET_SWING)) + DOMAIN, SERVICE_SET_SWING_MODE, swing_set_service, + descriptions.get(SERVICE_SET_SWING_MODE)) return True @@ -330,19 +330,30 @@ class HvacDevice(Entity): ATTR_MAX_TEMP: self._convert_for_display(self.max_temp), ATTR_TEMPERATURE: self._convert_for_display(self.target_temperature), - ATTR_HUMIDITY: self.target_humidity, - ATTR_CURRENT_HUMIDITY: self.current_humidity, - ATTR_MIN_HUMIDITY: self.min_humidity, - ATTR_MAX_HUMIDITY: self.max_humidity, - ATTR_FAN_LIST: self.fan_list, - ATTR_OPERATION_LIST: self.operation_list, - ATTR_SWING_LIST: self.swing_list, - ATTR_OPERATION: self.current_operation, - ATTR_FAN: self.current_fan_mode, - ATTR_SWING_MODE: self.current_swing_mode, - } + humidity = self.target_humidity + if humidity is not None: + data[ATTR_HUMIDITY] = humidity + data[ATTR_CURRENT_HUMIDITY] = self.current_humidity + data[ATTR_MIN_HUMIDITY] = self.min_humidity + data[ATTR_MAX_HUMIDITY] = self.max_humidity + + fan_mode = self.current_fan_mode + if fan_mode is not None: + data[ATTR_FAN_MODE] = fan_mode + data[ATTR_FAN_LIST] = self.fan_list + + operation_mode = self.current_operation + if operation_mode is not None: + data[ATTR_OPERATION_MODE] = operation_mode + data[ATTR_OPERATION_LIST] = self.operation_list + + swing_mode = self.current_swing_mode + if swing_mode is not None: + data[ATTR_SWING_MODE] = swing_mode + data[ATTR_SWING_LIST] = self.swing_list + is_away = self.is_away_mode_on if is_away is not None: data[ATTR_AWAY_MODE] = STATE_ON if is_away else STATE_OFF @@ -430,11 +441,11 @@ class HvacDevice(Entity): """Set new target fan mode.""" pass - def set_operation(self, operation_mode): + def set_operation_mode(self, operation_mode): """Set new target operation mode.""" pass - def set_swing(self, swing_mode): + def set_swing_mode(self, swing_mode): """Set new target swing operation.""" pass diff --git a/homeassistant/components/hvac/demo.py b/homeassistant/components/hvac/demo.py index cb2f0c4b364..9e4f2c15d29 100644 --- a/homeassistant/components/hvac/demo.py +++ b/homeassistant/components/hvac/demo.py @@ -118,7 +118,7 @@ class DemoHvac(HvacDevice): self._target_humidity = humidity self.update_ha_state() - def set_swing(self, swing_mode): + def set_swing_mode(self, swing_mode): """Set new target temperature.""" self._current_swing_mode = swing_mode self.update_ha_state() @@ -128,7 +128,7 @@ class DemoHvac(HvacDevice): self._current_fan_mode = fan self.update_ha_state() - def set_operation(self, operation_mode): + def set_operation_mode(self, operation_mode): """Set new target temperature.""" self._current_operation = operation_mode self.update_ha_state() diff --git a/homeassistant/components/hvac/zwave.py b/homeassistant/components/hvac/zwave.py index f02a3e74f98..9c573f1a509 100644 --- a/homeassistant/components/hvac/zwave.py +++ b/homeassistant/components/hvac/zwave.py @@ -204,13 +204,13 @@ class ZWaveHvac(ZWaveDeviceEntity, HvacDevice): if value.command_class == 68 and value.index == 0: value.data = bytes(fan, 'utf-8') - def set_operation(self, operation_mode): + def set_operation_mode(self, operation_mode): """Set new target operation mode.""" for value in self._node.get_values(class_id=0x40).values(): if value.command_class == 64 and value.index == 0: value.data = bytes(operation_mode, 'utf-8') - def set_swing(self, swing_mode): + def set_swing_mode(self, swing_mode): """Set new target swing mode.""" if self._zxt_120 == 1: for value in self._node.get_values(class_id=0x70).values(): diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index b8585621913..078bbb27b20 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -16,8 +16,8 @@ from homeassistant.components.thermostat import ( ATTR_AWAY_MODE, ATTR_FAN, SERVICE_SET_AWAY_MODE, SERVICE_SET_FAN_MODE, SERVICE_SET_TEMPERATURE) from homeassistant.components.hvac import ( - ATTR_HUMIDITY, ATTR_SWING_MODE, ATTR_OPERATION, ATTR_AUX_HEAT, - SERVICE_SET_HUMIDITY, SERVICE_SET_SWING, + ATTR_HUMIDITY, ATTR_SWING_MODE, ATTR_OPERATION_MODE, ATTR_AUX_HEAT, + SERVICE_SET_HUMIDITY, SERVICE_SET_SWING_MODE, SERVICE_SET_OPERATION_MODE, SERVICE_SET_AUX_HEAT) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, SERVICE_ALARM_ARM_AWAY, @@ -48,8 +48,8 @@ SERVICE_ATTRIBUTES = { SERVICE_SET_FAN_MODE: [ATTR_FAN], SERVICE_SET_TEMPERATURE: [ATTR_TEMPERATURE], SERVICE_SET_HUMIDITY: [ATTR_HUMIDITY], - SERVICE_SET_SWING: [ATTR_SWING_MODE], - SERVICE_SET_OPERATION_MODE: [ATTR_OPERATION], + SERVICE_SET_SWING_MODE: [ATTR_SWING_MODE], + SERVICE_SET_OPERATION_MODE: [ATTR_OPERATION_MODE], SERVICE_SET_AUX_HEAT: [ATTR_AUX_HEAT], SERVICE_SELECT_SOURCE: [ATTR_INPUT_SOURCE], } diff --git a/tests/components/hvac/test_demo.py b/tests/components/hvac/test_demo.py index 59a51d52011..0a9a1fdd99d 100644 --- a/tests/components/hvac/test_demo.py +++ b/tests/components/hvac/test_demo.py @@ -33,7 +33,7 @@ class TestDemoHvac(unittest.TestCase): self.assertEqual(21, state.attributes.get('temperature')) self.assertEqual('on', state.attributes.get('away_mode')) self.assertEqual(22, state.attributes.get('current_temperature')) - self.assertEqual("On High", state.attributes.get('fan')) + self.assertEqual("On High", state.attributes.get('fan_mode')) self.assertEqual(67, state.attributes.get('humidity')) self.assertEqual(54, state.attributes.get('current_humidity')) self.assertEqual("Off", state.attributes.get('swing_mode')) @@ -81,17 +81,17 @@ class TestDemoHvac(unittest.TestCase): def test_set_fan_mode_bad_attr(self): """Test setting fan mode without required attribute.""" state = self.hass.states.get(ENTITY_HVAC) - self.assertEqual("On High", state.attributes.get('fan')) + self.assertEqual("On High", state.attributes.get('fan_mode')) hvac.set_fan_mode(self.hass, None, ENTITY_HVAC) self.hass.pool.block_till_done() - self.assertEqual("On High", state.attributes.get('fan')) + self.assertEqual("On High", state.attributes.get('fan_mode')) def test_set_fan_mode(self): """Test setting of new fan mode.""" hvac.set_fan_mode(self.hass, "On Low", ENTITY_HVAC) self.hass.pool.block_till_done() state = self.hass.states.get(ENTITY_HVAC) - self.assertEqual("On Low", state.attributes.get('fan')) + self.assertEqual("On Low", state.attributes.get('fan_mode')) def test_set_swing_mode_bad_attr(self): """Test setting swing mode without required attribute.""" From 499257c8e1baded7e3d5895fbe7df1ebbef298d8 Mon Sep 17 00:00:00 2001 From: jazzaj Date: Mon, 9 May 2016 23:30:22 +0200 Subject: [PATCH 12/95] Corrected link to documentation (#2022) --- homeassistant/components/scene/hunterdouglas_powerview.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/scene/hunterdouglas_powerview.py b/homeassistant/components/scene/hunterdouglas_powerview.py index 586f33b44b9..934d06e2122 100644 --- a/homeassistant/components/scene/hunterdouglas_powerview.py +++ b/homeassistant/components/scene/hunterdouglas_powerview.py @@ -2,7 +2,7 @@ Support for Powerview scenes from a Powerview hub. For more details about this component, please refer to the documentation at -https://home-assistant.io/components/scene/ +https://home-assistant.io/components/scene.hunterdouglas_powerview/ """ import logging From 25e8c7bc5fa59beaaa1fc6844d4cb6a1685cedf2 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 9 May 2016 15:14:30 -0700 Subject: [PATCH 13/95] en_UK->en_GB. Closes #2019. --- homeassistant/components/sensor/fitbit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index f928b9a78e9..37281058e02 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -85,7 +85,7 @@ FITBIT_MEASUREMENTS = { "liquids": "fl. oz.", "blood glucose": "mg/dL", }, - "en_UK": { + "en_GB": { "duration": "milliseconds", "distance": "kilometers", "elevation": "meters", From 8735bfe92654b98b9ef240fda978b59f3ddf061d Mon Sep 17 00:00:00 2001 From: Philip Lundrigan Date: Mon, 9 May 2016 16:19:19 -0600 Subject: [PATCH 14/95] Fix problem with default channel --- homeassistant/components/notify/slack.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/notify/slack.py b/homeassistant/components/notify/slack.py index 624683af020..aba02c020b9 100644 --- a/homeassistant/components/notify/slack.py +++ b/homeassistant/components/notify/slack.py @@ -51,7 +51,7 @@ class SlackNotificationService(BaseNotificationService): """Send a message to a user.""" import slacker - channel = kwargs.get('target', self._default_channel) + channel = kwargs.get('target') or self._default_channel try: self.slack.chat.post_message(channel, message) except slacker.Error: From c8cbc528eb6b8652ba4166ffa76e1ec4ea6dfea9 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 9 May 2016 15:31:47 -0700 Subject: [PATCH 15/95] Minor Fitbit tweaks. Correct the copy, dont require auth on the routes, get the client_id/client_secret from fitbit.conf instead of the YAML --- homeassistant/components/sensor/fitbit.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index 37281058e02..57e0ba093ab 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -153,7 +153,7 @@ def request_app_setup(hass, config, add_devices, config_path, else: setup_platform(hass, config, add_devices, discovery_info) - start_url = "{}{}".format(hass.config.api.base_url, FITBIT_AUTH_START) + start_url = "{}{}".format(hass.config.api.base_url, FITBIT_AUTH_CALLBACK_PATH) description = """Please create a Fitbit developer app at https://dev.fitbit.com/apps/new. @@ -222,8 +222,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): access_token = config_file.get("access_token") refresh_token = config_file.get("refresh_token") if None not in (access_token, refresh_token): - authd_client = fitbit.Fitbit(config.get("client_id"), - config.get("client_secret"), + authd_client = fitbit.Fitbit(config_file.get("client_id"), + config_file.get("client_secret"), access_token=access_token, refresh_token=refresh_token) @@ -239,8 +239,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices(dev) else: - oauth = fitbit.api.FitbitOauth2Client(config.get("client_id"), - config.get("client_secret")) + oauth = fitbit.api.FitbitOauth2Client(config_file.get("client_id"), + config_file.get("client_secret")) redirect_uri = "{}{}".format(hass.config.api.base_url, FITBIT_AUTH_CALLBACK_PATH) @@ -301,9 +301,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): setup_platform(hass, config, add_devices, discovery_info=None) - hass.http.register_path("GET", FITBIT_AUTH_START, _start_fitbit_auth) + hass.http.register_path("GET", FITBIT_AUTH_START, _start_fitbit_auth, require_auth=False) hass.http.register_path("GET", FITBIT_AUTH_CALLBACK_PATH, - _finish_fitbit_auth) + _finish_fitbit_auth, require_auth=False) request_oauth_completion(hass) From a7292af3b187868e2d39c2335df0755a21fdb36f Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 9 May 2016 15:33:04 -0700 Subject: [PATCH 16/95] Fitbit flake8 and pylint fixes. Forgot to do it before pushing :( --- homeassistant/components/sensor/fitbit.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index 57e0ba093ab..7e53f986515 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -153,7 +153,8 @@ def request_app_setup(hass, config, add_devices, config_path, else: setup_platform(hass, config, add_devices, discovery_info) - start_url = "{}{}".format(hass.config.api.base_url, FITBIT_AUTH_CALLBACK_PATH) + start_url = "{}{}".format(hass.config.api.base_url, + FITBIT_AUTH_CALLBACK_PATH) description = """Please create a Fitbit developer app at https://dev.fitbit.com/apps/new. @@ -301,7 +302,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): setup_platform(hass, config, add_devices, discovery_info=None) - hass.http.register_path("GET", FITBIT_AUTH_START, _start_fitbit_auth, require_auth=False) + hass.http.register_path("GET", FITBIT_AUTH_START, _start_fitbit_auth, + require_auth=False) hass.http.register_path("GET", FITBIT_AUTH_CALLBACK_PATH, _finish_fitbit_auth, require_auth=False) From 1d0bc1ee66b327e7e063791a1bd7bfa09c111abb Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 10 May 2016 07:33:21 +0200 Subject: [PATCH 17/95] Upgrade flake8 to 2.5.4 (#2018) --- homeassistant/components/hvac/zwave.py | 6 +++++- requirements_test.txt | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hvac/zwave.py b/homeassistant/components/hvac/zwave.py index 9c573f1a509..3edf160d7ee 100644 --- a/homeassistant/components/hvac/zwave.py +++ b/homeassistant/components/hvac/zwave.py @@ -1,5 +1,9 @@ -"""ZWave Hvac device.""" +""" +Support for ZWave HVAC devices. +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/hvac.zwave/ +""" # Because we do not compile openzwave on CI # pylint: disable=import-error import logging diff --git a/requirements_test.txt b/requirements_test.txt index 6707d35171b..52fc23680b9 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -flake8>=2.5.1 +flake8>=2.5.4 pylint>=1.5.5 coveralls>=1.1 pytest>=2.9.1 From ec9544b9c318a88987892e512e5643431d13fa8c Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Tue, 10 May 2016 07:48:03 +0200 Subject: [PATCH 18/95] Add a load_platform mechanism (#2012) * discovery.load_platform method * rm grep --- .gitignore | 2 ++ homeassistant/components/discovery.py | 28 +++++++++++++++++++++++ homeassistant/helpers/entity_component.py | 12 ++++++++++ 3 files changed, 42 insertions(+) diff --git a/.gitignore b/.gitignore index 58b27eb7d49..f049564253f 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,5 @@ venv # vimmy stuff *.swp *.swo + +ctags.tmp diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 0ba0c1ffb11..01211398f72 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -19,6 +19,8 @@ REQUIREMENTS = ['netdisco==0.6.6'] SCAN_INTERVAL = 300 # seconds +LOAD_PLATFORM = 'load_platform' + SERVICE_WEMO = 'belkin_wemo' SERVICE_HUE = 'philips_hue' SERVICE_CAST = 'google_cast' @@ -73,6 +75,32 @@ def discover(hass, service, discovered=None, component=None, hass_config=None): hass.bus.fire(EVENT_PLATFORM_DISCOVERED, data) +def load_platform(hass, component, platform, info=None, hass_config=None): + """Helper method for generic platform loading. + + This method allows a platform to be loaded dynamically without it being + known at runtime (in the DISCOVERY_PLATFORMS list of the component). + Advantages of using this method: + - Any component & platforms combination can be dynamically added + - A component (i.e. light) does not have to import every component + that can dynamically add a platform (e.g. wemo, wink, insteon_hub) + - Custom user components can take advantage of discovery/loading + + Target components will be loaded and an EVENT_PLATFORM_DISCOVERED will be + fired to load the platform. The event will contain: + { ATTR_SERVICE = LOAD_PLATFORM + '.' + <> + ATTR_DISCOVERED = {LOAD_PLATFORM: <>} } + + * dev note: This listener can be found in entity_component.py + """ + if info is None: + info = {LOAD_PLATFORM: platform} + else: + info[LOAD_PLATFORM] = platform + discover(hass, LOAD_PLATFORM + '.' + component, info, component, + hass_config) + + def setup(hass, config): """Start a discovery service.""" logger = logging.getLogger(__name__) diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 3ce72e62835..2b94369bc69 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -55,12 +55,24 @@ class EntityComponent(object): self._setup_platform(p_type, p_config) if self.discovery_platforms: + # Discovery listener for all items in discovery_platforms array + # passed from a component's setup method (e.g. light/__init__.py) discovery.listen( self.hass, self.discovery_platforms.keys(), lambda service, info: self._setup_platform(self.discovery_platforms[service], {}, info)) + # Generic discovery listener for loading platform dynamically + # Refer to: homeassistant.components.discovery.load_platform() + def load_platform_callback(service, info): + """Callback to load a platform.""" + platform = info.pop(discovery.LOAD_PLATFORM) + self._setup_platform(platform, {}, info if info else None) + discovery.listen( + self.hass, discovery.LOAD_PLATFORM + '.' + self.domain, + load_platform_callback) + def extract_from_service(self, service): """Extract all known entities from a service call. From 26ea4e41cbcc6825bdfcc94b0e0df23450350227 Mon Sep 17 00:00:00 2001 From: Nolan Gilley Date: Tue, 10 May 2016 01:49:26 -0400 Subject: [PATCH 19/95] Bring back custom scan intervals and service for speedtest.net component (#1980) * Bring back the functionality that was removed in PR 1717. This includes the speedtest service and the ability to define the scan times in the configuration file. Restore default functionality of 1 scan per hour on the hour. * remove unnecessary code. --- homeassistant/components/sensor/speedtest.py | 28 +++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/sensor/speedtest.py b/homeassistant/components/sensor/speedtest.py index 8898259754a..b781cdbad68 100644 --- a/homeassistant/components/sensor/speedtest.py +++ b/homeassistant/components/sensor/speedtest.py @@ -7,11 +7,12 @@ https://home-assistant.io/components/sensor.speedtest/ import logging import re import sys -from datetime import timedelta from subprocess import check_output +import homeassistant.util.dt as dt_util +from homeassistant.components.sensor import DOMAIN from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle +from homeassistant.helpers.event import track_time_change REQUIREMENTS = ['speedtest-cli==0.3.4'] _LOGGER = logging.getLogger(__name__) @@ -30,13 +31,10 @@ SENSOR_TYPES = { 'upload': ['Upload', 'Mbit/s'], } -# Return cached results if last scan was less then this time ago -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) - def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Speedtest sensor.""" - data = SpeedtestData() + data = SpeedtestData(hass, config) dev = [] for sensor in config[CONF_MONITORED_CONDITIONS]: if sensor not in SENSOR_TYPES: @@ -46,6 +44,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices(dev) + def update(call=None): + """Update service for manual updates.""" + data.update(dt_util.now()) + for sensor in dev: + sensor.update() + + hass.services.register(DOMAIN, 'update_speedtest', update) + # pylint: disable=too-few-public-methods class SpeedtestSensor(Entity): @@ -76,7 +82,6 @@ class SpeedtestSensor(Entity): def update(self): """Get the latest data and update the states.""" - self.speedtest_client.update() data = self.speedtest_client.data if data is None: return @@ -92,12 +97,15 @@ class SpeedtestSensor(Entity): class SpeedtestData(object): """Get the latest data from speedtest.net.""" - def __init__(self): + def __init__(self, hass, config): """Initialize the data object.""" self.data = None + track_time_change(hass, self.update, + minute=config.get(CONF_MINUTE, 0), + hour=config.get(CONF_HOUR, None), + day=config.get(CONF_DAY, None)) - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): + def update(self, now): """Get the latest data from speedtest.net.""" import speedtest_cli From d5a1c52359f57364960c90dcf1ec4036ff0e4168 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 9 May 2016 23:31:02 -0700 Subject: [PATCH 20/95] Add a Jinja filter for relative time --- homeassistant/helpers/template.py | 8 ++++++++ requirements_all.txt | 1 + 2 files changed, 9 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 8e039432728..2d9563ade86 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -6,6 +6,8 @@ import logging import jinja2 from jinja2.sandbox import ImmutableSandboxedEnvironment +import humanize + from homeassistant.components import group from homeassistant.const import STATE_UNKNOWN, ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import State @@ -38,6 +40,11 @@ def render_with_possible_json_value(hass, template, value, return value if error_value is _SENTINEL else error_value +def relative_time(end_time): + """Return a relative (human readable) timestamp for the given time.""" + return humanize.naturaltime(dt_util.now() - end_time) + + def render(hass, template, variables=None, **kwargs): """Render given template.""" if variables is not None: @@ -57,6 +64,7 @@ def render(hass, template, variables=None, **kwargs): 'states': AllStates(hass), 'utcnow': utcnow, 'as_timestamp': dt_util.as_timestamp, + 'relative_time': relative_time }).render(kwargs).strip() except jinja2.TemplateError as err: raise TemplateError(err) diff --git a/requirements_all.txt b/requirements_all.txt index ddfc5a0802f..6fd11b160b2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -6,6 +6,7 @@ pip>=7.0.0 vincenty==0.1.4 jinja2>=2.8 voluptuous==0.8.9 +humanize==0.5.1 # homeassistant.components.isy994 PyISY==1.0.5 From 8163b986c93e3c3c6f1317cc0b59df25c0322e1b Mon Sep 17 00:00:00 2001 From: Landrash Date: Tue, 10 May 2016 08:48:48 +0200 Subject: [PATCH 21/95] Fixed minor miss-spelling (#2028) Changed millileters to milliliters. Changed case of mmol/l to mmol/L. --- homeassistant/components/sensor/fitbit.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index 7e53f986515..7d122f857d7 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -92,8 +92,8 @@ FITBIT_MEASUREMENTS = { "height": "centimeters", "weight": "stone", "body": "centimeters", - "liquids": "millileters", - "blood glucose": "mmol/l" + "liquids": "milliliters", + "blood glucose": "mmol/L" }, "metric": { "duration": "milliseconds", @@ -102,8 +102,8 @@ FITBIT_MEASUREMENTS = { "height": "centimeters", "weight": "kilograms", "body": "centimeters", - "liquids": "millileters", - "blood glucose": "mmol/l" + "liquids": "milliliters", + "blood glucose": "mmol/L" } } From 16933abce95a434e3cab0bab10a30170758fc5d1 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Tue, 10 May 2016 00:04:53 -0700 Subject: [PATCH 22/95] Remove humanize and use a relative time thing that @balloob found on Github --- homeassistant/helpers/template.py | 4 +-- homeassistant/util/dt.py | 46 +++++++++++++++++++++++++++++++ requirements_all.txt | 1 - 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 2d9563ade86..a2a1856d3c2 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -6,8 +6,6 @@ import logging import jinja2 from jinja2.sandbox import ImmutableSandboxedEnvironment -import humanize - from homeassistant.components import group from homeassistant.const import STATE_UNKNOWN, ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import State @@ -42,7 +40,7 @@ def render_with_possible_json_value(hass, template, value, def relative_time(end_time): """Return a relative (human readable) timestamp for the given time.""" - return humanize.naturaltime(dt_util.now() - end_time) + return dt_util.get_age(end_time) def render(hass, template, variables=None, **kwargs): diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 16e8dfebfd1..b06b70f2cdd 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -152,3 +152,49 @@ def parse_time(time_str): except ValueError: # ValueError if value cannot be converted to an int or not in range return None + + +# Found in this gist: https://gist.github.com/zhangsen/1199964 +def get_age(date): + """ + Take a datetime and return its "age" as a string. + + The age can be in second, minute, hour, day, month or year. Only the + biggest unit is considered, e.g. if it's 2 days and 3 hours, "2 days" will + be returned. + Make sure date is not in the future, or else it won't work. + """ + def formatn(number, unit): + """Add "unit" if it's plural.""" + if number == 1: + return "1 %s" % unit + elif number > 1: + return "%d %ss" % (number, unit) + + def q_n_r(first, second): + """Return quotient and remaining.""" + return first // second, first % second + + # pylint: disable=too-few-public-methods + class PrettyDelta: + """A class for relative times.""" + + def __init__(self, subDt): + delta = now() - subDt + self.day = delta.days + self.second = delta.seconds + + self.year, self.day = q_n_r(self.day, 365) + self.month, self.day = q_n_r(self.day, 30) + self.hour, self.second = q_n_r(self.second, 3600) + self.minute, self.second = q_n_r(self.second, 60) + + def format(self): + """Format a datetime to relative time string.""" + for period in ['year', 'month', 'day', 'hour', 'minute', 'second']: + number = getattr(self, period) + if number > 0: + return formatn(number, period) + return "0 second" + + return PrettyDelta(date).format() diff --git a/requirements_all.txt b/requirements_all.txt index 6fd11b160b2..ddfc5a0802f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -6,7 +6,6 @@ pip>=7.0.0 vincenty==0.1.4 jinja2>=2.8 voluptuous==0.8.9 -humanize==0.5.1 # homeassistant.components.isy994 PyISY==1.0.5 From c7cfa8d2458bc7d317622537a8def9680dad13d6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 10 May 2016 13:36:03 -0700 Subject: [PATCH 23/95] Update README.rst --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 5cea28f5dca..b63377dbaf0 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ Home Assistant |Build Status| |Coverage Status| |Join the chat at https://gitter.im/home-assistant/home-assistant| |Join the dev chat at https://gitter.im/home-assistant/home-assistant/devs| -================================================================================================================== +============================================================================================================================================================================================== Home Assistant is a home automation platform running on Python 3. The goal of Home Assistant is to be able to track and control all devices at From a7d1f52ac8ddc3acd32f5ebe2dda6cd9ce515de2 Mon Sep 17 00:00:00 2001 From: Nolan Gilley Date: Tue, 10 May 2016 23:51:55 -0400 Subject: [PATCH 24/95] Use Throttle on speedtest update (#2036) * use throttle * fix flake8 --- homeassistant/components/sensor/speedtest.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/sensor/speedtest.py b/homeassistant/components/sensor/speedtest.py index b781cdbad68..d3250ce1221 100644 --- a/homeassistant/components/sensor/speedtest.py +++ b/homeassistant/components/sensor/speedtest.py @@ -7,7 +7,9 @@ https://home-assistant.io/components/sensor.speedtest/ import logging import re import sys +from datetime import timedelta from subprocess import check_output +from homeassistant.util import Throttle import homeassistant.util.dt as dt_util from homeassistant.components.sensor import DOMAIN @@ -30,6 +32,7 @@ SENSOR_TYPES = { 'download': ['Download', 'Mbit/s'], 'upload': ['Upload', 'Mbit/s'], } +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) def setup_platform(hass, config, add_devices, discovery_info=None): @@ -105,6 +108,7 @@ class SpeedtestData(object): hour=config.get(CONF_HOUR, None), day=config.get(CONF_DAY, None)) + @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self, now): """Get the latest data from speedtest.net.""" import speedtest_cli From 2f118c532753fd6f442e3e782a297e5af68628ab Mon Sep 17 00:00:00 2001 From: Erik Eriksson Date: Wed, 11 May 2016 06:12:14 +0200 Subject: [PATCH 25/95] log received mqtt messages (#2031) --- homeassistant/components/mqtt/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 8d34c153682..e9087d9c578 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -388,6 +388,8 @@ class MQTT(object): def _mqtt_on_message(self, _mqttc, _userdata, msg): """Message received callback.""" + _LOGGER.debug("received message on %s: %s", + msg.topic, msg.payload.decode('utf-8')) self.hass.bus.fire(EVENT_MQTT_MESSAGE_RECEIVED, { ATTR_TOPIC: msg.topic, ATTR_QOS: msg.qos, From 3c9e4934946ce99f5193ca550296034e86337997 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 10 May 2016 21:49:58 -0700 Subject: [PATCH 26/95] Make AND and OR conditions valid (#2037) --- homeassistant/helpers/config_validation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index cea7e95ac5a..031ab5227dc 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -369,6 +369,8 @@ CONDITION_SCHEMA = vol.Any( TEMPLATE_CONDITION_SCHEMA, TIME_CONDITION_SCHEMA, ZONE_CONDITION_SCHEMA, + AND_CONDITION_SCHEMA, + OR_CONDITION_SCHEMA, ) _SCRIPT_DELAY_SCHEMA = vol.Schema({ From b8a5d392c540c3ba5311e19e1b5bc9b240316bab Mon Sep 17 00:00:00 2001 From: Nolan Gilley Date: Wed, 11 May 2016 11:24:50 -0400 Subject: [PATCH 27/95] Fix speedtest by removing Throttle and adding second parameter for track_time_change (#2040) --- homeassistant/components/sensor/speedtest.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/speedtest.py b/homeassistant/components/sensor/speedtest.py index d3250ce1221..ddb14f6af81 100644 --- a/homeassistant/components/sensor/speedtest.py +++ b/homeassistant/components/sensor/speedtest.py @@ -7,9 +7,7 @@ https://home-assistant.io/components/sensor.speedtest/ import logging import re import sys -from datetime import timedelta from subprocess import check_output -from homeassistant.util import Throttle import homeassistant.util.dt as dt_util from homeassistant.components.sensor import DOMAIN @@ -24,6 +22,7 @@ _SPEEDTEST_REGEX = re.compile(r'Ping:\s(\d+\.\d+)\sms[\r\n]+' r'Upload:\s(\d+\.\d+)\sMbit/s[\r\n]+') CONF_MONITORED_CONDITIONS = 'monitored_conditions' +CONF_SECOND = 'second' CONF_MINUTE = 'minute' CONF_HOUR = 'hour' CONF_DAY = 'day' @@ -32,7 +31,6 @@ SENSOR_TYPES = { 'download': ['Download', 'Mbit/s'], 'upload': ['Upload', 'Mbit/s'], } -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) def setup_platform(hass, config, add_devices, discovery_info=None): @@ -104,11 +102,11 @@ class SpeedtestData(object): """Initialize the data object.""" self.data = None track_time_change(hass, self.update, + second=config.get(CONF_SECOND, 0), minute=config.get(CONF_MINUTE, 0), hour=config.get(CONF_HOUR, None), day=config.get(CONF_DAY, None)) - @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self, now): """Get the latest data from speedtest.net.""" import speedtest_cli From f9d97c4356caaa4d26e05a15851a35c37446d8b4 Mon Sep 17 00:00:00 2001 From: Nolan Gilley Date: Thu, 12 May 2016 00:52:56 -0400 Subject: [PATCH 28/95] fix away mode. issue 2032 (#2044) --- homeassistant/components/thermostat/ecobee.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/thermostat/ecobee.py b/homeassistant/components/thermostat/ecobee.py index 756c6c6913a..abeda6be736 100644 --- a/homeassistant/components/thermostat/ecobee.py +++ b/homeassistant/components/thermostat/ecobee.py @@ -40,7 +40,6 @@ class Thermostat(ThermostatDevice): self.thermostat = self.data.ecobee.get_thermostat( self.thermostat_index) self._name = self.thermostat['name'] - self._away = 'away' in self.thermostat['program']['currentClimateRef'] self.hold_temp = hold_temp def update(self): @@ -121,9 +120,7 @@ class Thermostat(ThermostatDevice): @property def mode(self): """Return current mode ie. home, away, sleep.""" - mode = self.thermostat['program']['currentClimateRef'] - self._away = 'away' in mode - return mode + return self.thermostat['program']['currentClimateRef'] @property def hvac_mode(self): @@ -144,11 +141,16 @@ class Thermostat(ThermostatDevice): @property def is_away_mode_on(self): """Return true if away mode is on.""" - return self._away + mode = self.mode + events = self.thermostat['events'] + for event in events: + if event['running']: + mode = event['holdClimateRef'] + break + return 'away' in mode def turn_away_mode_on(self): """Turn away on.""" - self._away = True if self.hold_temp: self.data.ecobee.set_climate_hold(self.thermostat_index, "away", "indefinite") @@ -157,7 +159,6 @@ class Thermostat(ThermostatDevice): def turn_away_mode_off(self): """Turn away off.""" - self._away = False self.data.ecobee.resume_program(self.thermostat_index) def set_temperature(self, temperature): @@ -180,20 +181,16 @@ class Thermostat(ThermostatDevice): # def turn_home_mode_on(self): # """ Turns home mode on. """ - # self._away = False # self.data.ecobee.set_climate_hold(self.thermostat_index, "home") # def turn_home_mode_off(self): # """ Turns home mode off. """ - # self._away = False # self.data.ecobee.resume_program(self.thermostat_index) # def turn_sleep_mode_on(self): # """ Turns sleep mode on. """ - # self._away = False # self.data.ecobee.set_climate_hold(self.thermostat_index, "sleep") # def turn_sleep_mode_off(self): # """ Turns sleep mode off. """ - # self._away = False # self.data.ecobee.resume_program(self.thermostat_index) From c341ae0a393bdea908e9eb1205e2929c658a20d6 Mon Sep 17 00:00:00 2001 From: happyleavesaoc Date: Wed, 11 May 2016 21:53:56 -0700 Subject: [PATCH 29/95] Media Player - MPD: handle more exceptions (#2045) --- homeassistant/components/media_player/mpd.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index 266203e52e7..fefdab68685 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -89,7 +89,13 @@ class MpdDevice(MediaPlayerDevice): try: self.status = self.client.status() self.currentsong = self.client.currentsong() - except mpd.ConnectionError: + except (mpd.ConnectionError, BrokenPipeError, ValueError): + # Cleanly disconnect in case connection is not in valid state + try: + self.client.disconnect() + except mpd.ConnectionError: + pass + self.client.connect(self.server, self.port) if self.password is not None: From fbe940139ab5c07aa46f4f976e6534786f84d1b0 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Thu, 12 May 2016 06:58:22 +0200 Subject: [PATCH 30/95] Discovery listener on all EntityComponents (#2042) --- homeassistant/helpers/entity_component.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 2b94369bc69..2a99b57da55 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -63,15 +63,14 @@ class EntityComponent(object): self._setup_platform(self.discovery_platforms[service], {}, info)) - # Generic discovery listener for loading platform dynamically - # Refer to: homeassistant.components.discovery.load_platform() - def load_platform_callback(service, info): - """Callback to load a platform.""" - platform = info.pop(discovery.LOAD_PLATFORM) - self._setup_platform(platform, {}, info if info else None) - discovery.listen( - self.hass, discovery.LOAD_PLATFORM + '.' + self.domain, - load_platform_callback) + # Generic discovery listener for loading platform dynamically + # Refer to: homeassistant.components.discovery.load_platform() + def load_platform_callback(service, info): + """Callback to load a platform.""" + platform = info.pop(discovery.LOAD_PLATFORM) + self._setup_platform(platform, {}, info if info else None) + discovery.listen(self.hass, discovery.LOAD_PLATFORM + '.' + + self.domain, load_platform_callback) def extract_from_service(self, service): """Extract all known entities from a service call. From 894ceacd40ea06eb2107dadf28f11c81cfd9d61d Mon Sep 17 00:00:00 2001 From: Nolan Gilley Date: Thu, 12 May 2016 01:03:21 -0400 Subject: [PATCH 31/95] Add Ecobee notify platform (#2021) * add send_message to ecobee via service call * farcy fixes * fix pydocstyle * ecobee notify component --- homeassistant/components/ecobee.py | 2 +- homeassistant/components/notify/ecobee.py | 31 +++++++++++++++++++++++ requirements_all.txt | 2 +- 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/notify/ecobee.py diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py index 21dd73ea6e6..11c49fd44eb 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -22,7 +22,7 @@ HOLD_TEMP = 'hold_temp' REQUIREMENTS = [ 'https://github.com/nkgilley/python-ecobee-api/archive/' - '92a2f330cbaf601d0618456fdd97e5a8c42c1c47.zip#python-ecobee==0.0.4'] + '4a884bc146a93991b4210f868f3d6aecf0a181e6.zip#python-ecobee==0.0.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/ecobee.py b/homeassistant/components/notify/ecobee.py new file mode 100644 index 00000000000..861d5439e4c --- /dev/null +++ b/homeassistant/components/notify/ecobee.py @@ -0,0 +1,31 @@ +""" +Support for ecobee Send Message service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.ecobee/ +""" +import logging +from homeassistant.components import ecobee +from homeassistant.components.notify import BaseNotificationService + +DEPENDENCIES = ['ecobee'] +_LOGGER = logging.getLogger(__name__) + + +def get_service(hass, config): + """Get the Ecobee notification service.""" + index = int(config['index']) if 'index' in config else 0 + return EcobeeNotificationService(index) + + +# pylint: disable=too-few-public-methods +class EcobeeNotificationService(BaseNotificationService): + """Implement the notification service for the Ecobee thermostat.""" + + def __init__(self, thermostat_index): + """Initialize the service.""" + self.thermostat_index = thermostat_index + + def send_message(self, message="", **kwargs): + """Send a message to a command line.""" + ecobee.NETWORK.ecobee.send_message(self.thermostat_index, message) diff --git a/requirements_all.txt b/requirements_all.txt index ddfc5a0802f..5331fd414e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -110,7 +110,7 @@ https://github.com/danieljkemp/onkyo-eiscp/archive/python3.zip#onkyo-eiscp==0.9. https://github.com/jamespcole/home-assistant-nzb-clients/archive/616cad59154092599278661af17e2a9f2cf5e2a9.zip#python-sabnzbd==0.1 # homeassistant.components.ecobee -https://github.com/nkgilley/python-ecobee-api/archive/92a2f330cbaf601d0618456fdd97e5a8c42c1c47.zip#python-ecobee==0.0.4 +https://github.com/nkgilley/python-ecobee-api/archive/4a884bc146a93991b4210f868f3d6aecf0a181e6.zip#python-ecobee==0.0.5 # homeassistant.components.switch.edimax https://github.com/rkabadi/pyedimax/archive/365301ce3ff26129a7910c501ead09ea625f3700.zip#pyedimax==0.1 From b75aa6ac08a5dbe76559d76c6b8ffbb734c31327 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 11 May 2016 22:29:55 -0700 Subject: [PATCH 32/95] Add get_age tests --- tests/util/test_dt.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index da5b56d42a9..1b51287384c 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -133,3 +133,8 @@ class TestDateUtil(unittest.TestCase): def test_parse_datetime_returns_none_for_incorrect_format(self): """Test parse_datetime returns None if incorrect format.""" self.assertIsNone(dt_util.parse_datetime("not a datetime string")) + + def test_get_age(self): + """Test get_age returns 5 minutes.""" + fiveminago = dt_util.now() - timedelta(minutes=5) + self.assertEqual(dt_util.get_age(fiveminago), "5 minutes") From fca4ec2b3e37ef9a182457d4f14b96230f35d266 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 11 May 2016 22:37:37 -0700 Subject: [PATCH 33/95] simplify the relative_time function --- homeassistant/helpers/template.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index a2a1856d3c2..58dcd300500 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -37,12 +37,6 @@ def render_with_possible_json_value(hass, template, value, _LOGGER.error('Error parsing value: %s', ex) return value if error_value is _SENTINEL else error_value - -def relative_time(end_time): - """Return a relative (human readable) timestamp for the given time.""" - return dt_util.get_age(end_time) - - def render(hass, template, variables=None, **kwargs): """Render given template.""" if variables is not None: @@ -62,7 +56,7 @@ def render(hass, template, variables=None, **kwargs): 'states': AllStates(hass), 'utcnow': utcnow, 'as_timestamp': dt_util.as_timestamp, - 'relative_time': relative_time + 'relative_time': dt_util.get_age }).render(kwargs).strip() except jinja2.TemplateError as err: raise TemplateError(err) From 4d0b9f1e94d4d95f6ee511cb755b71c711ba460c Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 11 May 2016 22:44:44 -0700 Subject: [PATCH 34/95] Stupid blank lines --- homeassistant/helpers/template.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 58dcd300500..41de1782a1f 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -37,6 +37,7 @@ def render_with_possible_json_value(hass, template, value, _LOGGER.error('Error parsing value: %s', ex) return value if error_value is _SENTINEL else error_value + def render(hass, template, variables=None, **kwargs): """Render given template.""" if variables is not None: From 69929f15fb0941a207e41bfd9238dce33f6413f1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 11 May 2016 22:56:05 -0700 Subject: [PATCH 35/95] Ignore RPI-RF in requirements_all --- requirements_all.txt | 2 +- script/gen_requirements_all.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements_all.txt b/requirements_all.txt index 5331fd414e3..fc79356bd47 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -280,7 +280,7 @@ pywemo==0.4.2 radiotherm==1.2 # homeassistant.components.switch.rpi_rf -rpi-rf==0.9.5 +# rpi-rf==0.9.5 # homeassistant.components.media_player.yamaha rxv==0.1.11 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 32e7dc01dac..76ed3acba39 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -8,6 +8,7 @@ import sys COMMENT_REQUIREMENTS = [ 'RPi.GPIO', + 'rpi-rf', 'Adafruit_Python_DHT', 'fritzconnection', 'pybluez', From f1eda430cd2e24f6455e78f1cd1cb26b5cf27835 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 12 May 2016 00:13:48 -0700 Subject: [PATCH 36/95] Update rpi_rf.py --- homeassistant/components/switch/rpi_rf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/switch/rpi_rf.py b/homeassistant/components/switch/rpi_rf.py index 2b090af0320..b96a1d70dc5 100644 --- a/homeassistant/components/switch/rpi_rf.py +++ b/homeassistant/components/switch/rpi_rf.py @@ -14,7 +14,7 @@ REQUIREMENTS = ['rpi-rf==0.9.5'] _LOGGER = logging.getLogger(__name__) -# pylint: disable=unused-argument +# pylint: disable=unused-argument, import-error def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Find and return switches controlled by a generic RF device via GPIO.""" import rpi_rf From 93fd6fa11b30c2fa0941847c721e9e4ff3dfe938 Mon Sep 17 00:00:00 2001 From: Alex Harvey Date: Thu, 12 May 2016 10:33:22 -0700 Subject: [PATCH 37/95] fixes for pep and delay start --- homeassistant/components/recorder.py | 11 ++++++++--- tests/components/test_recorder.py | 11 +++++------ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/recorder.py b/homeassistant/components/recorder.py index f760fc882f5..0c7454ad694 100644 --- a/homeassistant/components/recorder.py +++ b/homeassistant/components/recorder.py @@ -22,6 +22,7 @@ from homeassistant.const import ( EVENT_TIME_CHANGED, MATCH_ALL) from homeassistant.core import Event, EventOrigin, State from homeassistant.remote import JSONEncoder +from homeassistant.helpers.event import track_point_in_utc_time DOMAIN = "recorder" @@ -33,8 +34,9 @@ RETURN_ONE_ROW = "one_row" CONF_PURGE_DAYS = "purge_days" CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.All(dict, { - CONF_PURGE_DAYS: int + DOMAIN: vol.Schema({ + vol.Optional(CONF_PURGE_DAYS): vol.All(vol.Coerce(int), + vol.Range(min=1)), }) }, extra=vol.ALLOW_EXTRA) @@ -204,7 +206,10 @@ class Recorder(threading.Thread): """Start processing events to save.""" self._setup_connection() self._setup_run() - self._purge_old_data() + if self.purge_days is not None: + track_point_in_utc_time(self.hass, + lambda now: self._purge_old_data(), + dt_util.utcnow() + timedelta(minutes=5)) while True: event = self.queue.get() diff --git a/tests/components/test_recorder.py b/tests/components/test_recorder.py index cb96e079a63..b2db41efc3c 100644 --- a/tests/components/test_recorder.py +++ b/tests/components/test_recorder.py @@ -43,12 +43,13 @@ class TestRecorder(unittest.TestCase): timestamp = now state = 'dontpurgeme' recorder.query("INSERT INTO states (" - "entity_id, domain, state, attributes, last_changed," - "last_updated, created, utc_offset, event_id)" + "entity_id, domain, state, attributes," + "last_changed, last_updated, created," + "utc_offset, event_id)" "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ('test.recorder2', 'sensor', state, - json.dumps(attributes), timestamp, timestamp, - timestamp, -18000, event_id + 1000)) + json.dumps(attributes), timestamp, timestamp, + timestamp, -18000, event_id + 1000)) def _add_test_events(self): """Adds a few events for testing.""" @@ -155,7 +156,6 @@ class TestRecorder(unittest.TestCase): 'event_type LIKE "EVENT_TEST%"') self.assertEqual(len(events), 3) - def test_purge_disabled(self): """Tests leaving purge_days disabled.""" self._add_test_states() @@ -167,7 +167,6 @@ class TestRecorder(unittest.TestCase): self.assertEqual(len(states), 5) self.assertEqual(len(events), 5) - # run purge_old_data() recorder._INSTANCE.purge_days = None recorder._INSTANCE._purge_old_data() From 65ac1ae84af311211df8a04529bc28a56910c0ff Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Fri, 13 May 2016 06:39:30 +0200 Subject: [PATCH 38/95] Added QwikSwitch component & platforms (#1970) * Added QwikSwitch platform farcy - worst than my english teacher * Clean up comments * Import only inside functions * Moved imports, no global var, load_platform * add_device reworked * Only serializable content on bus * Fixed imports & removed some logging --- .coveragerc | 3 + homeassistant/components/light/qwikswitch.py | 37 +++++ homeassistant/components/qwikswitch.py | 139 ++++++++++++++++++ homeassistant/components/switch/qwikswitch.py | 36 +++++ requirements_all.txt | 3 + 5 files changed, 218 insertions(+) create mode 100644 homeassistant/components/light/qwikswitch.py create mode 100644 homeassistant/components/qwikswitch.py create mode 100644 homeassistant/components/switch/qwikswitch.py diff --git a/.coveragerc b/.coveragerc index 028aacead28..07de825e839 100644 --- a/.coveragerc +++ b/.coveragerc @@ -38,6 +38,9 @@ omit = homeassistant/components/octoprint.py homeassistant/components/*/octoprint.py + homeassistant/components/qwikswitch.py + homeassistant/components/*/qwikswitch.py + homeassistant/components/rpi_gpio.py homeassistant/components/*/rpi_gpio.py diff --git a/homeassistant/components/light/qwikswitch.py b/homeassistant/components/light/qwikswitch.py new file mode 100644 index 00000000000..cd35078031c --- /dev/null +++ b/homeassistant/components/light/qwikswitch.py @@ -0,0 +1,37 @@ +""" +Support for Qwikswitch Relays and Dimmers as HA Lights. + +See the main component for more info +""" +import logging +import homeassistant.components.qwikswitch as qwikswitch +from homeassistant.components.light import Light + +DEPENDENCIES = ['qwikswitch'] + + +class QSLight(qwikswitch.QSToggleEntity, Light): + """Light based on a Qwikswitch relay/dimmer module.""" + + pass + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Store add_devices for the 'light' components.""" + if discovery_info is None or 'qsusb_id' not in discovery_info: + logging.getLogger(__name__).error( + 'Configure main Qwikswitch component') + return False + + qsusb = qwikswitch.QSUSB[discovery_info['qsusb_id']] + + for item in qsusb.ha_devices: + if item['id'] in qsusb.ha_objects or \ + item['type'] not in ['dim', 'rel']: + continue + if item['type'] == 'rel' and item['name'].lower().endswith(' switch'): + continue + dev = QSLight(item, qsusb) + add_devices([dev]) + qsusb.ha_objects[item['id']] = dev diff --git a/homeassistant/components/qwikswitch.py b/homeassistant/components/qwikswitch.py new file mode 100644 index 00000000000..29c2aee37fe --- /dev/null +++ b/homeassistant/components/qwikswitch.py @@ -0,0 +1,139 @@ +""" +Support for Qwikswitch lights and switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/qwikswitch +""" + +import logging +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.components.light import ATTR_BRIGHTNESS +from homeassistant.components.discovery import load_platform + +REQUIREMENTS = ['https://github.com/kellerza/pyqwikswitch/archive/v0.1.zip' + '#pyqwikswitch==0.1'] +DEPENDENCIES = [] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'qwikswitch' +QSUSB = None + + +class QSToggleEntity(object): + """Representation of a Qwikswitch Entiry. + + Implement base QS methods. Modeled around HA ToggleEntity[1] & should only + be used in a class that extends both QSToggleEntity *and* ToggleEntity. + + Implemented: + - QSLight extends QSToggleEntity and Light[2] (ToggleEntity[1]) + - QSSwitch extends QSToggleEntity and SwitchDevice[3] (ToggleEntity[1]) + + [1] /helpers/entity.py + [2] /components/light/__init__.py + [3] /components/switch/__init__.py + """ + + def __init__(self, qsitem, qsusb): + """Initialize the light.""" + self._id = qsitem['id'] + self._name = qsitem['name'] + self._qsusb = qsusb + self._value = qsitem.get('value', 0) + self._dim = qsitem['type'] == 'dim' + + @property + def brightness(self): + """Return the brightness of this light between 0..100.""" + return self._value if self._dim else None + + # pylint: disable=no-self-use + @property + def should_poll(self): + """State Polling needed.""" + return False + + @property + def name(self): + """Return the name of the light.""" + return self._name + + @property + def is_on(self): + """Check if On (non-zero).""" + return self._value > 0 + + def update_value(self, value): + """Decode QSUSB value & update HA state.""" + self._value = value + # pylint: disable=no-member + super().update_ha_state() # Part of Entity/ToggleEntity + return self._value + + # pylint: disable=unused-argument + def turn_on(self, **kwargs): + """Turn the device on.""" + if ATTR_BRIGHTNESS in kwargs: + self._value = kwargs[ATTR_BRIGHTNESS] + else: + self._value = 100 + return self._qsusb.set(self._id, self._value) + + # pylint: disable=unused-argument + def turn_off(self, **kwargs): + """Turn the device off.""" + return self._qsusb.set(self._id, 0) + + +# pylint: disable=too-many-locals +def setup(hass, config): + """Setup the QSUSB component.""" + from pyqwikswitch import QSUsb + + try: + url = config[DOMAIN].get('url', 'http://127.0.0.1:2020') + qsusb = QSUsb(url, _LOGGER) + + # Ensure qsusb terminates threads correctly + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, + lambda event: qsusb.stop()) + except ValueError as val_err: + _LOGGER.error(str(val_err)) + return False + + qsusb.ha_devices = qsusb.devices() + qsusb.ha_objects = {} + + global QSUSB + if QSUSB is None: + QSUSB = {} + QSUSB[id(qsusb)] = qsusb + + # Register add_device callbacks onto the gloabl ADD_DEVICES + # Switch called first since they are [type=rel] and end with ' switch' + for comp_name in ('switch', 'light'): + load_platform(hass, comp_name, 'qwikswitch', + {'qsusb_id': id(qsusb)}, config) + + def qs_callback(item): + """Typically a btn press or update signal.""" + from pyqwikswitch import CMD_BUTTONS + + # If button pressed, fire a hass event + if item.get('type', '') in CMD_BUTTONS: + _LOGGER.info('qwikswitch.button.%s', item['id']) + hass.bus.fire('qwikswitch.button.{}'.format(item['id'])) + return + + # Update all ha_objects + qsreply = qsusb.devices() + if qsreply is False: + return + for item in qsreply: + item_id = item.get('id', '') + if item_id in qsusb.ha_objects: + qsusb.ha_objects[item_id].update_value(item['value']) + + qsusb.listen(callback=qs_callback, timeout=10) + return True diff --git a/homeassistant/components/switch/qwikswitch.py b/homeassistant/components/switch/qwikswitch.py new file mode 100644 index 00000000000..75da97d6296 --- /dev/null +++ b/homeassistant/components/switch/qwikswitch.py @@ -0,0 +1,36 @@ +""" +Support for Qwikswitch Relays as HA Switches. + +See the main component for more info +""" +import logging +import homeassistant.components.qwikswitch as qwikswitch +from homeassistant.components.switch import SwitchDevice + +DEPENDENCIES = ['qwikswitch'] + + +class QSSwitch(qwikswitch.QSToggleEntity, SwitchDevice): + """Switch based on a Qwikswitch relay module.""" + + pass + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Store add_devices for the 'switch' components.""" + if discovery_info is None or 'qsusb_id' not in discovery_info: + logging.getLogger(__name__).error( + 'Configure main Qwikswitch component') + return False + + qsusb = qwikswitch.QSUSB[discovery_info['qsusb_id']] + + for item in qsusb.ha_devices: + if item['type'] == 'rel' and \ + item['name'].lower().endswith(' switch'): + # Remove the ' Switch' name postfix for HA + item['name'] = item['name'][:-7] + dev = QSSwitch(item, qsusb) + add_devices([dev]) + qsusb.ha_objects[item['id']] = dev diff --git a/requirements_all.txt b/requirements_all.txt index fc79356bd47..1084da242b2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -109,6 +109,9 @@ https://github.com/danieljkemp/onkyo-eiscp/archive/python3.zip#onkyo-eiscp==0.9. # homeassistant.components.sensor.sabnzbd https://github.com/jamespcole/home-assistant-nzb-clients/archive/616cad59154092599278661af17e2a9f2cf5e2a9.zip#python-sabnzbd==0.1 +# homeassistant.components.qwikswitch +https://github.com/kellerza/pyqwikswitch/archive/v0.1.zip#pyqwikswitch==0.1 + # homeassistant.components.ecobee https://github.com/nkgilley/python-ecobee-api/archive/4a884bc146a93991b4210f868f3d6aecf0a181e6.zip#python-ecobee==0.0.5 From 8682e2def879e398cb81269c5240dde24121e4ed Mon Sep 17 00:00:00 2001 From: happyleavesaoc Date: Thu, 12 May 2016 22:16:58 -0700 Subject: [PATCH 39/95] supervisord sensor (#2056) --- .coveragerc | 1 + .../components/sensor/supervisord.py | 61 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 homeassistant/components/sensor/supervisord.py diff --git a/.coveragerc b/.coveragerc index 07de825e839..02752a9a361 100644 --- a/.coveragerc +++ b/.coveragerc @@ -166,6 +166,7 @@ omit = homeassistant/components/sensor/sabnzbd.py homeassistant/components/sensor/speedtest.py homeassistant/components/sensor/steam_online.py + homeassistant/components/sensor/supervisord.py homeassistant/components/sensor/swiss_public_transport.py homeassistant/components/sensor/systemmonitor.py homeassistant/components/sensor/temper.py diff --git a/homeassistant/components/sensor/supervisord.py b/homeassistant/components/sensor/supervisord.py new file mode 100644 index 00000000000..cebdfb83f14 --- /dev/null +++ b/homeassistant/components/sensor/supervisord.py @@ -0,0 +1,61 @@ +""" +Sensor for Supervisord process status. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.supervisord/ +""" +import logging +import xmlrpc.client + +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Supervisord platform.""" + try: + supervisor_server = xmlrpc.client.ServerProxy( + config.get('url', 'http://localhost:9001/RPC2')) + except ConnectionRefusedError: + _LOGGER.error('Could not connect to Supervisord') + return + processes = supervisor_server.supervisor.getAllProcessInfo() + add_devices( + [SupervisorProcessSensor(info, supervisor_server) + for info in processes]) + + +class SupervisorProcessSensor(Entity): + """Represent a supervisor-monitored process.""" + + # pylint: disable=abstract-method + def __init__(self, info, server): + """Initialize the sensor.""" + self._info = info + self._server = server + self.update() + + @property + def name(self): + """Return the name of the sensor.""" + return self._info.get('name') + + @property + def state(self): + """Return the state of the sensor.""" + return self._info.get('statename') + + def update(self): + """Update device state.""" + self._info = self._server.supervisor.getProcessInfo( + self._info.get('name')) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + 'group': self._info.get('group'), + 'description': self._info.get('description') + } From d229cb46b1ad583410d33784968331ed180340e8 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Thu, 12 May 2016 22:37:08 -0700 Subject: [PATCH 40/95] Google travel time improvements (#2047) * Update google_travel_time.py * Update google_travel_time.py * pylint: disable=too-many-instance-attributes * Add the mode to the title of the sensor * Expose the travel mode on the sensor attributes * Big improvements to the Google Travel Time sensor. Allow passing any options that Google supports in the options dict of your configuration. Deprecate travel_mode. Change name format to show the mode * fu farcy * Dynamically convert departure and arrival times * Add a warning if user provides both departure and arrival times * Add deprecation warning for travel_mode outside options and other minor fixes * Use a copy of options dict to not overwrite the departure/arrival times constantly. * Remove default travel_mode, but set default options.mode to driving * Google doesnt let us query time in the past, so if the date we generate from a time string is in the past, add 1 day * spacing fix * Add config validation for all possible parameters * flake8 and pylint fixes --- .../components/sensor/google_travel_time.py | 110 +++++++++++++++--- 1 file changed, 91 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/sensor/google_travel_time.py b/homeassistant/components/sensor/google_travel_time.py index 867a84b1f03..a264723b47a 100644 --- a/homeassistant/components/sensor/google_travel_time.py +++ b/homeassistant/components/sensor/google_travel_time.py @@ -10,8 +10,10 @@ import logging import voluptuous as vol from homeassistant.helpers.entity import Entity -from homeassistant.const import CONF_API_KEY, TEMP_CELSIUS +from homeassistant.const import CONF_API_KEY, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -23,47 +25,102 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) CONF_ORIGIN = 'origin' CONF_DESTINATION = 'destination' CONF_TRAVEL_MODE = 'travel_mode' +CONF_OPTIONS = 'options' +CONF_MODE = 'mode' +CONF_NAME = 'name' + +ALL_LANGUAGES = ['ar', 'bg', 'bn', 'ca', 'cs', 'da', 'de', 'el', 'en', 'es', + 'eu', 'fa', 'fi', 'fr', 'gl', 'gu', 'hi', 'hr', 'hu', 'id', + 'it', 'iw', 'ja', 'kn', 'ko', 'lt', 'lv', 'ml', 'mr', 'nl', + 'no', 'pl', 'pt', 'pt-BR', 'pt-PT', 'ro', 'ru', 'sk', 'sl', + 'sr', 'sv', 'ta', 'te', 'th', 'tl', 'tr', 'uk', 'vi', + 'zh-CN', 'zh-TW'] + +TRANSIT_PREFS = ['less_walking', 'fewer_transfers'] PLATFORM_SCHEMA = vol.Schema({ vol.Required('platform'): 'google_travel_time', + vol.Optional(CONF_NAME): vol.Coerce(str), vol.Required(CONF_API_KEY): vol.Coerce(str), vol.Required(CONF_ORIGIN): vol.Coerce(str), vol.Required(CONF_DESTINATION): vol.Coerce(str), - vol.Optional(CONF_TRAVEL_MODE, default='driving'): - vol.In(["driving", "walking", "bicycling", "transit"]) + vol.Optional(CONF_TRAVEL_MODE): + vol.In(["driving", "walking", "bicycling", "transit"]), + vol.Optional(CONF_OPTIONS): vol.All( + dict, vol.Schema({ + vol.Optional(CONF_MODE, default='driving'): + vol.In(["driving", "walking", "bicycling", "transit"]), + vol.Optional('language'): vol.In(ALL_LANGUAGES), + vol.Optional('avoid'): vol.In(['tolls', 'highways', + 'ferries', 'indoor']), + vol.Optional('units'): vol.In(['metric', 'imperial']), + vol.Exclusive('arrival_time', 'time'): cv.string, + vol.Exclusive('departure_time', 'time'): cv.string, + vol.Optional('traffic_model'): vol.In(['best_guess', + 'pessimistic', + 'optimistic']), + vol.Optional('transit_mode'): vol.In(['bus', 'subway', 'train', + 'tram', 'rail']), + vol.Optional('transit_routing_preference'): vol.In(TRANSIT_PREFS) + })) }) +def convert_time_to_utc(timestr): + """Take a string like 08:00:00 and convert it to a unix timestamp.""" + combined = datetime.combine(dt_util.start_of_local_day(), + dt_util.parse_time(timestr)) + if combined < datetime.now(): + combined = combined + timedelta(days=1) + return dt_util.as_timestamp(combined) + + def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Setup the travel time platform.""" # pylint: disable=too-many-locals + options = config.get(CONF_OPTIONS) - is_metric = (hass.config.temperature_unit == TEMP_CELSIUS) + if options.get('units') is None: + if hass.config.temperature_unit is TEMP_CELSIUS: + options['units'] = 'metric' + elif hass.config.temperature_unit is TEMP_FAHRENHEIT: + options['units'] = 'imperial' + + travel_mode = config.get(CONF_TRAVEL_MODE) + mode = options.get(CONF_MODE) + + if travel_mode is not None: + wstr = ("Google Travel Time: travel_mode is deprecated, please add " + "mode to the options dictionary instead!") + _LOGGER.warning(wstr) + if mode is None: + options[CONF_MODE] = travel_mode + + titled_mode = options.get(CONF_MODE, 'driving').title() + formatted_name = "Google Travel Time - {}".format(titled_mode) + name = config.get(CONF_NAME, formatted_name) api_key = config.get(CONF_API_KEY) origin = config.get(CONF_ORIGIN) destination = config.get(CONF_DESTINATION) - travel_mode = config.get(CONF_TRAVEL_MODE) - sensor = GoogleTravelTimeSensor(api_key, origin, destination, - travel_mode, is_metric) + sensor = GoogleTravelTimeSensor(name, api_key, origin, destination, + options) if sensor.valid_api_connection: add_devices_callback([sensor]) +# pylint: disable=too-many-instance-attributes class GoogleTravelTimeSensor(Entity): """Representation of a tavel time sensor.""" # pylint: disable=too-many-arguments - def __init__(self, api_key, origin, destination, travel_mode, is_metric): + def __init__(self, name, api_key, origin, destination, options): """Initialize the sensor.""" - if is_metric: - self._unit = 'metric' - else: - self._unit = 'imperial' + self._name = name + self._options = options self._origin = origin self._destination = destination - self._travel_mode = travel_mode self._matrix = None self.valid_api_connection = True @@ -84,12 +141,13 @@ class GoogleTravelTimeSensor(Entity): @property def name(self): """Get the name of the sensor.""" - return "Google Travel time" + return self._name @property def device_state_attributes(self): """Return the state attributes.""" res = self._matrix.copy() + res.update(self._options) del res['rows'] _data = self._matrix['rows'][0]['elements'][0] if 'duration_in_traffic' in _data: @@ -108,10 +166,24 @@ class GoogleTravelTimeSensor(Entity): @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data from Google.""" - now = datetime.now() + options_copy = self._options.copy() + dtime = options_copy.get('departure_time') + atime = options_copy.get('arrival_time') + if dtime is not None and ':' in dtime: + options_copy['departure_time'] = convert_time_to_utc(dtime) + + if atime is not None and ':' in atime: + options_copy['arrival_time'] = convert_time_to_utc(atime) + + departure_time = options_copy.get('departure_time') + arrival_time = options_copy.get('arrival_time') + if departure_time is not None and arrival_time is not None: + wstr = ("Google Travel Time: You can not provide both arrival " + "and departure times! Deleting the arrival time...") + _LOGGER.warning(wstr) + del options_copy['arrival_time'] + del self._options['arrival_time'] + self._matrix = self._client.distance_matrix(self._origin, self._destination, - mode=self._travel_mode, - units=self._unit, - departure_time=now, - traffic_model="optimistic") + **options_copy) From aa7fa7b5507eb1f1c584da0d813e9e6b0698c32b Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Thu, 12 May 2016 22:49:12 -0700 Subject: [PATCH 41/95] Dont default to driving anymore, re: #2047 --- homeassistant/components/sensor/google_travel_time.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/google_travel_time.py b/homeassistant/components/sensor/google_travel_time.py index a264723b47a..8bfe5aa142b 100644 --- a/homeassistant/components/sensor/google_travel_time.py +++ b/homeassistant/components/sensor/google_travel_time.py @@ -96,7 +96,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): if mode is None: options[CONF_MODE] = travel_mode - titled_mode = options.get(CONF_MODE, 'driving').title() + titled_mode = options.get(CONF_MODE).title() formatted_name = "Google Travel Time - {}".format(titled_mode) name = config.get(CONF_NAME, formatted_name) api_key = config.get(CONF_API_KEY) From 96b73684eb7b25e6390f9dc936be9d7c5005ead8 Mon Sep 17 00:00:00 2001 From: Lewis Juggins Date: Fri, 13 May 2016 15:55:52 +0100 Subject: [PATCH 42/95] Update Dockerfile to use OpenSSL 1.0.2h to resolve certificate issues (#2057) --- Dockerfile | 8 ++++++++ script/build_python_openzwave | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 013afcca674..9257a2be7d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,14 @@ RUN script/build_python_openzwave && \ COPY requirements_all.txt requirements_all.txt RUN pip3 install --no-cache-dir -r requirements_all.txt +RUN wget http://www.openssl.org/source/openssl-1.0.2h.tar.gz && \ + tar -xvzf openssl-1.0.2h.tar.gz && \ + cd openssl-1.0.2h && \ + ./config --prefix=/usr/ && \ + make && \ + make install && \ + rm -rf openssl-1.0.2h* + # Copy source COPY . . diff --git a/script/build_python_openzwave b/script/build_python_openzwave index 2a5283c44bd..8f88cace558 100755 --- a/script/build_python_openzwave +++ b/script/build_python_openzwave @@ -15,7 +15,7 @@ if [ -d python-openzwave ]; then git pull --recurse-submodules=yes git submodule update --init --recursive else - git clone --recursive https://github.com/OpenZWave/python-openzwave.git + git clone --recursive --depth 1 https://github.com/OpenZWave/python-openzwave.git cd python-openzwave fi From cba85cad8d98427a41baa294b71a7f12881e3307 Mon Sep 17 00:00:00 2001 From: Alex Harvey Date: Fri, 13 May 2016 14:42:08 -0700 Subject: [PATCH 43/95] Fixes for farcy --- tests/components/test_recorder.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/test_recorder.py b/tests/components/test_recorder.py index b2db41efc3c..3efd84cc0f4 100644 --- a/tests/components/test_recorder.py +++ b/tests/components/test_recorder.py @@ -28,7 +28,7 @@ class TestRecorder(unittest.TestCase): recorder._INSTANCE.block_till_done() def _add_test_states(self): - """Adds multiple states to the db for testing.""" + """Add multiple states to the db for testing.""" now = int(time.time()) five_days_ago = now - (60*60*24*5) attributes = {'test_attr': 5, 'test_attr_10': 'nice'} @@ -52,7 +52,7 @@ class TestRecorder(unittest.TestCase): timestamp, -18000, event_id + 1000)) def _add_test_events(self): - """Adds a few events for testing.""" + """Add a few events for testing.""" now = int(time.time()) five_days_ago = now - (60*60*24*5) event_data = {'test_attr': 5, 'test_attr_10': 'nice'} @@ -126,7 +126,7 @@ class TestRecorder(unittest.TestCase): db_event.time_fired.replace(microsecond=0) def test_purge_old_states(self): - """Tests deleting old states.""" + """Test deleting old states.""" self._add_test_states() # make sure we start with 5 states states = recorder.query_states('SELECT * FROM states') @@ -157,7 +157,7 @@ class TestRecorder(unittest.TestCase): self.assertEqual(len(events), 3) def test_purge_disabled(self): - """Tests leaving purge_days disabled.""" + """Test leaving purge_days disabled.""" self._add_test_states() self._add_test_events() # make sure we start with 5 states and events From 53d7e0730c8627a24e6ea064ded9e369f4bb3fe2 Mon Sep 17 00:00:00 2001 From: Alex Harvey Date: Fri, 13 May 2016 14:43:22 -0700 Subject: [PATCH 44/95] Fixes for farcy --- tests/components/test_recorder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/test_recorder.py b/tests/components/test_recorder.py index 3efd84cc0f4..0577ab27889 100644 --- a/tests/components/test_recorder.py +++ b/tests/components/test_recorder.py @@ -141,7 +141,7 @@ class TestRecorder(unittest.TestCase): self.assertEqual(len(states), 2) def test_purge_old_events(self): - """Tests deleting old events.""" + """Test deleting old events.""" self._add_test_events() events = recorder.query_events('SELECT * FROM events WHERE ' 'event_type LIKE "EVENT_TEST%"') From 954b56475eb524791d443a16a47fce9c306e2b31 Mon Sep 17 00:00:00 2001 From: mnestor Date: Sat, 14 May 2016 00:16:04 -0400 Subject: [PATCH 45/95] YAML: add !include_named_dir and ! include_list_dir (#2054) * add include_dir constructor for yaml parsing * changed to allow for flat and name based directory including * fixed ci errors * changed flat to list --- homeassistant/util/yaml.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/homeassistant/util/yaml.py b/homeassistant/util/yaml.py index bdf0c6d5c41..d70a8f1e3e0 100644 --- a/homeassistant/util/yaml.py +++ b/homeassistant/util/yaml.py @@ -3,6 +3,7 @@ import logging import os from collections import OrderedDict +import glob import yaml from homeassistant.exceptions import HomeAssistantError @@ -44,6 +45,22 @@ def _include_yaml(loader, node): return load_yaml(fname) +def _include_dir_named_yaml(loader, node): + """Load multiple files from dir.""" + mapping = OrderedDict() + files = os.path.join(os.path.dirname(loader.name), node.value, '*.yaml') + for fname in glob.glob(files): + filename = os.path.splitext(os.path.basename(fname))[0] + mapping[filename] = load_yaml(fname) + return mapping + + +def _include_dir_list_yaml(loader, node): + """Load multiple files from dir.""" + files = os.path.join(os.path.dirname(loader.name), node.value, '*.yaml') + return [load_yaml(f) for f in glob.glob(files)] + + def _ordered_dict(loader, node): """Load YAML mappings into an ordered dict to preserve key order.""" loader.flatten_mapping(node) @@ -84,3 +101,5 @@ yaml.SafeLoader.add_constructor('!include', _include_yaml) yaml.SafeLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _ordered_dict) yaml.SafeLoader.add_constructor('!env_var', _env_var_yaml) +yaml.SafeLoader.add_constructor('!include_dir_list', _include_dir_list_yaml) +yaml.SafeLoader.add_constructor('!include_dir_named', _include_dir_named_yaml) From c5401b21c25a9ea682785faad9af423eed4b727b Mon Sep 17 00:00:00 2001 From: Igor Shults Date: Sat, 14 May 2016 11:45:32 -0500 Subject: [PATCH 46/95] Fix typo in system monitor ('recieved') (#2062) --- homeassistant/components/sensor/systemmonitor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) mode change 100644 => 100755 homeassistant/components/sensor/systemmonitor.py diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py old mode 100644 new mode 100755 index 0f2a60d6dd5..66b6b9c4d8e --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -24,9 +24,9 @@ SENSOR_TYPES = { 'swap_use': ['Swap Use', 'GiB', 'mdi:harddisk'], 'swap_free': ['Swap Free', 'GiB', 'mdi:harddisk'], 'network_out': ['Sent', 'MiB', 'mdi:server-network'], - 'network_in': ['Recieved', 'MiB', 'mdi:server-network'], + 'network_in': ['Received', 'MiB', 'mdi:server-network'], 'packets_out': ['Packets sent', '', 'mdi:server-network'], - 'packets_in': ['Packets recieved', '', 'mdi:server-network'], + 'packets_in': ['Packets received', '', 'mdi:server-network'], 'ipv4_address': ['IPv4 address', '', 'mdi:server-network'], 'ipv6_address': ['IPv6 address', '', 'mdi:server-network'], 'last_boot': ['Last Boot', '', 'mdi:clock'], From 24788b106b9cdd70e7240dc3eccac82fba290c85 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 14 May 2016 20:18:33 +0200 Subject: [PATCH 47/95] Add test for yaml enviroment --- tests/util/test_yaml.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/util/test_yaml.py b/tests/util/test_yaml.py index e865b5bba32..106cb01264e 100644 --- a/tests/util/test_yaml.py +++ b/tests/util/test_yaml.py @@ -1,6 +1,7 @@ """Test Home Assistant yaml loader.""" import io import unittest +import os from homeassistant.util import yaml @@ -32,3 +33,23 @@ class TestYaml(unittest.TestCase): pass else: assert 0 + + def test_enviroment_variable(self): + """Test config file with enviroment variable.""" + os.environ["PASSWORD"] = "secret_password" + conf = "password: !env_var PASSWORD" + with io.StringIO(conf) as f: + doc = yaml.yaml.safe_load(f) + assert doc['password'] == "secret_password" + del os.environ["PASSWORD"] + + def test_invalid_enviroment_variable(self): + """Test config file with no enviroment variable sat.""" + conf = "password: !env_var PASSWORD" + try: + with io.StringIO(conf) as f: + yaml.yaml.safe_load(f) + except Exception: + pass + else: + assert 0 From 630b7377bd5b620c25ced808ba695759ae73e091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Sat, 14 May 2016 21:05:46 +0200 Subject: [PATCH 48/95] Refactor get_age in util/dt (#2067) --- homeassistant/util/dt.py | 41 +++++++++++++++++++++------------------- tests/util/test_dt.py | 30 ++++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 22 deletions(-) diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index b06b70f2cdd..a875087fed6 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -156,6 +156,7 @@ def parse_time(time_str): # Found in this gist: https://gist.github.com/zhangsen/1199964 def get_age(date): + # pylint: disable=too-many-return-statements """ Take a datetime and return its "age" as a string. @@ -175,26 +176,28 @@ def get_age(date): """Return quotient and remaining.""" return first // second, first % second - # pylint: disable=too-few-public-methods - class PrettyDelta: - """A class for relative times.""" + delta = now() - date + day = delta.days + second = delta.seconds - def __init__(self, subDt): - delta = now() - subDt - self.day = delta.days - self.second = delta.seconds + year, day = q_n_r(day, 365) + if year > 0: + return formatn(year, 'year') - self.year, self.day = q_n_r(self.day, 365) - self.month, self.day = q_n_r(self.day, 30) - self.hour, self.second = q_n_r(self.second, 3600) - self.minute, self.second = q_n_r(self.second, 60) + month, day = q_n_r(day, 30) + if month > 0: + return formatn(month, 'month') + if day > 0: + return formatn(day, 'day') - def format(self): - """Format a datetime to relative time string.""" - for period in ['year', 'month', 'day', 'hour', 'minute', 'second']: - number = getattr(self, period) - if number > 0: - return formatn(number, period) - return "0 second" + hour, second = q_n_r(second, 3600) + if hour > 0: + return formatn(hour, 'hour') - return PrettyDelta(date).format() + minute, second = q_n_r(second, 60) + if minute > 0: + return formatn(minute, 'minute') + if second > 0: + return formatn(second, 'second') + + return "0 second" diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index 1b51287384c..bf5284a0b04 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -135,6 +135,30 @@ class TestDateUtil(unittest.TestCase): self.assertIsNone(dt_util.parse_datetime("not a datetime string")) def test_get_age(self): - """Test get_age returns 5 minutes.""" - fiveminago = dt_util.now() - timedelta(minutes=5) - self.assertEqual(dt_util.get_age(fiveminago), "5 minutes") + """Test get_age.""" + diff = dt_util.now() - timedelta(seconds=0) + self.assertEqual(dt_util.get_age(diff), "0 second") + + diff = dt_util.now() - timedelta(seconds=30) + self.assertEqual(dt_util.get_age(diff), "30 seconds") + + diff = dt_util.now() - timedelta(minutes=5) + self.assertEqual(dt_util.get_age(diff), "5 minutes") + + diff = dt_util.now() - timedelta(minutes=1) + self.assertEqual(dt_util.get_age(diff), "1 minute") + + diff = dt_util.now() - timedelta(minutes=300) + self.assertEqual(dt_util.get_age(diff), "5 hours") + + diff = dt_util.now() - timedelta(minutes=320) + self.assertEqual(dt_util.get_age(diff), "5 hours") + + diff = dt_util.now() - timedelta(minutes=2*60*24) + self.assertEqual(dt_util.get_age(diff), "2 days") + + diff = dt_util.now() - timedelta(minutes=32*60*24) + self.assertEqual(dt_util.get_age(diff), "1 month") + + diff = dt_util.now() - timedelta(minutes=365*60*24) + self.assertEqual(dt_util.get_age(diff), "1 year") From 8656bbbc795d855f14f6006e61ae4f19bf214467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Sat, 14 May 2016 21:14:13 +0200 Subject: [PATCH 49/95] fix bugs in google travel time (#2069) --- .../components/sensor/google_travel_time.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/sensor/google_travel_time.py b/homeassistant/components/sensor/google_travel_time.py index 8bfe5aa142b..30b50829c6c 100644 --- a/homeassistant/components/sensor/google_travel_time.py +++ b/homeassistant/components/sensor/google_travel_time.py @@ -46,7 +46,7 @@ PLATFORM_SCHEMA = vol.Schema({ vol.Required(CONF_DESTINATION): vol.Coerce(str), vol.Optional(CONF_TRAVEL_MODE): vol.In(["driving", "walking", "bicycling", "transit"]), - vol.Optional(CONF_OPTIONS): vol.All( + vol.Optional(CONF_OPTIONS, default=dict()): vol.All( dict, vol.Schema({ vol.Optional(CONF_MODE, default='driving'): vol.In(["driving", "walking", "bicycling", "transit"]), @@ -136,7 +136,11 @@ class GoogleTravelTimeSensor(Entity): @property def state(self): """Return the state of the sensor.""" - return self._matrix['rows'][0]['elements'][0]['duration']['value']/60.0 + try: + res = self._matrix['rows'][0]['elements'][0]['duration']['value'] + return res/60.0 + except KeyError: + return None @property def name(self): @@ -175,15 +179,6 @@ class GoogleTravelTimeSensor(Entity): if atime is not None and ':' in atime: options_copy['arrival_time'] = convert_time_to_utc(atime) - departure_time = options_copy.get('departure_time') - arrival_time = options_copy.get('arrival_time') - if departure_time is not None and arrival_time is not None: - wstr = ("Google Travel Time: You can not provide both arrival " - "and departure times! Deleting the arrival time...") - _LOGGER.warning(wstr) - del options_copy['arrival_time'] - del self._options['arrival_time'] - self._matrix = self._client.distance_matrix(self._origin, self._destination, **options_copy) From 8df91e6a17fb1a5bb6ad2bddca4087857b8e0cb4 Mon Sep 17 00:00:00 2001 From: happyleavesaoc Date: Sat, 14 May 2016 12:29:57 -0700 Subject: [PATCH 50/95] numeric state: validate multiple entities (#2066) * validate multiple entities * point to current entity --- homeassistant/components/automation/numeric_state.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index 8a5c993b683..3a148b0880f 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -16,7 +16,7 @@ from homeassistant.helpers import condition, config_validation as cv TRIGGER_SCHEMA = vol.All(vol.Schema({ vol.Required(CONF_PLATFORM): 'numeric_state', - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_ids, CONF_BELOW: vol.Coerce(float), CONF_ABOVE: vol.Coerce(float), vol.Optional(CONF_VALUE_TEMPLATE): cv.template, @@ -41,7 +41,7 @@ def trigger(hass, config, action): variables = { 'trigger': { 'platform': 'numeric_state', - 'entity_id': entity_id, + 'entity_id': entity, 'below': below, 'above': above, } From 429bf2c1434e4335eb7d58f4253cb85d4f9d46c2 Mon Sep 17 00:00:00 2001 From: Rowan Date: Sat, 14 May 2016 21:28:42 +0100 Subject: [PATCH 51/95] Google Play Music Desktop Player component (#1788) * Added GPM Desktop Plaeyr component * Updated requirements_all.txt * Pylint fix * Updated GPMDP.py to include @balloob's comments * Updated to work with the latest version of GPMDP * Removed setting "self._ws.recv()" as a variable * Made line 52 shorter * Updated to check weather it is connected or not * Pylint and @balloob fixes * Updated with simplified code and pylint fix * Made `json.loads` shorter * Pylint fix --- .coveragerc | 1 + .../components/media_player/gpmdp.py | 158 ++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 162 insertions(+) create mode 100644 homeassistant/components/media_player/gpmdp.py diff --git a/.coveragerc b/.coveragerc index 02752a9a361..1ba134b63ff 100644 --- a/.coveragerc +++ b/.coveragerc @@ -114,6 +114,7 @@ omit = homeassistant/components/media_player/cast.py homeassistant/components/media_player/denon.py homeassistant/components/media_player/firetv.py + homeassistant/components/media_player/gpmdp.py homeassistant/components/media_player/itunes.py homeassistant/components/media_player/kodi.py homeassistant/components/media_player/mpd.py diff --git a/homeassistant/components/media_player/gpmdp.py b/homeassistant/components/media_player/gpmdp.py new file mode 100644 index 00000000000..e478cc6e2a5 --- /dev/null +++ b/homeassistant/components/media_player/gpmdp.py @@ -0,0 +1,158 @@ +""" +Support for Google Play Music Desktop Player. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.gpm_dp/ +""" +import logging +import json +import socket + +from homeassistant.components.media_player import ( + MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, + SUPPORT_PAUSE, MediaPlayerDevice) +from homeassistant.const import ( + STATE_PLAYING, STATE_PAUSED, STATE_OFF) + +_LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ['websocket-client==0.35.0'] +SUPPORT_GPMDP = SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the GPMDP platform.""" + from websocket import create_connection + + name = config.get("name", "GPM Desktop Player") + address = config.get("address") + + if address is None: + _LOGGER.error("Missing address in config") + return False + + add_devices([GPMDP(name, address, create_connection)]) + + +class GPMDP(MediaPlayerDevice): + """Representation of a GPMDP.""" + + # pylint: disable=too-many-public-methods, abstract-method + # pylint: disable=too-many-instance-attributes + def __init__(self, name, address, create_connection): + """Initialize.""" + self._connection = create_connection + self._address = address + self._name = name + self._status = STATE_OFF + self._ws = None + self._title = None + self._artist = None + self._albumart = None + self.update() + + def get_ws(self): + """Check if the websocket is setup and connected.""" + if self._ws is None: + try: + self._ws = self._connection(("ws://" + self._address + + ":5672"), timeout=1) + except (socket.timeout, ConnectionRefusedError, + ConnectionResetError): + self._ws = None + elif self._ws.connected is True: + self._ws.close() + try: + self._ws = self._connection(("ws://" + self._address + + ":5672"), timeout=1) + except (socket.timeout, ConnectionRefusedError, + ConnectionResetError): + self._ws = None + return self._ws + + def update(self): + """Get the latest details from the player.""" + websocket = self.get_ws() + if websocket is None: + self._status = STATE_OFF + return + else: + state = websocket.recv() + state = ((json.loads(state))['payload']) + if state is True: + websocket.recv() + websocket.recv() + song = websocket.recv() + song = json.loads(song) + self._title = (song['payload']['title']) + self._artist = (song['payload']['artist']) + self._albumart = (song['payload']['albumArt']) + self._status = STATE_PLAYING + elif state is False: + self._status = STATE_PAUSED + + @property + def media_content_type(self): + """Content type of current playing media.""" + return MEDIA_TYPE_MUSIC + + @property + def state(self): + """Return the state of the device.""" + return self._status + + @property + def media_title(self): + """Title of current playing media.""" + return self._title + + @property + def media_artist(self): + """Artist of current playing media (Music track only).""" + return self._artist + + @property + def media_image_url(self): + """Image url of current playing media.""" + return self._albumart + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def supported_media_commands(self): + """Flag of media commands that are supported.""" + return SUPPORT_GPMDP + + def media_next_track(self): + """Send media_next command to media player.""" + websocket = self.get_ws() + if websocket is None: + return + websocket.send('{"namespace": "playback", "method": "forward"}') + + def media_previous_track(self): + """Send media_previous command to media player.""" + websocket = self.get_ws() + if websocket is None: + return + websocket.send('{"namespace": "playback", "method": "rewind"}') + + def media_play(self): + """Send media_play command to media player.""" + websocket = self.get_ws() + if websocket is None: + return + websocket.send('{"namespace": "playback", "method": "playPause"}') + self._status = STATE_PAUSED + self.update_ha_state() + + def media_pause(self): + """Send media_pause command to media player.""" + websocket = self.get_ws() + if websocket is None: + return + websocket.send('{"namespace": "playback", "method": "playPause"}') + self._status = STATE_PAUSED + self.update_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index 1084da242b2..75830e79a53 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -347,6 +347,9 @@ vsure==0.8.1 # homeassistant.components.switch.wake_on_lan wakeonlan==0.2.2 +# homeassistant.components.media_player.gpmdp +websocket-client==0.35.0 + # homeassistant.components.zigbee xbee-helper==0.0.7 From a7db208b8a07a9c02d4e6c569dc31c258167aa8c Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 14 May 2016 13:32:00 -0700 Subject: [PATCH 52/95] Fix Google Voice documentation URL --- homeassistant/components/notify/googlevoice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/notify/googlevoice.py b/homeassistant/components/notify/googlevoice.py index 021496fa00b..3f1b9d641b0 100644 --- a/homeassistant/components/notify/googlevoice.py +++ b/homeassistant/components/notify/googlevoice.py @@ -2,7 +2,7 @@ Google Voice SMS platform for notify component. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/notify.free_mobile/ +https://home-assistant.io/components/notify.google_voice/ """ import logging From 6254d4a983f2459577e7a7e6be9a1d567c00a527 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 14 May 2016 14:02:14 -0700 Subject: [PATCH 53/95] Add lines for associated documentation PR --- .github/PULL_REQUEST_TEMPLATE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index bb40754c97a..484fe20f11f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -3,6 +3,8 @@ **Related issue (if applicable):** # +**Pull request in [home-assistant.io](https://github.com/home-assistant/home-assistant.io) with documentation (if applicable):** home-assistant/home-assistant.io# + **Example entry for `configuration.yaml` (if applicable):** ```yaml @@ -10,6 +12,9 @@ **Checklist:** +If user exposed functionality or configuration variables are added/changed: + - [ ] Documentation added/updated in [home-assistant.io](https://github.com/home-assistant/home-assistant.io) + If code communicates with devices: - [ ] Local tests with `tox` run successfully. **Your PR cannot be merged unless tests pass** - [ ] New dependencies have been added to the `REQUIREMENTS` variable ([example][ex-requir]). From 0adc8537416d787ad5f3881f0ff84600f256818d Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 14 May 2016 14:09:28 -0700 Subject: [PATCH 54/95] Add notify.twilio_sms component (#2070) --- .coveragerc | 1 + homeassistant/components/notify/twilio_sms.py | 62 +++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 66 insertions(+) create mode 100644 homeassistant/components/notify/twilio_sms.py diff --git a/.coveragerc b/.coveragerc index 1ba134b63ff..c6af4c298b9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -142,6 +142,7 @@ omit = homeassistant/components/notify/smtp.py homeassistant/components/notify/syslog.py homeassistant/components/notify/telegram.py + homeassistant/components/notify/twilio_sms.py homeassistant/components/notify/twitter.py homeassistant/components/notify/xmpp.py homeassistant/components/scene/hunterdouglas_powerview.py diff --git a/homeassistant/components/notify/twilio_sms.py b/homeassistant/components/notify/twilio_sms.py new file mode 100644 index 00000000000..f7700240b67 --- /dev/null +++ b/homeassistant/components/notify/twilio_sms.py @@ -0,0 +1,62 @@ +""" +Twilio SMS platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.twilio_sms/ +""" +import logging + +from homeassistant.components.notify import ( + ATTR_TARGET, DOMAIN, BaseNotificationService) +from homeassistant.helpers import validate_config + +_LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ["twilio==5.4.0"] + +CONF_ACCOUNT_SID = "account_sid" +CONF_AUTH_TOKEN = "auth_token" +CONF_FROM_NUMBER = "from_number" + + +def get_service(hass, config): + """Get the Twilio SMS notification service.""" + if not validate_config({DOMAIN: config}, + {DOMAIN: [CONF_ACCOUNT_SID, + CONF_AUTH_TOKEN, + CONF_FROM_NUMBER]}, + _LOGGER): + return None + + # pylint: disable=import-error + from twilio.rest import TwilioRestClient + + twilio_client = TwilioRestClient(config[CONF_ACCOUNT_SID], + config[CONF_AUTH_TOKEN]) + + return TwilioSMSNotificationService(twilio_client, + config[CONF_FROM_NUMBER]) + + +# pylint: disable=too-few-public-methods +class TwilioSMSNotificationService(BaseNotificationService): + """Implement the notification service for the Twilio SMS service.""" + + def __init__(self, twilio_client, from_number): + """Initialize the service.""" + self.client = twilio_client + self.from_number = from_number + + def send_message(self, message="", **kwargs): + """Send SMS to specified target user cell.""" + targets = kwargs.get(ATTR_TARGET) + + if not targets: + _LOGGER.info("At least 1 target is required") + return + + if not isinstance(targets, list): + targets = [targets] + + for target in targets: + self.client.messages.create(to=target, body=message, + from_=self.from_number) diff --git a/requirements_all.txt b/requirements_all.txt index 75830e79a53..d874db1a91b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -329,6 +329,9 @@ tellive-py==0.5.2 # homeassistant.components.switch.transmission transmissionrpc==0.11 +# homeassistant.components.notify.twilio_sms +twilio==5.4.0 + # homeassistant.components.sensor.uber uber_rides==0.2.1 From 6dae005b65ede6a52276a9cdebdec4bea81bd4b9 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Sat, 14 May 2016 23:21:05 +0200 Subject: [PATCH 55/95] Resolved UI flicker, new config vars, brightness up to 255, fixed buttons, fixed race condition (#2072) --- homeassistant/components/light/qwikswitch.py | 5 +- homeassistant/components/qwikswitch.py | 52 +++++++++++-------- homeassistant/components/switch/qwikswitch.py | 5 +- 3 files changed, 33 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/light/qwikswitch.py b/homeassistant/components/light/qwikswitch.py index cd35078031c..b0aba4f34dd 100644 --- a/homeassistant/components/light/qwikswitch.py +++ b/homeassistant/components/light/qwikswitch.py @@ -27,10 +27,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): qsusb = qwikswitch.QSUSB[discovery_info['qsusb_id']] for item in qsusb.ha_devices: - if item['id'] in qsusb.ha_objects or \ - item['type'] not in ['dim', 'rel']: - continue - if item['type'] == 'rel' and item['name'].lower().endswith(' switch'): + if item['type'] not in ['dim', 'rel']: continue dev = QSLight(item, qsusb) add_devices([dev]) diff --git a/homeassistant/components/qwikswitch.py b/homeassistant/components/qwikswitch.py index 29c2aee37fe..19b0f6bfe28 100644 --- a/homeassistant/components/qwikswitch.py +++ b/homeassistant/components/qwikswitch.py @@ -21,7 +21,7 @@ QSUSB = None class QSToggleEntity(object): - """Representation of a Qwikswitch Entiry. + """Representation of a Qwikswitch Entity. Implement base QS methods. Modeled around HA ToggleEntity[1] & should only be used in a class that extends both QSToggleEntity *and* ToggleEntity. @@ -36,7 +36,7 @@ class QSToggleEntity(object): """ def __init__(self, qsitem, qsusb): - """Initialize the light.""" + """Initialize the ToggleEntity.""" self._id = qsitem['id'] self._name = qsitem['name'] self._qsusb = qsusb @@ -66,34 +66,41 @@ class QSToggleEntity(object): def update_value(self, value): """Decode QSUSB value & update HA state.""" - self._value = value - # pylint: disable=no-member - super().update_ha_state() # Part of Entity/ToggleEntity + if value != self._value: + self._value = value + # pylint: disable=no-member + super().update_ha_state() # Part of Entity/ToggleEntity return self._value - # pylint: disable=unused-argument def turn_on(self, **kwargs): """Turn the device on.""" if ATTR_BRIGHTNESS in kwargs: - self._value = kwargs[ATTR_BRIGHTNESS] + self.update_value(kwargs[ATTR_BRIGHTNESS]) else: - self._value = 100 - return self._qsusb.set(self._id, self._value) + self.update_value(255) + + return self._qsusb.set(self._id, round(min(self._value, 255)/2.55)) # pylint: disable=unused-argument def turn_off(self, **kwargs): """Turn the device off.""" + self.update_value(0) return self._qsusb.set(self._id, 0) -# pylint: disable=too-many-locals def setup(hass, config): """Setup the QSUSB component.""" - from pyqwikswitch import QSUsb + from pyqwikswitch import QSUsb, CMD_BUTTONS + + # Override which cmd's in /&listen packets will fire events + # By default only buttons of type [TOGGLE,SCENE EXE,LEVEL] + cmd_buttons = config[DOMAIN].get('button_events', ','.join(CMD_BUTTONS)) + cmd_buttons = cmd_buttons.split(',') try: url = config[DOMAIN].get('url', 'http://127.0.0.1:2020') - qsusb = QSUsb(url, _LOGGER) + dimmer_adjust = float(config[DOMAIN].get('dimmer_adjust', '1')) + qsusb = QSUsb(url, _LOGGER, dimmer_adjust) # Ensure qsusb terminates threads correctly hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, @@ -105,25 +112,27 @@ def setup(hass, config): qsusb.ha_devices = qsusb.devices() qsusb.ha_objects = {} + # Identify switches & remove ' Switch' postfix in name + for item in qsusb.ha_devices: + if item['type'] == 'rel' and item['name'].lower().endswith(' switch'): + item['type'] = 'switch' + item['name'] = item['name'][:-7] + global QSUSB if QSUSB is None: QSUSB = {} QSUSB[id(qsusb)] = qsusb - # Register add_device callbacks onto the gloabl ADD_DEVICES - # Switch called first since they are [type=rel] and end with ' switch' + # Load sub-components for qwikswitch for comp_name in ('switch', 'light'): load_platform(hass, comp_name, 'qwikswitch', {'qsusb_id': id(qsusb)}, config) def qs_callback(item): """Typically a btn press or update signal.""" - from pyqwikswitch import CMD_BUTTONS - # If button pressed, fire a hass event - if item.get('type', '') in CMD_BUTTONS: - _LOGGER.info('qwikswitch.button.%s', item['id']) - hass.bus.fire('qwikswitch.button.{}'.format(item['id'])) + if item.get('cmd', '') in cmd_buttons: + hass.bus.fire('qwikswitch.button.' + item.get('id', '@no_id')) return # Update all ha_objects @@ -133,7 +142,8 @@ def setup(hass, config): for item in qsreply: item_id = item.get('id', '') if item_id in qsusb.ha_objects: - qsusb.ha_objects[item_id].update_value(item['value']) + qsusb.ha_objects[item_id].update_value( + round(min(item['value'], 100) * 2.55)) - qsusb.listen(callback=qs_callback, timeout=10) + qsusb.listen(callback=qs_callback, timeout=30) return True diff --git a/homeassistant/components/switch/qwikswitch.py b/homeassistant/components/switch/qwikswitch.py index 75da97d6296..86d698b2a70 100644 --- a/homeassistant/components/switch/qwikswitch.py +++ b/homeassistant/components/switch/qwikswitch.py @@ -27,10 +27,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): qsusb = qwikswitch.QSUSB[discovery_info['qsusb_id']] for item in qsusb.ha_devices: - if item['type'] == 'rel' and \ - item['name'].lower().endswith(' switch'): - # Remove the ' Switch' name postfix for HA - item['name'] = item['name'][:-7] + if item['type'] == 'switch': dev = QSSwitch(item, qsusb) add_devices([dev]) qsusb.ha_objects[item['id']] = dev From 49acdaa8fd49e17d277a916a139fa7b14381b777 Mon Sep 17 00:00:00 2001 From: froz Date: Sun, 15 May 2016 12:20:17 -0700 Subject: [PATCH 56/95] Device Tracker - ASUSWRT: Replaced telnet with ssh (#2079) --- .../components/device_tracker/asuswrt.py | 31 ++++++++----------- requirements_all.txt | 1 + 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index b4784505d2d..dd0c43efb2f 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -6,7 +6,6 @@ https://home-assistant.io/components/device_tracker.asuswrt/ """ import logging import re -import telnetlib import threading from datetime import timedelta @@ -19,6 +18,7 @@ from homeassistant.util import Throttle MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ['pexpect==4.0.1'] _LEASES_REGEX = re.compile( r'\w+\s' + @@ -102,24 +102,19 @@ class AsusWrtDeviceScanner(object): def get_asuswrt_data(self): """Retrieve data from ASUSWRT and return parsed result.""" + from pexpect import pxssh try: - telnet = telnetlib.Telnet(self.host) - telnet.read_until(b'login: ') - telnet.write((self.username + '\n').encode('ascii')) - telnet.read_until(b'Password: ') - telnet.write((self.password + '\n').encode('ascii')) - prompt_string = telnet.read_until(b'#').split(b'\n')[-1] - telnet.write('ip neigh\n'.encode('ascii')) - neighbors = telnet.read_until(prompt_string).split(b'\n')[1:-1] - telnet.write('cat /var/lib/misc/dnsmasq.leases\n'.encode('ascii')) - leases_result = telnet.read_until(prompt_string).split(b'\n')[1:-1] - telnet.write('exit\n'.encode('ascii')) - except EOFError: - _LOGGER.exception("Unexpected response from router") - return - except ConnectionRefusedError: - _LOGGER.exception("Connection refused by router," + - " is telnet enabled?") + ssh = pxssh.pxssh() + ssh.login(self.host, self.username, self.password) + ssh.sendline('ip neigh') + ssh.prompt() + neighbors = ssh.before.split(b'\n')[1:-1] + ssh.sendline('cat /var/lib/misc/dnsmasq.leases') + ssh.prompt() + leases_result = ssh.before.split(b'\n')[1:-1] + ssh.logout() + except pxssh.ExceptionPxssh as exc: + _LOGGER.exception('Unexpected response from router: %s', exc) return devices = {} diff --git a/requirements_all.txt b/requirements_all.txt index d874db1a91b..7b9b57ee158 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -171,6 +171,7 @@ paho-mqtt==1.1 panasonic_viera==0.2 # homeassistant.components.device_tracker.aruba +# homeassistant.components.device_tracker.asuswrt pexpect==4.0.1 # homeassistant.components.light.hue From 0340710e5c475614baa4ca2fe010b77cd9f569f5 Mon Sep 17 00:00:00 2001 From: Richard Cox Date: Sun, 15 May 2016 12:29:12 -0700 Subject: [PATCH 57/95] Support for Nest Protect smoke alarms (#2076) * Support for Nest Protect smoke alarms * Fixing formatting issues from tox --- homeassistant/components/nest.py | 14 ++++++- homeassistant/components/sensor/nest.py | 55 ++++++++++++++++++++++--- requirements_all.txt | 2 +- 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/nest.py b/homeassistant/components/nest.py index 7b866919b73..453b6b72bd4 100644 --- a/homeassistant/components/nest.py +++ b/homeassistant/components/nest.py @@ -1,5 +1,5 @@ """ -Support for Nest thermostats. +Support for Nest thermostats and protect smoke alarms. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/thermostat.nest/ @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -REQUIREMENTS = ['python-nest==2.6.0'] +REQUIREMENTS = ['python-nest==2.9.1'] DOMAIN = 'nest' NEST = None @@ -36,6 +36,16 @@ def devices(): _LOGGER.error("Connection error logging into the nest web service.") +def protect_devices(): + """Generator returning list of protect devices.""" + try: + for structure in NEST.structures: + for device in structure.protectdevices: + yield(structure, device) + except socket.error: + _LOGGER.error("Connection error logging into the nest web service.") + + # pylint: disable=unused-argument def setup(hass, config): """Setup the Nest thermostat component.""" diff --git a/homeassistant/components/sensor/nest.py b/homeassistant/components/sensor/nest.py index c3b68430b99..7a89265a8bd 100644 --- a/homeassistant/components/sensor/nest.py +++ b/homeassistant/components/sensor/nest.py @@ -4,6 +4,8 @@ Support for Nest Thermostat Sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.nest/ """ +from itertools import chain + import voluptuous as vol import homeassistant.components.nest as nest @@ -29,9 +31,13 @@ WEATHER_VARS = {'weather_humidity': 'humidity', SENSOR_UNITS = {'humidity': '%', 'battery_level': 'V', 'kph': 'kph', 'temperature': '°C'} +PROTECT_VARS = ['co_status', + 'smoke_status', + 'battery_level'] + SENSOR_TEMP_TYPES = ['temperature', 'target'] -_VALID_SENSOR_TYPES = SENSOR_TYPES + SENSOR_TEMP_TYPES + \ +_VALID_SENSOR_TYPES = SENSOR_TYPES + SENSOR_TEMP_TYPES + PROTECT_VARS + \ list(WEATHER_VARS.keys()) PLATFORM_SCHEMA = vol.Schema({ @@ -44,20 +50,34 @@ PLATFORM_SCHEMA = vol.Schema({ def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Nest Sensor.""" - for structure, device in nest.devices(): + for structure, device in chain(nest.devices(), nest.protect_devices()): sensors = [NestBasicSensor(structure, device, variable) for variable in config[CONF_MONITORED_CONDITIONS] - if variable in SENSOR_TYPES] + if variable in SENSOR_TYPES and is_thermostat(device)] sensors += [NestTempSensor(structure, device, variable) for variable in config[CONF_MONITORED_CONDITIONS] - if variable in SENSOR_TEMP_TYPES] + if variable in SENSOR_TEMP_TYPES and is_thermostat(device)] sensors += [NestWeatherSensor(structure, device, WEATHER_VARS[variable]) for variable in config[CONF_MONITORED_CONDITIONS] - if variable in WEATHER_VARS] + if variable in WEATHER_VARS and is_thermostat(device)] + sensors += [NestProtectSensor(structure, device, variable) + for variable in config[CONF_MONITORED_CONDITIONS] + if variable in PROTECT_VARS and is_protect(device)] + add_devices(sensors) +def is_thermostat(device): + """Target devices that are Nest Thermostats.""" + return bool(device.__class__.__name__ == 'Device') + + +def is_protect(device): + """Target devices that are Nest Protect Smoke Alarms.""" + return bool(device.__class__.__name__ == 'ProtectDevice') + + class NestSensor(Entity): """Representation of a Nest sensor.""" @@ -130,3 +150,28 @@ class NestWeatherSensor(NestSensor): def unit_of_measurement(self): """Return the unit the value is expressed in.""" return SENSOR_UNITS.get(self.variable, None) + + +class NestProtectSensor(NestSensor): + """Return the state of nest protect.""" + + @property + def state(self): + """Return the state of the sensor.""" + state = getattr(self.device, self.variable) + if self.variable == 'battery_level': + return getattr(self.device, self.variable) + else: + if state == 0: + return 'Ok' + if state == 1 or state == 2: + return 'Warning' + if state == 3: + return 'Emergency' + + return 'Unknown' + + @property + def name(self): + """Return the name of the nest, if any.""" + return "{} {}".format(self.device.where.capitalize(), self.variable) diff --git a/requirements_all.txt b/requirements_all.txt index 7b9b57ee158..60461a5eddf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -245,7 +245,7 @@ python-forecastio==1.3.4 python-mpd2==0.5.5 # homeassistant.components.nest -python-nest==2.6.0 +python-nest==2.9.1 # homeassistant.components.device_tracker.nmap_tracker python-nmap==0.6.0 From 3ed6be5b4e898e7d92261313aaae37f8772c88ec Mon Sep 17 00:00:00 2001 From: mnestor Date: Sun, 15 May 2016 15:56:29 -0400 Subject: [PATCH 58/95] add link ability to configurator (#2035) --- homeassistant/components/configurator.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/configurator.py b/homeassistant/components/configurator.py index aecaa1cfadc..8705f9ce077 100644 --- a/homeassistant/components/configurator.py +++ b/homeassistant/components/configurator.py @@ -19,6 +19,8 @@ SERVICE_CONFIGURE = "configure" STATE_CONFIGURE = "configure" STATE_CONFIGURED = "configured" +ATTR_LINK_NAME = "link_name" +ATTR_LINK_URL = "link_url" ATTR_CONFIGURE_ID = "configure_id" ATTR_DESCRIPTION = "description" ATTR_DESCRIPTION_IMAGE = "description_image" @@ -34,7 +36,7 @@ _LOGGER = logging.getLogger(__name__) # pylint: disable=too-many-arguments def request_config( hass, name, callback, description=None, description_image=None, - submit_caption=None, fields=None): + submit_caption=None, fields=None, link_name=None, link_url=None): """Create a new request for configuration. Will return an ID to be used for sequent calls. @@ -43,7 +45,8 @@ def request_config( request_id = instance.request_config( name, callback, - description, description_image, submit_caption, fields) + description, description_image, submit_caption, + fields, link_name, link_url) _REQUESTS[request_id] = instance @@ -100,7 +103,8 @@ class Configurator(object): # pylint: disable=too-many-arguments def request_config( self, name, callback, - description, description_image, submit_caption, fields): + description, description_image, submit_caption, + fields, link_name, link_url): """Setup a request for configuration.""" entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, hass=self.hass) @@ -121,6 +125,8 @@ class Configurator(object): (ATTR_DESCRIPTION, description), (ATTR_DESCRIPTION_IMAGE, description_image), (ATTR_SUBMIT_CAPTION, submit_caption), + (ATTR_LINK_NAME, link_name), + (ATTR_LINK_URL, link_url), ] if value is not None }) From 88d13f0ac96af181a5e2c1bf12de2e56f23f03cc Mon Sep 17 00:00:00 2001 From: Brent Date: Sun, 15 May 2016 15:00:31 -0500 Subject: [PATCH 59/95] Added support for the roku media player (#2046) --- .coveragerc | 1 + homeassistant/components/discovery.py | 4 +- .../components/media_player/__init__.py | 1 + homeassistant/components/media_player/roku.py | 187 ++++++++++++++++++ requirements_all.txt | 5 +- 5 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/media_player/roku.py diff --git a/.coveragerc b/.coveragerc index c6af4c298b9..7a3978d04cc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -122,6 +122,7 @@ omit = homeassistant/components/media_player/panasonic_viera.py homeassistant/components/media_player/pioneer.py homeassistant/components/media_player/plex.py + homeassistant/components/media_player/roku.py homeassistant/components/media_player/samsungtv.py homeassistant/components/media_player/snapcast.py homeassistant/components/media_player/sonos.py diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 01211398f72..fac94aacf69 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -15,7 +15,7 @@ from homeassistant.const import ( EVENT_PLATFORM_DISCOVERED) DOMAIN = "discovery" -REQUIREMENTS = ['netdisco==0.6.6'] +REQUIREMENTS = ['netdisco==0.6.7'] SCAN_INTERVAL = 300 # seconds @@ -29,6 +29,7 @@ SERVICE_SONOS = 'sonos' SERVICE_PLEX = 'plex_mediaserver' SERVICE_SQUEEZEBOX = 'logitech_mediaserver' SERVICE_PANASONIC_VIERA = 'panasonic_viera' +SERVICE_ROKU = 'roku' SERVICE_HANDLERS = { SERVICE_WEMO: "wemo", @@ -39,6 +40,7 @@ SERVICE_HANDLERS = { SERVICE_PLEX: 'media_player', SERVICE_SQUEEZEBOX: 'media_player', SERVICE_PANASONIC_VIERA: 'media_player', + SERVICE_ROKU: 'media_player', } diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index ff8eb8113b9..072a145129b 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -36,6 +36,7 @@ DISCOVERY_PLATFORMS = { discovery.SERVICE_PLEX: 'plex', discovery.SERVICE_SQUEEZEBOX: 'squeezebox', discovery.SERVICE_PANASONIC_VIERA: 'panasonic_viera', + discovery.SERVICE_ROKU: 'roku', } SERVICE_PLAY_MEDIA = 'play_media' diff --git a/homeassistant/components/media_player/roku.py b/homeassistant/components/media_player/roku.py new file mode 100644 index 00000000000..3a196fe38d4 --- /dev/null +++ b/homeassistant/components/media_player/roku.py @@ -0,0 +1,187 @@ +""" +Support for the roku media player. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.roku/ +""" + +import logging + +from homeassistant.components.media_player import ( + MEDIA_TYPE_VIDEO, SUPPORT_NEXT_TRACK, SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, + SUPPORT_SELECT_SOURCE, MediaPlayerDevice) + +from homeassistant.const import ( + CONF_HOST, STATE_IDLE, STATE_PLAYING, STATE_UNKNOWN, STATE_HOME) + +REQUIREMENTS = [ + 'https://github.com/bah2830/python-roku/archive/3.1.1.zip' + '#python-roku==3.1.1'] + +KNOWN_HOSTS = [] +DEFAULT_PORT = 8060 + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_ROKU = SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK |\ + SUPPORT_PLAY_MEDIA | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\ + SUPPORT_SELECT_SOURCE + + +# pylint: disable=abstract-method +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Roku platform.""" + hosts = [] + + if discovery_info and discovery_info in KNOWN_HOSTS: + return + + if discovery_info is not None: + _LOGGER.debug('Discovered Roku: %s', discovery_info[0]) + hosts.append(discovery_info[0]) + + elif CONF_HOST in config: + hosts.append(config[CONF_HOST]) + + rokus = [] + for host in hosts: + rokus.append(RokuDevice(host)) + KNOWN_HOSTS.append(host) + + add_devices(rokus) + + +class RokuDevice(MediaPlayerDevice): + """Representation of a Roku device on the network.""" + + # pylint: disable=abstract-method + # pylint: disable=too-many-public-methods + def __init__(self, host): + """Initialize the Roku device.""" + from roku import Roku + + self.roku = Roku(host) + self.update() + + def update(self): + """Retrieve latest state.""" + self.roku_name = "roku_" + self.roku.device_info.sernum + self.ip_address = self.roku.host + self.channels = self.get_source_list() + + if self.roku.current_app is not None: + self.current_app = self.roku.current_app + else: + self.current_app = None + + def get_source_list(self): + """Get the list of applications to be used as sources.""" + return ["Home"] + sorted(channel.name for channel in self.roku.apps) + + @property + def should_poll(self): + """Device should be polled.""" + return True + + @property + def name(self): + """Return the name of the device.""" + return self.roku_name + + @property + def state(self): + """Return the state of the device.""" + if self.current_app.name in ["Power Saver", "Default screensaver"]: + return STATE_IDLE + elif self.current_app.name == "Roku": + return STATE_HOME + elif self.current_app.name is not None: + return STATE_PLAYING + + return STATE_UNKNOWN + + @property + def supported_media_commands(self): + """Flag of media commands that are supported.""" + return SUPPORT_ROKU + + @property + def media_content_type(self): + """Content type of current playing media.""" + if self.current_app is None: + return None + elif self.current_app.name == "Power Saver": + return None + elif self.current_app.name == "Roku": + return None + else: + return MEDIA_TYPE_VIDEO + + @property + def media_image_url(self): + """Image url of current playing media.""" + if self.current_app is None: + return None + elif self.current_app.name == "Roku": + return None + elif self.current_app.name == "Power Saver": + return None + elif self.current_app.id is None: + return None + + return 'http://{0}:{1}/query/icon/{2}'.format(self.ip_address, + DEFAULT_PORT, + self.current_app.id) + + @property + def app_name(self): + """Name of the current running app.""" + return self.current_app.name + + @property + def app_id(self): + """Return the ID of the current running app.""" + return self.current_app.id + + @property + def source(self): + """Return the current input source.""" + return self.current_app.name + + @property + def source_list(self): + """List of available input sources.""" + return self.channels + + def media_play_pause(self): + """Send play/pause command.""" + self.roku.play() + + def media_previous_track(self): + """Send previous track command.""" + self.roku.reverse() + + def media_next_track(self): + """Send next track command.""" + self.roku.forward() + + def mute_volume(self, mute): + """Mute the volume.""" + self.roku.volume_mute() + + def volume_up(self): + """Volume up media player.""" + self.roku.volume_up() + + def volume_down(self): + """Volume down media player.""" + self.roku.volume_down() + + def select_source(self, source): + """Select input source.""" + if source == "Home": + self.roku.home() + else: + channel = self.roku[source] + channel.launch() diff --git a/requirements_all.txt b/requirements_all.txt index 60461a5eddf..90d88e0bcae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -97,6 +97,9 @@ https://github.com/TheRealLink/pythinkingcleaner/archive/v0.0.2.zip#pythinkingcl # homeassistant.components.alarm_control_panel.alarmdotcom https://github.com/Xorso/pyalarmdotcom/archive/0.1.1.zip#pyalarmdotcom==0.1.1 +# homeassistant.components.media_player.roku +https://github.com/bah2830/python-roku/archive/3.1.1.zip#python-roku==3.1.1 + # homeassistant.components.modbus https://github.com/bashwork/pymodbus/archive/d7fc4f1cc975631e0a9011390e8017f64b612661.zip#pymodbus==1.2.0 @@ -156,7 +159,7 @@ messagebird==1.2.0 mficlient==0.3.0 # homeassistant.components.discovery -netdisco==0.6.6 +netdisco==0.6.7 # homeassistant.components.sensor.neurio_energy neurio==0.2.10 From cbf0caa88a64384514ab938c15e4f5358799aa9d Mon Sep 17 00:00:00 2001 From: Rowan Date: Sun, 15 May 2016 21:11:41 +0100 Subject: [PATCH 60/95] Last.fm sensor (#2071) * Last.fm component * Pylint fixes * Last.fm component * Pylint fixes * Updated with `.coveragerc` and `requirements_all.txt` * Pylint fixes * Updated * Pylint fix * Pylint fix --- .coveragerc | 1 + homeassistant/components/sensor/lastfm.py | 90 +++++++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 94 insertions(+) create mode 100644 homeassistant/components/sensor/lastfm.py diff --git a/.coveragerc b/.coveragerc index 7a3978d04cc..aec863174b8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -159,6 +159,7 @@ omit = homeassistant/components/sensor/glances.py homeassistant/components/sensor/google_travel_time.py homeassistant/components/sensor/gtfs.py + homeassistant/components/sensor/lastfm.py homeassistant/components/sensor/loopenergy.py homeassistant/components/sensor/netatmo.py homeassistant/components/sensor/neurio_energy.py diff --git a/homeassistant/components/sensor/lastfm.py b/homeassistant/components/sensor/lastfm.py new file mode 100644 index 00000000000..3001171081e --- /dev/null +++ b/homeassistant/components/sensor/lastfm.py @@ -0,0 +1,90 @@ +""" +Sensor for Last.fm account status. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.lastfm/ +""" +import re +from homeassistant.helpers.entity import Entity +from homeassistant.const import CONF_API_KEY + +ICON = 'mdi:lastfm' + +REQUIREMENTS = ['pylast==1.6.0'] + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Last.fm platform.""" + import pylast as lastfm + network = lastfm.LastFMNetwork(api_key=config.get(CONF_API_KEY)) + add_devices( + [LastfmSensor(username, + network) for username in config.get("users", [])]) + + +class LastfmSensor(Entity): + """A class for the Last.fm account.""" + + # pylint: disable=abstract-method, too-many-instance-attributes + def __init__(self, user, lastfm): + """Initialize the sensor.""" + self._user = lastfm.get_user(user) + self._name = user + self._lastfm = lastfm + self._state = "Not Scrobbling" + self._playcount = None + self._lastplayed = None + self._topplayed = None + self._cover = None + self.update() + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def entity_id(self): + """Return the entity ID.""" + return 'sensor.lastfm_{}'.format(self._name) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + # pylint: disable=no-member + def update(self): + """Update device state.""" + self._cover = self._user.get_image() + self._playcount = self._user.get_playcount() + last = self._user.get_recent_tracks(limit=2)[0] + self._lastplayed = "{} - {}".format(last.track.artist, + last.track.title) + top = self._user.get_top_tracks(limit=1)[0] + toptitle = re.search("', '(.+?)',", str(top)) + topartist = re.search("'(.+?)',", str(top)) + self._topplayed = "{} - {}".format(topartist.group(1), + toptitle.group(1)) + if self._user.get_now_playing() is None: + self._state = "Not Scrobbling" + return + now = self._user.get_now_playing() + self._state = "{} - {}".format(now.artist, now.title) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return {'Play Count': self._playcount, 'Last Played': + self._lastplayed, 'Top Played': self._topplayed} + + @property + def entity_picture(self): + """Avatar of the user.""" + return self._cover + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return ICON diff --git a/requirements_all.txt b/requirements_all.txt index 90d88e0bcae..9a876cbe012 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -222,6 +222,9 @@ pyfttt==0.3 # homeassistant.components.device_tracker.icloud pyicloud==0.8.3 +# homeassistant.components.sensor.lastfm +pylast==1.6.0 + # homeassistant.components.sensor.loopenergy pyloopenergy==0.0.10 From 84cb7a4f20c65324a3e9a3c7854059f9b0f77953 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sun, 15 May 2016 13:17:35 -0700 Subject: [PATCH 61/95] Add AWS notify platforms (Lambda, SNS, SQS) (#2073) * AWS SNS notify platform * Attach kwargs as MessageAttributes * Initial pass of AWS SQS platform * Add Lambda notify platform * Remove unused import * Change single quotes to double quotes because I am crazy * Forgot to run pydocstyle * Improve context support for Lambda * compress the message_attributes logic --- .coveragerc | 3 + homeassistant/components/notify/aws_lambda.py | 89 +++++++++++++++++++ homeassistant/components/notify/aws_sns.py | 78 ++++++++++++++++ homeassistant/components/notify/aws_sqs.py | 82 +++++++++++++++++ requirements_all.txt | 5 ++ 5 files changed, 257 insertions(+) create mode 100644 homeassistant/components/notify/aws_lambda.py create mode 100644 homeassistant/components/notify/aws_sns.py create mode 100644 homeassistant/components/notify/aws_sqs.py diff --git a/.coveragerc b/.coveragerc index aec863174b8..29dd544f911 100644 --- a/.coveragerc +++ b/.coveragerc @@ -128,6 +128,9 @@ omit = homeassistant/components/media_player/sonos.py homeassistant/components/media_player/squeezebox.py homeassistant/components/media_player/yamaha.py + homeassistant/components/notify/aws_lambda.py + homeassistant/components/notify/aws_sns.py + homeassistant/components/notify/aws_sqs.py homeassistant/components/notify/free_mobile.py homeassistant/components/notify/gntp.py homeassistant/components/notify/googlevoice.py diff --git a/homeassistant/components/notify/aws_lambda.py b/homeassistant/components/notify/aws_lambda.py new file mode 100644 index 00000000000..2c73462d92b --- /dev/null +++ b/homeassistant/components/notify/aws_lambda.py @@ -0,0 +1,89 @@ +""" +AWS Lambda platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.aws_lambda/ +""" +import logging +import json +import base64 +import voluptuous as vol + +from homeassistant.const import ( + CONF_PLATFORM, CONF_NAME) +from homeassistant.components.notify import ( + ATTR_TARGET, BaseNotificationService) + +_LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ["boto3==1.3.1"] + +CONF_REGION = "region_name" +CONF_ACCESS_KEY_ID = "aws_access_key_id" +CONF_SECRET_ACCESS_KEY = "aws_secret_access_key" +CONF_PROFILE_NAME = "profile_name" +CONF_CONTEXT = "context" + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): "aws_lambda", + vol.Optional(CONF_NAME): vol.Coerce(str), + vol.Optional(CONF_REGION, default="us-east-1"): vol.Coerce(str), + vol.Inclusive(CONF_ACCESS_KEY_ID, "credentials"): vol.Coerce(str), + vol.Inclusive(CONF_SECRET_ACCESS_KEY, "credentials"): vol.Coerce(str), + vol.Exclusive(CONF_PROFILE_NAME, "credentials"): vol.Coerce(str), + vol.Optional(CONF_CONTEXT, default=dict()): vol.Coerce(dict) +}) + + +def get_service(hass, config): + """Get the AWS Lambda notification service.""" + context_str = json.dumps({'hass': hass.config.as_dict(), + 'custom': config[CONF_CONTEXT]}) + context_b64 = base64.b64encode(context_str.encode("utf-8")) + context = context_b64.decode("utf-8") + + # pylint: disable=import-error + import boto3 + + aws_config = config.copy() + + del aws_config[CONF_PLATFORM] + del aws_config[CONF_NAME] + del aws_config[CONF_CONTEXT] + + if aws_config[CONF_PROFILE_NAME]: + boto3.setup_default_session(profile_name=aws_config[CONF_PROFILE_NAME]) + del aws_config[CONF_PROFILE_NAME] + + lambda_client = boto3.client("lambda", **aws_config) + + return AWSLambda(lambda_client, context) + + +# pylint: disable=too-few-public-methods +class AWSLambda(BaseNotificationService): + """Implement the notification service for the AWS Lambda service.""" + + def __init__(self, lambda_client, context): + """Initialize the service.""" + self.client = lambda_client + self.context = context + + def send_message(self, message="", **kwargs): + """Send notification to specified LAMBDA ARN.""" + targets = kwargs.get(ATTR_TARGET) + + if not targets: + _LOGGER.info("At least 1 target is required") + return + + if not isinstance(targets, list): + targets = [targets] + + for target in targets: + cleaned_kwargs = dict((k, v) for k, v in kwargs.items() if v) + payload = {"message": message} + payload.update(cleaned_kwargs) + + self.client.invoke(FunctionName=target, + Payload=json.dumps(payload), + ClientContext=self.context) diff --git a/homeassistant/components/notify/aws_sns.py b/homeassistant/components/notify/aws_sns.py new file mode 100644 index 00000000000..d6727b3de23 --- /dev/null +++ b/homeassistant/components/notify/aws_sns.py @@ -0,0 +1,78 @@ +""" +AWS SNS platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.aws_sns/ +""" +import logging +import json +import voluptuous as vol + +from homeassistant.const import ( + CONF_PLATFORM, CONF_NAME) +from homeassistant.components.notify import ( + ATTR_TITLE, ATTR_TARGET, BaseNotificationService) + +_LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ["boto3==1.3.1"] + +CONF_REGION = "region_name" +CONF_ACCESS_KEY_ID = "aws_access_key_id" +CONF_SECRET_ACCESS_KEY = "aws_secret_access_key" +CONF_PROFILE_NAME = "profile_name" + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): "aws_sns", + vol.Optional(CONF_NAME): vol.Coerce(str), + vol.Optional(CONF_REGION, default="us-east-1"): vol.Coerce(str), + vol.Inclusive(CONF_ACCESS_KEY_ID, "credentials"): vol.Coerce(str), + vol.Inclusive(CONF_SECRET_ACCESS_KEY, "credentials"): vol.Coerce(str), + vol.Exclusive(CONF_PROFILE_NAME, "credentials"): vol.Coerce(str) +}) + + +def get_service(hass, config): + """Get the AWS SNS notification service.""" + # pylint: disable=import-error + import boto3 + + aws_config = config.copy() + + del aws_config[CONF_PLATFORM] + del aws_config[CONF_NAME] + + if aws_config[CONF_PROFILE_NAME]: + boto3.setup_default_session(profile_name=aws_config[CONF_PROFILE_NAME]) + del aws_config[CONF_PROFILE_NAME] + + sns_client = boto3.client("sns", **aws_config) + + return AWSSNS(sns_client) + + +# pylint: disable=too-few-public-methods +class AWSSNS(BaseNotificationService): + """Implement the notification service for the AWS SNS service.""" + + def __init__(self, sns_client): + """Initialize the service.""" + self.client = sns_client + + def send_message(self, message="", **kwargs): + """Send notification to specified SNS ARN.""" + targets = kwargs.get(ATTR_TARGET) + + if not targets: + _LOGGER.info("At least 1 target is required") + return + + if not isinstance(targets, list): + targets = [targets] + + message_attributes = {k: {"StringValue": json.dumps(v), + "DataType": "String"} + for k, v in kwargs.items() if v} + for target in targets: + self.client.publish(TargetArn=target, Message=message, + Subject=kwargs.get(ATTR_TITLE), + MessageAttributes=message_attributes) diff --git a/homeassistant/components/notify/aws_sqs.py b/homeassistant/components/notify/aws_sqs.py new file mode 100644 index 00000000000..122f667959b --- /dev/null +++ b/homeassistant/components/notify/aws_sqs.py @@ -0,0 +1,82 @@ +""" +AWS SQS platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.aws_sqs/ +""" +import logging +import json +import voluptuous as vol + +from homeassistant.const import ( + CONF_PLATFORM, CONF_NAME) +from homeassistant.components.notify import ( + ATTR_TARGET, BaseNotificationService) + +_LOGGER = logging.getLogger(__name__) +REQUIREMENTS = ["boto3==1.3.1"] + +CONF_REGION = "region_name" +CONF_ACCESS_KEY_ID = "aws_access_key_id" +CONF_SECRET_ACCESS_KEY = "aws_secret_access_key" +CONF_PROFILE_NAME = "profile_name" + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): "aws_sqs", + vol.Optional(CONF_NAME): vol.Coerce(str), + vol.Optional(CONF_REGION, default="us-east-1"): vol.Coerce(str), + vol.Inclusive(CONF_ACCESS_KEY_ID, "credentials"): vol.Coerce(str), + vol.Inclusive(CONF_SECRET_ACCESS_KEY, "credentials"): vol.Coerce(str), + vol.Exclusive(CONF_PROFILE_NAME, "credentials"): vol.Coerce(str) +}) + + +def get_service(hass, config): + """Get the AWS SQS notification service.""" + # pylint: disable=import-error + import boto3 + + aws_config = config.copy() + + del aws_config[CONF_PLATFORM] + del aws_config[CONF_NAME] + + if aws_config[CONF_PROFILE_NAME]: + boto3.setup_default_session(profile_name=aws_config[CONF_PROFILE_NAME]) + del aws_config[CONF_PROFILE_NAME] + + sqs_client = boto3.client("sqs", **aws_config) + + return AWSSQS(sqs_client) + + +# pylint: disable=too-few-public-methods +class AWSSQS(BaseNotificationService): + """Implement the notification service for the AWS SQS service.""" + + def __init__(self, sqs_client): + """Initialize the service.""" + self.client = sqs_client + + def send_message(self, message="", **kwargs): + """Send notification to specified SQS ARN.""" + targets = kwargs.get(ATTR_TARGET) + + if not targets: + _LOGGER.info("At least 1 target is required") + return + + if not isinstance(targets, list): + targets = [targets] + + for target in targets: + cleaned_kwargs = dict((k, v) for k, v in kwargs.items() if v) + message_body = {"message": message} + message_body.update(cleaned_kwargs) + message_attributes = {} + for key, val in cleaned_kwargs.items(): + message_attributes[key] = {"StringValue": json.dumps(val), + "DataType": "String"} + self.client.send_message(QueueUrl=target, + MessageBody=json.dumps(message_body), + MessageAttributes=message_attributes) diff --git a/requirements_all.txt b/requirements_all.txt index 9a876cbe012..a3e2e7f2939 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -37,6 +37,11 @@ blockchain==1.3.1 # homeassistant.components.thermostat.eq3btsmart # bluepy_devices>=0.2.0 +# homeassistant.components.notify.aws_lambda +# homeassistant.components.notify.aws_sns +# homeassistant.components.notify.aws_sqs +boto3==1.3.1 + # homeassistant.components.notify.xmpp dnspython3==1.12.0 From 4ded79574053c250e621d6ce0b12d238bbc3bbbf Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 16 May 2016 11:37:17 +0200 Subject: [PATCH 62/95] Round minutes to integer in google travel time, Fix issue #2080 --- homeassistant/components/sensor/google_travel_time.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/google_travel_time.py b/homeassistant/components/sensor/google_travel_time.py index 30b50829c6c..b8513fa9bb6 100644 --- a/homeassistant/components/sensor/google_travel_time.py +++ b/homeassistant/components/sensor/google_travel_time.py @@ -138,7 +138,7 @@ class GoogleTravelTimeSensor(Entity): """Return the state of the sensor.""" try: res = self._matrix['rows'][0]['elements'][0]['duration']['value'] - return res/60.0 + return round(res/60) except KeyError: return None From 0a79a5e964b0fc3efc25745664d87389d14bb04b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 16 May 2016 21:59:39 -0700 Subject: [PATCH 63/95] Update frontend repo --- .../components/frontend/www_static/home-assistant-polymer | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer index 6a8e6a5a081..77f4dd1fed3 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit 6a8e6a5a081415690bf89e87697d15b6ce9ebf8b +Subproject commit 77f4dd1fed3d29c7ad8960c704a748af80748a59 From 7208ff515eb6a0d12daf4ecb249e1273ce175b96 Mon Sep 17 00:00:00 2001 From: Alexander Fortin Date: Tue, 17 May 2016 07:58:57 +0200 Subject: [PATCH 64/95] Better handle exceptions from Sonos players (#2085) Sonos players can be dynamically set in various modes, for example as TV players or Line-IN or straming from radios channels, therefore some methods could not be available, and when invoked they cause long exceptions to be logged. This partially solves the problem reducing the output and logging some more informative error message --- .../components/media_player/sonos.py | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 6f0513745f4..8f4bebdc19b 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -74,16 +74,26 @@ def only_if_coordinator(func): If used as decorator, avoid calling the decorated method if player is not a coordinator. If not, a grouped speaker (not in coordinator role) will - throw soco.exceptions.SoCoSlaveException + throw soco.exceptions.SoCoSlaveException. + + Also, partially catch exceptions like: + + soco.exceptions.SoCoUPnPException: UPnP Error 701 received: + Transition not available from """ def wrapper(*args, **kwargs): """Decorator wrapper.""" if args[0].is_coordinator: - return func(*args, **kwargs) + from soco.exceptions import SoCoUPnPException + try: + func(*args, **kwargs) + except SoCoUPnPException: + _LOGGER.error('command "%s" for Sonos device "%s" ' + 'not available in this mode', + func.__name__, args[0].name) else: - _LOGGER.debug('Ignore command "%s" for Sonos device "%s" ' - '(not coordinator)', - func.__name__, args[0].name) + _LOGGER.debug('Ignore command "%s" for Sonos device "%s" (%s)', + func.__name__, args[0].name, 'not coordinator') return wrapper From a431277de15a0d420e51b322bb65b6997493878a Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Tue, 17 May 2016 00:06:55 -0700 Subject: [PATCH 65/95] Accept human readable color names to change light colors (#2075) * Add support for providing color_name which accepts a CSS3 valid, human readable string such as red or blue * Forgot the schema validation! * ugh farcy * use html5_parse_legacy_color for more input options * Add webcolors==1.5 to setup.py * Block pylint no-member errors on tuple * add color_name_to_rgb test * whoops * revert changes to individual platforms * If color_name is set, pop it off params and set rgb_color with it * Forgot to reset wink.py * Import the legacy function as color_name_to_rgb directly * reset test_color.py * Improve light services.yaml --- homeassistant/components/light/__init__.py | 10 +++++++++- homeassistant/components/light/services.yaml | 4 ++++ homeassistant/util/color.py | 2 ++ requirements_all.txt | 1 + setup.py | 1 + 5 files changed, 17 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index f78e1dbdcba..d1fe0b93f4c 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -39,6 +39,7 @@ ATTR_TRANSITION = "transition" ATTR_RGB_COLOR = "rgb_color" ATTR_XY_COLOR = "xy_color" ATTR_COLOR_TEMP = "color_temp" +ATTR_COLOR_NAME = "color_name" # int with value 0 .. 255 representing brightness of the light. ATTR_BRIGHTNESS = "brightness" @@ -87,6 +88,7 @@ LIGHT_TURN_ON_SCHEMA = vol.Schema({ ATTR_PROFILE: str, ATTR_TRANSITION: VALID_TRANSITION, ATTR_BRIGHTNESS: cv.byte, + ATTR_COLOR_NAME: str, ATTR_RGB_COLOR: vol.All(vol.ExactSequence((cv.byte, cv.byte, cv.byte)), vol.Coerce(tuple)), ATTR_XY_COLOR: vol.All(vol.ExactSequence((cv.small_float, cv.small_float)), @@ -122,7 +124,7 @@ def is_on(hass, entity_id=None): # pylint: disable=too-many-arguments def turn_on(hass, entity_id=None, transition=None, brightness=None, rgb_color=None, xy_color=None, color_temp=None, profile=None, - flash=None, effect=None): + flash=None, effect=None, color_name=None): """Turn all or specified light on.""" data = { key: value for key, value in [ @@ -135,6 +137,7 @@ def turn_on(hass, entity_id=None, transition=None, brightness=None, (ATTR_COLOR_TEMP, color_temp), (ATTR_FLASH, flash), (ATTR_EFFECT, effect), + (ATTR_COLOR_NAME, color_name), ] if value is not None } @@ -228,6 +231,11 @@ def setup(hass, config): params.setdefault(ATTR_XY_COLOR, profile[:2]) params.setdefault(ATTR_BRIGHTNESS, profile[2]) + color_name = params.pop(ATTR_COLOR_NAME, None) + + if color_name is not None: + params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name) + for light in target_lights: light.turn_on(**params) diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 8ad2ea97a6b..392be490dc3 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -16,6 +16,10 @@ turn_on: description: Color for the light in RGB-format example: '[255, 100, 100]' + color_name: + description: A human readable color name + example: 'red' + xy_color: description: Color for the light in XY-format example: '[0.52, 0.43]' diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 2a662b15f5e..940d435ed44 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -1,5 +1,7 @@ """Color util methods.""" import math +# pylint: disable=unused-import +from webcolors import html5_parse_legacy_color as color_name_to_rgb # noqa HASS_COLOR_MAX = 500 # mireds (inverted) HASS_COLOR_MIN = 154 diff --git a/requirements_all.txt b/requirements_all.txt index a3e2e7f2939..cb7bb18c8ec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -6,6 +6,7 @@ pip>=7.0.0 vincenty==0.1.4 jinja2>=2.8 voluptuous==0.8.9 +webcolors==1.5 # homeassistant.components.isy994 PyISY==1.0.5 diff --git a/setup.py b/setup.py index f5efbb46e25..d315ae7d386 100755 --- a/setup.py +++ b/setup.py @@ -18,6 +18,7 @@ REQUIRES = [ 'vincenty==0.1.4', 'jinja2>=2.8', 'voluptuous==0.8.9', + 'webcolors==1.5', ] setup( From 15f89fc636f00de1703d9200745d8edc2c025934 Mon Sep 17 00:00:00 2001 From: happyleavesaoc Date: Tue, 17 May 2016 18:47:44 -0400 Subject: [PATCH 66/95] add some include_dir options (#2074) * add some include_dir options * validate, and extend instead of add * add yaml include tests --- homeassistant/util/yaml.py | 30 +++++++++++++- tests/util/test_yaml.py | 82 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/homeassistant/util/yaml.py b/homeassistant/util/yaml.py index d70a8f1e3e0..58458986063 100644 --- a/homeassistant/util/yaml.py +++ b/homeassistant/util/yaml.py @@ -46,7 +46,7 @@ def _include_yaml(loader, node): def _include_dir_named_yaml(loader, node): - """Load multiple files from dir.""" + """Load multiple files from dir as a dict.""" mapping = OrderedDict() files = os.path.join(os.path.dirname(loader.name), node.value, '*.yaml') for fname in glob.glob(files): @@ -55,12 +55,34 @@ def _include_dir_named_yaml(loader, node): return mapping +def _include_dir_merge_named_yaml(loader, node): + """Load multiple files from dir as a merged dict.""" + mapping = OrderedDict() + files = os.path.join(os.path.dirname(loader.name), node.value, '*.yaml') + for fname in glob.glob(files): + loaded_yaml = load_yaml(fname) + if isinstance(loaded_yaml, dict): + mapping.update(loaded_yaml) + return mapping + + def _include_dir_list_yaml(loader, node): - """Load multiple files from dir.""" + """Load multiple files from dir as a list.""" files = os.path.join(os.path.dirname(loader.name), node.value, '*.yaml') return [load_yaml(f) for f in glob.glob(files)] +def _include_dir_merge_list_yaml(loader, node): + """Load multiple files from dir as a merged list.""" + files = os.path.join(os.path.dirname(loader.name), node.value, '*.yaml') + merged_list = [] + for fname in glob.glob(files): + loaded_yaml = load_yaml(fname) + if isinstance(loaded_yaml, list): + merged_list.extend(loaded_yaml) + return merged_list + + def _ordered_dict(loader, node): """Load YAML mappings into an ordered dict to preserve key order.""" loader.flatten_mapping(node) @@ -102,4 +124,8 @@ yaml.SafeLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _ordered_dict) yaml.SafeLoader.add_constructor('!env_var', _env_var_yaml) yaml.SafeLoader.add_constructor('!include_dir_list', _include_dir_list_yaml) +yaml.SafeLoader.add_constructor('!include_dir_merge_list', + _include_dir_merge_list_yaml) yaml.SafeLoader.add_constructor('!include_dir_named', _include_dir_named_yaml) +yaml.SafeLoader.add_constructor('!include_dir_merge_named', + _include_dir_merge_named_yaml) diff --git a/tests/util/test_yaml.py b/tests/util/test_yaml.py index 106cb01264e..244f9323334 100644 --- a/tests/util/test_yaml.py +++ b/tests/util/test_yaml.py @@ -2,6 +2,7 @@ import io import unittest import os +import tempfile from homeassistant.util import yaml @@ -53,3 +54,84 @@ class TestYaml(unittest.TestCase): pass else: assert 0 + + def test_include_yaml(self): + """Test include yaml.""" + with tempfile.NamedTemporaryFile() as include_file: + include_file.write(b"value") + include_file.seek(0) + conf = "key: !include {}".format(include_file.name) + with io.StringIO(conf) as f: + doc = yaml.yaml.safe_load(f) + assert doc["key"] == "value" + + def test_include_dir_list(self): + """Test include dir list yaml.""" + with tempfile.TemporaryDirectory() as include_dir: + file_1 = tempfile.NamedTemporaryFile(dir=include_dir, + suffix=".yaml", delete=False) + file_1.write(b"one") + file_1.close() + file_2 = tempfile.NamedTemporaryFile(dir=include_dir, + suffix=".yaml", delete=False) + file_2.write(b"two") + file_2.close() + conf = "key: !include_dir_list {}".format(include_dir) + with io.StringIO(conf) as f: + doc = yaml.yaml.safe_load(f) + assert sorted(doc["key"]) == sorted(["one", "two"]) + + def test_include_dir_named(self): + """Test include dir named yaml.""" + with tempfile.TemporaryDirectory() as include_dir: + file_1 = tempfile.NamedTemporaryFile(dir=include_dir, + suffix=".yaml", delete=False) + file_1.write(b"one") + file_1.close() + file_2 = tempfile.NamedTemporaryFile(dir=include_dir, + suffix=".yaml", delete=False) + file_2.write(b"two") + file_2.close() + conf = "key: !include_dir_named {}".format(include_dir) + correct = {} + correct[os.path.splitext(os.path.basename(file_1.name))[0]] = "one" + correct[os.path.splitext(os.path.basename(file_2.name))[0]] = "two" + with io.StringIO(conf) as f: + doc = yaml.yaml.safe_load(f) + assert doc["key"] == correct + + def test_include_dir_merge_list(self): + """Test include dir merge list yaml.""" + with tempfile.TemporaryDirectory() as include_dir: + file_1 = tempfile.NamedTemporaryFile(dir=include_dir, + suffix=".yaml", delete=False) + file_1.write(b"- one") + file_1.close() + file_2 = tempfile.NamedTemporaryFile(dir=include_dir, + suffix=".yaml", delete=False) + file_2.write(b"- two\n- three") + file_2.close() + conf = "key: !include_dir_merge_list {}".format(include_dir) + with io.StringIO(conf) as f: + doc = yaml.yaml.safe_load(f) + assert sorted(doc["key"]) == sorted(["one", "two", "three"]) + + def test_include_dir_merge_named(self): + """Test include dir merge named yaml.""" + with tempfile.TemporaryDirectory() as include_dir: + file_1 = tempfile.NamedTemporaryFile(dir=include_dir, + suffix=".yaml", delete=False) + file_1.write(b"key1: one") + file_1.close() + file_2 = tempfile.NamedTemporaryFile(dir=include_dir, + suffix=".yaml", delete=False) + file_2.write(b"key2: two\nkey3: three") + file_2.close() + conf = "key: !include_dir_merge_named {}".format(include_dir) + with io.StringIO(conf) as f: + doc = yaml.yaml.safe_load(f) + assert doc["key"] == { + "key1": "one", + "key2": "two", + "key3": "three" + } From 8d34b76d510c64eaf1fea257260d11ec5a206478 Mon Sep 17 00:00:00 2001 From: froz Date: Tue, 17 May 2016 15:55:12 -0700 Subject: [PATCH 67/95] Restored telnet as an option. Activate with config option 'protocol: telnet'. Default is ssh (#2096) --- .../components/device_tracker/asuswrt.py | 45 ++++++++++++++++--- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index dd0c43efb2f..fb8a76a1488 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/device_tracker.asuswrt/ """ import logging import re +import telnetlib import threading from datetime import timedelta @@ -20,12 +21,14 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['pexpect==4.0.1'] +_LEASES_CMD = 'cat /var/lib/misc/dnsmasq.leases' _LEASES_REGEX = re.compile( r'\w+\s' + r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s' + r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s' + r'(?P([^\s]+))') +_IP_NEIGH_CMD = 'ip neigh' _IP_NEIGH_REGEX = re.compile( r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s' + r'\w+\s' + @@ -55,6 +58,7 @@ class AsusWrtDeviceScanner(object): self.host = config[CONF_HOST] self.username = str(config[CONF_USERNAME]) self.password = str(config[CONF_PASSWORD]) + self.protocol = config.get('protocol') self.lock = threading.Lock() @@ -100,22 +104,53 @@ class AsusWrtDeviceScanner(object): self.last_results = active_clients return True - def get_asuswrt_data(self): - """Retrieve data from ASUSWRT and return parsed result.""" + def ssh_connection(self): + """Retrieve data from ASUSWRT via the ssh protocol.""" from pexpect import pxssh try: ssh = pxssh.pxssh() ssh.login(self.host, self.username, self.password) - ssh.sendline('ip neigh') + ssh.sendline(_IP_NEIGH_CMD) ssh.prompt() neighbors = ssh.before.split(b'\n')[1:-1] - ssh.sendline('cat /var/lib/misc/dnsmasq.leases') + ssh.sendline(_LEASES_CMD) ssh.prompt() leases_result = ssh.before.split(b'\n')[1:-1] ssh.logout() + return (neighbors, leases_result) except pxssh.ExceptionPxssh as exc: _LOGGER.exception('Unexpected response from router: %s', exc) - return + return ('', '') + + def telnet_connection(self): + """Retrieve data from ASUSWRT via the telnet protocol.""" + try: + telnet = telnetlib.Telnet(self.host) + telnet.read_until(b'login: ') + telnet.write((self.username + '\n').encode('ascii')) + telnet.read_until(b'Password: ') + telnet.write((self.password + '\n').encode('ascii')) + prompt_string = telnet.read_until(b'#').split(b'\n')[-1] + telnet.write('{}\n'.format(_IP_NEIGH_CMD).encode('ascii')) + neighbors = telnet.read_until(prompt_string).split(b'\n')[1:-1] + telnet.write('{}\n'.format(_LEASES_CMD).encode('ascii')) + leases_result = telnet.read_until(prompt_string).split(b'\n')[1:-1] + telnet.write('exit\n'.encode('ascii')) + return (neighbors, leases_result) + except EOFError: + _LOGGER.exception("Unexpected response from router") + return ('', '') + except ConnectionRefusedError: + _LOGGER.exception("Connection refused by router," + " is telnet enabled?") + return ('', '') + + def get_asuswrt_data(self): + """Retrieve data from ASUSWRT and return parsed result.""" + if self.protocol == 'telnet': + neighbors, leases_result = self.telnet_connection() + else: + neighbors, leases_result = self.ssh_connection() devices = {} for lease in leases_result: From a565cc4b73a1e4ff27fd710ed3dede8728dff1f7 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Tue, 17 May 2016 16:51:32 -0700 Subject: [PATCH 68/95] Catch a gntp networkerror (#2099) --- homeassistant/components/notify/gntp.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/notify/gntp.py b/homeassistant/components/notify/gntp.py index 67e80e2c424..ed192256cfc 100644 --- a/homeassistant/components/notify/gntp.py +++ b/homeassistant/components/notify/gntp.py @@ -41,8 +41,9 @@ class GNTPNotificationService(BaseNotificationService): # pylint: disable=too-many-arguments def __init__(self, app_name, app_icon, hostname, password, port): """Initialize the service.""" - from gntp import notifier - self.gntp = notifier.GrowlNotifier( + import gntp.notifier + import gntp.errors + self.gntp = gntp.notifier.GrowlNotifier( applicationName=app_name, notifications=["Notification"], applicationIcon=app_icon, @@ -50,7 +51,11 @@ class GNTPNotificationService(BaseNotificationService): password=password, port=port ) - self.gntp.register() + try: + self.gntp.register() + except gntp.errors.NetworkError: + _LOGGER.error('Unable to register with the GNTP host.') + return def send_message(self, message="", **kwargs): """Send a message to a user.""" From c96a5d5b2ba3782e9c4f6fd8fc60af70ab137f8a Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Tue, 17 May 2016 16:51:38 -0700 Subject: [PATCH 69/95] Fix profile usage with aws notify platforms (#2100) --- homeassistant/components/notify/aws_lambda.py | 6 ++++-- homeassistant/components/notify/aws_sns.py | 6 ++++-- homeassistant/components/notify/aws_sqs.py | 6 ++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/notify/aws_lambda.py b/homeassistant/components/notify/aws_lambda.py index 2c73462d92b..68f0de7a934 100644 --- a/homeassistant/components/notify/aws_lambda.py +++ b/homeassistant/components/notify/aws_lambda.py @@ -50,8 +50,10 @@ def get_service(hass, config): del aws_config[CONF_NAME] del aws_config[CONF_CONTEXT] - if aws_config[CONF_PROFILE_NAME]: - boto3.setup_default_session(profile_name=aws_config[CONF_PROFILE_NAME]) + profile = aws_config.get(CONF_PROFILE_NAME) + + if profile is not None: + boto3.setup_default_session(profile_name=profile) del aws_config[CONF_PROFILE_NAME] lambda_client = boto3.client("lambda", **aws_config) diff --git a/homeassistant/components/notify/aws_sns.py b/homeassistant/components/notify/aws_sns.py index d6727b3de23..dec72b18633 100644 --- a/homeassistant/components/notify/aws_sns.py +++ b/homeassistant/components/notify/aws_sns.py @@ -41,8 +41,10 @@ def get_service(hass, config): del aws_config[CONF_PLATFORM] del aws_config[CONF_NAME] - if aws_config[CONF_PROFILE_NAME]: - boto3.setup_default_session(profile_name=aws_config[CONF_PROFILE_NAME]) + profile = aws_config.get(CONF_PROFILE_NAME) + + if profile is not None: + boto3.setup_default_session(profile_name=profile) del aws_config[CONF_PROFILE_NAME] sns_client = boto3.client("sns", **aws_config) diff --git a/homeassistant/components/notify/aws_sqs.py b/homeassistant/components/notify/aws_sqs.py index 122f667959b..a600878cda7 100644 --- a/homeassistant/components/notify/aws_sqs.py +++ b/homeassistant/components/notify/aws_sqs.py @@ -41,8 +41,10 @@ def get_service(hass, config): del aws_config[CONF_PLATFORM] del aws_config[CONF_NAME] - if aws_config[CONF_PROFILE_NAME]: - boto3.setup_default_session(profile_name=aws_config[CONF_PROFILE_NAME]) + profile = aws_config.get(CONF_PROFILE_NAME) + + if profile is not None: + boto3.setup_default_session(profile_name=profile) del aws_config[CONF_PROFILE_NAME] sqs_client = boto3.client("sqs", **aws_config) From a032e649f57fe7ccc07bf350bde261976321ee94 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 19 May 2016 02:04:59 +0200 Subject: [PATCH 70/95] Upgrade psutil to 4.2.0 (#2101) --- homeassistant/components/sensor/systemmonitor.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 66b6b9c4d8e..20af7e71a59 100755 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -10,7 +10,7 @@ import homeassistant.util.dt as dt_util from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['psutil==4.1.0'] +REQUIREMENTS = ['psutil==4.2.0'] SENSOR_TYPES = { 'disk_use_percent': ['Disk Use', '%', 'mdi:harddisk'], 'disk_use': ['Disk Use', 'GiB', 'mdi:harddisk'], diff --git a/requirements_all.txt b/requirements_all.txt index cb7bb18c8ec..b1508ae2321 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -193,7 +193,7 @@ plexapi==1.1.0 proliphix==0.1.0 # homeassistant.components.sensor.systemmonitor -psutil==4.1.0 +psutil==4.2.0 # homeassistant.components.notify.pushbullet pushbullet.py==0.10.0 From bfd64ce96e6baa6c4433253fcebe1e4a06251651 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 19 May 2016 02:05:08 +0200 Subject: [PATCH 71/95] Upgrade python-telegram-bot to 4.1.1 (#2102) --- homeassistant/components/notify/telegram.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/telegram.py b/homeassistant/components/notify/telegram.py index d005e434601..a446644ef04 100644 --- a/homeassistant/components/notify/telegram.py +++ b/homeassistant/components/notify/telegram.py @@ -14,7 +14,7 @@ from homeassistant.helpers import validate_config _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['python-telegram-bot==4.0.1'] +REQUIREMENTS = ['python-telegram-bot==4.1.1'] def get_service(hass, config): diff --git a/requirements_all.txt b/requirements_all.txt index b1508ae2321..264c4ff7253 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -269,7 +269,7 @@ python-pushover==0.2 python-statsd==1.7.2 # homeassistant.components.notify.telegram -python-telegram-bot==4.0.1 +python-telegram-bot==4.1.1 # homeassistant.components.sensor.twitch python-twitch==1.2.0 From 5f98a70c21bfa3e4cce61c82cf52de118609b373 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Thu, 19 May 2016 15:36:11 +0200 Subject: [PATCH 72/95] Fix bug in flaky rfxtrx test (#2107) --- tests/components/test_rfxtrx.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/test_rfxtrx.py b/tests/components/test_rfxtrx.py index 9637f7666bc..3ad9522ec53 100644 --- a/tests/components/test_rfxtrx.py +++ b/tests/components/test_rfxtrx.py @@ -37,10 +37,10 @@ class TestRFXTRX(unittest.TestCase): 'automatic_add': True, 'devices': {}}})) - while len(rfxtrx.RFX_DEVICES) < 1: + while len(rfxtrx.RFX_DEVICES) < 2: time.sleep(0.1) - self.assertEqual(len(rfxtrx.RFXOBJECT.sensors()), 1) + self.assertEqual(len(rfxtrx.RFXOBJECT.sensors()), 2) def test_valid_config(self): """Test configuration.""" From dd1703469e5c205271166e193222d408b185cd59 Mon Sep 17 00:00:00 2001 From: pavoni Date: Thu, 19 May 2016 16:04:55 +0100 Subject: [PATCH 73/95] Handle region enter/leave with spaces. --- .../components/device_tracker/owntracks.py | 4 ++-- tests/components/device_tracker/test_owntracks.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 8254285a98b..40cb19e05e7 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -11,7 +11,7 @@ from collections import defaultdict import homeassistant.components.mqtt as mqtt from homeassistant.const import STATE_HOME -from homeassistant.util import convert +from homeassistant.util import convert, slugify DEPENDENCIES = ['mqtt'] @@ -91,7 +91,7 @@ def setup_scanner(hass, config, see): return # OwnTracks uses - at the start of a beacon zone # to switch on 'hold mode' - ignore this - location = data['desc'].lstrip("-") + location = slugify(data['desc'].lstrip("-")) if location.lower() == 'home': location = STATE_HOME diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index 7fa290e4005..e5fcd454835 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -230,6 +230,20 @@ class TestDeviceTrackerOwnTracks(unittest.TestCase): # Left clean zone state self.assertFalse(owntracks.REGIONS_ENTERED[USER]) + def test_event_with_spaces(self): + """Test the entry event.""" + message = REGION_ENTER_MESSAGE.copy() + message['desc'] = "inner 2" + self.send_message(EVENT_TOPIC, message) + self.assert_location_state('inner_2') + + message = REGION_LEAVE_MESSAGE.copy() + message['desc'] = "inner 2" + self.send_message(EVENT_TOPIC, message) + + # Left clean zone state + self.assertFalse(owntracks.REGIONS_ENTERED[USER]) + def test_event_entry_exit_inaccurate(self): """Test the event for inaccurate exit.""" self.send_message(EVENT_TOPIC, REGION_ENTER_MESSAGE) From 8ff950613879b4a1c00e138431a0ea3bbb64e067 Mon Sep 17 00:00:00 2001 From: pavoni Date: Thu, 19 May 2016 16:16:43 +0100 Subject: [PATCH 74/95] Ignore acc: 0 updates. --- .../components/device_tracker/owntracks.py | 6 +++++ .../device_tracker/test_owntracks.py | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 40cb19e05e7..f59fc06c59a 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -53,6 +53,12 @@ def setup_scanner(hass, config, see): 'accuracy %s is not met: %s', data_type, max_gps_accuracy, data) return None + if convert(data.get('acc'), float, 1.0) == 0.0: + _LOGGER.debug('Skipping %s update because GPS accuracy' + 'is zero', + data_type) + return None + return data def owntracks_location_update(topic, payload, qos): diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index e5fcd454835..756f6271e59 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -55,6 +55,21 @@ LOCATION_MESSAGE_INACCURATE = { 'tst': 1, 'vel': 0} +LOCATION_MESSAGE_ZERO_ACCURACY = { + 'batt': 92, + 'cog': 248, + 'tid': 'user', + 'lon': 2.0, + 't': 'u', + 'alt': 27, + 'acc': 0, + 'p': 101.3977584838867, + 'vac': 4, + 'lat': 6.0, + '_type': 'location', + 'tst': 1, + 'vel': 0} + REGION_ENTER_MESSAGE = { 'lon': 1.0, 'event': 'enter', @@ -204,6 +219,14 @@ class TestDeviceTrackerOwnTracks(unittest.TestCase): self.assert_location_latitude(2.0) self.assert_location_longitude(1.0) + def test_location_zero_accuracy_gps(self): + """Ignore the location for zero accuracy GPS information.""" + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) + self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_ZERO_ACCURACY) + + self.assert_location_latitude(2.0) + self.assert_location_longitude(1.0) + def test_event_entry_exit(self): """Test the entry event.""" self.send_message(EVENT_TOPIC, REGION_ENTER_MESSAGE) From 62de16804b5f3ccae7f15e60255e22af9b1fe566 Mon Sep 17 00:00:00 2001 From: pavoni Date: Thu, 19 May 2016 17:12:19 +0100 Subject: [PATCH 75/95] Bump loop energy library version. --- homeassistant/components/sensor/loopenergy.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/loopenergy.py b/homeassistant/components/sensor/loopenergy.py index 31b957192d9..7b27b0e89a4 100644 --- a/homeassistant/components/sensor/loopenergy.py +++ b/homeassistant/components/sensor/loopenergy.py @@ -14,7 +14,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "loopenergy" -REQUIREMENTS = ['pyloopenergy==0.0.10'] +REQUIREMENTS = ['pyloopenergy==0.0.12'] def setup_platform(hass, config, add_devices, discovery_info=None): diff --git a/requirements_all.txt b/requirements_all.txt index 264c4ff7253..e277b9268ec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -232,7 +232,7 @@ pyicloud==0.8.3 pylast==1.6.0 # homeassistant.components.sensor.loopenergy -pyloopenergy==0.0.10 +pyloopenergy==0.0.12 # homeassistant.components.device_tracker.netgear pynetgear==0.3.3 From f0f1fadee1abbe87e67a25a9ff1de0c95c6f2f70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Per=20Sandstr=C3=B6m?= Date: Fri, 20 May 2016 08:20:07 +0200 Subject: [PATCH 76/95] redirect daemon file descriptors (#2103) --- homeassistant/__main__.py | 16 ++++++++---- homeassistant/bootstrap.py | 51 +++++++++++++++++++------------------- 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index f12758a354d..583945c8541 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -151,6 +151,13 @@ def daemonize(): if pid > 0: sys.exit(0) + # redirect standard file descriptors to devnull + sys.stdout.flush() + sys.stderr.flush() + os.dup2(open(os.devnull, 'r').fileno(), sys.stdin.fileno()) + os.dup2(open(os.devnull, 'a+').fileno(), sys.stdout.fileno()) + os.dup2(open(os.devnull, 'a+').fileno(), sys.stderr.fileno()) + def check_pid(pid_file): """Check that HA is not already running.""" @@ -234,15 +241,14 @@ def setup_and_run_hass(config_dir, args, top_process=False): 'demo': {} } hass = bootstrap.from_config_dict( - config, config_dir=config_dir, daemon=args.daemon, - verbose=args.verbose, skip_pip=args.skip_pip, - log_rotate_days=args.log_rotate_days) + config, config_dir=config_dir, verbose=args.verbose, + skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days) else: config_file = ensure_config_file(config_dir) print('Config directory:', config_dir) hass = bootstrap.from_config_file( - config_file, daemon=args.daemon, verbose=args.verbose, - skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days) + config_file, verbose=args.verbose, skip_pip=args.skip_pip, + log_rotate_days=args.log_rotate_days) if hass is None: return diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index a8e85ca3bd3..99382bebe74 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -215,7 +215,7 @@ def mount_local_lib_path(config_dir): # pylint: disable=too-many-branches, too-many-statements, too-many-arguments def from_config_dict(config, hass=None, config_dir=None, enable_log=True, - verbose=False, daemon=False, skip_pip=False, + verbose=False, skip_pip=False, log_rotate_days=None): """Try to configure Home Assistant from a config dict. @@ -240,7 +240,7 @@ def from_config_dict(config, hass=None, config_dir=None, enable_log=True, process_ha_config_upgrade(hass) if enable_log: - enable_logging(hass, verbose, daemon, log_rotate_days) + enable_logging(hass, verbose, log_rotate_days) hass.config.skip_pip = skip_pip if skip_pip: @@ -278,8 +278,8 @@ def from_config_dict(config, hass=None, config_dir=None, enable_log=True, return hass -def from_config_file(config_path, hass=None, verbose=False, daemon=False, - skip_pip=True, log_rotate_days=None): +def from_config_file(config_path, hass=None, verbose=False, skip_pip=True, + log_rotate_days=None): """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter if given, @@ -293,7 +293,7 @@ def from_config_file(config_path, hass=None, verbose=False, daemon=False, hass.config.config_dir = config_dir mount_local_lib_path(config_dir) - enable_logging(hass, verbose, daemon, log_rotate_days) + enable_logging(hass, verbose, log_rotate_days) try: config_dict = config_util.load_yaml_config_file(config_path) @@ -304,28 +304,27 @@ def from_config_file(config_path, hass=None, verbose=False, daemon=False, skip_pip=skip_pip) -def enable_logging(hass, verbose=False, daemon=False, log_rotate_days=None): +def enable_logging(hass, verbose=False, log_rotate_days=None): """Setup the logging.""" - if not daemon: - logging.basicConfig(level=logging.INFO) - fmt = ("%(log_color)s%(asctime)s %(levelname)s (%(threadName)s) " - "[%(name)s] %(message)s%(reset)s") - try: - from colorlog import ColoredFormatter - logging.getLogger().handlers[0].setFormatter(ColoredFormatter( - fmt, - datefmt='%y-%m-%d %H:%M:%S', - reset=True, - log_colors={ - 'DEBUG': 'cyan', - 'INFO': 'green', - 'WARNING': 'yellow', - 'ERROR': 'red', - 'CRITICAL': 'red', - } - )) - except ImportError: - pass + logging.basicConfig(level=logging.INFO) + fmt = ("%(log_color)s%(asctime)s %(levelname)s (%(threadName)s) " + "[%(name)s] %(message)s%(reset)s") + try: + from colorlog import ColoredFormatter + logging.getLogger().handlers[0].setFormatter(ColoredFormatter( + fmt, + datefmt='%y-%m-%d %H:%M:%S', + reset=True, + log_colors={ + 'DEBUG': 'cyan', + 'INFO': 'green', + 'WARNING': 'yellow', + 'ERROR': 'red', + 'CRITICAL': 'red', + } + )) + except ImportError: + pass # Log errors to a file if we have write access to file or config dir err_log_path = hass.config.path(ERROR_LOG_FILENAME) From 5f92ceeea93d5c7599bce15a083a5394d18df203 Mon Sep 17 00:00:00 2001 From: Jan Harkes Date: Fri, 20 May 2016 02:20:59 -0400 Subject: [PATCH 77/95] Allow for restart without using parent/child processes. (#1793) * Allow for restart without using parent/child processes. Assuming that we normally correctly shut down running threads and release resources, we just do some minimal scrubbing of open file descriptors and child processes which would stay around across an exec() boundary. * Use sys.executable instead of multiprocessing.spawn.get_executable() * Limit how many file descriptors we try to close. Don't even try to close on OSX/Darwin until we figure out how to recognize guarded fds because the kernel will yell at us, and kill the process. * Use the close on exec flag on MacOS to clean up. * Introduce a small process runner to handle restart on windows. * Handle missing signal.SIGHUP on Windows. --- homeassistant/__main__.py | 151 +++++++++++++++++++++++++------------- homeassistant/core.py | 20 +++-- 2 files changed, 116 insertions(+), 55 deletions(-) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 583945c8541..467303317d6 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -3,11 +3,12 @@ from __future__ import print_function import argparse import os +import platform import signal +import subprocess import sys import threading import time -from multiprocessing import Process from homeassistant.const import ( __version__, @@ -87,8 +88,7 @@ def get_arguments(): parser.add_argument( '--debug', action='store_true', - help='Start Home Assistant in debug mode. Runs in single process to ' - 'enable use of interactive debuggers.') + help='Start Home Assistant in debug mode') parser.add_argument( '--open-ui', action='store_true', @@ -123,15 +123,20 @@ def get_arguments(): '--restart-osx', action='store_true', help='Restarts on OS X.') - if os.name != "nt": + parser.add_argument( + '--runner', + action='store_true', + help='On restart exit with code {}'.format(RESTART_EXIT_CODE)) + if os.name == "posix": parser.add_argument( '--daemon', action='store_true', help='Run Home Assistant as daemon') arguments = parser.parse_args() - if os.name == "nt": + if os.name != "posix" or arguments.debug or arguments.runner: arguments.daemon = False + return arguments @@ -144,7 +149,6 @@ def daemonize(): # Decouple fork os.setsid() - os.umask(0) # Create second fork pid = os.fork() @@ -227,14 +231,44 @@ def uninstall_osx(): print("Home Assistant has been uninstalled.") -def setup_and_run_hass(config_dir, args, top_process=False): - """Setup HASS and run. +def closefds_osx(min_fd, max_fd): + """Make sure file descriptors get closed when we restart. - Block until stopped. Will assume it is running in a subprocess unless - top_process is set to true. + We cannot call close on guarded fds, and we cannot easily test which fds + are guarded. But we can set the close-on-exec flag on everything we want to + get rid of. """ + from fcntl import fcntl, F_GETFD, F_SETFD, FD_CLOEXEC + + for _fd in range(min_fd, max_fd): + try: + val = fcntl(_fd, F_GETFD) + if not val & FD_CLOEXEC: + fcntl(_fd, F_SETFD, val | FD_CLOEXEC) + except IOError: + pass + + +def cmdline(): + """Collect path and arguments to re-execute the current hass instance.""" + return [sys.executable] + [arg for arg in sys.argv if arg != '--daemon'] + + +def setup_and_run_hass(config_dir, args): + """Setup HASS and run.""" from homeassistant import bootstrap + # Run a simple daemon runner process on Windows to handle restarts + if os.name == 'nt' and '--runner' not in sys.argv: + args = cmdline() + ['--runner'] + while True: + try: + subprocess.check_call(args) + sys.exit(0) + except subprocess.CalledProcessError as exc: + if exc.returncode != RESTART_EXIT_CODE: + sys.exit(exc.returncode) + if args.demo_mode: config = { 'frontend': {}, @@ -262,42 +296,68 @@ def setup_and_run_hass(config_dir, args, top_process=False): hass.bus.listen_once(EVENT_HOMEASSISTANT_START, open_browser) + print('Starting Home-Assistant') hass.start() exit_code = int(hass.block_till_stopped()) - if not top_process: - sys.exit(exit_code) return exit_code -def run_hass_process(hass_proc): - """Run a child hass process. Returns True if it should be restarted.""" - requested_stop = threading.Event() - hass_proc.daemon = True +def try_to_restart(): + """Attempt to clean up state and start a new homeassistant instance.""" + # Things should be mostly shut down already at this point, now just try + # to clean up things that may have been left behind. + sys.stderr.write('Home Assistant attempting to restart.\n') - def request_stop(*args): - """Request hass stop, *args is for signal handler callback.""" - requested_stop.set() - hass_proc.terminate() + # Count remaining threads, ideally there should only be one non-daemonized + # thread left (which is us). Nothing we really do with it, but it might be + # useful when debugging shutdown/restart issues. + nthreads = sum(thread.isAlive() and not thread.isDaemon() + for thread in threading.enumerate()) + if nthreads > 1: + sys.stderr.write("Found {} non-daemonic threads.\n".format(nthreads)) - try: - signal.signal(signal.SIGTERM, request_stop) - except ValueError: - print('Could not bind to SIGTERM. Are you running in a thread?') + # Send terminate signal to all processes in our process group which + # should be any children that have not themselves changed the process + # group id. Don't bother if couldn't even call setpgid. + if hasattr(os, 'setpgid'): + sys.stderr.write("Signalling child processes to terminate...\n") + os.kill(0, signal.SIGTERM) - hass_proc.start() - try: - hass_proc.join() - except KeyboardInterrupt: - request_stop() + # wait for child processes to terminate try: - hass_proc.join() - except KeyboardInterrupt: - return False + while True: + time.sleep(1) + if os.waitpid(0, os.WNOHANG) == (0, 0): + break + except OSError: + pass - return (not requested_stop.isSet() and - hass_proc.exitcode == RESTART_EXIT_CODE, - hass_proc.exitcode) + elif os.name == 'nt': + # Maybe one of the following will work, but how do we indicate which + # processes are our children if there is no process group? + # os.kill(0, signal.CTRL_C_EVENT) + # os.kill(0, signal.CTRL_BREAK_EVENT) + pass + + # Try to not leave behind open filedescriptors with the emphasis on try. + try: + max_fd = os.sysconf("SC_OPEN_MAX") + except ValueError: + max_fd = 256 + + if platform.system() == 'Darwin': + closefds_osx(3, max_fd) + else: + os.closerange(3, max_fd) + + # Now launch into a new instance of Home-Assistant. If this fails we + # fall through and exit with error 100 (RESTART_EXIT_CODE) in which case + # systemd will restart us when RestartForceExitStatus=100 is set in the + # systemd.service file. + sys.stderr.write("Restarting Home-Assistant\n") + args = cmdline() + os.execv(args[0], args) def main(): @@ -331,21 +391,14 @@ def main(): if args.pid_file: write_pid(args.pid_file) - # Run hass in debug mode if requested - if args.debug: - sys.stderr.write('Running in debug mode. ' - 'Home Assistant will not be able to restart.\n') - exit_code = setup_and_run_hass(config_dir, args, top_process=True) - if exit_code == RESTART_EXIT_CODE: - sys.stderr.write('Home Assistant requested a ' - 'restart in debug mode.\n') - return exit_code + # Create new process group if we can + if hasattr(os, 'setpgid'): + os.setpgid(0, 0) + + exit_code = setup_and_run_hass(config_dir, args) + if exit_code == RESTART_EXIT_CODE and not args.runner: + try_to_restart() - # Run hass as child process. Restart if necessary. - keep_running = True - while keep_running: - hass_proc = Process(target=setup_and_run_hass, args=(config_dir, args)) - keep_running, exit_code = run_hass_process(hass_proc) return exit_code diff --git a/homeassistant/core.py b/homeassistant/core.py index 9a237cb58bd..ffaccdeae43 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -79,6 +79,7 @@ class HomeAssistant(object): def restart_homeassistant(*args): """Reset Home Assistant.""" + _LOGGER.warning('Home Assistant requested a restart.') request_restart.set() request_shutdown.set() @@ -92,14 +93,21 @@ class HomeAssistant(object): except ValueError: _LOGGER.warning( 'Could not bind to SIGTERM. Are you running in a thread?') - - while not request_shutdown.isSet(): - try: + try: + signal.signal(signal.SIGHUP, restart_homeassistant) + except ValueError: + _LOGGER.warning( + 'Could not bind to SIGHUP. Are you running in a thread?') + except AttributeError: + pass + try: + while not request_shutdown.isSet(): time.sleep(1) - except KeyboardInterrupt: - break + except KeyboardInterrupt: + pass + finally: + self.stop() - self.stop() return RESTART_EXIT_CODE if request_restart.isSet() else 0 def stop(self): From f7b401a20e9acb7b3705942d81dd82bbf2ebf8eb Mon Sep 17 00:00:00 2001 From: wokar Date: Fri, 20 May 2016 08:27:47 +0200 Subject: [PATCH 78/95] Added the lg_netcast platform to control a LG Smart TV running NetCast 3.0 or 4.0 (#2081) * Added the `lgtv` platform to control a LG Smart TV running NetCast 3.0 (LG Smart TV models released in 2012) and NetCast 4.0 (LG Smart TV models released in 2013). * Fixed multi-line docstring closing quotes * Rename lgtv to lg_netcast * Rename lgtv to lg_netcast * Extracted class to control the LG TV into a separate Python package 'pylgnetcast' and changed requirements accordingly. * regenerated requirements_all.txt with script * now uses pylgnetcast v0.2.0 which uses the requests package for the communication with the TV * fixed lint error: Catching too general exception Exception --- .coveragerc | 1 + .../components/media_player/lg_netcast.py | 210 ++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 214 insertions(+) create mode 100644 homeassistant/components/media_player/lg_netcast.py diff --git a/.coveragerc b/.coveragerc index 29dd544f911..734a5c7b78d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -117,6 +117,7 @@ omit = homeassistant/components/media_player/gpmdp.py homeassistant/components/media_player/itunes.py homeassistant/components/media_player/kodi.py + homeassistant/components/media_player/lg_netcast.py homeassistant/components/media_player/mpd.py homeassistant/components/media_player/onkyo.py homeassistant/components/media_player/panasonic_viera.py diff --git a/homeassistant/components/media_player/lg_netcast.py b/homeassistant/components/media_player/lg_netcast.py new file mode 100644 index 00000000000..fa215731d0d --- /dev/null +++ b/homeassistant/components/media_player/lg_netcast.py @@ -0,0 +1,210 @@ +""" +Support for LG TV running on NetCast 3 or 4. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.lg_netcast/ +""" +from datetime import timedelta +import logging + +from requests import RequestException +import voluptuous as vol +import homeassistant.helpers.config_validation as cv +from homeassistant.components.media_player import ( + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, + SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, + SUPPORT_SELECT_SOURCE, MEDIA_TYPE_CHANNEL, MediaPlayerDevice) +from homeassistant.const import ( + CONF_PLATFORM, CONF_HOST, CONF_NAME, CONF_ACCESS_TOKEN, + STATE_OFF, STATE_PLAYING, STATE_PAUSED, STATE_UNKNOWN) +import homeassistant.util as util + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['https://github.com/wokar/pylgnetcast/archive/' + 'v0.2.0.zip#pylgnetcast==0.2.0'] + +SUPPORT_LGTV = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ + SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | \ + SUPPORT_NEXT_TRACK | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE + +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) +MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) + +DEFAULT_NAME = 'LG TV Remote' + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): "lg_netcast", + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_ACCESS_TOKEN): vol.All(cv.string, vol.Length(max=6)), +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the LG TV platform.""" + from pylgnetcast import LgNetCastClient + client = LgNetCastClient(config[CONF_HOST], config[CONF_ACCESS_TOKEN]) + add_devices([LgTVDevice(client, config[CONF_NAME])]) + + +# pylint: disable=too-many-public-methods, abstract-method +# pylint: disable=too-many-instance-attributes +class LgTVDevice(MediaPlayerDevice): + """Representation of a LG TV.""" + + def __init__(self, client, name): + """Initialize the LG TV device.""" + self._client = client + self._name = name + self._muted = False + # Assume that the TV is in Play mode + self._playing = True + self._volume = 0 + self._channel_name = '' + self._program_name = '' + self._state = STATE_UNKNOWN + self._sources = {} + self._source_names = [] + + self.update() + + def send_command(self, command): + """Send remote control commands to the TV.""" + from pylgnetcast import LgNetCastError + try: + with self._client as client: + client.send_command(command) + except (LgNetCastError, RequestException): + self._state = STATE_OFF + + @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) + def update(self): + """Retrieve the latest data from the LG TV.""" + from pylgnetcast import LgNetCastError + try: + with self._client as client: + self._state = STATE_PLAYING + volume_info = client.query_data('volume_info') + if volume_info: + volume_info = volume_info[0] + self._volume = float(volume_info.find('level').text) + self._muted = volume_info.find('mute').text == 'true' + + channel_info = client.query_data('cur_channel') + if channel_info: + channel_info = channel_info[0] + self._channel_name = channel_info.find('chname').text + self._program_name = channel_info.find('progName').text + + channel_list = client.query_data('channel_list') + if channel_list: + channel_names = [str(c.find('chname').text) for + c in channel_list] + self._sources = dict(zip(channel_names, channel_list)) + # sort source names by the major channel number + source_tuples = [(k, self._sources[k].find('major').text) + for k in self._sources.keys()] + sorted_sources = sorted( + source_tuples, key=lambda channel: int(channel[1])) + self._source_names = [n for n, k in sorted_sources] + except (LgNetCastError, RequestException): + self._state = STATE_OFF + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._muted + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self._volume / 100.0 + + @property + def source(self): + """Return the current input source.""" + return self._channel_name + + @property + def source_list(self): + """List of available input sources.""" + return self._source_names + + @property + def media_content_type(self): + """Content type of current playing media.""" + return MEDIA_TYPE_CHANNEL + + @property + def media_channel(self): + """Channel currently playing.""" + return self._channel_name + + @property + def media_title(self): + """Title of current playing media.""" + return self._program_name + + @property + def supported_media_commands(self): + """Flag of media commands that are supported.""" + return SUPPORT_LGTV + + def turn_off(self): + """Turn off media player.""" + self.send_command(1) + + def volume_up(self): + """Volume up the media player.""" + self.send_command(24) + + def volume_down(self): + """Volume down media player.""" + self.send_command(25) + + def mute_volume(self, mute): + """Send mute command.""" + self.send_command(26) + + def select_source(self, source): + """Select input source.""" + self._client.change_channel(self._sources[source]) + + def media_play_pause(self): + """Simulate play pause media player.""" + if self._playing: + self.media_pause() + else: + self.media_play() + + def media_play(self): + """Send play command.""" + self._playing = True + self._state = STATE_PLAYING + self.send_command(33) + + def media_pause(self): + """Send media pause command to media player.""" + self._playing = False + self._state = STATE_PAUSED + self.send_command(34) + + def media_next_track(self): + """Send next track command.""" + self.send_command(36) + + def media_previous_track(self): + """Send the previous track command.""" + self.send_command(37) diff --git a/requirements_all.txt b/requirements_all.txt index e277b9268ec..8938a75951a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -142,6 +142,9 @@ https://github.com/theolind/pymysensors/archive/cc5d0b325e13c2b623fa934f69eea7cd # homeassistant.components.notify.googlevoice https://github.com/w1ll1am23/pygooglevoice-sms/archive/7c5ee9969b97a7992fc86a753fe9f20e3ffa3f7c.zip#pygooglevoice-sms==0.0.1 +# homeassistant.components.media_player.lg_netcast +https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0 + # homeassistant.components.influxdb influxdb==2.12.0 From 1eb3181c14110c379ac15ea06f483eeca38da3e5 Mon Sep 17 00:00:00 2001 From: John Arild Berentsen Date: Fri, 20 May 2016 08:28:53 +0200 Subject: [PATCH 79/95] Fix fitbit KeyError (#2077) * Fix fitbit KeyError * Set units compared to temperature_unit * Pass true or false for is_metric --- homeassistant/components/sensor/fitbit.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index 7d122f857d7..eb9e6fdc00d 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -10,7 +10,7 @@ import logging import datetime import time -from homeassistant.const import HTTP_OK +from homeassistant.const import HTTP_OK, TEMP_CELSIUS from homeassistant.util import Throttle from homeassistant.helpers.entity import Entity from homeassistant.loader import get_component @@ -236,7 +236,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev = [] for resource in config.get("monitored_resources", FITBIT_DEFAULT_RESOURCE_LIST): - dev.append(FitbitSensor(authd_client, config_path, resource)) + dev.append(FitbitSensor(authd_client, config_path, resource, + hass.config.temperature_unit == + TEMP_CELSIUS)) add_devices(dev) else: @@ -314,8 +316,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class FitbitSensor(Entity): """Implementation of a Fitbit sensor.""" - def __init__(self, client, config_path, resource_type): - """Initialize the Uber sensor.""" + def __init__(self, client, config_path, resource_type, is_metric): + """Initialize the Fitbit sensor.""" self.client = client self.config_path = config_path self.resource_type = resource_type @@ -328,7 +330,13 @@ class FitbitSensor(Entity): unit_type = FITBIT_RESOURCES_LIST[self.resource_type] if unit_type == "": split_resource = self.resource_type.split("/") - measurement_system = FITBIT_MEASUREMENTS[self.client.system] + try: + measurement_system = FITBIT_MEASUREMENTS[self.client.system] + except KeyError: + if is_metric: + measurement_system = FITBIT_MEASUREMENTS["metric"] + else: + measurement_system = FITBIT_MEASUREMENTS["en_US"] unit_type = measurement_system[split_resource[-1]] self._unit_of_measurement = unit_type self._state = 0 From a4409da700829bdae5c0e09b6e3600043afc8776 Mon Sep 17 00:00:00 2001 From: Alexander Fortin Date: Fri, 20 May 2016 08:30:19 +0200 Subject: [PATCH 80/95] Add add_uri_to_queue support to (sonos) media player (#1946) Sonos (SoCo) supports add_uri_to_queue capability, making it possible to stream media available via HTTP for example. This patch extends media_player component and sonos platform to support this feature --- .../components/media_player/__init__.py | 14 +++++++++-- homeassistant/components/media_player/cast.py | 2 +- homeassistant/components/media_player/demo.py | 2 +- .../components/media_player/itunes.py | 2 +- homeassistant/components/media_player/kodi.py | 2 +- homeassistant/components/media_player/mpd.py | 2 +- .../components/media_player/sonos.py | 24 ++++++++++++++----- .../components/media_player/universal.py | 2 +- 8 files changed, 36 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 072a145129b..c5e2eb00e57 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -63,6 +63,7 @@ ATTR_APP_NAME = 'app_name' ATTR_SUPPORTED_MEDIA_COMMANDS = 'supported_media_commands' ATTR_INPUT_SOURCE = 'source' ATTR_INPUT_SOURCE_LIST = 'source_list' +ATTR_MEDIA_ENQUEUE = 'enqueue' MEDIA_TYPE_MUSIC = 'music' MEDIA_TYPE_TVSHOW = 'tvshow' @@ -145,6 +146,7 @@ MEDIA_PLAYER_MEDIA_SEEK_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ MEDIA_PLAYER_PLAY_MEDIA_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ vol.Required(ATTR_MEDIA_CONTENT_TYPE): cv.string, vol.Required(ATTR_MEDIA_CONTENT_ID): cv.string, + ATTR_MEDIA_ENQUEUE: cv.boolean, }) MEDIA_PLAYER_SELECT_SOURCE_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ @@ -256,7 +258,7 @@ def media_seek(hass, position, entity_id=None): hass.services.call(DOMAIN, SERVICE_MEDIA_SEEK, data) -def play_media(hass, media_type, media_id, entity_id=None): +def play_media(hass, media_type, media_id, entity_id=None, enqueue=None): """Send the media player the command for playing media.""" data = {ATTR_MEDIA_CONTENT_TYPE: media_type, ATTR_MEDIA_CONTENT_ID: media_id} @@ -264,6 +266,9 @@ def play_media(hass, media_type, media_id, entity_id=None): if entity_id: data[ATTR_ENTITY_ID] = entity_id + if enqueue: + data[ATTR_MEDIA_ENQUEUE] = enqueue + hass.services.call(DOMAIN, SERVICE_PLAY_MEDIA, data) @@ -364,9 +369,14 @@ def setup(hass, config): """Play specified media_id on the media player.""" media_type = service.data.get(ATTR_MEDIA_CONTENT_TYPE) media_id = service.data.get(ATTR_MEDIA_CONTENT_ID) + enqueue = service.data.get(ATTR_MEDIA_ENQUEUE) + + kwargs = { + ATTR_MEDIA_ENQUEUE: enqueue, + } for player in component.extract_from_service(service): - player.play_media(media_type, media_id) + player.play_media(media_type, media_id, **kwargs) if player.should_poll: player.update_ha_state(True) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 022a2d2d762..6c05984d9a4 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -253,7 +253,7 @@ class CastDevice(MediaPlayerDevice): """Seek the media to a specific location.""" self.cast.media_controller.seek(position) - def play_media(self, media_type, media_id): + def play_media(self, media_type, media_id, **kwargs): """Play media from a URL.""" self.cast.media_controller.play_media(media_id, media_type) diff --git a/homeassistant/components/media_player/demo.py b/homeassistant/components/media_player/demo.py index e4015cd5b71..ddc5b368d78 100644 --- a/homeassistant/components/media_player/demo.py +++ b/homeassistant/components/media_player/demo.py @@ -152,7 +152,7 @@ class DemoYoutubePlayer(AbstractDemoPlayer): """Flag of media commands that are supported.""" return YOUTUBE_PLAYER_SUPPORT - def play_media(self, media_type, media_id): + def play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" self.youtube_id = media_id self.update_ha_state() diff --git a/homeassistant/components/media_player/itunes.py b/homeassistant/components/media_player/itunes.py index 9418d1c5703..60f12456812 100644 --- a/homeassistant/components/media_player/itunes.py +++ b/homeassistant/components/media_player/itunes.py @@ -320,7 +320,7 @@ class ItunesDevice(MediaPlayerDevice): response = self.client.previous() self.update_state(response) - def play_media(self, media_type, media_id): + def play_media(self, media_type, media_id, **kwargs): """Send the play_media command to the media player.""" if media_type == MEDIA_TYPE_PLAYLIST: response = self.client.play_playlist(media_id) diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index e1c4bd79c8f..4dc306d03e7 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -278,6 +278,6 @@ class KodiDevice(MediaPlayerDevice): self.update_ha_state() - def play_media(self, media_type, media_id): + def play_media(self, media_type, media_id, **kwargs): """Send the play_media command to the media player.""" self._server.Player.Open({media_type: media_id}, {}) diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index fefdab68685..c04184d6bda 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -212,7 +212,7 @@ class MpdDevice(MediaPlayerDevice): """Service to send the MPD the command for previous track.""" self.client.previous() - def play_media(self, media_type, media_id): + def play_media(self, media_type, media_id, **kwargs): """Send the media player the command for playing a playlist.""" _LOGGER.info(str.format("Playing playlist: {0}", media_id)) if media_type == MEDIA_TYPE_PLAYLIST: diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 8f4bebdc19b..7367682c253 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -9,10 +9,9 @@ import logging import socket from homeassistant.components.media_player import ( - MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, + ATTR_MEDIA_ENQUEUE, MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, - SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, - MediaPlayerDevice) + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice) from homeassistant.const import ( STATE_IDLE, STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN, STATE_OFF) @@ -268,9 +267,22 @@ class SonosDevice(MediaPlayerDevice): self._player.play() @only_if_coordinator - def play_media(self, media_type, media_id): - """Send the play_media command to the media player.""" - self._player.play_uri(media_id) + def play_media(self, media_type, media_id, **kwargs): + """ + Send the play_media command to the media player. + + If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue. + """ + if kwargs.get(ATTR_MEDIA_ENQUEUE): + from soco.exceptions import SoCoUPnPException + try: + self._player.add_uri_to_queue(media_id) + except SoCoUPnPException: + _LOGGER.error('Error parsing media uri "%s", ' + "please check it's a valid media resource " + 'supported by Sonos', media_id) + else: + self._player.play_uri(media_id) @property def available(self): diff --git a/homeassistant/components/media_player/universal.py b/homeassistant/components/media_player/universal.py index 3498a9e5580..f5fa8cc486c 100644 --- a/homeassistant/components/media_player/universal.py +++ b/homeassistant/components/media_player/universal.py @@ -402,7 +402,7 @@ class UniversalMediaPlayer(MediaPlayerDevice): data = {ATTR_MEDIA_SEEK_POSITION: position} self._call_service(SERVICE_MEDIA_SEEK, data) - def play_media(self, media_type, media_id): + def play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" data = {ATTR_MEDIA_CONTENT_TYPE: media_type, ATTR_MEDIA_CONTENT_ID: media_id} From 6b724f7da43ce38a6c67c36db4b23114ad3ad160 Mon Sep 17 00:00:00 2001 From: Jan Harkes Date: Fri, 20 May 2016 10:03:08 -0400 Subject: [PATCH 81/95] Not sure why, but this fixed a bad filedescriptor error. (#2116) --- homeassistant/__main__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 467303317d6..0cc99cb03f2 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -156,11 +156,13 @@ def daemonize(): sys.exit(0) # redirect standard file descriptors to devnull + infd = open(os.devnull, 'r') + outfd = open(os.devnull, 'a+') sys.stdout.flush() sys.stderr.flush() - os.dup2(open(os.devnull, 'r').fileno(), sys.stdin.fileno()) - os.dup2(open(os.devnull, 'a+').fileno(), sys.stdout.fileno()) - os.dup2(open(os.devnull, 'a+').fileno(), sys.stderr.fileno()) + os.dup2(infd.fileno(), sys.stdin.fileno()) + os.dup2(outfd.fileno(), sys.stdout.fileno()) + os.dup2(outfd.fileno(), sys.stderr.fileno()) def check_pid(pid_file): From 7eeb623b8f860a09dc89bb9394da2d352de24720 Mon Sep 17 00:00:00 2001 From: Alexander Fortin Date: Fri, 20 May 2016 18:54:15 +0200 Subject: [PATCH 82/95] Add media_player.sonos_group_players service (#2087) Sonos platform supports a `party mode` feature that groups all available players into a single group, of which the calling player will be the coordinator. --- .../components/media_player/services.yaml | 8 ++++ .../components/media_player/sonos.py | 39 +++++++++++++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index ebf882825bb..5f1f35f065f 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -145,3 +145,11 @@ select_source: source: description: Name of the source to switch to. Platform dependent. example: 'video1' + +sonos_group_players: + description: Send Sonos media player the command for grouping all players into one (party mode). + + fields: + entity_id: + description: Name(s) of entites that will coordinate the grouping. Platform dependent. + example: 'media_player.living_room_sonos' diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 7367682c253..01e3f8d9efc 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -7,13 +7,15 @@ https://home-assistant.io/components/media_player.sonos/ import datetime import logging import socket +from os import path from homeassistant.components.media_player import ( - ATTR_MEDIA_ENQUEUE, MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, - SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, + ATTR_MEDIA_ENQUEUE, DOMAIN, MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice) from homeassistant.const import ( STATE_IDLE, STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN, STATE_OFF) +from homeassistant.config import load_yaml_config_file REQUIREMENTS = ['SoCo==0.11.1'] @@ -31,6 +33,8 @@ SUPPORT_SONOS = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_PLAY_MEDIA |\ SUPPORT_SEEK +SERVICE_GROUP_PLAYERS = 'sonos_group_players' + # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): @@ -62,9 +66,31 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.warning('No Sonos speakers found.') return False - add_devices(SonosDevice(hass, p) for p in players) + devices = [SonosDevice(hass, p) for p in players] + add_devices(devices) _LOGGER.info('Added %s Sonos speakers', len(players)) + def group_players_service(service): + """Group media players, use player as coordinator.""" + entity_id = service.data.get('entity_id') + + if entity_id: + _devices = [device for device in devices + if device.entity_id == entity_id] + else: + _devices = devices + + for device in _devices: + device.group_players() + device.update_ha_state(True) + + descriptions = load_yaml_config_file( + path.join(path.dirname(__file__), 'services.yaml')) + + hass.services.register(DOMAIN, SERVICE_GROUP_PLAYERS, + group_players_service, + descriptions.get(SERVICE_GROUP_PLAYERS)) + return True @@ -113,7 +139,7 @@ class SonosDevice(MediaPlayerDevice): @property def should_poll(self): - """No polling needed.""" + """Polling needed.""" return True def update_sonos(self, now): @@ -284,6 +310,11 @@ class SonosDevice(MediaPlayerDevice): else: self._player.play_uri(media_id) + @only_if_coordinator + def group_players(self): + """Group all players under this coordinator.""" + self._player.partymode() + @property def available(self): """Return True if player is reachable, False otherwise.""" From 53d51a467dc6db2d28db5082e8dbf3b7deb49ebd Mon Sep 17 00:00:00 2001 From: Jan Harkes Date: Fri, 20 May 2016 14:45:16 -0400 Subject: [PATCH 83/95] Single process restart fixes (#2118) * Ignore permission errors on setpgid. When launched in a docker container we got a permission denied error from setpgid. * Don't fail if we find our own pidfile. When we restart using exec we are running a new instance of home-assistant with the same process id so we shouldn't be surprised to find an existing pidfile in that case. * Allow restart to work when started as python -m homeassistant. When we are started with `python -m homeassistant`, the restart command line becomes `python /path/to/hass/homeassistant/__main__.py`. But in that case the python path includes `/path/to/hass/homeassistant` instead of `/path/to/hass` and we fail on the first import. Fix this by recognizing `/__main__.py` as part of the first argument and injecting the proper path as PYTHONPATH environment before we start the new home-assistant instance. --- homeassistant/__main__.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 0cc99cb03f2..9494c2a02d1 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -174,6 +174,10 @@ def check_pid(pid_file): # PID File does not exist return + # If we just restarted, we just found our own pidfile. + if pid == os.getpid(): + return + try: os.kill(pid, 0) except OSError: @@ -253,6 +257,9 @@ def closefds_osx(min_fd, max_fd): def cmdline(): """Collect path and arguments to re-execute the current hass instance.""" + if sys.argv[0].endswith('/__main__.py'): + modulepath = os.path.dirname(sys.argv[0]) + os.environ['PYTHONPATH'] = os.path.dirname(modulepath) return [sys.executable] + [arg for arg in sys.argv if arg != '--daemon'] @@ -395,7 +402,10 @@ def main(): # Create new process group if we can if hasattr(os, 'setpgid'): - os.setpgid(0, 0) + try: + os.setpgid(0, 0) + except PermissionError: + pass exit_code = setup_and_run_hass(config_dir, args) if exit_code == RESTART_EXIT_CODE and not args.runner: From 7f0b8c5e7098f6d3bfcdfd054891ccf844d0a73e Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 21 May 2016 16:59:52 +0200 Subject: [PATCH 84/95] Docs (#2124) * Add link to docs * Update link --- homeassistant/components/light/qwikswitch.py | 7 ++++--- homeassistant/components/media_player/gpmdp.py | 4 ++-- homeassistant/components/qwikswitch.py | 15 +++++++-------- homeassistant/components/switch/qwikswitch.py | 7 ++++--- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/light/qwikswitch.py b/homeassistant/components/light/qwikswitch.py index b0aba4f34dd..e0681e68b87 100644 --- a/homeassistant/components/light/qwikswitch.py +++ b/homeassistant/components/light/qwikswitch.py @@ -1,7 +1,8 @@ """ -Support for Qwikswitch Relays and Dimmers as HA Lights. +Support for Qwikswitch Relays and Dimmers. -See the main component for more info +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.qwikswitch/ """ import logging import homeassistant.components.qwikswitch as qwikswitch @@ -18,7 +19,7 @@ class QSLight(qwikswitch.QSToggleEntity, Light): # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """Store add_devices for the 'light' components.""" + """Store add_devices for the light components.""" if discovery_info is None or 'qsusb_id' not in discovery_info: logging.getLogger(__name__).error( 'Configure main Qwikswitch component') diff --git a/homeassistant/components/media_player/gpmdp.py b/homeassistant/components/media_player/gpmdp.py index e478cc6e2a5..8259d043cf3 100644 --- a/homeassistant/components/media_player/gpmdp.py +++ b/homeassistant/components/media_player/gpmdp.py @@ -2,7 +2,7 @@ Support for Google Play Music Desktop Player. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/media_player.gpm_dp/ +https://home-assistant.io/components/media_player.gpmdp/ """ import logging import json @@ -39,7 +39,7 @@ class GPMDP(MediaPlayerDevice): # pylint: disable=too-many-public-methods, abstract-method # pylint: disable=too-many-instance-attributes def __init__(self, name, address, create_connection): - """Initialize.""" + """Initialize the media player.""" self._connection = create_connection self._address = address self._name = name diff --git a/homeassistant/components/qwikswitch.py b/homeassistant/components/qwikswitch.py index 19b0f6bfe28..dd0e7d41b50 100644 --- a/homeassistant/components/qwikswitch.py +++ b/homeassistant/components/qwikswitch.py @@ -1,10 +1,9 @@ """ -Support for Qwikswitch lights and switches. +Support for Qwikswitch devices. -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/qwikswitch +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/qwikswitch/ """ - import logging from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.components.light import ATTR_BRIGHTNESS @@ -51,7 +50,7 @@ class QSToggleEntity(object): # pylint: disable=no-self-use @property def should_poll(self): - """State Polling needed.""" + """No polling needed.""" return False @property @@ -61,11 +60,11 @@ class QSToggleEntity(object): @property def is_on(self): - """Check if On (non-zero).""" + """Check if device is on (non-zero).""" return self._value > 0 def update_value(self, value): - """Decode QSUSB value & update HA state.""" + """Decode the QSUSB value and update the Home assistant state.""" if value != self._value: self._value = value # pylint: disable=no-member @@ -129,7 +128,7 @@ def setup(hass, config): {'qsusb_id': id(qsusb)}, config) def qs_callback(item): - """Typically a btn press or update signal.""" + """Typically a button press or update signal.""" # If button pressed, fire a hass event if item.get('cmd', '') in cmd_buttons: hass.bus.fire('qwikswitch.button.' + item.get('id', '@no_id')) diff --git a/homeassistant/components/switch/qwikswitch.py b/homeassistant/components/switch/qwikswitch.py index 86d698b2a70..1041aa020e6 100644 --- a/homeassistant/components/switch/qwikswitch.py +++ b/homeassistant/components/switch/qwikswitch.py @@ -1,7 +1,8 @@ """ -Support for Qwikswitch Relays as HA Switches. +Support for Qwikswitch relays. -See the main component for more info +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.qwikswitch/ """ import logging import homeassistant.components.qwikswitch as qwikswitch @@ -18,7 +19,7 @@ class QSSwitch(qwikswitch.QSToggleEntity, SwitchDevice): # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """Store add_devices for the 'switch' components.""" + """Store add_devices for the switch components.""" if discovery_info is None or 'qsusb_id' not in discovery_info: logging.getLogger(__name__).error( 'Configure main Qwikswitch component') From eaebe834297b32b104fa602f2b2031a95964ca8f Mon Sep 17 00:00:00 2001 From: Felix Date: Sat, 21 May 2016 18:58:59 +0200 Subject: [PATCH 85/95] Moldindicator Sensor (#1575) * Adds MoldIndicator sensor platform This sensor may be used to get an indication for possible mold growth in rooms. It calculates the humidity at a pre-calibrated indoor point (wall, window). * Automatic conversion to Fahrenheit for mold_indicator * Minor change to critical temp label * Fixed docstrings and styles * Minor changes to MoldIndicator implementation * Added first (non-working) implementation for mold_indicator test * Small style changes * Minor improvements to mold_indicator * Completed unit test for mold indicator * Fix to moldindicator initialization * Adds missing period. Now that really matters.. * Adds test for sensor_changed function --- .../components/sensor/mold_indicator.py | 268 ++++++++++++++++++ tests/components/sensor/test_moldindicator.py | 131 +++++++++ 2 files changed, 399 insertions(+) create mode 100644 homeassistant/components/sensor/mold_indicator.py create mode 100644 tests/components/sensor/test_moldindicator.py diff --git a/homeassistant/components/sensor/mold_indicator.py b/homeassistant/components/sensor/mold_indicator.py new file mode 100644 index 00000000000..8f45647f5a2 --- /dev/null +++ b/homeassistant/components/sensor/mold_indicator.py @@ -0,0 +1,268 @@ +""" +Calculates mold growth indication from temperature and humidity. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.mold_indicator/ +""" +import logging +import math + +import homeassistant.util as util +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import track_state_change +from homeassistant.const import (ATTR_UNIT_OF_MEASUREMENT, + TEMP_CELSIUS, TEMP_FAHRENHEIT) + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Mold Indicator" +CONF_INDOOR_TEMP = "indoor_temp_sensor" +CONF_OUTDOOR_TEMP = "outdoor_temp_sensor" +CONF_INDOOR_HUMIDITY = "indoor_humidity_sensor" +CONF_CALIBRATION_FACTOR = "calibration_factor" + +MAGNUS_K2 = 17.62 +MAGNUS_K3 = 243.12 + +ATTR_DEWPOINT = "Dewpoint" +ATTR_CRITICAL_TEMP = "Est. Crit. Temp" + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices_callback, discovery_info=None): + """Setup MoldIndicator sensor.""" + name = config.get('name', DEFAULT_NAME) + indoor_temp_sensor = config.get(CONF_INDOOR_TEMP) + outdoor_temp_sensor = config.get(CONF_OUTDOOR_TEMP) + indoor_humidity_sensor = config.get(CONF_INDOOR_HUMIDITY) + calib_factor = util.convert(config.get(CONF_CALIBRATION_FACTOR), + float, None) + + if None in (indoor_temp_sensor, + outdoor_temp_sensor, indoor_humidity_sensor): + _LOGGER.error('Missing required key %s, %s or %s', + CONF_INDOOR_TEMP, CONF_OUTDOOR_TEMP, + CONF_INDOOR_HUMIDITY) + return False + + add_devices_callback([MoldIndicator( + hass, name, indoor_temp_sensor, + outdoor_temp_sensor, indoor_humidity_sensor, + calib_factor)]) + + +# pylint: disable=too-many-instance-attributes +class MoldIndicator(Entity): + """Represents a MoldIndication sensor.""" + + # pylint: disable=too-many-arguments + def __init__(self, hass, name, indoor_temp_sensor, outdoor_temp_sensor, + indoor_humidity_sensor, calib_factor): + """Initialize the sensor.""" + self._state = None + self._name = name + self._indoor_temp_sensor = indoor_temp_sensor + self._indoor_humidity_sensor = indoor_humidity_sensor + self._outdoor_temp_sensor = outdoor_temp_sensor + self._calib_factor = calib_factor + self._is_metric = (hass.config.temperature_unit == TEMP_CELSIUS) + + self._dewpoint = None + self._indoor_temp = None + self._outdoor_temp = None + self._indoor_hum = None + self._crit_temp = None + + track_state_change(hass, indoor_temp_sensor, self._sensor_changed) + track_state_change(hass, outdoor_temp_sensor, self._sensor_changed) + track_state_change(hass, indoor_humidity_sensor, self._sensor_changed) + + # Read initial state + indoor_temp = hass.states.get(indoor_temp_sensor) + outdoor_temp = hass.states.get(outdoor_temp_sensor) + indoor_hum = hass.states.get(indoor_humidity_sensor) + + if indoor_temp: + self._indoor_temp = \ + MoldIndicator._update_temp_sensor(indoor_temp) + + if outdoor_temp: + self._outdoor_temp = \ + MoldIndicator._update_temp_sensor(outdoor_temp) + + if indoor_hum: + self._indoor_hum = \ + MoldIndicator._update_hum_sensor(indoor_hum) + + self.update() + + @staticmethod + def _update_temp_sensor(state): + """Parse temperature sensor value.""" + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + temp = util.convert(state.state, float) + + if temp is None: + _LOGGER.error('Unable to parse sensor temperature: %s', + state.state) + return None + + # convert to celsius if necessary + if unit == TEMP_FAHRENHEIT: + return util.temperature.fahrenheit_to_celcius(temp) + elif unit == TEMP_CELSIUS: + return temp + else: + _LOGGER.error("Temp sensor has unsupported unit: %s" + " (allowed: %s, %s)", + unit, TEMP_CELSIUS, TEMP_FAHRENHEIT) + + return None + + @staticmethod + def _update_hum_sensor(state): + """Parse humidity sensor value.""" + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + hum = util.convert(state.state, float) + + if hum is None: + _LOGGER.error('Unable to parse sensor humidity: %s', + state.state) + return None + + # check unit + if unit != "%": + _LOGGER.error( + "Humidity sensor has unsupported unit: %s %s", + unit, + " (allowed: %)") + + # check range + if hum > 100 or hum < 0: + _LOGGER.error( + "Humidity sensor out of range: %s %s", + hum, + " (allowed: 0-100%)") + + return hum + + def update(self): + """Calculate latest state.""" + # check all sensors + if None in (self._indoor_temp, self._indoor_hum, self._outdoor_temp): + return + + # re-calculate dewpoint and mold indicator + self._calc_dewpoint() + self._calc_moldindicator() + + def _sensor_changed(self, entity_id, old_state, new_state): + """Called when sensor values change.""" + if new_state is None: + return + + if entity_id == self._indoor_temp_sensor: + # update the indoor temp sensor + self._indoor_temp = MoldIndicator._update_temp_sensor(new_state) + + elif entity_id == self._outdoor_temp_sensor: + # update outdoor temp sensor + self._outdoor_temp = MoldIndicator._update_temp_sensor(new_state) + + elif entity_id == self._indoor_humidity_sensor: + # update humidity + self._indoor_hum = MoldIndicator._update_hum_sensor(new_state) + + self.update() + self.update_ha_state() + + def _calc_dewpoint(self): + """Calculate the dewpoint for the indoor air.""" + # use magnus approximation to calculate the dew point + alpha = MAGNUS_K2 * self._indoor_temp / (MAGNUS_K3 + self._indoor_temp) + beta = MAGNUS_K2 * MAGNUS_K3 / (MAGNUS_K3 + self._indoor_temp) + + if self._indoor_hum == 0: + self._dewpoint = -50 # not defined, assume very low value + else: + self._dewpoint = \ + MAGNUS_K3 * (alpha + math.log(self._indoor_hum / 100.0)) / \ + (beta - math.log(self._indoor_hum / 100.0)) + _LOGGER.debug("Dewpoint: %f " + TEMP_CELSIUS, self._dewpoint) + + def _calc_moldindicator(self): + """Calculate the humidity at the (cold) calibration point.""" + if None in (self._dewpoint, self._calib_factor) or \ + self._calib_factor == 0: + + _LOGGER.debug("Invalid inputs - dewpoint: %s," + " calibration-factor: %s", + self._dewpoint, self._calib_factor) + self._state = None + return + + # first calculate the approximate temperature at the calibration point + self._crit_temp = \ + self._outdoor_temp + (self._indoor_temp - self._outdoor_temp) / \ + self._calib_factor + + _LOGGER.debug( + "Estimated Critical Temperature: %f " + + TEMP_CELSIUS, self._crit_temp) + + # Then calculate the humidity at this point + alpha = MAGNUS_K2 * self._crit_temp / (MAGNUS_K3 + self._crit_temp) + beta = MAGNUS_K2 * MAGNUS_K3 / (MAGNUS_K3 + self._crit_temp) + + crit_humidity = \ + math.exp( + (self._dewpoint * beta - MAGNUS_K3 * alpha) / + (self._dewpoint + MAGNUS_K3)) * 100.0 + + # check bounds and format + if crit_humidity > 100: + self._state = '100' + elif crit_humidity < 0: + self._state = '0' + else: + self._state = '{0:d}'.format(int(crit_humidity)) + + _LOGGER.debug('Mold indicator humidity: %s ', self._state) + + @property + def should_poll(self): + """Polling needed.""" + return False + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return "%" + + @property + def state(self): + """Return the state of the entity.""" + return self._state + + @property + def state_attributes(self): + """Return the state attributes.""" + if self._is_metric: + return { + ATTR_DEWPOINT: self._dewpoint, + ATTR_CRITICAL_TEMP: self._crit_temp, + } + else: + return { + ATTR_DEWPOINT: + util.temperature.celcius_to_fahrenheit( + self._dewpoint), + ATTR_CRITICAL_TEMP: + util.temperature.celcius_to_fahrenheit( + self._crit_temp), + } diff --git a/tests/components/sensor/test_moldindicator.py b/tests/components/sensor/test_moldindicator.py new file mode 100644 index 00000000000..878e6334339 --- /dev/null +++ b/tests/components/sensor/test_moldindicator.py @@ -0,0 +1,131 @@ +"""The tests for the MoldIndicator sensor""" +import unittest + +import homeassistant.components.sensor as sensor +from homeassistant.components.sensor.mold_indicator import (ATTR_DEWPOINT, + ATTR_CRITICAL_TEMP) +from homeassistant.const import (ATTR_UNIT_OF_MEASUREMENT, + TEMP_CELSIUS) + +from tests.common import get_test_home_assistant + + +class TestSensorMoldIndicator(unittest.TestCase): + """Test the MoldIndicator sensor.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.states.set('test.indoortemp', '20', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.states.set('test.outdoortemp', '10', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.states.set('test.indoorhumidity', '50', + {ATTR_UNIT_OF_MEASUREMENT: '%'}) + self.hass.pool.block_till_done() + + def tearDown(self): + """Stop down everything that was started.""" + self.hass.stop() + + def test_setup(self): + """Test the mold indicator sensor setup""" + self.assertTrue(sensor.setup(self.hass, { + 'sensor': { + 'platform': 'mold_indicator', + 'indoor_temp_sensor': 'test.indoortemp', + 'outdoor_temp_sensor': 'test.outdoortemp', + 'indoor_humidity_sensor': 'test.indoorhumidity', + 'calibration_factor': '2.0' + } + })) + + moldind = self.hass.states.get('sensor.mold_indicator') + assert moldind + assert '%' == moldind.attributes.get('unit_of_measurement') + + def test_invalidhum(self): + """Test invalid sensor values""" + self.hass.states.set('test.indoortemp', '10', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.states.set('test.outdoortemp', '10', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.states.set('test.indoorhumidity', '0', + {ATTR_UNIT_OF_MEASUREMENT: '%'}) + + self.assertTrue(sensor.setup(self.hass, { + 'sensor': { + 'platform': 'mold_indicator', + 'indoor_temp_sensor': 'test.indoortemp', + 'outdoor_temp_sensor': 'test.outdoortemp', + 'indoor_humidity_sensor': 'test.indoorhumidity', + 'calibration_factor': '2.0' + } + })) + moldind = self.hass.states.get('sensor.mold_indicator') + assert moldind + + # assert state + assert moldind.state == '0' + + def test_calculation(self): + """Test the mold indicator internal calculations""" + self.assertTrue(sensor.setup(self.hass, { + 'sensor': { + 'platform': 'mold_indicator', + 'indoor_temp_sensor': 'test.indoortemp', + 'outdoor_temp_sensor': 'test.outdoortemp', + 'indoor_humidity_sensor': 'test.indoorhumidity', + 'calibration_factor': '2.0' + } + })) + + moldind = self.hass.states.get('sensor.mold_indicator') + assert moldind + + # assert dewpoint + dewpoint = moldind.attributes.get(ATTR_DEWPOINT) + assert dewpoint + assert dewpoint > 9.25 + assert dewpoint < 9.26 + + # assert temperature estimation + esttemp = moldind.attributes.get(ATTR_CRITICAL_TEMP) + assert esttemp + assert esttemp > 14.9 + assert esttemp < 15.1 + + # assert mold indicator value + state = moldind.state + assert state + assert state == '68' + + def test_sensor_changed(self): + """Test the sensor_changed function""" + self.assertTrue(sensor.setup(self.hass, { + 'sensor': { + 'platform': 'mold_indicator', + 'indoor_temp_sensor': 'test.indoortemp', + 'outdoor_temp_sensor': 'test.outdoortemp', + 'indoor_humidity_sensor': 'test.indoorhumidity', + 'calibration_factor': '2.0' + } + })) + + # Change indoor temp + self.hass.states.set('test.indoortemp', '30', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.pool.block_till_done() + assert self.hass.states.get('sensor.mold_indicator').state == '90' + + # Change outdoor temp + self.hass.states.set('test.outdoortemp', '25', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.pool.block_till_done() + assert self.hass.states.get('sensor.mold_indicator').state == '57' + + # Change humidity + self.hass.states.set('test.indoorhumidity', '20', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.pool.block_till_done() + assert self.hass.states.get('sensor.mold_indicator').state == '23' From c9b5ea97da1412bc440865f8fe4d0db53f20b15e Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 21 May 2016 10:03:24 -0700 Subject: [PATCH 86/95] Fix docstring issues with MoldIndicator --- tests/components/sensor/test_moldindicator.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/components/sensor/test_moldindicator.py b/tests/components/sensor/test_moldindicator.py index 878e6334339..da2798e2a4d 100644 --- a/tests/components/sensor/test_moldindicator.py +++ b/tests/components/sensor/test_moldindicator.py @@ -1,4 +1,4 @@ -"""The tests for the MoldIndicator sensor""" +"""The tests for the MoldIndicator sensor.""" import unittest import homeassistant.components.sensor as sensor @@ -29,7 +29,7 @@ class TestSensorMoldIndicator(unittest.TestCase): self.hass.stop() def test_setup(self): - """Test the mold indicator sensor setup""" + """Test the mold indicator sensor setup.""" self.assertTrue(sensor.setup(self.hass, { 'sensor': { 'platform': 'mold_indicator', @@ -45,7 +45,7 @@ class TestSensorMoldIndicator(unittest.TestCase): assert '%' == moldind.attributes.get('unit_of_measurement') def test_invalidhum(self): - """Test invalid sensor values""" + """Test invalid sensor values.""" self.hass.states.set('test.indoortemp', '10', {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) self.hass.states.set('test.outdoortemp', '10', @@ -69,7 +69,7 @@ class TestSensorMoldIndicator(unittest.TestCase): assert moldind.state == '0' def test_calculation(self): - """Test the mold indicator internal calculations""" + """Test the mold indicator internal calculations.""" self.assertTrue(sensor.setup(self.hass, { 'sensor': { 'platform': 'mold_indicator', @@ -101,7 +101,7 @@ class TestSensorMoldIndicator(unittest.TestCase): assert state == '68' def test_sensor_changed(self): - """Test the sensor_changed function""" + """Test the sensor_changed function.""" self.assertTrue(sensor.setup(self.hass, { 'sensor': { 'platform': 'mold_indicator', From dee6355cc59d7d84f8f7f3a4f1a8dc9b13f9d99e Mon Sep 17 00:00:00 2001 From: Dan Date: Sat, 21 May 2016 13:04:08 -0400 Subject: [PATCH 87/95] Onkyo updates (#2084) * use sane defaults for openzwave config Use sane default if libopenzwave is installed. In most cases this will mean that the zwave config path will not need to e manually specified. * Resuming work on onkyo component * Source control added to UI for onkyo receiver Source will now display in the UI. Source mappings can be defined in the config, and a rudimentary mapping is defined by default as a fallback. When the onkyo source is updated, it will resolve to a defined name if possible. This may break existing automations. * fix lint errors * Updated Onkyo receiver Now takes an optional ip/name in additional to atempting to discover deivces. Source select will now take a sources mapping in the config. It will provide default values if no source mapping is provided. example: - platform: onkyo host: 10.0.0.2 name: receiver sources: HTPC: 'pc' Chromecast: 'aux1' Bluray: 'bd' Wii U: 'game' * fix pylint error * Use HA's error log instead of stack trace * Flipped source mappings, code cleanup --- .../components/media_player/onkyo.py | 58 ++++++++++++++++--- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/media_player/onkyo.py b/homeassistant/components/media_player/onkyo.py index 7810ac63444..d1b5282fa6e 100644 --- a/homeassistant/components/media_player/onkyo.py +++ b/homeassistant/components/media_player/onkyo.py @@ -9,7 +9,7 @@ import logging from homeassistant.components.media_player import ( SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_SELECT_SOURCE, MediaPlayerDevice) -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON, CONF_HOST, CONF_NAME REQUIREMENTS = ['https://github.com/danieljkemp/onkyo-eiscp/archive/' 'python3.zip#onkyo-eiscp==0.9.2'] @@ -17,29 +17,59 @@ _LOGGER = logging.getLogger(__name__) SUPPORT_ONKYO = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE +KNOWN_HOSTS = [] +DEFAULT_SOURCES = {"tv": "TV", "bd": "Bluray", "game": "Game", "aux1": "Aux1", + "video1": "Video 1", "video2": "Video 2", + "video3": "Video 3", "video4": "Video 4", + "video5": "Video 5", "video6": "Video 6", + "video7": "Video 7"} +CONFIG_SOURCE_LIST = "sources" def setup_platform(hass, config, add_devices, discovery_info=None): """Setup the Onkyo platform.""" + import eiscp from eiscp import eISCP - add_devices(OnkyoDevice(receiver) - for receiver in eISCP.discover()) + hosts = [] + + if CONF_HOST in config and config[CONF_HOST] not in KNOWN_HOSTS: + try: + hosts.append(OnkyoDevice(eiscp.eISCP(config[CONF_HOST]), + config.get(CONFIG_SOURCE_LIST, + DEFAULT_SOURCES), + name=config[CONF_NAME])) + KNOWN_HOSTS.append(config[CONF_HOST]) + except OSError: + _LOGGER.error('Unable to connect to receiver at %s.', + config[CONF_HOST]) + else: + for receiver in eISCP.discover(): + if receiver.host not in KNOWN_HOSTS: + hosts.append(OnkyoDevice(receiver, + config.get(CONFIG_SOURCE_LIST, + DEFAULT_SOURCES))) + KNOWN_HOSTS.append(receiver.host) + add_devices(hosts) +# pylint: disable=too-many-instance-attributes class OnkyoDevice(MediaPlayerDevice): """Representation of a Onkyo device.""" # pylint: disable=too-many-public-methods, abstract-method - def __init__(self, receiver): + def __init__(self, receiver, sources, name=None): """Initialize the Onkyo Receiver.""" self._receiver = receiver self._muted = False self._volume = 0 self._pwstate = STATE_OFF - self.update() - self._name = '{}_{}'.format( + self._name = name or '{}_{}'.format( receiver.info['model_name'], receiver.info['identifier']) self._current_source = None + self._source_list = list(sources.values()) + self._source_mapping = sources + self._reverse_mapping = {value: key for key, value in sources.items()} + self.update() def update(self): """Get the latest details from the device.""" @@ -52,8 +82,13 @@ class OnkyoDevice(MediaPlayerDevice): volume_raw = self._receiver.command('volume query') mute_raw = self._receiver.command('audio-muting query') current_source_raw = self._receiver.command('input-selector query') - self._current_source = '_'.join('_'.join( - [i for i in current_source_raw[1]])) + for source in current_source_raw[1]: + if source in self._source_mapping: + self._current_source = self._source_mapping[source] + break + else: + self._current_source = '_'.join( + [i for i in current_source_raw[1]]) self._muted = bool(mute_raw[1] == 'on') self._volume = int(volume_raw[1], 16)/80.0 @@ -87,6 +122,11 @@ class OnkyoDevice(MediaPlayerDevice): """"Return the current input source of the device.""" return self._current_source + @property + def source_list(self): + """List of available input sources.""" + return self._source_list + def turn_off(self): """Turn off media player.""" self._receiver.command('system-power standby') @@ -108,4 +148,6 @@ class OnkyoDevice(MediaPlayerDevice): def select_source(self, source): """Set the input source.""" + if source in self._source_list: + source = self._reverse_mapping[source] self._receiver.command('input-selector {}'.format(source)) From 31c2d45a7a4f1f2cbdf4eb8ddf36c83faf9a8a9f Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Sat, 21 May 2016 20:12:42 +0300 Subject: [PATCH 88/95] Updated pyqwikswitch & QS<->HA UI behaviour (#2123) * Updated pyqwikswitch & constants * Disable too-many-locals --- homeassistant/components/qwikswitch.py | 48 ++++++++++++++------------ requirements_all.txt | 2 +- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/qwikswitch.py b/homeassistant/components/qwikswitch.py index dd0e7d41b50..1bb77ec1435 100644 --- a/homeassistant/components/qwikswitch.py +++ b/homeassistant/components/qwikswitch.py @@ -9,8 +9,8 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.components.discovery import load_platform -REQUIREMENTS = ['https://github.com/kellerza/pyqwikswitch/archive/v0.1.zip' - '#pyqwikswitch==0.1'] +REQUIREMENTS = ['https://github.com/kellerza/pyqwikswitch/archive/v0.3.zip' + '#pyqwikswitch==0.3'] DEPENDENCIES = [] _LOGGER = logging.getLogger(__name__) @@ -36,11 +36,12 @@ class QSToggleEntity(object): def __init__(self, qsitem, qsusb): """Initialize the ToggleEntity.""" - self._id = qsitem['id'] - self._name = qsitem['name'] + from pyqwikswitch import (QS_ID, QS_NAME, QSType, PQS_VALUE, PQS_TYPE) + self._id = qsitem[QS_ID] + self._name = qsitem[QS_NAME] + self._value = qsitem[PQS_VALUE] self._qsusb = qsusb - self._value = qsitem.get('value', 0) - self._dim = qsitem['type'] == 'dim' + self._dim = qsitem[PQS_TYPE] == QSType.dimmer @property def brightness(self): @@ -73,23 +74,24 @@ class QSToggleEntity(object): def turn_on(self, **kwargs): """Turn the device on.""" + newvalue = 255 if ATTR_BRIGHTNESS in kwargs: - self.update_value(kwargs[ATTR_BRIGHTNESS]) - else: - self.update_value(255) - - return self._qsusb.set(self._id, round(min(self._value, 255)/2.55)) + newvalue = kwargs[ATTR_BRIGHTNESS] + if self._qsusb.set(self._id, round(min(newvalue, 255)/2.55)) >= 0: + self.update_value(newvalue) # pylint: disable=unused-argument def turn_off(self, **kwargs): """Turn the device off.""" - self.update_value(0) - return self._qsusb.set(self._id, 0) + if self._qsusb.set(self._id, 0) >= 0: + self.update_value(0) +# pylint: disable=too-many-locals def setup(hass, config): """Setup the QSUSB component.""" - from pyqwikswitch import QSUsb, CMD_BUTTONS + from pyqwikswitch import (QSUsb, CMD_BUTTONS, QS_NAME, QS_ID, QS_CMD, + QS_TYPE, PQS_VALUE, PQS_TYPE, QSType) # Override which cmd's in /&listen packets will fire events # By default only buttons of type [TOGGLE,SCENE EXE,LEVEL] @@ -113,9 +115,10 @@ def setup(hass, config): # Identify switches & remove ' Switch' postfix in name for item in qsusb.ha_devices: - if item['type'] == 'rel' and item['name'].lower().endswith(' switch'): - item['type'] = 'switch' - item['name'] = item['name'][:-7] + if item[PQS_TYPE] == QSType.relay and \ + item[QS_NAME].lower().endswith(' switch'): + item[QS_TYPE] = 'switch' + item[QS_NAME] = item[QS_NAME][:-7] global QSUSB if QSUSB is None: @@ -130,8 +133,8 @@ def setup(hass, config): def qs_callback(item): """Typically a button press or update signal.""" # If button pressed, fire a hass event - if item.get('cmd', '') in cmd_buttons: - hass.bus.fire('qwikswitch.button.' + item.get('id', '@no_id')) + if item.get(QS_CMD, '') in cmd_buttons: + hass.bus.fire('qwikswitch.button.' + item.get(QS_ID, '@no_id')) return # Update all ha_objects @@ -139,10 +142,9 @@ def setup(hass, config): if qsreply is False: return for item in qsreply: - item_id = item.get('id', '') - if item_id in qsusb.ha_objects: - qsusb.ha_objects[item_id].update_value( - round(min(item['value'], 100) * 2.55)) + if item[QS_ID] in qsusb.ha_objects: + qsusb.ha_objects[item[QS_ID]].update_value( + round(min(item[PQS_VALUE], 100) * 2.55)) qsusb.listen(callback=qs_callback, timeout=30) return True diff --git a/requirements_all.txt b/requirements_all.txt index 8938a75951a..f079a525d94 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -119,7 +119,7 @@ https://github.com/danieljkemp/onkyo-eiscp/archive/python3.zip#onkyo-eiscp==0.9. https://github.com/jamespcole/home-assistant-nzb-clients/archive/616cad59154092599278661af17e2a9f2cf5e2a9.zip#python-sabnzbd==0.1 # homeassistant.components.qwikswitch -https://github.com/kellerza/pyqwikswitch/archive/v0.1.zip#pyqwikswitch==0.1 +https://github.com/kellerza/pyqwikswitch/archive/v0.3.zip#pyqwikswitch==0.3 # homeassistant.components.ecobee https://github.com/nkgilley/python-ecobee-api/archive/4a884bc146a93991b4210f868f3d6aecf0a181e6.zip#python-ecobee==0.0.5 From 191fc8f8d412320688edc5b5be391fbf9ff09450 Mon Sep 17 00:00:00 2001 From: Nolan Gilley Date: Sat, 21 May 2016 13:19:27 -0400 Subject: [PATCH 89/95] Change color_RGB_to_xy formula & return brightness (#2095) * Use RGB to XY calculations from Philips Hue developer site * uppercase X,Y,Z * rename cx,cy to x,y * return brightness in color_RGB_to_xy * remove try/catch * update existing platforms using color_RGB_to_xy * improve wemo w/ jaharkes suggestion * allow brightness override of rgb_to_xy --- homeassistant/components/light/hue.py | 10 +++-- homeassistant/components/light/wemo.py | 1 + homeassistant/components/light/wink.py | 4 +- homeassistant/util/color.py | 51 ++++++++++++-------------- tests/util/test_color.py | 11 +++--- 5 files changed, 40 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 60c1e7f6605..614df66b133 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -235,14 +235,16 @@ class HueLight(Light): if ATTR_TRANSITION in kwargs: command['transitiontime'] = kwargs[ATTR_TRANSITION] * 10 - if ATTR_BRIGHTNESS in kwargs: - command['bri'] = kwargs[ATTR_BRIGHTNESS] - if ATTR_XY_COLOR in kwargs: command['xy'] = kwargs[ATTR_XY_COLOR] elif ATTR_RGB_COLOR in kwargs: - command['xy'] = color_util.color_RGB_to_xy( + xyb = color_util.color_RGB_to_xy( *(int(val) for val in kwargs[ATTR_RGB_COLOR])) + command['xy'] = xyb[0], xyb[1] + command['bri'] = xyb[2] + + if ATTR_BRIGHTNESS in kwargs: + command['bri'] = kwargs[ATTR_BRIGHTNESS] if ATTR_COLOR_TEMP in kwargs: command['ct'] = kwargs[ATTR_COLOR_TEMP] diff --git a/homeassistant/components/light/wemo.py b/homeassistant/components/light/wemo.py index d2844826400..a4aa6686a17 100644 --- a/homeassistant/components/light/wemo.py +++ b/homeassistant/components/light/wemo.py @@ -105,6 +105,7 @@ class WemoLight(Light): elif ATTR_RGB_COLOR in kwargs: xycolor = color_util.color_RGB_to_xy( *(int(val) for val in kwargs[ATTR_RGB_COLOR])) + kwargs.setdefault(ATTR_BRIGHTNESS, xycolor[2]) else: xycolor = None diff --git a/homeassistant/components/light/wink.py b/homeassistant/components/light/wink.py index 86d2a29c21f..c7a7637b047 100644 --- a/homeassistant/components/light/wink.py +++ b/homeassistant/components/light/wink.py @@ -97,7 +97,9 @@ class WinkLight(Light): } if rgb_color: - state_kwargs['color_xy'] = color_util.color_RGB_to_xy(*rgb_color) + xyb = color_util.color_RGB_to_xy(*rgb_color) + state_kwargs['color_xy'] = xyb[0], xyb[1] + state_kwargs['brightness'] = xyb[2] if color_temp_mired: state_kwargs['color_kelvin'] = mired_to_kelvin(color_temp_mired) diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 940d435ed44..23412344a85 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -7,44 +7,41 @@ HASS_COLOR_MAX = 500 # mireds (inverted) HASS_COLOR_MIN = 154 -# Taken from: http://www.cse.unr.edu/~quiroz/inc/colortransforms.py +# Taken from: +# http://www.developers.meethue.com/documentation/color-conversions-rgb-xy # License: Code is given as is. Use at your own risk and discretion. # pylint: disable=invalid-name def color_RGB_to_xy(R, G, B): """Convert from RGB color to XY color.""" if R + G + B == 0: - return 0, 0 + return 0, 0, 0 - var_R = (R / 255.) - var_G = (G / 255.) - var_B = (B / 255.) + R = R / 255 + B = B / 255 + G = G / 255 - if var_R > 0.04045: - var_R = ((var_R + 0.055) / 1.055) ** 2.4 - else: - var_R /= 12.92 + # Gamma correction + R = pow((R + 0.055) / (1.0 + 0.055), + 2.4) if (R > 0.04045) else (R / 12.92) + G = pow((G + 0.055) / (1.0 + 0.055), + 2.4) if (G > 0.04045) else (G / 12.92) + B = pow((B + 0.055) / (1.0 + 0.055), + 2.4) if (B > 0.04045) else (B / 12.92) - if var_G > 0.04045: - var_G = ((var_G + 0.055) / 1.055) ** 2.4 - else: - var_G /= 12.92 + # Wide RGB D65 conversion formula + X = R * 0.664511 + G * 0.154324 + B * 0.162028 + Y = R * 0.313881 + G * 0.668433 + B * 0.047685 + Z = R * 0.000088 + G * 0.072310 + B * 0.986039 - if var_B > 0.04045: - var_B = ((var_B + 0.055) / 1.055) ** 2.4 - else: - var_B /= 12.92 + # Convert XYZ to xy + x = X / (X + Y + Z) + y = Y / (X + Y + Z) - var_R *= 100 - var_G *= 100 - var_B *= 100 + # Brightness + Y = 1 if Y > 1 else Y + brightness = round(Y * 255) - # Observer. = 2 deg, Illuminant = D65 - X = var_R * 0.4124 + var_G * 0.3576 + var_B * 0.1805 - Y = var_R * 0.2126 + var_G * 0.7152 + var_B * 0.0722 - Z = var_R * 0.0193 + var_G * 0.1192 + var_B * 0.9505 - - # Convert XYZ to xy, see CIE 1931 color space on wikipedia - return X / (X + Y + Z), Y / (X + Y + Z) + return round(x, 3), round(y, 3), brightness # taken from diff --git a/tests/util/test_color.py b/tests/util/test_color.py index 06d413778cf..82222a0b8a1 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -9,16 +9,17 @@ class TestColorUtil(unittest.TestCase): # pylint: disable=invalid-name def test_color_RGB_to_xy(self): """Test color_RGB_to_xy.""" - self.assertEqual((0, 0), color_util.color_RGB_to_xy(0, 0, 0)) - self.assertEqual((0.3127159072215825, 0.3290014805066623), + self.assertEqual((0, 0, 0), color_util.color_RGB_to_xy(0, 0, 0)) + self.assertEqual((0.32, 0.336, 255), color_util.color_RGB_to_xy(255, 255, 255)) - self.assertEqual((0.15001662234042554, 0.060006648936170214), + self.assertEqual((0.136, 0.04, 12), color_util.color_RGB_to_xy(0, 0, 255)) - self.assertEqual((0.3, 0.6), color_util.color_RGB_to_xy(0, 255, 0)) + self.assertEqual((0.172, 0.747, 170), + color_util.color_RGB_to_xy(0, 255, 0)) - self.assertEqual((0.6400744994567747, 0.3299705106316933), + self.assertEqual((0.679, 0.321, 80), color_util.color_RGB_to_xy(255, 0, 0)) def test_color_xy_brightness_to_RGB(self): From 3ce6c732ab6eded68dee57d313f40f8c4c9f2a58 Mon Sep 17 00:00:00 2001 From: Igor Shults Date: Sat, 21 May 2016 12:56:20 -0500 Subject: [PATCH 90/95] #2120 Fix hvac z-wave fan list (#2121) * #2120 Fix hvac z-wave fan list * Properly name methods --- homeassistant/components/hvac/zwave.py | 35 ++++++++++++++++++-------- 1 file changed, 24 insertions(+), 11 deletions(-) mode change 100644 => 100755 homeassistant/components/hvac/zwave.py diff --git a/homeassistant/components/hvac/zwave.py b/homeassistant/components/hvac/zwave.py old mode 100644 new mode 100755 index 3edf160d7ee..a170d3a9e79 --- a/homeassistant/components/hvac/zwave.py +++ b/homeassistant/components/hvac/zwave.py @@ -23,6 +23,12 @@ REMOTEC = 0x5254 REMOTEC_ZXT_120 = 0x8377 REMOTEC_ZXT_120_THERMOSTAT = (REMOTEC, REMOTEC_ZXT_120, 0) +COMMAND_CLASS_SENSOR_MULTILEVEL = 0x31 +COMMAND_CLASS_THERMOSTAT_MODE = 0x40 +COMMAND_CLASS_THERMOSTAT_SETPOINT = 0x43 +COMMAND_CLASS_THERMOSTAT_FAN_MODE = 0x44 +COMMAND_CLASS_CONFIGURATION = 0x70 + WORKAROUND_ZXT_120 = 'zxt_120' DEVICE_MAPPINGS = { @@ -100,22 +106,24 @@ class ZWaveHvac(ZWaveDeviceEntity, HvacDevice): def update_properties(self): """Callback on data change for the registered node/value pair.""" # Set point - for value in self._node.get_values(class_id=0x43).values(): + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_SETPOINT).values(): if int(value.data) != 0: self._target_temperature = int(value.data) # Operation Mode - for value in self._node.get_values(class_id=0x40).values(): + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_MODE).values(): self._current_operation = value.data self._operation_list = list(value.data_items) _LOGGER.debug("self._operation_list=%s", self._operation_list) # Current Temp - for value in self._node.get_values(class_id=0x31).values(): + for value in self._node.get_values( + class_id=COMMAND_CLASS_SENSOR_MULTILEVEL).values(): self._current_temperature = int(value.data) self._unit = value.units # Fan Mode - fan_class_id = 0x44 if self._zxt_120 else 0x42 - _LOGGER.debug("fan_class_id=%s", fan_class_id) - for value in self._node.get_values(class_id=fan_class_id).values(): + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_FAN_MODE).values(): self._current_operation_state = value.data self._fan_list = list(value.data_items) _LOGGER.debug("self._fan_list=%s", self._fan_list) @@ -123,7 +131,8 @@ class ZWaveHvac(ZWaveDeviceEntity, HvacDevice): self._current_operation_state) # Swing mode if self._zxt_120 == 1: - for value in self._node.get_values(class_id=0x70).values(): + for value in self._node.get_values( + class_id=COMMAND_CLASS_CONFIGURATION).values(): if value.command_class == 112 and value.index == 33: self._current_swing_mode = value.data self._swing_list = [0, 1] @@ -188,7 +197,8 @@ class ZWaveHvac(ZWaveDeviceEntity, HvacDevice): def set_temperature(self, temperature): """Set new target temperature.""" - for value in self._node.get_values(class_id=0x43).values(): + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_SETPOINT).values(): if value.command_class != 67: continue if self._zxt_120: @@ -204,20 +214,23 @@ class ZWaveHvac(ZWaveDeviceEntity, HvacDevice): def set_fan_mode(self, fan): """Set new target fan mode.""" - for value in self._node.get_values(class_id=0x44).values(): + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_FAN_MODE).values(): if value.command_class == 68 and value.index == 0: value.data = bytes(fan, 'utf-8') def set_operation_mode(self, operation_mode): """Set new target operation mode.""" - for value in self._node.get_values(class_id=0x40).values(): + for value in self._node.get_values( + class_id=COMMAND_CLASS_THERMOSTAT_MODE).values(): if value.command_class == 64 and value.index == 0: value.data = bytes(operation_mode, 'utf-8') def set_swing_mode(self, swing_mode): """Set new target swing mode.""" if self._zxt_120 == 1: - for value in self._node.get_values(class_id=0x70).values(): + for value in self._node.get_values( + class_id=COMMAND_CLASS_CONFIGURATION).values(): if value.command_class == 112 and value.index == 33: value.data = int(swing_mode) From 0f1c4d2f8c409d512b11a09064a370fa88ef9d2b Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Sat, 21 May 2016 11:04:18 -0700 Subject: [PATCH 91/95] GTFS fixes (#2119) * Change to official PyGTFS source * Threading fixes for GTFS * Actually pygtfs 0.1.3 * Update requirements_all.txt * Update gtfs version --- homeassistant/components/sensor/gtfs.py | 122 ++++++++++++------------ requirements_all.txt | 2 +- 2 files changed, 64 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/sensor/gtfs.py b/homeassistant/components/sensor/gtfs.py index cdea0d24624..2355d03d34a 100644 --- a/homeassistant/components/sensor/gtfs.py +++ b/homeassistant/components/sensor/gtfs.py @@ -7,14 +7,15 @@ https://home-assistant.io/components/sensor.gtfs/ import os import logging import datetime +import threading from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ["https://github.com/robbiet480/pygtfs/archive/" - "432414b720c580fb2667a0a48f539118a2d95969.zip#" - "pygtfs==0.1.2"] + "00546724e4bbcb3053110d844ca44e2246267dd8.zip#" + "pygtfs==0.1.3"] ICON = "mdi:train" @@ -152,9 +153,22 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.error("The given GTFS data file/folder was not found!") return False + import pygtfs + + split_file_name = os.path.splitext(config["data"]) + + sqlite_file = "{}.sqlite".format(split_file_name[0]) + joined_path = os.path.join(gtfs_dir, sqlite_file) + gtfs = pygtfs.Schedule(joined_path) + + # pylint: disable=no-member + if len(gtfs.feeds) < 1: + pygtfs.append_feed(gtfs, os.path.join(gtfs_dir, + config["data"])) + dev = [] - dev.append(GTFSDepartureSensor(config["data"], gtfs_dir, - config["origin"], config["destination"])) + dev.append(GTFSDepartureSensor(gtfs, config["origin"], + config["destination"])) add_devices(dev) # pylint: disable=too-many-instance-attributes,too-few-public-methods @@ -163,16 +177,16 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class GTFSDepartureSensor(Entity): """Implementation of an GTFS departures sensor.""" - def __init__(self, data_source, gtfs_folder, origin, destination): + def __init__(self, pygtfs, origin, destination): """Initialize the sensor.""" - self._data_source = data_source - self._gtfs_folder = gtfs_folder + self._pygtfs = pygtfs self.origin = origin self.destination = destination self._name = "GTFS Sensor" self._unit_of_measurement = "min" self._state = 0 self._attributes = {} + self.lock = threading.Lock() self.update() @property @@ -202,62 +216,52 @@ class GTFSDepartureSensor(Entity): def update(self): """Get the latest data from GTFS and update the states.""" - import pygtfs + with self.lock: + self._departure = get_next_departure(self._pygtfs, self.origin, + self.destination) + self._state = self._departure["minutes_until_departure"] - split_file_name = os.path.splitext(self._data_source) + origin_station = self._departure["origin_station"] + destination_station = self._departure["destination_station"] + origin_stop_time = self._departure["origin_stop_time"] + destination_stop_time = self._departure["destination_stop_time"] + agency = self._departure["agency"] + route = self._departure["route"] + trip = self._departure["trip"] - sqlite_file = "{}.sqlite".format(split_file_name[0]) - gtfs = pygtfs.Schedule(os.path.join(self._gtfs_folder, sqlite_file)) + name = "{} {} to {} next departure" + self._name = name.format(agency.agency_name, + origin_station.stop_id, + destination_station.stop_id) - # pylint: disable=no-member - if len(gtfs.feeds) < 1: - pygtfs.append_feed(gtfs, os.path.join(self._gtfs_folder, - self._data_source)) + # Build attributes - self._departure = get_next_departure(gtfs, self.origin, - self.destination) - self._state = self._departure["minutes_until_departure"] + self._attributes = {} - origin_station = self._departure["origin_station"] - destination_station = self._departure["destination_station"] - origin_stop_time = self._departure["origin_stop_time"] - destination_stop_time = self._departure["destination_stop_time"] - agency = self._departure["agency"] - route = self._departure["route"] - trip = self._departure["trip"] + def dict_for_table(resource): + """Return a dict for the SQLAlchemy resource given.""" + return dict((col, getattr(resource, col)) + for col in resource.__table__.columns.keys()) - name = "{} {} to {} next departure" - self._name = name.format(agency.agency_name, - origin_station.stop_id, - destination_station.stop_id) + def append_keys(resource, prefix=None): + """Properly format key val pairs to append to attributes.""" + for key, val in resource.items(): + if val == "" or val is None or key == "feed_id": + continue + pretty_key = key.replace("_", " ") + pretty_key = pretty_key.title() + pretty_key = pretty_key.replace("Id", "ID") + pretty_key = pretty_key.replace("Url", "URL") + if prefix is not None and \ + pretty_key.startswith(prefix) is False: + pretty_key = "{} {}".format(prefix, pretty_key) + self._attributes[pretty_key] = val - # Build attributes - - self._attributes = {} - - def dict_for_table(resource): - """Return a dict for the SQLAlchemy resource given.""" - return dict((col, getattr(resource, col)) - for col in resource.__table__.columns.keys()) - - def append_keys(resource, prefix=None): - """Properly format key val pairs to append to attributes.""" - for key, val in resource.items(): - if val == "" or val is None or key == "feed_id": - continue - pretty_key = key.replace("_", " ") - pretty_key = pretty_key.title() - pretty_key = pretty_key.replace("Id", "ID") - pretty_key = pretty_key.replace("Url", "URL") - if prefix is not None and \ - pretty_key.startswith(prefix) is False: - pretty_key = "{} {}".format(prefix, pretty_key) - self._attributes[pretty_key] = val - - append_keys(dict_for_table(agency), "Agency") - append_keys(dict_for_table(route), "Route") - append_keys(dict_for_table(trip), "Trip") - append_keys(dict_for_table(origin_station), "Origin Station") - append_keys(dict_for_table(destination_station), "Destination Station") - append_keys(origin_stop_time, "Origin Stop") - append_keys(destination_stop_time, "Destination Stop") + append_keys(dict_for_table(agency), "Agency") + append_keys(dict_for_table(route), "Route") + append_keys(dict_for_table(trip), "Trip") + append_keys(dict_for_table(origin_station), "Origin Station") + append_keys(dict_for_table(destination_station), + "Destination Station") + append_keys(origin_stop_time, "Origin Stop") + append_keys(destination_stop_time, "Destination Stop") diff --git a/requirements_all.txt b/requirements_all.txt index f079a525d94..fc572cd2362 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -131,7 +131,7 @@ https://github.com/rkabadi/pyedimax/archive/365301ce3ff26129a7910c501ead09ea625f https://github.com/rkabadi/temper-python/archive/3dbdaf2d87b8db9a3cd6e5585fc704537dd2d09b.zip#temperusb==1.2.3 # homeassistant.components.sensor.gtfs -https://github.com/robbiet480/pygtfs/archive/432414b720c580fb2667a0a48f539118a2d95969.zip#pygtfs==0.1.2 +https://github.com/robbiet480/pygtfs/archive/00546724e4bbcb3053110d844ca44e2246267dd8.zip#pygtfs==0.1.3 # homeassistant.components.scene.hunterdouglas_powerview https://github.com/sander76/powerviewApi/archive/master.zip#powerviewApi==0.2 From d8c1959715243d5d4b8f704a72a3d5d65bfcce6f Mon Sep 17 00:00:00 2001 From: Ardi Mehist Date: Sat, 21 May 2016 19:21:23 +0100 Subject: [PATCH 92/95] Add support for Logentries (#1945) * Add support for Logentries Supports sending has events to Logentries web hook endpoint see logentries.com for more Inspired by the Splunk component * bugfix * fix summary * fix test * fix logentries url and tests * update tests * mock token * Bug fixes * typo * typo * fix string splitting * remove redundant backslash --- homeassistant/components/logentries.py | 61 ++++++++++++++++++ tests/components/test_logentries.py | 88 ++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 homeassistant/components/logentries.py create mode 100644 tests/components/test_logentries.py diff --git a/homeassistant/components/logentries.py b/homeassistant/components/logentries.py new file mode 100644 index 00000000000..5aaaf2df562 --- /dev/null +++ b/homeassistant/components/logentries.py @@ -0,0 +1,61 @@ +""" +Support for sending data to Logentries webhook endpoint. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/logentries/ +""" +import json +import logging +import requests +import homeassistant.util as util +from homeassistant.const import EVENT_STATE_CHANGED +from homeassistant.helpers import state as state_helper +from homeassistant.helpers import validate_config + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "logentries" +DEPENDENCIES = [] + +DEFAULT_HOST = 'https://webhook.logentries.com/noformat/logs/' + +CONF_TOKEN = 'token' + + +def setup(hass, config): + """Setup the Logentries component.""" + if not validate_config(config, {DOMAIN: ['token']}, _LOGGER): + _LOGGER.error("Logentries token not present") + return False + conf = config[DOMAIN] + token = util.convert(conf.get(CONF_TOKEN), str) + le_wh = DEFAULT_HOST + token + + def logentries_event_listener(event): + """Listen for new messages on the bus and sends them to Logentries.""" + state = event.data.get('new_state') + if state is None: + return + try: + _state = state_helper.state_as_number(state) + except ValueError: + _state = state.state + json_body = [ + { + 'domain': state.domain, + 'entity_id': state.object_id, + 'attributes': dict(state.attributes), + 'time': str(event.time_fired), + 'value': _state, + } + ] + try: + payload = {"host": le_wh, + "event": json_body} + requests.post(le_wh, data=json.dumps(payload), timeout=10) + except requests.exceptions.RequestException as error: + _LOGGER.exception('Error sending to Logentries: %s', error) + + hass.bus.listen(EVENT_STATE_CHANGED, logentries_event_listener) + + return True diff --git a/tests/components/test_logentries.py b/tests/components/test_logentries.py new file mode 100644 index 00000000000..c5bad5332f4 --- /dev/null +++ b/tests/components/test_logentries.py @@ -0,0 +1,88 @@ +"""The tests for the Logentries component.""" + +import unittest +from unittest import mock + +import homeassistant.components.logentries as logentries +from homeassistant.const import STATE_ON, STATE_OFF, EVENT_STATE_CHANGED + + +class TestLogentries(unittest.TestCase): + """Test the Logentries component.""" + + def test_setup_config_full(self): + """Test setup with all data.""" + config = { + 'logentries': { + 'host': 'host', + 'token': 'secret', + } + } + hass = mock.MagicMock() + self.assertTrue(logentries.setup(hass, config)) + self.assertTrue(hass.bus.listen.called) + self.assertEqual(EVENT_STATE_CHANGED, + hass.bus.listen.call_args_list[0][0][0]) + + def test_setup_config_defaults(self): + """Test setup with defaults.""" + config = { + 'logentries': { + 'host': 'host', + 'token': 'token', + } + } + hass = mock.MagicMock() + self.assertTrue(logentries.setup(hass, config)) + self.assertTrue(hass.bus.listen.called) + self.assertEqual(EVENT_STATE_CHANGED, + hass.bus.listen.call_args_list[0][0][0]) + + def _setup(self, mock_requests): + """Test the setup.""" + self.mock_post = mock_requests.post + self.mock_request_exception = Exception + mock_requests.exceptions.RequestException = self.mock_request_exception + config = { + 'logentries': { + 'host': 'https://webhook.logentries.com/noformat/logs/token', + 'token': 'token' + } + } + self.hass = mock.MagicMock() + logentries.setup(self.hass, config) + self.handler_method = self.hass.bus.listen.call_args_list[0][0][1] + + @mock.patch.object(logentries, 'requests') + @mock.patch('json.dumps') + def test_event_listener(self, mock_dump, mock_requests): + """Test event listener.""" + mock_dump.side_effect = lambda x: x + self._setup(mock_requests) + + valid = {'1': 1, + '1.0': 1.0, + STATE_ON: 1, + STATE_OFF: 0, + 'foo': 'foo'} + for in_, out in valid.items(): + state = mock.MagicMock(state=in_, + domain='fake', + object_id='entity', + attributes={}) + event = mock.MagicMock(data={'new_state': state}, + time_fired=12345) + body = [{ + 'domain': 'fake', + 'entity_id': 'entity', + 'attributes': {}, + 'time': '12345', + 'value': out, + }] + payload = {'host': 'https://webhook.logentries.com/noformat/' + 'logs/token', + 'event': body} + self.handler_method(event) + self.mock_post.assert_called_once_with( + payload['host'], data=payload, timeout=10) + self.mock_post.reset_mock() From 5bedf5d604c0bd39bcbd79861c491540eec0ed34 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 21 May 2016 11:57:33 -0700 Subject: [PATCH 93/95] Upgrade Nest to 2.9.2 (#2126) --- homeassistant/components/nest.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nest.py b/homeassistant/components/nest.py index 453b6b72bd4..afccc043223 100644 --- a/homeassistant/components/nest.py +++ b/homeassistant/components/nest.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -REQUIREMENTS = ['python-nest==2.9.1'] +REQUIREMENTS = ['python-nest==2.9.2'] DOMAIN = 'nest' NEST = None diff --git a/requirements_all.txt b/requirements_all.txt index fc572cd2362..1c441790028 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -260,7 +260,7 @@ python-forecastio==1.3.4 python-mpd2==0.5.5 # homeassistant.components.nest -python-nest==2.9.1 +python-nest==2.9.2 # homeassistant.components.device_tracker.nmap_tracker python-nmap==0.6.0 From 3ea179cc0b99321cbd7d1922a03c6ec977be716c Mon Sep 17 00:00:00 2001 From: Jan Harkes Date: Sat, 21 May 2016 15:58:14 -0400 Subject: [PATCH 94/95] Let systemd handle home-assistant process restarts. (#2127) --- script/home-assistant@.service | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/script/home-assistant@.service b/script/home-assistant@.service index a36a6e743b1..8e520952db9 100644 --- a/script/home-assistant@.service +++ b/script/home-assistant@.service @@ -12,8 +12,9 @@ User=%i # Enable the following line if you get network-related HA errors during boot #ExecStartPre=/usr/bin/sleep 60 # Use `whereis hass` to determine the path of hass -ExecStart=/usr/bin/hass +ExecStart=/usr/bin/hass --runner SendSIGKILL=no +RestartForceExitStatus=100 [Install] WantedBy=multi-user.target From ab60b32326b0bc25386d035455725c19f0791440 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 21 May 2016 14:06:07 -0700 Subject: [PATCH 95/95] Update frontend --- homeassistant/components/frontend/version.py | 2 +- .../frontend/www_static/frontend.html | 22 +++++++++---------- .../www_static/home-assistant-polymer | 2 +- .../frontend/www_static/service_worker.js | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 4847879d4d3..8bdf1755b04 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 = "77c51c270b0241ce7ba0d1df2d254d6f" +VERSION = "0a226e905af198b2dabf1ce154844568" diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 367f302d4cd..09118970c8d 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -1,8 +1,8 @@ \ No newline at end of file + clear: both;white-space:pre-wrap} \ 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 77f4dd1fed3..4a667eb77e2 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit 77f4dd1fed3d29c7ad8960c704a748af80748a59 +Subproject commit 4a667eb77e28a27dc766ca6f7bbd04e3866124d9 diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js index 5cb7633b20f..4346db8b9a0 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -1 +1 @@ -!function(e){function t(r){if(n[r])return n[r].exports;var s=n[r]={i:r,l:!1,exports:{}};return e[r].call(s.exports,s,s.exports,t),s.l=!0,s.exports}var n={};return t.m=e,t.c=n,t.p="",t(t.s=192)}({192:function(e,t,n){var r="0.10",s="/",c=["/","/logbook","/history","/map","/devService","/devState","/devEvent","/devInfo","/states"],i=["/static/favicon-192x192.png"];self.addEventListener("install",function(e){e.waitUntil(caches.open(r).then(function(e){return e.addAll(i.concat(s))}))}),self.addEventListener("activate",function(e){}),self.addEventListener("message",function(e){}),self.addEventListener("fetch",function(e){var t=e.request.url.substr(e.request.url.indexOf("/",8));i.includes(t)&&e.respondWith(caches.open(r).then(function(t){return t.match(e.request)})),c.includes(t)&&e.respondWith(caches.open(r).then(function(t){return t.match(s).then(function(n){return n||fetch(e.request).then(function(e){return t.put(s,e.clone()),e})})}))})}}); \ No newline at end of file +!function(e){function t(r){if(n[r])return n[r].exports;var s=n[r]={i:r,l:!1,exports:{}};return e[r].call(s.exports,s,s.exports,t),s.l=!0,s.exports}var n={};return t.m=e,t.c=n,t.p="",t(t.s=194)}({194:function(e,t,n){var r="0.10",s="/",c=["/","/logbook","/history","/map","/devService","/devState","/devEvent","/devInfo","/states"],i=["/static/favicon-192x192.png"];self.addEventListener("install",function(e){e.waitUntil(caches.open(r).then(function(e){return e.addAll(i.concat(s))}))}),self.addEventListener("activate",function(e){}),self.addEventListener("message",function(e){}),self.addEventListener("fetch",function(e){var t=e.request.url.substr(e.request.url.indexOf("/",8));i.includes(t)&&e.respondWith(caches.open(r).then(function(t){return t.match(e.request)})),c.includes(t)&&e.respondWith(caches.open(r).then(function(t){return t.match(s).then(function(n){return n||fetch(e.request).then(function(e){return t.put(s,e.clone()),e})})}))})}}); \ No newline at end of file