From 7c36c5d9b476aa45590c485c256ae90b37b76724 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 9 Feb 2018 14:56:46 -0800 Subject: [PATCH 001/173] Version bump to 0.64.0.dev0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 915ee5ac216..b7b6061e757 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 63 +MINOR_VERSION = 64 PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) From 1db4df6d3a37b568c26849ce92ae895e4e0c9ee3 Mon Sep 17 00:00:00 2001 From: Albert Lee Date: Fri, 9 Feb 2018 17:21:10 -0600 Subject: [PATCH 002/173] device_tracker.asuswrt: Clean up unused connection param (#12262) --- homeassistant/components/device_tracker/asuswrt.py | 11 ++++------- tests/components/device_tracker/test_asuswrt.py | 4 ++-- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index fb47b26a687..ee243f12988 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -118,11 +118,10 @@ class AsusWrtDeviceScanner(DeviceScanner): if self.protocol == 'ssh': self.connection = SshConnection( self.host, self.port, self.username, self.password, - self.ssh_key, self.mode == 'ap') + self.ssh_key) else: self.connection = TelnetConnection( - self.host, self.port, self.username, self.password, - self.mode == 'ap') + self.host, self.port, self.username, self.password) self.last_results = {} @@ -253,7 +252,7 @@ class _Connection: class SshConnection(_Connection): """Maintains an SSH connection to an ASUS-WRT router.""" - def __init__(self, host, port, username, password, ssh_key, ap): + def __init__(self, host, port, username, password, ssh_key): """Initialize the SSH connection properties.""" super().__init__() @@ -263,7 +262,6 @@ class SshConnection(_Connection): self._username = username self._password = password self._ssh_key = ssh_key - self._ap = ap def run_command(self, command): """Run commands through an SSH connection. @@ -323,7 +321,7 @@ class SshConnection(_Connection): class TelnetConnection(_Connection): """Maintains a Telnet connection to an ASUS-WRT router.""" - def __init__(self, host, port, username, password, ap): + def __init__(self, host, port, username, password): """Initialize the Telnet connection properties.""" super().__init__() @@ -332,7 +330,6 @@ class TelnetConnection(_Connection): self._port = port self._username = username self._password = password - self._ap = ap self._prompt_string = None def run_command(self, command): diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py index f8d3fdf128b..bf7d5145e33 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -473,7 +473,7 @@ class TestSshConnection(unittest.TestCase): def setUp(self): """Setup test env.""" self.connection = SshConnection( - 'fake', 'fake', 'fake', 'fake', 'fake', 'fake') + 'fake', 'fake', 'fake', 'fake', 'fake') self.connection._connected = True def test_run_command_exception_eof(self): @@ -513,7 +513,7 @@ class TestTelnetConnection(unittest.TestCase): def setUp(self): """Setup test env.""" self.connection = TelnetConnection( - 'fake', 'fake', 'fake', 'fake', 'fake') + 'fake', 'fake', 'fake', 'fake') self.connection._connected = True def test_run_command_exception_eof(self): From cad9e9a4cbd0adb6b341c71ab8dccc3f3a020195 Mon Sep 17 00:00:00 2001 From: escoand Date: Sat, 10 Feb 2018 00:22:50 +0100 Subject: [PATCH 003/173] allow wildcards in subscription (#12247) * allow wildcards in subscription * remove whitespaces * make function public * also implement for mqtt_json * avoid mqtt-outside topic matching * add wildcard tests * add not matching wildcard tests * fix not-matching tests --- .../components/device_tracker/mqtt.py | 17 ++--- .../components/device_tracker/mqtt_json.py | 42 +++++----- tests/components/device_tracker/test_mqtt.py | 76 +++++++++++++++++++ .../device_tracker/test_mqtt_json.py | 74 ++++++++++++++++++ 4 files changed, 175 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/device_tracker/mqtt.py b/homeassistant/components/device_tracker/mqtt.py index aab5b43acea..2e2d9b10d98 100644 --- a/homeassistant/components/device_tracker/mqtt.py +++ b/homeassistant/components/device_tracker/mqtt.py @@ -31,17 +31,14 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None): devices = config[CONF_DEVICES] qos = config[CONF_QOS] - dev_id_lookup = {} - - @callback - def async_tracker_message_received(topic, payload, qos): - """Handle received MQTT message.""" - hass.async_add_job( - async_see(dev_id=dev_id_lookup[topic], location_name=payload)) - for dev_id, topic in devices.items(): - dev_id_lookup[topic] = dev_id + @callback + def async_message_received(topic, payload, qos, dev_id=dev_id): + """Handle received MQTT message.""" + hass.async_add_job( + async_see(dev_id=dev_id, location_name=payload)) + yield from mqtt.async_subscribe( - hass, topic, async_tracker_message_received, qos) + hass, topic, async_message_received, qos) return True diff --git a/homeassistant/components/device_tracker/mqtt_json.py b/homeassistant/components/device_tracker/mqtt_json.py index 0ef4f1835b6..7bcad60236a 100644 --- a/homeassistant/components/device_tracker/mqtt_json.py +++ b/homeassistant/components/device_tracker/mqtt_json.py @@ -41,32 +41,26 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None): devices = config[CONF_DEVICES] qos = config[CONF_QOS] - dev_id_lookup = {} - - @callback - def async_tracker_message_received(topic, payload, qos): - """Handle received MQTT message.""" - dev_id = dev_id_lookup[topic] - - try: - data = GPS_JSON_PAYLOAD_SCHEMA(json.loads(payload)) - except vol.MultipleInvalid: - _LOGGER.error("Skipping update for following data " - "because of missing or malformatted data: %s", - payload) - return - except ValueError: - _LOGGER.error("Error parsing JSON payload: %s", payload) - return - - kwargs = _parse_see_args(dev_id, data) - hass.async_add_job( - async_see(**kwargs)) - for dev_id, topic in devices.items(): - dev_id_lookup[topic] = dev_id + @callback + def async_message_received(topic, payload, qos, dev_id=dev_id): + """Handle received MQTT message.""" + try: + data = GPS_JSON_PAYLOAD_SCHEMA(json.loads(payload)) + except vol.MultipleInvalid: + _LOGGER.error("Skipping update for following data " + "because of missing or malformatted data: %s", + payload) + return + except ValueError: + _LOGGER.error("Error parsing JSON payload: %s", payload) + return + + kwargs = _parse_see_args(dev_id, data) + hass.async_add_job(async_see(**kwargs)) + yield from mqtt.async_subscribe( - hass, topic, async_tracker_message_received, qos) + hass, topic, async_message_received, qos) return True diff --git a/tests/components/device_tracker/test_mqtt.py b/tests/components/device_tracker/test_mqtt.py index 4905ab4d029..78750e91f83 100644 --- a/tests/components/device_tracker/test_mqtt.py +++ b/tests/components/device_tracker/test_mqtt.py @@ -70,3 +70,79 @@ class TestComponentsDeviceTrackerMQTT(unittest.TestCase): fire_mqtt_message(self.hass, topic, location) self.hass.block_till_done() self.assertEqual(location, self.hass.states.get(entity_id).state) + + def test_single_level_wildcard_topic(self): + """Test single level wildcard topic.""" + dev_id = 'paulus' + entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + subscription = '/location/+/paulus' + topic = '/location/room/paulus' + location = 'work' + + self.hass.config.components = set(['mqtt', 'zone']) + assert setup_component(self.hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt', + 'devices': {dev_id: subscription} + } + }) + fire_mqtt_message(self.hass, topic, location) + self.hass.block_till_done() + self.assertEqual(location, self.hass.states.get(entity_id).state) + + def test_multi_level_wildcard_topic(self): + """Test multi level wildcard topic.""" + dev_id = 'paulus' + entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + subscription = '/location/#' + topic = '/location/room/paulus' + location = 'work' + + self.hass.config.components = set(['mqtt', 'zone']) + assert setup_component(self.hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt', + 'devices': {dev_id: subscription} + } + }) + fire_mqtt_message(self.hass, topic, location) + self.hass.block_till_done() + self.assertEqual(location, self.hass.states.get(entity_id).state) + + def test_single_level_wildcard_topic_not_matching(self): + """Test not matching single level wildcard topic.""" + dev_id = 'paulus' + entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + subscription = '/location/+/paulus' + topic = '/location/paulus' + location = 'work' + + self.hass.config.components = set(['mqtt', 'zone']) + assert setup_component(self.hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt', + 'devices': {dev_id: subscription} + } + }) + fire_mqtt_message(self.hass, topic, location) + self.hass.block_till_done() + self.assertIsNone(self.hass.states.get(entity_id)) + + def test_multi_level_wildcard_topic_not_matching(self): + """Test not matching multi level wildcard topic.""" + dev_id = 'paulus' + entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + subscription = '/location/#' + topic = '/somewhere/room/paulus' + location = 'work' + + self.hass.config.components = set(['mqtt', 'zone']) + assert setup_component(self.hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt', + 'devices': {dev_id: subscription} + } + }) + fire_mqtt_message(self.hass, topic, location) + self.hass.block_till_done() + self.assertIsNone(self.hass.states.get(entity_id)) diff --git a/tests/components/device_tracker/test_mqtt_json.py b/tests/components/device_tracker/test_mqtt_json.py index 1755f424d29..43f4fc3bbf3 100644 --- a/tests/components/device_tracker/test_mqtt_json.py +++ b/tests/components/device_tracker/test_mqtt_json.py @@ -123,3 +123,77 @@ class TestComponentsDeviceTrackerJSONMQTT(unittest.TestCase): "Skipping update for following data because of missing " "or malformatted data: {\"longitude\": 2.0}", test_handle.output[0]) + + def test_single_level_wildcard_topic(self): + """Test single level wildcard topic.""" + dev_id = 'zanzito' + subscription = 'location/+/zanzito' + topic = 'location/room/zanzito' + location = json.dumps(LOCATION_MESSAGE) + + assert setup_component(self.hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt_json', + 'devices': {dev_id: subscription} + } + }) + fire_mqtt_message(self.hass, topic, location) + self.hass.block_till_done() + state = self.hass.states.get('device_tracker.zanzito') + self.assertEqual(state.attributes.get('latitude'), 2.0) + self.assertEqual(state.attributes.get('longitude'), 1.0) + + def test_multi_level_wildcard_topic(self): + """Test multi level wildcard topic.""" + dev_id = 'zanzito' + subscription = 'location/#' + topic = 'location/zanzito' + location = json.dumps(LOCATION_MESSAGE) + + assert setup_component(self.hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt_json', + 'devices': {dev_id: subscription} + } + }) + fire_mqtt_message(self.hass, topic, location) + self.hass.block_till_done() + state = self.hass.states.get('device_tracker.zanzito') + self.assertEqual(state.attributes.get('latitude'), 2.0) + self.assertEqual(state.attributes.get('longitude'), 1.0) + + def test_single_level_wildcard_topic_not_matching(self): + """Test not matching single level wildcard topic.""" + dev_id = 'zanzito' + entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + subscription = 'location/+/zanzito' + topic = 'location/zanzito' + location = json.dumps(LOCATION_MESSAGE) + + assert setup_component(self.hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt_json', + 'devices': {dev_id: subscription} + } + }) + fire_mqtt_message(self.hass, topic, location) + self.hass.block_till_done() + self.assertIsNone(self.hass.states.get(entity_id)) + + def test_multi_level_wildcard_topic_not_matching(self): + """Test not matching multi level wildcard topic.""" + dev_id = 'zanzito' + entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + subscription = 'location/#' + topic = 'somewhere/zanzito' + location = json.dumps(LOCATION_MESSAGE) + + assert setup_component(self.hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt_json', + 'devices': {dev_id: subscription} + } + }) + fire_mqtt_message(self.hass, topic, location) + self.hass.block_till_done() + self.assertIsNone(self.hass.states.get(entity_id)) From 134445f62244c4b85395be1c381d60cbdb1fd20e Mon Sep 17 00:00:00 2001 From: David K Date: Sat, 10 Feb 2018 10:45:00 +0100 Subject: [PATCH 004/173] Fix some rfxtrx devices with multiple sensors (#12264) * Fix some rfxtrx devices with multiple sensors Some combined temperature/humidity sensors send one packet for each of their sensors. Without this fix one of the home assistant sensors would always display an unknown value. * Add comment --- homeassistant/components/sensor/rfxtrx.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/rfxtrx.py b/homeassistant/components/sensor/rfxtrx.py index 1696e8e3770..4a555905d50 100644 --- a/homeassistant/components/sensor/rfxtrx.py +++ b/homeassistant/components/sensor/rfxtrx.py @@ -71,14 +71,19 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if device_id in rfxtrx.RFX_DEVICES: sensors = rfxtrx.RFX_DEVICES[device_id] - for key in sensors: - sensor = sensors[key] + for data_type in sensors: + # Some multi-sensor devices send individual messages for each + # of their sensors. Update only if event contains the + # right data_type for the sensor. + if data_type not in event.values: + continue + sensor = sensors[data_type] sensor.event = event # Fire event - if sensors[key].should_fire_event: + if sensor.should_fire_event: sensor.hass.bus.fire( "signal_received", { - ATTR_ENTITY_ID: sensors[key].entity_id, + ATTR_ENTITY_ID: sensor.entity_id, } ) return From f2296e1ff8318fb34ea83a2b3e9215da474fee8a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 10 Feb 2018 02:40:24 -0800 Subject: [PATCH 005/173] Retry keyset cloud (#12270) * Use less threads in helpers.event tests * Add helpers.event.async_call_later * Cloud: retry fetching keyset --- homeassistant/components/cloud/__init__.py | 56 ++++++++++------------ homeassistant/helpers/event.py | 9 ++++ tests/components/cloud/test_http_api.py | 4 +- tests/components/cloud/test_init.py | 2 +- tests/components/cloud/test_iot.py | 8 ++-- tests/helpers/test_event.py | 27 +++++++++-- 6 files changed, 66 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index a5bbf805d42..e17c9ee1b1e 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -16,8 +16,7 @@ import voluptuous as vol from homeassistant.const import ( EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE, CONF_NAME, CONF_TYPE) -from homeassistant.helpers import entityfilter -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import entityfilter, config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import dt as dt_util from homeassistant.components.alexa import smart_home as alexa_sh @@ -105,12 +104,7 @@ def async_setup(hass, config): ) cloud = hass.data[DOMAIN] = Cloud(hass, **kwargs) - - success = yield from cloud.initialize() - - if not success: - return False - + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, cloud.async_start) yield from http_api.async_setup(hass) return True @@ -192,19 +186,6 @@ class Cloud: return self._gactions_config - @asyncio.coroutine - def initialize(self): - """Initialize and load cloud info.""" - jwt_success = yield from self._fetch_jwt_keyset() - - if not jwt_success: - return False - - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, self._start_cloud) - - return True - def path(self, *parts): """Get config path inside cloud dir. @@ -234,19 +215,34 @@ class Cloud: 'refresh_token': self.refresh_token, }, indent=4)) - def _start_cloud(self, event): + @asyncio.coroutine + def async_start(self, _): """Start the cloud component.""" - # Ensure config dir exists - path = self.hass.config.path(CONFIG_DIR) - if not os.path.isdir(path): - os.mkdir(path) + success = yield from self._fetch_jwt_keyset() - user_info = self.user_info_path - if not os.path.isfile(user_info): + # Fetching keyset can fail if internet is not up yet. + if not success: + self.hass.helpers.async_call_later(5, self.async_start) return - with open(user_info, 'rt') as file: - info = json.loads(file.read()) + def load_config(): + """Load config.""" + # Ensure config dir exists + path = self.hass.config.path(CONFIG_DIR) + if not os.path.isdir(path): + os.mkdir(path) + + user_info = self.user_info_path + if not os.path.isfile(user_info): + return None + + with open(user_info, 'rt') as file: + return json.loads(file.read()) + + info = yield from self.hass.async_add_job(load_config) + + if info is None: + return # Validate tokens try: diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index f11b2eacf3a..eab2d583f45 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1,4 +1,5 @@ """Helpers for listening to events.""" +from datetime import timedelta import functools as ft from homeassistant.loader import bind_hass @@ -219,6 +220,14 @@ track_point_in_utc_time = threaded_listener_factory( async_track_point_in_utc_time) +@callback +@bind_hass +def async_call_later(hass, delay, action): + """Add a listener that is called in .""" + return async_track_point_in_utc_time( + hass, action, dt_util.utcnow() + timedelta(seconds=delay)) + + @callback @bind_hass def async_track_time_interval(hass, action, interval): diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 7623b25d401..69cd540e7d5 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -14,8 +14,8 @@ from tests.common import mock_coro @pytest.fixture def cloud_client(hass, test_client): """Fixture that can fetch from the cloud client.""" - with patch('homeassistant.components.cloud.Cloud.initialize', - return_value=mock_coro(True)): + with patch('homeassistant.components.cloud.Cloud.async_start', + return_value=mock_coro()): hass.loop.run_until_complete(async_setup_component(hass, 'cloud', { 'cloud': { 'mode': 'development', diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 7d23d9faad4..70990519a0b 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -87,7 +87,7 @@ def test_initialize_loads_info(mock_os, hass): with patch('homeassistant.components.cloud.open', mopen, create=True), \ patch('homeassistant.components.cloud.Cloud._decode_claims'): - cl._start_cloud(None) + yield from cl.async_start(None) assert cl.id_token == 'test-id-token' assert cl.access_token == 'test-access-token' diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index 529559f56af..53340ecede1 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -266,8 +266,8 @@ def test_handler_alexa(hass): hass.states.async_set( 'switch.test2', 'on', {'friendly_name': "Test switch 2"}) - with patch('homeassistant.components.cloud.Cloud.initialize', - return_value=mock_coro(True)): + with patch('homeassistant.components.cloud.Cloud.async_start', + return_value=mock_coro()): setup = yield from async_setup_component(hass, 'cloud', { 'cloud': { 'alexa': { @@ -309,8 +309,8 @@ def test_handler_google_actions(hass): hass.states.async_set( 'switch.test2', 'on', {'friendly_name': "Test switch 2"}) - with patch('homeassistant.components.cloud.Cloud.initialize', - return_value=mock_coro(True)): + with patch('homeassistant.components.cloud.Cloud.async_start', + return_value=mock_coro()): setup = yield from async_setup_component(hass, 'cloud', { 'cloud': { 'google_actions': { diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 7d601c7a78d..73f2b9ff5a4 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -7,10 +7,12 @@ from datetime import datetime, timedelta from astral import Astral import pytest +from homeassistant.core import callback from homeassistant.setup import setup_component import homeassistant.core as ha from homeassistant.const import MATCH_ALL from homeassistant.helpers.event import ( + async_call_later, track_point_in_utc_time, track_point_in_time, track_utc_time_change, @@ -52,7 +54,7 @@ class TestEventHelpers(unittest.TestCase): runs = [] track_point_in_utc_time( - self.hass, lambda x: runs.append(1), birthday_paulus) + self.hass, callback(lambda x: runs.append(1)), birthday_paulus) self._send_time_changed(before_birthday) self.hass.block_till_done() @@ -68,14 +70,14 @@ class TestEventHelpers(unittest.TestCase): self.assertEqual(1, len(runs)) track_point_in_time( - self.hass, lambda x: runs.append(1), birthday_paulus) + self.hass, callback(lambda x: runs.append(1)), birthday_paulus) self._send_time_changed(after_birthday) self.hass.block_till_done() self.assertEqual(2, len(runs)) unsub = track_point_in_time( - self.hass, lambda x: runs.append(1), birthday_paulus) + self.hass, callback(lambda x: runs.append(1)), birthday_paulus) unsub() self._send_time_changed(after_birthday) @@ -642,3 +644,22 @@ class TestEventHelpers(unittest.TestCase): self._send_time_changed(datetime(2014, 5, 2, 0, 0, 0)) self.hass.block_till_done() self.assertEqual(0, len(specific_runs)) + + +@asyncio.coroutine +def test_async_call_later(hass): + """Test calling an action later.""" + def action(): pass + now = datetime(2017, 12, 19, 15, 40, 0, tzinfo=dt_util.UTC) + + with patch('homeassistant.helpers.event' + '.async_track_point_in_utc_time') as mock, \ + patch('homeassistant.util.dt.utcnow', return_value=now): + remove = async_call_later(hass, 3, action) + + assert len(mock.mock_calls) == 1 + p_hass, p_action, p_point = mock.mock_calls[0][1] + assert hass is hass + assert p_action is action + assert p_point == now + timedelta(seconds=3) + assert remove is mock() From a9e2dd3427c3b00bfb27bad72e95b42fa185005f Mon Sep 17 00:00:00 2001 From: Slava Date: Sat, 10 Feb 2018 21:59:04 +0100 Subject: [PATCH 006/173] Update limitlessled requirement to v1.0.9 (#12275) * Update limitlessled requirement to v1.0.9 * trigger cla * take back empty line --- homeassistant/components/light/limitlessled.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index aad2abdd183..0c6b1143bbd 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, Light, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['limitlessled==1.0.8'] +REQUIREMENTS = ['limitlessled==1.0.9'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index f50c010072e..3ca5b9fc763 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -453,7 +453,7 @@ liffylights==0.9.4 lightify==1.0.6.1 # homeassistant.components.light.limitlessled -limitlessled==1.0.8 +limitlessled==1.0.9 # homeassistant.components.linode linode-api==4.1.4b2 From fe1a85047e74bcbd6d7e58f7106c7867a430eba2 Mon Sep 17 00:00:00 2001 From: Thom Troy Date: Sat, 10 Feb 2018 23:06:24 +0000 Subject: [PATCH 007/173] have climate fallback to state if no ATTR_OPERATION_MODE (#12271) (#12279) --- homeassistant/components/google_assistant/smart_home.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index b718c009160..a2444e46ec1 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -237,7 +237,10 @@ def query_response_sensor( def query_response_climate( entity: Entity, config: Config, units: UnitSystem) -> dict: """Convert a climate entity to a QUERY response.""" - mode = entity.attributes.get(climate.ATTR_OPERATION_MODE).lower() + mode = entity.attributes.get(climate.ATTR_OPERATION_MODE) + if mode is None: + mode = entity.state + mode = mode.lower() if mode not in CLIMATE_SUPPORTED_MODES: mode = 'heat' attrs = entity.attributes From 65c6f72c9dad1a12e07227bcae7553fdc096297b Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Sun, 11 Feb 2018 09:40:48 +0200 Subject: [PATCH 008/173] check_config check bootstrap errors (#12291) --- homeassistant/scripts/check_config.py | 7 +++++++ tests/scripts/test_check_config.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 5cfcf628ec5..ba66d7d2605 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -30,6 +30,8 @@ MOCKS = { config_util._log_pkg_error), 'logger_exception': ("homeassistant.setup._LOGGER.error", setup._LOGGER.error), + 'logger_exception_bootstrap': ("homeassistant.bootstrap._LOGGER.error", + bootstrap._LOGGER.error), } SILENCE = ( 'homeassistant.bootstrap.clear_secret_cache', @@ -229,6 +231,11 @@ def check(config_path): res['except'].setdefault(ERROR_STR, []).append(msg % params) MOCKS['logger_exception'][1](msg, *params) + def mock_logger_exception_bootstrap(msg, *params): + """Log logger.exceptions.""" + res['except'].setdefault(ERROR_STR, []).append(msg % params) + MOCKS['logger_exception_bootstrap'][1](msg, *params) + # Patches to skip functions for sil in SILENCE: PATCHES[sil] = patch(sil) diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index a454a5a64b4..165bf121552 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -212,3 +212,20 @@ class TestCheckConfig(unittest.TestCase): assert res['components'] == {} assert res['secret_cache'] == {} assert res['secrets'] == {} + + def test_bootstrap_error(self): \ + # pylint: disable=no-self-use,invalid-name + """Test a valid platform setup.""" + files = { + 'badbootstrap.yaml': BASE_CONFIG + 'automation: !include no.yaml', + } + with patch_yaml_files(files): + res = check_config.check(get_test_config_dir('badbootstrap.yaml')) + change_yaml_files(res) + + err = res['except'].pop(check_config.ERROR_STR) + assert len(err) == 1 + assert res['except'] == {} + assert res['components'] == {} + assert res['secret_cache'] == {} + assert res['secrets'] == {} From 8b9eab196c0bed4c13437f47318e608082005484 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 11 Feb 2018 09:00:02 -0800 Subject: [PATCH 009/173] Attempt fixing flakiness of check config test (#12283) * Attempt fixing check_config script test flakiness * Fix logging * remove cleanup as Python will exit * Make sure we don't enqueue magicmocks * Lint * Reinstate cleanup as it broke secret tests --- homeassistant/scripts/check_config.py | 38 +++++++++++++++++---------- tests/scripts/test_check_config.py | 14 +++------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index ba66d7d2605..ec55b1d70c5 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -1,4 +1,5 @@ """Script to ensure a configuration file exists.""" +import asyncio import argparse import logging import os @@ -34,13 +35,10 @@ MOCKS = { bootstrap._LOGGER.error), } SILENCE = ( + 'homeassistant.bootstrap.async_enable_logging', 'homeassistant.bootstrap.clear_secret_cache', 'homeassistant.bootstrap.async_register_signal_handling', - 'homeassistant.core._LOGGER.info', - 'homeassistant.loader._LOGGER.info', - 'homeassistant.bootstrap._LOGGER.info', - 'homeassistant.bootstrap._LOGGER.warning', - 'homeassistant.util.yaml._LOGGER.debug', + 'homeassistant.config.process_ha_config_upgrade', ) PATCHES = {} @@ -48,6 +46,12 @@ C_HEAD = 'bold' ERROR_STR = 'General Errors' +@asyncio.coroutine +def mock_coro(*args): + """Coroutine that returns None.""" + return None + + def color(the_color, *args, reset=None): """Color helper.""" from colorlog.escape_codes import escape_codes, parse_colors @@ -155,6 +159,11 @@ def run(script_args: List) -> int: def check(config_path): """Perform a check by mocking hass load functions.""" + logging.getLogger('homeassistant.core').setLevel(logging.WARNING) + logging.getLogger('homeassistant.loader').setLevel(logging.WARNING) + logging.getLogger('homeassistant.setup').setLevel(logging.WARNING) + logging.getLogger('homeassistant.bootstrap').setLevel(logging.ERROR) + logging.getLogger('homeassistant.util.yaml').setLevel(logging.INFO) res = { 'yaml_files': OrderedDict(), # yaml_files loaded 'secrets': OrderedDict(), # secret cache and secrets loaded @@ -172,11 +181,12 @@ def check(config_path): # pylint: disable=unused-variable def mock_get(comp_name): """Mock hass.loader.get_component to replace setup & setup_platform.""" - def mock_setup(*kwargs): + @asyncio.coroutine + def mock_async_setup(*args): """Mock setup, only record the component name & config.""" assert comp_name not in res['components'], \ "Components should contain a list of platforms" - res['components'][comp_name] = kwargs[1].get(comp_name) + res['components'][comp_name] = args[1].get(comp_name) return True module = MOCKS['get'][1](comp_name) @@ -189,15 +199,15 @@ def check(config_path): # Test if platform/component and overwrite setup if '.' in comp_name: - module.setup_platform = mock_setup + module.async_setup_platform = mock_async_setup - if hasattr(module, 'async_setup_platform'): - del module.async_setup_platform + if hasattr(module, 'setup_platform'): + del module.setup_platform else: - module.setup = mock_setup + module.async_setup = mock_async_setup - if hasattr(module, 'async_setup'): - del module.async_setup + if hasattr(module, 'setup'): + del module.setup return module @@ -238,7 +248,7 @@ def check(config_path): # Patches to skip functions for sil in SILENCE: - PATCHES[sil] = patch(sil) + PATCHES[sil] = patch(sil, return_value=mock_coro()) # Patches with local mock functions for key, val in MOCKS.items(): diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 165bf121552..9b37659090f 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -1,10 +1,10 @@ """Test check_config script.""" import asyncio import logging -import os import unittest import homeassistant.scripts.check_config as check_config +from homeassistant.loader import set_component from tests.common import patch_yaml_files, get_test_config_dir _LOGGER = logging.getLogger(__name__) @@ -36,14 +36,6 @@ def change_yaml_files(check_dict): check_dict['yaml_files'].append('...' + key[len(root):]) -def tearDownModule(self): # pylint: disable=invalid-name - """Clean files.""" - # .HA_VERSION created during bootstrap's config update - path = get_test_config_dir('.HA_VERSION') - if os.path.isfile(path): - os.remove(path) - - class TestCheckConfig(unittest.TestCase): """Tests for the homeassistant.scripts.check_config module.""" @@ -124,6 +116,9 @@ class TestCheckConfig(unittest.TestCase): def test_component_platform_not_found(self): """Test errors if component or platform not found.""" + # Make sure they don't exist + set_component('beer', None) + set_component('light.beer', None) files = { 'badcomponent.yaml': BASE_CONFIG + 'beer:', 'badplatform.yaml': BASE_CONFIG + 'light:\n platform: beer', @@ -162,7 +157,6 @@ class TestCheckConfig(unittest.TestCase): 'secrets.yaml': ('logger: debug\n' 'http_pw: abc123'), } - self.maxDiff = None with patch_yaml_files(files): config_path = get_test_config_dir('secret.yaml') From 17e5740a0cdddc2374c41ba0eeb2c6eb55d192e7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 11 Feb 2018 09:16:01 -0800 Subject: [PATCH 010/173] Allow overriding name via entity registry (#12292) * Allow overriding name via entity registry * Update requirements --- .../components/remote/xiaomi_miio.py | 1 - homeassistant/helpers/entity.py | 5 ++- homeassistant/helpers/entity_component.py | 10 +++--- homeassistant/helpers/entity_platform.py | 1 + homeassistant/helpers/entity_registry.py | 30 +++++++++++----- homeassistant/package_constraints.txt | 1 + requirements_all.txt | 1 + setup.py | 1 + tests/common.py | 4 +-- tests/helpers/test_entity_platform.py | 23 +++++++++++- tests/helpers/test_entity_registry.py | 35 +++++++++++++++++-- 11 files changed, 91 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/remote/xiaomi_miio.py b/homeassistant/components/remote/xiaomi_miio.py index aa05246c9cd..32fde57b61a 100644 --- a/homeassistant/components/remote/xiaomi_miio.py +++ b/homeassistant/components/remote/xiaomi_miio.py @@ -226,7 +226,6 @@ class XiaomiMiioRemote(RemoteDevice): _LOGGER.error("Device does not support turn_off, " + "please use 'remote.send_command' to send commands.") - # pylint: enable=R0201 def _send_command(self, payload): """Send a command.""" from miio import DeviceException diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index c7653d5d5b9..6b882d2fdad 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -80,6 +80,9 @@ class Entity(object): # Process updates in parallel parallel_updates = None + # Name in the entity registry + registry_name = None + @property def should_poll(self) -> bool: """Return True if entity has to be polled for state. @@ -225,7 +228,7 @@ class Entity(object): if unit_of_measurement is not None: attr[ATTR_UNIT_OF_MEASUREMENT] = unit_of_measurement - name = self.name + name = self.registry_name or self.name if name is not None: attr[ATTR_FRIENDLY_NAME] = name diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 9dfbe580c16..2dcde6fdeda 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -40,19 +40,19 @@ class EntityComponent(object): self.config = None self._platforms = { - 'core': EntityPlatform( + domain: EntityPlatform( hass=hass, logger=logger, domain=domain, - platform_name='core', + platform_name=domain, scan_interval=self.scan_interval, parallel_updates=0, entity_namespace=None, async_entities_added_callback=self._async_update_group, ) } - self.async_add_entities = self._platforms['core'].async_add_entities - self.add_entities = self._platforms['core'].add_entities + self.async_add_entities = self._platforms[domain].async_add_entities + self.add_entities = self._platforms[domain].add_entities @property def entities(self): @@ -190,7 +190,7 @@ class EntityComponent(object): yield from asyncio.wait(tasks, loop=self.hass.loop) self._platforms = { - 'core': self._platforms['core'] + self.domain: self._platforms[self.domain] } self.config = None diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 3362f1e3b3f..5c1d437c7cf 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -213,6 +213,7 @@ class EntityPlatform(object): self.domain, self.platform_name, entity.unique_id, suggested_object_id=suggested_object_id) entity.entity_id = entry.entity_id + entity.registry_name = entry.name # We won't generate an entity ID if the platform has already set one # We will however make sure that platform cannot pick a registered ID diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 350c8273232..d33ca93f290 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -11,22 +11,37 @@ After initializing, call EntityRegistry.async_ensure_loaded to load the data from disk. """ import asyncio -from collections import namedtuple, OrderedDict +from collections import OrderedDict from itertools import chain import logging import os +import attr + from ..core import callback, split_entity_id from ..util import ensure_unique_string, slugify from ..util.yaml import load_yaml, save_yaml PATH_REGISTRY = 'entity_registry.yaml' SAVE_DELAY = 10 -Entry = namedtuple('EntityRegistryEntry', - 'entity_id,unique_id,platform,domain') _LOGGER = logging.getLogger(__name__) +@attr.s(slots=True, frozen=True) +class RegistryEntry: + """Entity Registry Entry.""" + + entity_id = attr.ib(type=str) + unique_id = attr.ib(type=str) + platform = attr.ib(type=str) + name = attr.ib(type=str, default=None) + domain = attr.ib(type=str, default=None, init=False, repr=False) + + def __attrs_post_init__(self): + """Computed properties.""" + object.__setattr__(self, "domain", split_entity_id(self.entity_id)[0]) + + class EntityRegistry: """Class to hold a registry of entities.""" @@ -65,11 +80,10 @@ class EntityRegistry: entity_id = self.async_generate_entity_id( domain, suggested_object_id or '{}_{}'.format(platform, unique_id)) - entity = Entry( + entity = RegistryEntry( entity_id=entity_id, unique_id=unique_id, platform=platform, - domain=domain, ) self.entities[entity_id] = entity _LOGGER.info('Registered new %s.%s entity: %s', @@ -98,11 +112,11 @@ class EntityRegistry: data = yield from self.hass.async_add_job(load_yaml, path) for entity_id, info in data.items(): - entities[entity_id] = Entry( - domain=split_entity_id(entity_id)[0], + entities[entity_id] = RegistryEntry( entity_id=entity_id, unique_id=info['unique_id'], - platform=info['platform'] + platform=info['platform'], + name=info.get('name') ) self.entities = entities diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ee3a37bbd53..7d182aebfa3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,6 +11,7 @@ async_timeout==2.0.0 chardet==3.0.4 astral==1.5 certifi>=2017.4.17 +attrs==17.4.0 # Breaks Python 3.6 and is not needed for our supported Pythons enum34==1000000000.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 3ca5b9fc763..308a81e16bb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -12,6 +12,7 @@ async_timeout==2.0.0 chardet==3.0.4 astral==1.5 certifi>=2017.4.17 +attrs==17.4.0 # homeassistant.components.nuimo_controller --only-binary=all https://github.com/getSenic/nuimo-linux-python/archive/29fc42987f74d8090d0e2382e8f248ff5990b8c9.zip#nuimo==1.0.0 diff --git a/setup.py b/setup.py index 5af84fc8e0e..0a454f9eb4d 100755 --- a/setup.py +++ b/setup.py @@ -61,6 +61,7 @@ REQUIRES = [ 'chardet==3.0.4', 'astral==1.5', 'certifi>=2017.4.17', + 'attrs==17.4.0', ] MIN_PY_VERSION = '.'.join(map( diff --git a/tests/common.py b/tests/common.py index 22af8ecb8a3..511d59dbdfe 100644 --- a/tests/common.py +++ b/tests/common.py @@ -317,10 +317,10 @@ def mock_component(hass, component): hass.config.components.add(component) -def mock_registry(hass): +def mock_registry(hass, mock_entries=None): """Mock the Entity Registry.""" registry = entity_registry.EntityRegistry(hass) - registry.entities = {} + registry.entities = mock_entries or {} hass.data[entity_platform.DATA_REGISTRY] = registry return registry diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 4c27cc45a00..e398349cf7a 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -9,7 +9,7 @@ import homeassistant.loader as loader from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_component import ( EntityComponent, DEFAULT_SCAN_INTERVAL) -from homeassistant.helpers import entity_platform +from homeassistant.helpers import entity_platform, entity_registry import homeassistant.util.dt as dt_util @@ -433,3 +433,24 @@ def test_entity_with_name_and_entity_id_getting_registered(hass): MockEntity(unique_id='1234', name='bla', entity_id='test_domain.world')]) assert 'test_domain.world' in hass.states.async_entity_ids() + + +@asyncio.coroutine +def test_overriding_name_from_registry(hass): + """Test that we can override a name via the Entity Registry.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + mock_registry(hass, { + 'test_domain.world': entity_registry.RegistryEntry( + entity_id='test_domain.world', + unique_id='1234', + # Using component.async_add_entities is equal to platform "domain" + platform='test_domain', + name='Overridden' + ) + }) + yield from component.async_add_entities([ + MockEntity(unique_id='1234', name='Device Name')]) + + state = hass.states.get('test_domain.world') + assert state is not None + assert state.name == 'Overridden' diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index d19a3f3fe49..7e1150638c1 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -9,6 +9,9 @@ from homeassistant.helpers import entity_registry from tests.common import mock_registry +YAML__OPEN_PATH = 'homeassistant.util.yaml.open' + + @pytest.fixture def registry(hass): """Return an empty, loaded, registry.""" @@ -82,13 +85,12 @@ def test_save_timer_reset_on_subsequent_save(hass, registry): @asyncio.coroutine def test_loading_saving_data(hass, registry): """Test that we load/save data correctly.""" - yaml_path = 'homeassistant.util.yaml.open' orig_entry1 = registry.async_get_or_create('light', 'hue', '1234') orig_entry2 = registry.async_get_or_create('light', 'hue', '5678') assert len(registry.entities) == 2 - with patch(yaml_path, mock_open(), create=True) as mock_write: + with patch(YAML__OPEN_PATH, mock_open(), create=True) as mock_write: yield from registry._async_save() # Mock open calls are: open file, context enter, write, context leave @@ -98,7 +100,7 @@ def test_loading_saving_data(hass, registry): registry2 = entity_registry.EntityRegistry(hass) with patch('os.path.isfile', return_value=True), \ - patch(yaml_path, mock_open(read_data=written), create=True): + patch(YAML__OPEN_PATH, mock_open(read_data=written), create=True): yield from registry2._async_load() # Ensure same order @@ -133,3 +135,30 @@ def test_is_registered(registry): entry = registry.async_get_or_create('light', 'hue', '1234') assert registry.async_is_registered(entry.entity_id) assert not registry.async_is_registered('light.non_existing') + + +@asyncio.coroutine +def test_loading_extra_values(hass): + """Test we load extra data from the registry.""" + written = """ +test.named: + platform: super_platform + unique_id: with-name + name: registry override +test.no_name: + platform: super_platform + unique_id: without-name +""" + + registry = entity_registry.EntityRegistry(hass) + + with patch('os.path.isfile', return_value=True), \ + patch(YAML__OPEN_PATH, mock_open(read_data=written), create=True): + yield from registry._async_load() + + entry_with_name = registry.async_get_or_create( + 'test', 'super_platform', 'with-name') + entry_without_name = registry.async_get_or_create( + 'test', 'super_platform', 'without-name') + assert entry_with_name.name == 'registry override' + assert entry_without_name.name is None From b1c0cabe6c48ffff7fda3403ec3f1ca919fec6e2 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Sun, 11 Feb 2018 18:17:58 +0100 Subject: [PATCH 011/173] Fix MQTT retained message not being re-dispatched (#12004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix MQTT retained message not being re-dispatched * Fix tests * Use paho-mqtt for retained messages * Improve code style * Store list of subscribers * Fix lint error * Adhere to Home Assistant's logging standard "Try to avoid brackets and additional quotes around the output to make it easier for users to parse the log." - https://home-assistant.io/developers/development_guidelines/ * Add reconnect tests * Fix lint error * Introduce Subscription Tests still need to be updated * Use namedtuple for MQTT messages ... And fix issues Accessing the config manually at runtime isn't ideal * Fix MQTT __init__.py tests * Updated usage of Mocks * Moved tests that were testing subscriptions out of the MQTTComponent test, because of how mock.patch was used * Adjusted the remaining tests for the MQTT clients new behavior - e.g. self.progress was removed * Updated the async_fire_mqtt_message helper * ✅ Update MQTT tests * Re-introduce the MQTT subscriptions through the dispatcher for tests - quite ugly though... 🚧 * Update fixtures to use our new MQTT mock 🎨 * 📝 Update base code according to comments * 🔨 Adjust MQTT test base * 🔨 Update other MQTT tests * 🍎 Fix carriage return in source files Apparently test_mqtt_json.py and test_mqtt_template.py were written on Windows. In order to not mess up the diff, I'll just redo the carriage return. * 🎨 Remove unused import * 📝 Remove fire_mqtt_client_message * 🐛 Fix using python 3.6 method What's very interesting is that 3.4 didn't fail on travis... * 🐛 Fix using assert directly --- homeassistant/components/mqtt/__init__.py | 196 +-- tests/common.py | 34 +- .../alarm_control_panel/test_manual_mqtt.py | 39 +- .../alarm_control_panel/test_mqtt.py | 12 +- tests/components/climate/test_mqtt.py | 48 +- tests/components/cover/test_mqtt.py | 55 +- tests/components/light/test_mqtt.py | 63 +- tests/components/light/test_mqtt_json.py | 1176 +++++++++-------- tests/components/light/test_mqtt_template.py | 1022 +++++++------- tests/components/lock/test_mqtt.py | 9 +- tests/components/mqtt/test_discovery.py | 2 +- tests/components/mqtt/test_init.py | 288 ++-- tests/components/switch/test_mqtt.py | 9 +- tests/components/vacuum/test_mqtt.py | 48 +- tests/conftest.py | 20 +- 15 files changed, 1531 insertions(+), 1490 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index cdf59b92606..30c18953964 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -5,6 +5,10 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/mqtt/ """ import asyncio +from collections import namedtuple +from itertools import groupby +from typing import Optional +from operator import attrgetter import logging import os import socket @@ -15,13 +19,12 @@ import requests.certs import voluptuous as vol +from homeassistant.helpers.typing import HomeAssistantType from homeassistant.core import callback from homeassistant.setup import async_prepare_setup_platform from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass -from homeassistant.helpers import template, config_validation as cv -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, dispatcher_send) +from homeassistant.helpers import template, ConfigType, config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util.async import ( run_coroutine_threadsafe, run_callback_threadsafe) @@ -39,7 +42,6 @@ DOMAIN = 'mqtt' DATA_MQTT = 'mqtt' SERVICE_PUBLISH = 'publish' -SIGNAL_MQTT_MESSAGE_RECEIVED = 'mqtt_message_received' CONF_EMBEDDED = 'embedded' CONF_BROKER = 'broker' @@ -173,7 +175,6 @@ MQTT_RW_PLATFORM_SCHEMA = MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_VALUE_TEMPLATE): cv.template, }) - # Service call validation schema MQTT_PUBLISH_SCHEMA = vol.Schema({ vol.Required(ATTR_TOPIC): valid_publish_topic, @@ -221,32 +222,13 @@ def publish_template(hass, topic, payload_template, qos=None, retain=None): @bind_hass def async_subscribe(hass, topic, msg_callback, qos=DEFAULT_QOS, encoding='utf-8'): - """Subscribe to an MQTT topic.""" - @callback - def async_mqtt_topic_subscriber(dp_topic, dp_payload, dp_qos): - """Match subscribed MQTT topic.""" - if not _match_topic(topic, dp_topic): - return + """Subscribe to an MQTT topic. - if encoding is not None: - try: - payload = dp_payload.decode(encoding) - _LOGGER.debug("Received message on %s: %s", dp_topic, payload) - except (AttributeError, UnicodeDecodeError): - _LOGGER.error("Illegal payload encoding %s from " - "MQTT topic: %s, Payload: %s", - encoding, dp_topic, dp_payload) - return - else: - _LOGGER.debug("Received binary message on %s", dp_topic) - payload = dp_payload - - hass.async_run_job(msg_callback, dp_topic, payload, dp_qos) - - async_remove = async_dispatcher_connect( - hass, SIGNAL_MQTT_MESSAGE_RECEIVED, async_mqtt_topic_subscriber) - - yield from hass.data[DATA_MQTT].async_subscribe(topic, qos) + Call the return value to unsubscribe. + """ + async_remove = \ + yield from hass.data[DATA_MQTT].async_subscribe(topic, msg_callback, + qos, encoding) return async_remove @@ -308,7 +290,7 @@ def _async_setup_discovery(hass, config): @asyncio.coroutine -def async_setup(hass, config): +def async_setup(hass: HomeAssistantType, config: ConfigType): """Start the MQTT protocol service.""" conf = config.get(DOMAIN) @@ -351,8 +333,8 @@ def async_setup(hass, config): return False # For cloudmqtt.com, secured connection, auto fill in certificate - if certificate is None and 19999 < port < 30000 and \ - broker.endswith('.cloudmqtt.com'): + if (certificate is None and 19999 < port < 30000 and + broker.endswith('.cloudmqtt.com')): certificate = os.path.join(os.path.dirname(__file__), 'addtrustexternalcaroot.crt') @@ -360,8 +342,12 @@ def async_setup(hass, config): if certificate == 'auto': certificate = requests.certs.where() - will_message = conf.get(CONF_WILL_MESSAGE) - birth_message = conf.get(CONF_BIRTH_MESSAGE) + will_message = None + if conf.get(CONF_WILL_MESSAGE) is not None: + will_message = Message(**conf.get(CONF_WILL_MESSAGE)) + birth_message = None + if conf.get(CONF_BIRTH_MESSAGE) is not None: + birth_message = Message(**conf.get(CONF_BIRTH_MESSAGE)) # Be able to override versions other than TLSv1.0 under Python3.6 conf_tls_version = conf.get(CONF_TLS_VERSION) @@ -414,8 +400,8 @@ def async_setup(hass, config): template.Template(payload_template, hass).async_render() except template.jinja2.TemplateError as exc: _LOGGER.error( - "Unable to publish to '%s': rendering payload template of " - "'%s' failed because %s", + "Unable to publish to %s: rendering payload template of " + "%s failed because %s", msg_topic, payload_template, exc) return @@ -432,13 +418,21 @@ def async_setup(hass, config): return True +Subscription = namedtuple('Subscription', + ['topic', 'callback', 'qos', 'encoding']) +Subscription.__new__.__defaults__ = (0, 'utf-8') + +Message = namedtuple('Message', ['topic', 'payload', 'qos', 'retain']) +Message.__new__.__defaults__ = (0, False) + + class MQTT(object): """Home Assistant MQTT client.""" def __init__(self, hass, broker, port, client_id, keepalive, username, password, certificate, client_key, client_cert, - tls_insecure, protocol, will_message, birth_message, - tls_version): + tls_insecure, protocol, will_message: Optional[Message], + birth_message: Optional[Message], tls_version): """Initialize Home Assistant MQTT client.""" import paho.mqtt.client as mqtt @@ -446,9 +440,7 @@ class MQTT(object): self.broker = broker self.port = port self.keepalive = keepalive - self.wanted_topics = {} - self.subscribed_topics = {} - self.progress = {} + self.subscriptions = [] self.birth_message = birth_message self._mqttc = None self._paho_lock = asyncio.Lock(loop=hass.loop) @@ -474,17 +466,12 @@ class MQTT(object): if tls_insecure is not None: self._mqttc.tls_insecure_set(tls_insecure) - self._mqttc.on_subscribe = self._mqtt_on_subscribe - self._mqttc.on_unsubscribe = self._mqtt_on_unsubscribe self._mqttc.on_connect = self._mqtt_on_connect self._mqttc.on_disconnect = self._mqtt_on_disconnect self._mqttc.on_message = self._mqtt_on_message if will_message: - self._mqttc.will_set(will_message.get(ATTR_TOPIC), - will_message.get(ATTR_PAYLOAD), - will_message.get(ATTR_QOS), - will_message.get(ATTR_RETAIN)) + self._mqttc.will_set(*will_message) @asyncio.coroutine def async_publish(self, topic, payload, qos, retain): @@ -526,36 +513,53 @@ class MQTT(object): return self.hass.async_add_job(stop) @asyncio.coroutine - def async_subscribe(self, topic, qos): - """Subscribe to a topic. + def async_subscribe(self, topic, msg_callback, qos, encoding): + """Set up a subscription to a topic with the provided qos. This method is a coroutine. """ if not isinstance(topic, str): - raise HomeAssistantError("topic need to be a string!") + raise HomeAssistantError("topic needs to be a string!") - with (yield from self._paho_lock): - if topic in self.subscribed_topics: + subscription = Subscription(topic, msg_callback, qos, encoding) + self.subscriptions.append(subscription) + + yield from self._async_perform_subscription(topic, qos) + + @callback + def async_remove(): + """Remove subscription.""" + if subscription not in self.subscriptions: + raise HomeAssistantError("Can't remove subscription twice") + self.subscriptions.remove(subscription) + + if any(other.topic == topic for other in self.subscriptions): + # Other subscriptions on topic remaining - don't unsubscribe. return - self.wanted_topics[topic] = qos - result, mid = yield from self.hass.async_add_job( - self._mqttc.subscribe, topic, qos) + self.hass.async_add_job(self._async_unsubscribe(topic)) - _raise_on_error(result) - self.progress[mid] = topic + return async_remove @asyncio.coroutine - def async_unsubscribe(self, topic): - """Unsubscribe from topic. + def _async_unsubscribe(self, topic): + """Unsubscribe from a topic. This method is a coroutine. """ - self.wanted_topics.pop(topic, None) - result, mid = yield from self.hass.async_add_job( - self._mqttc.unsubscribe, topic) + with (yield from self._paho_lock): + result, _ = yield from self.hass.async_add_job( + self._mqttc.unsubscribe, topic) + _raise_on_error(result) - _raise_on_error(result) - self.progress[mid] = topic + @asyncio.coroutine + def _async_perform_subscription(self, topic, qos): + """Perform a paho-mqtt subscription.""" + _LOGGER.debug("Subscribing to %s", topic) + + with (yield from self._paho_lock): + result, _ = yield from self.hass.async_add_job( + self._mqttc.subscribe, topic, qos) + _raise_on_error(result) def _mqtt_on_connect(self, _mqttc, _userdata, _flags, result_code): """On connect callback. @@ -571,50 +575,50 @@ class MQTT(object): self._mqttc.disconnect() return - self.progress = {} - self.subscribed_topics = {} - for topic, qos in self.wanted_topics.items(): - self.hass.add_job(self.async_subscribe, topic, qos) + # Group subscriptions to only re-subscribe once for each topic. + keyfunc = attrgetter('topic') + for topic, subs in groupby(sorted(self.subscriptions, key=keyfunc), + keyfunc): + # Re-subscribe with the highest requested qos + max_qos = max(subscription.qos for subscription in subs) + self.hass.add_job(self._async_perform_subscription, topic, max_qos) if self.birth_message: - self.hass.add_job(self.async_publish( - self.birth_message.get(ATTR_TOPIC), - self.birth_message.get(ATTR_PAYLOAD), - self.birth_message.get(ATTR_QOS), - self.birth_message.get(ATTR_RETAIN))) - - def _mqtt_on_subscribe(self, _mqttc, _userdata, mid, granted_qos): - """Subscribe successful callback.""" - topic = self.progress.pop(mid, None) - if topic is None: - return - self.subscribed_topics[topic] = granted_qos[0] + self.hass.add_job(self.async_publish(*self.birth_message)) def _mqtt_on_message(self, _mqttc, _userdata, msg): """Message received callback.""" - dispatcher_send( - self.hass, SIGNAL_MQTT_MESSAGE_RECEIVED, msg.topic, msg.payload, - msg.qos - ) + self.hass.async_add_job(self._mqtt_handle_message, msg) - def _mqtt_on_unsubscribe(self, _mqttc, _userdata, mid, granted_qos): - """Unsubscribe successful callback.""" - topic = self.progress.pop(mid, None) - if topic is None: - return - self.subscribed_topics.pop(topic, None) + @callback + def _mqtt_handle_message(self, msg): + _LOGGER.debug("Received message on %s: %s", msg.topic, msg.payload) + + for subscription in self.subscriptions: + if not _match_topic(subscription.topic, msg.topic): + continue + + payload = msg.payload + if subscription.encoding is not None: + try: + payload = msg.payload.decode(subscription.encoding) + except (AttributeError, UnicodeDecodeError): + _LOGGER.warning("Can't decode payload %s on %s " + "with encoding %s", + msg.payload, msg.topic, + subscription.encoding) + return + + self.hass.async_run_job(subscription.callback, + msg.topic, payload, msg.qos) def _mqtt_on_disconnect(self, _mqttc, _userdata, result_code): """Disconnected callback.""" - self.progress = {} - self.subscribed_topics = {} - # When disconnected because of calling disconnect() if result_code == 0: return tries = 0 - wait_time = 0 while True: try: @@ -693,7 +697,7 @@ class MqttAvailability(Entity): if self._availability_topic is not None: yield from async_subscribe( self.hass, self._availability_topic, - availability_message_received, self. _availability_qos) + availability_message_received, self._availability_qos) @property def available(self) -> bool: diff --git a/tests/common.py b/tests/common.py index 511d59dbdfe..cf896008d85 100644 --- a/tests/common.py +++ b/tests/common.py @@ -15,7 +15,7 @@ from homeassistant import core as ha, loader from homeassistant.setup import setup_component, async_setup_component from homeassistant.config import async_process_component_config from homeassistant.helpers import ( - intent, dispatcher, entity, restore_state, entity_registry, + intent, entity, restore_state, entity_registry, entity_platform) from homeassistant.util.unit_system import METRIC_SYSTEM import homeassistant.util.dt as date_util @@ -214,13 +214,12 @@ def async_mock_intent(hass, intent_typ): @ha.callback -def async_fire_mqtt_message(hass, topic, payload, qos=0): +def async_fire_mqtt_message(hass, topic, payload, qos=0, retain=False): """Fire the MQTT message.""" if isinstance(payload, str): payload = payload.encode('utf-8') - dispatcher.async_dispatcher_send( - hass, mqtt.SIGNAL_MQTT_MESSAGE_RECEIVED, topic, - payload, qos) + msg = mqtt.Message(topic, payload, qos, retain) + hass.async_run_job(hass.data['mqtt']._mqtt_on_message, None, None, msg) fire_mqtt_message = threadsafe_callback_factory(async_fire_mqtt_message) @@ -293,16 +292,25 @@ def mock_http_component_app(hass, api_password=None): @asyncio.coroutine -def async_mock_mqtt_component(hass): +def async_mock_mqtt_component(hass, config=None): """Mock the MQTT component.""" - with patch('homeassistant.components.mqtt.MQTT') as mock_mqtt: - mock_mqtt().async_connect.return_value = mock_coro(True) - yield from async_setup_component(hass, mqtt.DOMAIN, { - mqtt.DOMAIN: { - mqtt.CONF_BROKER: 'mock-broker', - } + if config is None: + config = {mqtt.CONF_BROKER: 'mock-broker'} + + with patch('paho.mqtt.client.Client') as mock_client: + mock_client().connect.return_value = 0 + mock_client().subscribe.return_value = (0, 0) + mock_client().publish.return_value = (0, 0) + + result = yield from async_setup_component(hass, mqtt.DOMAIN, { + mqtt.DOMAIN: config }) - return mock_mqtt + assert result + + hass.data['mqtt'] = MagicMock(spec_set=hass.data['mqtt'], + wraps=hass.data['mqtt']) + + return hass.data['mqtt'] mock_mqtt_component = threadsafe_coroutine_factory(async_mock_mqtt_component) diff --git a/tests/components/alarm_control_panel/test_manual_mqtt.py b/tests/components/alarm_control_panel/test_manual_mqtt.py index 83254d9104f..719352c5419 100644 --- a/tests/components/alarm_control_panel/test_manual_mqtt.py +++ b/tests/components/alarm_control_panel/test_manual_mqtt.py @@ -1395,53 +1395,60 @@ class TestAlarmControlPanelManualMqtt(unittest.TestCase): # Component should send disarmed alarm state on startup self.hass.block_till_done() - self.assertEqual(('alarm/state', STATE_ALARM_DISARMED, 0, True), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/state', STATE_ALARM_DISARMED, 0, True) + self.mock_publish.async_publish.reset_mock() # Arm in home mode alarm_control_panel.alarm_arm_home(self.hass) self.hass.block_till_done() - self.assertEqual(('alarm/state', STATE_ALARM_PENDING, 0, True), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/state', STATE_ALARM_PENDING, 0, True) + self.mock_publish.async_publish.reset_mock() # Fast-forward a little bit future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' 'dt_util.utcnow'), return_value=future): fire_time_changed(self.hass, future) self.hass.block_till_done() - self.assertEqual(('alarm/state', STATE_ALARM_ARMED_HOME, 0, True), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/state', STATE_ALARM_ARMED_HOME, 0, True) + self.mock_publish.async_publish.reset_mock() # Arm in away mode alarm_control_panel.alarm_arm_away(self.hass) self.hass.block_till_done() - self.assertEqual(('alarm/state', STATE_ALARM_PENDING, 0, True), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/state', STATE_ALARM_PENDING, 0, True) + self.mock_publish.async_publish.reset_mock() # Fast-forward a little bit future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' 'dt_util.utcnow'), return_value=future): fire_time_changed(self.hass, future) self.hass.block_till_done() - self.assertEqual(('alarm/state', STATE_ALARM_ARMED_AWAY, 0, True), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/state', STATE_ALARM_ARMED_AWAY, 0, True) + self.mock_publish.async_publish.reset_mock() # Arm in night mode alarm_control_panel.alarm_arm_night(self.hass) self.hass.block_till_done() - self.assertEqual(('alarm/state', STATE_ALARM_PENDING, 0, True), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/state', STATE_ALARM_PENDING, 0, True) + self.mock_publish.async_publish.reset_mock() # Fast-forward a little bit future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' 'dt_util.utcnow'), return_value=future): fire_time_changed(self.hass, future) self.hass.block_till_done() - self.assertEqual(('alarm/state', STATE_ALARM_ARMED_NIGHT, 0, True), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/state', STATE_ALARM_ARMED_NIGHT, 0, True) + self.mock_publish.async_publish.reset_mock() # Disarm alarm_control_panel.alarm_disarm(self.hass) self.hass.block_till_done() - self.assertEqual(('alarm/state', STATE_ALARM_DISARMED, 0, True), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/state', STATE_ALARM_DISARMED, 0, True) diff --git a/tests/components/alarm_control_panel/test_mqtt.py b/tests/components/alarm_control_panel/test_mqtt.py index 5a93a55254d..dee9b3959ca 100644 --- a/tests/components/alarm_control_panel/test_mqtt.py +++ b/tests/components/alarm_control_panel/test_mqtt.py @@ -106,8 +106,8 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): alarm_control_panel.alarm_arm_home(self.hass) self.hass.block_till_done() - self.assertEqual(('alarm/command', 'ARM_HOME', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/command', 'ARM_HOME', 0, False) def test_arm_home_not_publishes_mqtt_with_invalid_code(self): """Test not publishing of MQTT messages with invalid code.""" @@ -139,8 +139,8 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): alarm_control_panel.alarm_arm_away(self.hass) self.hass.block_till_done() - self.assertEqual(('alarm/command', 'ARM_AWAY', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/command', 'ARM_AWAY', 0, False) def test_arm_away_not_publishes_mqtt_with_invalid_code(self): """Test not publishing of MQTT messages with invalid code.""" @@ -172,8 +172,8 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): alarm_control_panel.alarm_disarm(self.hass) self.hass.block_till_done() - self.assertEqual(('alarm/command', 'DISARM', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/command', 'DISARM', 0, False) def test_disarm_not_publishes_mqtt_with_invalid_code(self): """Test not publishing of MQTT messages with invalid code.""" diff --git a/tests/components/climate/test_mqtt.py b/tests/components/climate/test_mqtt.py index c4fa2b304df..663393503ac 100644 --- a/tests/components/climate/test_mqtt.py +++ b/tests/components/climate/test_mqtt.py @@ -104,8 +104,8 @@ class TestMQTTClimate(unittest.TestCase): state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual("cool", state.attributes.get('operation_mode')) self.assertEqual("cool", state.state) - self.assertEqual(('mode-topic', 'cool', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'mode-topic', 'cool', 0, False) def test_set_operation_pessimistic(self): """Test setting operation mode in pessimistic mode.""" @@ -178,8 +178,8 @@ class TestMQTTClimate(unittest.TestCase): self.assertEqual("low", state.attributes.get('fan_mode')) climate.set_fan_mode(self.hass, 'high', ENTITY_CLIMATE) self.hass.block_till_done() - self.assertEqual(('fan-mode-topic', 'high', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'fan-mode-topic', 'high', 0, False) state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual('high', state.attributes.get('fan_mode')) @@ -226,8 +226,8 @@ class TestMQTTClimate(unittest.TestCase): self.assertEqual("off", state.attributes.get('swing_mode')) climate.set_swing_mode(self.hass, 'on', ENTITY_CLIMATE) self.hass.block_till_done() - self.assertEqual(('swing-mode-topic', 'on', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'swing-mode-topic', 'on', 0, False) state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual("on", state.attributes.get('swing_mode')) @@ -239,15 +239,16 @@ class TestMQTTClimate(unittest.TestCase): self.assertEqual(21, state.attributes.get('temperature')) climate.set_operation_mode(self.hass, 'heat', ENTITY_CLIMATE) self.hass.block_till_done() - self.assertEqual(('mode-topic', 'heat', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'mode-topic', 'heat', 0, False) + self.mock_publish.async_publish.reset_mock() climate.set_temperature(self.hass, temperature=47, entity_id=ENTITY_CLIMATE) self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual(47, state.attributes.get('temperature')) - self.assertEqual(('temperature-topic', 47, 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'temperature-topic', 47, 0, False) def test_set_target_temperature_pessimistic(self): """Test setting the target temperature.""" @@ -328,15 +329,16 @@ class TestMQTTClimate(unittest.TestCase): self.assertEqual('off', state.attributes.get('away_mode')) climate.set_away_mode(self.hass, True, ENTITY_CLIMATE) self.hass.block_till_done() - self.assertEqual(('away-mode-topic', 'AN', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'away-mode-topic', 'AN', 0, False) + self.mock_publish.async_publish.reset_mock() state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual('on', state.attributes.get('away_mode')) climate.set_away_mode(self.hass, False, ENTITY_CLIMATE) self.hass.block_till_done() - self.assertEqual(('away-mode-topic', 'AUS', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'away-mode-topic', 'AUS', 0, False) state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual('off', state.attributes.get('away_mode')) @@ -372,15 +374,16 @@ class TestMQTTClimate(unittest.TestCase): self.assertEqual(None, state.attributes.get('hold_mode')) climate.set_hold_mode(self.hass, 'on', ENTITY_CLIMATE) self.hass.block_till_done() - self.assertEqual(('hold-topic', 'on', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'hold-topic', 'on', 0, False) + self.mock_publish.async_publish.reset_mock() state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual('on', state.attributes.get('hold_mode')) climate.set_hold_mode(self.hass, 'off', ENTITY_CLIMATE) self.hass.block_till_done() - self.assertEqual(('hold-topic', 'off', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'hold-topic', 'off', 0, False) state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual('off', state.attributes.get('hold_mode')) @@ -421,15 +424,16 @@ class TestMQTTClimate(unittest.TestCase): self.assertEqual('off', state.attributes.get('aux_heat')) climate.set_aux_heat(self.hass, True, ENTITY_CLIMATE) self.hass.block_till_done() - self.assertEqual(('aux-topic', 'ON', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'aux-topic', 'ON', 0, False) + self.mock_publish.async_publish.reset_mock() state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual('on', state.attributes.get('aux_heat')) climate.set_aux_heat(self.hass, False, ENTITY_CLIMATE) self.hass.block_till_done() - self.assertEqual(('aux-topic', 'OFF', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'aux-topic', 'OFF', 0, False) state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual('off', state.attributes.get('aux_heat')) diff --git a/tests/components/cover/test_mqtt.py b/tests/components/cover/test_mqtt.py index 0b49e21674e..23a7b32fc28 100644 --- a/tests/components/cover/test_mqtt.py +++ b/tests/components/cover/test_mqtt.py @@ -116,16 +116,17 @@ class TestCoverMQTT(unittest.TestCase): cover.open_cover(self.hass, 'cover.test') self.hass.block_till_done() - self.assertEqual(('command-topic', 'OPEN', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'command-topic', 'OPEN', 0, False) + self.mock_publish.async_publish.reset_mock() state = self.hass.states.get('cover.test') self.assertEqual(STATE_OPEN, state.state) cover.close_cover(self.hass, 'cover.test') self.hass.block_till_done() - self.assertEqual(('command-topic', 'CLOSE', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'command-topic', 'CLOSE', 0, False) state = self.hass.states.get('cover.test') self.assertEqual(STATE_CLOSED, state.state) @@ -147,8 +148,8 @@ class TestCoverMQTT(unittest.TestCase): cover.open_cover(self.hass, 'cover.test') self.hass.block_till_done() - self.assertEqual(('command-topic', 'OPEN', 2, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'command-topic', 'OPEN', 2, False) state = self.hass.states.get('cover.test') self.assertEqual(STATE_UNKNOWN, state.state) @@ -170,8 +171,8 @@ class TestCoverMQTT(unittest.TestCase): cover.close_cover(self.hass, 'cover.test') self.hass.block_till_done() - self.assertEqual(('command-topic', 'CLOSE', 2, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'command-topic', 'CLOSE', 2, False) state = self.hass.states.get('cover.test') self.assertEqual(STATE_UNKNOWN, state.state) @@ -193,8 +194,8 @@ class TestCoverMQTT(unittest.TestCase): cover.stop_cover(self.hass, 'cover.test') self.hass.block_till_done() - self.assertEqual(('command-topic', 'STOP', 2, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'command-topic', 'STOP', 2, False) state = self.hass.states.get('cover.test') self.assertEqual(STATE_UNKNOWN, state.state) @@ -295,8 +296,8 @@ class TestCoverMQTT(unittest.TestCase): cover.set_cover_position(self.hass, 100, 'cover.test') self.hass.block_till_done() - self.assertEqual(('position-topic', '38', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'position-topic', '38', 0, False) def test_set_position_untemplated(self): """Test setting cover position via template.""" @@ -316,8 +317,8 @@ class TestCoverMQTT(unittest.TestCase): cover.set_cover_position(self.hass, 62, 'cover.test') self.hass.block_till_done() - self.assertEqual(('position-topic', 62, 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'position-topic', 62, 0, False) def test_no_command_topic(self): """Test with no command topic.""" @@ -401,14 +402,15 @@ class TestCoverMQTT(unittest.TestCase): cover.open_cover_tilt(self.hass, 'cover.test') self.hass.block_till_done() - self.assertEqual(('tilt-command-topic', 100, 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'tilt-command-topic', 100, 0, False) + self.mock_publish.async_publish.reset_mock() cover.close_cover_tilt(self.hass, 'cover.test') self.hass.block_till_done() - self.assertEqual(('tilt-command-topic', 0, 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'tilt-command-topic', 0, 0, False) def test_tilt_given_value(self): """Test tilting to a given value.""" @@ -432,14 +434,15 @@ class TestCoverMQTT(unittest.TestCase): cover.open_cover_tilt(self.hass, 'cover.test') self.hass.block_till_done() - self.assertEqual(('tilt-command-topic', 400, 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'tilt-command-topic', 400, 0, False) + self.mock_publish.async_publish.reset_mock() cover.close_cover_tilt(self.hass, 'cover.test') self.hass.block_till_done() - self.assertEqual(('tilt-command-topic', 125, 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'tilt-command-topic', 125, 0, False) def test_tilt_via_topic(self): """Test tilt by updating status via MQTT.""" @@ -538,8 +541,8 @@ class TestCoverMQTT(unittest.TestCase): cover.set_cover_tilt_position(self.hass, 50, 'cover.test') self.hass.block_till_done() - self.assertEqual(('tilt-command-topic', 50, 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'tilt-command-topic', 50, 0, False) def test_tilt_position_altered_range(self): """Test tilt via method invocation with altered range.""" @@ -565,8 +568,8 @@ class TestCoverMQTT(unittest.TestCase): cover.set_cover_tilt_position(self.hass, 50, 'cover.test') self.hass.block_till_done() - self.assertEqual(('tilt-command-topic', 25, 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'tilt-command-topic', 25, 0, False) def test_find_percentage_in_range_defaults(self): """Test find percentage in range with default range.""" diff --git a/tests/components/light/test_mqtt.py b/tests/components/light/test_mqtt.py index 7ef33aad2d9..6c56564df69 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -492,16 +492,18 @@ class TestLightMQTT(unittest.TestCase): light.turn_on(self.hass, 'light.test') self.hass.block_till_done() - self.assertEqual(('test_light_rgb/set', 'on', 2, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'on', 2, False) + self.mock_publish.async_publish.reset_mock() state = self.hass.states.get('light.test') self.assertEqual(STATE_ON, state.state) light.turn_off(self.hass, 'light.test') self.hass.block_till_done() - self.assertEqual(('test_light_rgb/set', 'off', 2, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'off', 2, False) + self.mock_publish.async_publish.reset_mock() state = self.hass.states.get('light.test') self.assertEqual(STATE_OFF, state.state) @@ -512,7 +514,7 @@ class TestLightMQTT(unittest.TestCase): white_value=80) self.hass.block_till_done() - self.mock_publish().async_publish.assert_has_calls([ + self.mock_publish.async_publish.assert_has_calls([ mock.call('test_light_rgb/set', 'on', 2, False), mock.call('test_light_rgb/rgb/set', '75,75,75', 2, False), mock.call('test_light_rgb/brightness/set', 50, 2, False), @@ -550,7 +552,7 @@ class TestLightMQTT(unittest.TestCase): light.turn_on(self.hass, 'light.test', rgb_color=[255, 128, 64]) self.hass.block_till_done() - self.mock_publish().async_publish.assert_has_calls([ + self.mock_publish.async_publish.assert_has_calls([ mock.call('test_light_rgb/set', 'on', 0, False), mock.call('test_light_rgb/rgb/set', '#ff8040', 0, False), ], any_order=True) @@ -701,16 +703,17 @@ class TestLightMQTT(unittest.TestCase): # Should get the following MQTT messages. # test_light/set: 'ON' # test_light/bright: 50 - self.assertEqual(('test_light/set', 'ON', 0, False), - self.mock_publish.mock_calls[-4][1]) - self.assertEqual(('test_light/bright', 50, 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_has_calls([ + mock.call('test_light/set', 'ON', 0, False), + mock.call('test_light/bright', 50, 0, False), + ], any_order=True) + self.mock_publish.async_publish.reset_mock() light.turn_off(self.hass, 'light.test') self.hass.block_till_done() - self.assertEqual(('test_light/set', 'OFF', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'test_light/set', 'OFF', 0, False) def test_on_command_last(self): """Test on command being sent after brightness.""" @@ -733,16 +736,17 @@ class TestLightMQTT(unittest.TestCase): # Should get the following MQTT messages. # test_light/bright: 50 # test_light/set: 'ON' - self.assertEqual(('test_light/bright', 50, 0, False), - self.mock_publish.mock_calls[-4][1]) - self.assertEqual(('test_light/set', 'ON', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_has_calls([ + mock.call('test_light/bright', 50, 0, False), + mock.call('test_light/set', 'ON', 0, False), + ], any_order=True) + self.mock_publish.async_publish.reset_mock() light.turn_off(self.hass, 'light.test') self.hass.block_till_done() - self.assertEqual(('test_light/set', 'OFF', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'test_light/set', 'OFF', 0, False) def test_on_command_brightness(self): """Test on command being sent as only brightness.""" @@ -767,21 +771,24 @@ class TestLightMQTT(unittest.TestCase): # Should get the following MQTT messages. # test_light/bright: 255 - self.assertEqual(('test_light/bright', 255, 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'test_light/bright', 255, 0, False) + self.mock_publish.async_publish.reset_mock() light.turn_off(self.hass, 'light.test') self.hass.block_till_done() - self.assertEqual(('test_light/set', 'OFF', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'test_light/set', 'OFF', 0, False) + self.mock_publish.async_publish.reset_mock() # Turn on w/ brightness light.turn_on(self.hass, 'light.test', brightness=50) self.hass.block_till_done() - self.assertEqual(('test_light/bright', 50, 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'test_light/bright', 50, 0, False) + self.mock_publish.async_publish.reset_mock() light.turn_off(self.hass, 'light.test') self.hass.block_till_done() @@ -791,10 +798,10 @@ class TestLightMQTT(unittest.TestCase): light.turn_on(self.hass, 'light.test', rgb_color=[75, 75, 75]) self.hass.block_till_done() - self.assertEqual(('test_light/rgb', '75,75,75', 0, False), - self.mock_publish.mock_calls[-4][1]) - self.assertEqual(('test_light/bright', 50, 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_has_calls([ + mock.call('test_light/rgb', '75,75,75', 0, False), + mock.call('test_light/bright', 50, 0, False) + ], any_order=True) def test_default_availability_payload(self): """Test availability by default payload with defined topic.""" diff --git a/tests/components/light/test_mqtt_json.py b/tests/components/light/test_mqtt_json.py index a06f8e7d093..d7eb80980ca 100644 --- a/tests/components/light/test_mqtt_json.py +++ b/tests/components/light/test_mqtt_json.py @@ -1,579 +1,597 @@ -"""The tests for the MQTT JSON light platform. - -Configuration with RGB, brightness, color temp, effect, white value and XY: - -light: - platform: mqtt_json - name: mqtt_json_light_1 - state_topic: "home/rgb1" - command_topic: "home/rgb1/set" - brightness: true - color_temp: true - effect: true - rgb: true - white_value: true - xy: true - -Configuration with RGB, brightness, color temp, effect, white value: - -light: - platform: mqtt_json - name: mqtt_json_light_1 - state_topic: "home/rgb1" - command_topic: "home/rgb1/set" - brightness: true - color_temp: true - effect: true - rgb: true - white_value: true - -Configuration with RGB, brightness, color temp and effect: - -light: - platform: mqtt_json - name: mqtt_json_light_1 - state_topic: "home/rgb1" - command_topic: "home/rgb1/set" - brightness: true - color_temp: true - effect: true - rgb: true - -Configuration with RGB, brightness and color temp: - -light: - platform: mqtt_json - name: mqtt_json_light_1 - state_topic: "home/rgb1" - command_topic: "home/rgb1/set" - brightness: true - rgb: true - color_temp: true - -Configuration with RGB, brightness: - -light: - platform: mqtt_json - name: mqtt_json_light_1 - state_topic: "home/rgb1" - command_topic: "home/rgb1/set" - brightness: true - rgb: true - -Config without RGB: - -light: - platform: mqtt_json - name: mqtt_json_light_1 - state_topic: "home/rgb1" - command_topic: "home/rgb1/set" - brightness: true - -Config without RGB and brightness: - -light: - platform: mqtt_json - name: mqtt_json_light_1 - state_topic: "home/rgb1" - command_topic: "home/rgb1/set" - -Config with brightness and scale: - -light: - platform: mqtt_json - name: test - state_topic: "mqtt_json_light_1" - command_topic: "mqtt_json_light_1/set" - brightness: true - brightness_scale: 99 -""" - -import json -import unittest - -from homeassistant.setup import setup_component -from homeassistant.const import ( - STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE, - ATTR_SUPPORTED_FEATURES) -import homeassistant.components.light as light -from tests.common import ( - get_test_home_assistant, mock_mqtt_component, fire_mqtt_message, - assert_setup_component) - - -class TestLightMQTTJSON(unittest.TestCase): - """Test the MQTT JSON light.""" - - def setUp(self): # pylint: disable=invalid-name - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.mock_publish = mock_mqtt_component(self.hass) - - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that was started.""" - self.hass.stop() - - def test_fail_setup_if_no_command_topic(self): \ - # pylint: disable=invalid-name - """Test if setup fails with no command topic.""" - with assert_setup_component(0, light.DOMAIN): - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_json', - 'name': 'test', - } - }) - self.assertIsNone(self.hass.states.get('light.test')) - - def test_no_color_brightness_color_temp_white_val_if_no_topics(self): \ - # pylint: disable=invalid-name - """Test for no RGB, brightness, color temp, effect, white val or XY.""" - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_json', - 'name': 'test', - 'state_topic': 'test_light_rgb', - 'command_topic': 'test_light_rgb/set', - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - self.assertEqual(40, state.attributes.get(ATTR_SUPPORTED_FEATURES)) - self.assertIsNone(state.attributes.get('rgb_color')) - self.assertIsNone(state.attributes.get('brightness')) - self.assertIsNone(state.attributes.get('color_temp')) - self.assertIsNone(state.attributes.get('effect')) - self.assertIsNone(state.attributes.get('white_value')) - self.assertIsNone(state.attributes.get('xy_color')) - - fire_mqtt_message(self.hass, 'test_light_rgb', '{"state":"ON"}') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertIsNone(state.attributes.get('rgb_color')) - self.assertIsNone(state.attributes.get('brightness')) - self.assertIsNone(state.attributes.get('color_temp')) - self.assertIsNone(state.attributes.get('effect')) - self.assertIsNone(state.attributes.get('white_value')) - self.assertIsNone(state.attributes.get('xy_color')) - - def test_controlling_state_via_topic(self): \ - # pylint: disable=invalid-name - """Test the controlling of the state via topic.""" - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_json', - 'name': 'test', - 'state_topic': 'test_light_rgb', - 'command_topic': 'test_light_rgb/set', - 'brightness': True, - 'color_temp': True, - 'effect': True, - 'rgb': True, - 'white_value': True, - 'xy': True, - 'qos': '0' - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - self.assertEqual(255, state.attributes.get(ATTR_SUPPORTED_FEATURES)) - self.assertIsNone(state.attributes.get('rgb_color')) - self.assertIsNone(state.attributes.get('brightness')) - self.assertIsNone(state.attributes.get('color_temp')) - self.assertIsNone(state.attributes.get('effect')) - self.assertIsNone(state.attributes.get('white_value')) - self.assertIsNone(state.attributes.get('xy_color')) - self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) - - # Turn on the light, full white - fire_mqtt_message(self.hass, 'test_light_rgb', - '{"state":"ON",' - '"color":{"r":255,"g":255,"b":255,' - '"x":0.123,"y":0.123},' - '"brightness":255,' - '"color_temp":155,' - '"effect":"colorloop",' - '"white_value":150}') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) - self.assertEqual(255, state.attributes.get('brightness')) - self.assertEqual(155, state.attributes.get('color_temp')) - self.assertEqual('colorloop', state.attributes.get('effect')) - self.assertEqual(150, state.attributes.get('white_value')) - self.assertEqual([0.123, 0.123], state.attributes.get('xy_color')) - - # Turn the light off - fire_mqtt_message(self.hass, 'test_light_rgb', '{"state":"OFF"}') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - - fire_mqtt_message(self.hass, 'test_light_rgb', - '{"state":"ON",' - '"brightness":100}') - self.hass.block_till_done() - - light_state = self.hass.states.get('light.test') - self.hass.block_till_done() - self.assertEqual(100, - light_state.attributes['brightness']) - - fire_mqtt_message(self.hass, 'test_light_rgb', - '{"state":"ON",' - '"color":{"r":125,"g":125,"b":125}}') - self.hass.block_till_done() - - light_state = self.hass.states.get('light.test') - self.assertEqual([125, 125, 125], - light_state.attributes.get('rgb_color')) - - fire_mqtt_message(self.hass, 'test_light_rgb', - '{"state":"ON",' - '"color":{"x":0.135,"y":0.135}}') - self.hass.block_till_done() - - light_state = self.hass.states.get('light.test') - self.assertEqual([0.135, 0.135], - light_state.attributes.get('xy_color')) - - fire_mqtt_message(self.hass, 'test_light_rgb', - '{"state":"ON",' - '"color_temp":155}') - self.hass.block_till_done() - - light_state = self.hass.states.get('light.test') - self.assertEqual(155, light_state.attributes.get('color_temp')) - - fire_mqtt_message(self.hass, 'test_light_rgb', - '{"state":"ON",' - '"effect":"colorloop"}') - self.hass.block_till_done() - - light_state = self.hass.states.get('light.test') - self.assertEqual('colorloop', light_state.attributes.get('effect')) - - fire_mqtt_message(self.hass, 'test_light_rgb', - '{"state":"ON",' - '"white_value":155}') - self.hass.block_till_done() - - light_state = self.hass.states.get('light.test') - self.assertEqual(155, light_state.attributes.get('white_value')) - - def test_sending_mqtt_commands_and_optimistic(self): \ - # pylint: disable=invalid-name - """Test the sending of command in optimistic mode.""" - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_json', - 'name': 'test', - 'command_topic': 'test_light_rgb/set', - 'brightness': True, - 'color_temp': True, - 'effect': True, - 'rgb': True, - 'white_value': True, - 'qos': 2 - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - self.assertEqual(191, state.attributes.get(ATTR_SUPPORTED_FEATURES)) - self.assertTrue(state.attributes.get(ATTR_ASSUMED_STATE)) - - light.turn_on(self.hass, 'light.test') - self.hass.block_till_done() - - self.assertEqual(('test_light_rgb/set', '{"state": "ON"}', 2, False), - self.mock_publish.mock_calls[-2][1]) - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - - light.turn_off(self.hass, 'light.test') - self.hass.block_till_done() - - self.assertEqual(('test_light_rgb/set', '{"state": "OFF"}', 2, False), - self.mock_publish.mock_calls[-2][1]) - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - - light.turn_on(self.hass, 'light.test', - brightness=50, color_temp=155, effect='colorloop', - white_value=170) - self.hass.block_till_done() - - self.assertEqual('test_light_rgb/set', - self.mock_publish.mock_calls[-2][1][0]) - self.assertEqual(2, self.mock_publish.mock_calls[-2][1][2]) - self.assertEqual(False, self.mock_publish.mock_calls[-2][1][3]) - # Get the sent message - message_json = json.loads(self.mock_publish.mock_calls[-2][1][1]) - self.assertEqual(50, message_json["brightness"]) - self.assertEqual(155, message_json["color_temp"]) - self.assertEqual('colorloop', message_json["effect"]) - self.assertEqual(170, message_json["white_value"]) - self.assertEqual("ON", message_json["state"]) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertEqual(50, state.attributes['brightness']) - self.assertEqual(155, state.attributes['color_temp']) - self.assertEqual('colorloop', state.attributes['effect']) - self.assertEqual(170, state.attributes['white_value']) - - def test_flash_short_and_long(self): \ - # pylint: disable=invalid-name - """Test for flash length being sent when included.""" - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_json', - 'name': 'test', - 'state_topic': 'test_light_rgb', - 'command_topic': 'test_light_rgb/set', - 'flash_time_short': 5, - 'flash_time_long': 15, - 'qos': 0 - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - self.assertEqual(40, state.attributes.get(ATTR_SUPPORTED_FEATURES)) - - light.turn_on(self.hass, 'light.test', flash="short") - self.hass.block_till_done() - - self.assertEqual('test_light_rgb/set', - self.mock_publish.mock_calls[-2][1][0]) - self.assertEqual(0, self.mock_publish.mock_calls[-2][1][2]) - self.assertEqual(False, self.mock_publish.mock_calls[-2][1][3]) - # Get the sent message - message_json = json.loads(self.mock_publish.mock_calls[-2][1][1]) - self.assertEqual(5, message_json["flash"]) - self.assertEqual("ON", message_json["state"]) - - light.turn_on(self.hass, 'light.test', flash="long") - self.hass.block_till_done() - - self.assertEqual('test_light_rgb/set', - self.mock_publish.mock_calls[-2][1][0]) - self.assertEqual(0, self.mock_publish.mock_calls[-2][1][2]) - self.assertEqual(False, self.mock_publish.mock_calls[-2][1][3]) - # Get the sent message - message_json = json.loads(self.mock_publish.mock_calls[-2][1][1]) - self.assertEqual(15, message_json["flash"]) - self.assertEqual("ON", message_json["state"]) - - def test_transition(self): - """Test for transition time being sent when included.""" - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_json', - 'name': 'test', - 'state_topic': 'test_light_rgb', - 'command_topic': 'test_light_rgb/set', - 'qos': 0 - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - self.assertEqual(40, state.attributes.get(ATTR_SUPPORTED_FEATURES)) - - light.turn_on(self.hass, 'light.test', transition=10) - self.hass.block_till_done() - - self.assertEqual('test_light_rgb/set', - self.mock_publish.mock_calls[-2][1][0]) - self.assertEqual(0, self.mock_publish.mock_calls[-2][1][2]) - self.assertEqual(False, self.mock_publish.mock_calls[-2][1][3]) - # Get the sent message - message_json = json.loads(self.mock_publish.mock_calls[-2][1][1]) - self.assertEqual(10, message_json["transition"]) - self.assertEqual("ON", message_json["state"]) - - # Transition back off - light.turn_off(self.hass, 'light.test', transition=10) - self.hass.block_till_done() - - self.assertEqual('test_light_rgb/set', - self.mock_publish.mock_calls[-2][1][0]) - self.assertEqual(0, self.mock_publish.mock_calls[-2][1][2]) - self.assertEqual(False, self.mock_publish.mock_calls[-2][1][3]) - # Get the sent message - message_json = json.loads(self.mock_publish.mock_calls[-2][1][1]) - self.assertEqual(10, message_json["transition"]) - self.assertEqual("OFF", message_json["state"]) - - def test_brightness_scale(self): - """Test for brightness scaling.""" - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_json', - 'name': 'test', - 'state_topic': 'test_light_bright_scale', - 'command_topic': 'test_light_bright_scale/set', - 'brightness': True, - 'brightness_scale': 99 - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - self.assertIsNone(state.attributes.get('brightness')) - self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) - - # Turn on the light - fire_mqtt_message(self.hass, 'test_light_bright_scale', - '{"state":"ON"}') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertEqual(255, state.attributes.get('brightness')) - - # Turn on the light with brightness - fire_mqtt_message(self.hass, 'test_light_bright_scale', - '{"state":"ON",' - '"brightness": 99}') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertEqual(255, state.attributes.get('brightness')) - - def test_invalid_color_brightness_and_white_values(self): \ - # pylint: disable=invalid-name - """Test that invalid color/brightness/white values are ignored.""" - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_json', - 'name': 'test', - 'state_topic': 'test_light_rgb', - 'command_topic': 'test_light_rgb/set', - 'brightness': True, - 'rgb': True, - 'white_value': True, - 'qos': '0' - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - self.assertEqual(185, state.attributes.get(ATTR_SUPPORTED_FEATURES)) - self.assertIsNone(state.attributes.get('rgb_color')) - self.assertIsNone(state.attributes.get('brightness')) - self.assertIsNone(state.attributes.get('white_value')) - self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) - - # Turn on the light - fire_mqtt_message(self.hass, 'test_light_rgb', - '{"state":"ON",' - '"color":{"r":255,"g":255,"b":255},' - '"brightness": 255,' - '"white_value": 255}') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) - self.assertEqual(255, state.attributes.get('brightness')) - self.assertEqual(255, state.attributes.get('white_value')) - - # Bad color values - fire_mqtt_message(self.hass, 'test_light_rgb', - '{"state":"ON",' - '"color":{"r":"bad","g":"val","b":"test"}}') - self.hass.block_till_done() - - # Color should not have changed - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) - - # Bad brightness values - fire_mqtt_message(self.hass, 'test_light_rgb', - '{"state":"ON",' - '"brightness": "badValue"}') - self.hass.block_till_done() - - # Brightness should not have changed - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertEqual(255, state.attributes.get('brightness')) - - # Bad white value - fire_mqtt_message(self.hass, 'test_light_rgb', - '{"state":"ON",' - '"white_value": "badValue"}') - self.hass.block_till_done() - - # White value should not have changed - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertEqual(255, state.attributes.get('white_value')) - - def test_default_availability_payload(self): - """Test availability by default payload with defined topic.""" - self.assertTrue(setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_json', - 'name': 'test', - 'state_topic': 'test_light_rgb', - 'command_topic': 'test_light_rgb/set', - 'availability_topic': 'availability-topic' - } - })) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_UNAVAILABLE, state.state) - - fire_mqtt_message(self.hass, 'availability-topic', 'online') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertNotEqual(STATE_UNAVAILABLE, state.state) - - fire_mqtt_message(self.hass, 'availability-topic', 'offline') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_UNAVAILABLE, state.state) - - def test_custom_availability_payload(self): - """Test availability by custom payload with defined topic.""" - self.assertTrue(setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_json', - 'name': 'test', - 'state_topic': 'test_light_rgb', - 'command_topic': 'test_light_rgb/set', - 'availability_topic': 'availability-topic', - 'payload_available': 'good', - 'payload_not_available': 'nogood' - } - })) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_UNAVAILABLE, state.state) - - fire_mqtt_message(self.hass, 'availability-topic', 'good') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertNotEqual(STATE_UNAVAILABLE, state.state) - - fire_mqtt_message(self.hass, 'availability-topic', 'nogood') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_UNAVAILABLE, state.state) +"""The tests for the MQTT JSON light platform. + +Configuration with RGB, brightness, color temp, effect, white value and XY: + +light: + platform: mqtt_json + name: mqtt_json_light_1 + state_topic: "home/rgb1" + command_topic: "home/rgb1/set" + brightness: true + color_temp: true + effect: true + rgb: true + white_value: true + xy: true + +Configuration with RGB, brightness, color temp, effect, white value: + +light: + platform: mqtt_json + name: mqtt_json_light_1 + state_topic: "home/rgb1" + command_topic: "home/rgb1/set" + brightness: true + color_temp: true + effect: true + rgb: true + white_value: true + +Configuration with RGB, brightness, color temp and effect: + +light: + platform: mqtt_json + name: mqtt_json_light_1 + state_topic: "home/rgb1" + command_topic: "home/rgb1/set" + brightness: true + color_temp: true + effect: true + rgb: true + +Configuration with RGB, brightness and color temp: + +light: + platform: mqtt_json + name: mqtt_json_light_1 + state_topic: "home/rgb1" + command_topic: "home/rgb1/set" + brightness: true + rgb: true + color_temp: true + +Configuration with RGB, brightness: + +light: + platform: mqtt_json + name: mqtt_json_light_1 + state_topic: "home/rgb1" + command_topic: "home/rgb1/set" + brightness: true + rgb: true + +Config without RGB: + +light: + platform: mqtt_json + name: mqtt_json_light_1 + state_topic: "home/rgb1" + command_topic: "home/rgb1/set" + brightness: true + +Config without RGB and brightness: + +light: + platform: mqtt_json + name: mqtt_json_light_1 + state_topic: "home/rgb1" + command_topic: "home/rgb1/set" + +Config with brightness and scale: + +light: + platform: mqtt_json + name: test + state_topic: "mqtt_json_light_1" + command_topic: "mqtt_json_light_1/set" + brightness: true + brightness_scale: 99 +""" + +import json +import unittest + +from homeassistant.setup import setup_component +from homeassistant.const import ( + STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE, + ATTR_SUPPORTED_FEATURES) +import homeassistant.components.light as light +from tests.common import ( + get_test_home_assistant, mock_mqtt_component, fire_mqtt_message, + assert_setup_component) + + +class TestLightMQTTJSON(unittest.TestCase): + """Test the MQTT JSON light.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.mock_publish = mock_mqtt_component(self.hass) + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + def test_fail_setup_if_no_command_topic(self): \ + # pylint: disable=invalid-name + """Test if setup fails with no command topic.""" + with assert_setup_component(0, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + } + }) + self.assertIsNone(self.hass.states.get('light.test')) + + def test_no_color_brightness_color_temp_white_val_if_no_topics(self): \ + # pylint: disable=invalid-name + """Test for no RGB, brightness, color temp, effect, white val or XY.""" + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertEqual(40, state.attributes.get(ATTR_SUPPORTED_FEATURES)) + self.assertIsNone(state.attributes.get('rgb_color')) + self.assertIsNone(state.attributes.get('brightness')) + self.assertIsNone(state.attributes.get('color_temp')) + self.assertIsNone(state.attributes.get('effect')) + self.assertIsNone(state.attributes.get('white_value')) + self.assertIsNone(state.attributes.get('xy_color')) + + fire_mqtt_message(self.hass, 'test_light_rgb', '{"state":"ON"}') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('rgb_color')) + self.assertIsNone(state.attributes.get('brightness')) + self.assertIsNone(state.attributes.get('color_temp')) + self.assertIsNone(state.attributes.get('effect')) + self.assertIsNone(state.attributes.get('white_value')) + self.assertIsNone(state.attributes.get('xy_color')) + + def test_controlling_state_via_topic(self): \ + # pylint: disable=invalid-name + """Test the controlling of the state via topic.""" + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'brightness': True, + 'color_temp': True, + 'effect': True, + 'rgb': True, + 'white_value': True, + 'xy': True, + 'qos': '0' + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertEqual(255, state.attributes.get(ATTR_SUPPORTED_FEATURES)) + self.assertIsNone(state.attributes.get('rgb_color')) + self.assertIsNone(state.attributes.get('brightness')) + self.assertIsNone(state.attributes.get('color_temp')) + self.assertIsNone(state.attributes.get('effect')) + self.assertIsNone(state.attributes.get('white_value')) + self.assertIsNone(state.attributes.get('xy_color')) + self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) + + # Turn on the light, full white + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + '"color":{"r":255,"g":255,"b":255,' + '"x":0.123,"y":0.123},' + '"brightness":255,' + '"color_temp":155,' + '"effect":"colorloop",' + '"white_value":150}') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) + self.assertEqual(255, state.attributes.get('brightness')) + self.assertEqual(155, state.attributes.get('color_temp')) + self.assertEqual('colorloop', state.attributes.get('effect')) + self.assertEqual(150, state.attributes.get('white_value')) + self.assertEqual([0.123, 0.123], state.attributes.get('xy_color')) + + # Turn the light off + fire_mqtt_message(self.hass, 'test_light_rgb', '{"state":"OFF"}') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + '"brightness":100}') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.hass.block_till_done() + self.assertEqual(100, + light_state.attributes['brightness']) + + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + '"color":{"r":125,"g":125,"b":125}}') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.assertEqual([125, 125, 125], + light_state.attributes.get('rgb_color')) + + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + '"color":{"x":0.135,"y":0.135}}') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.assertEqual([0.135, 0.135], + light_state.attributes.get('xy_color')) + + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + '"color_temp":155}') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.assertEqual(155, light_state.attributes.get('color_temp')) + + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + '"effect":"colorloop"}') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.assertEqual('colorloop', light_state.attributes.get('effect')) + + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + '"white_value":155}') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.assertEqual(155, light_state.attributes.get('white_value')) + + def test_sending_mqtt_commands_and_optimistic(self): \ + # pylint: disable=invalid-name + """Test the sending of command in optimistic mode.""" + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'brightness': True, + 'color_temp': True, + 'effect': True, + 'rgb': True, + 'white_value': True, + 'qos': 2 + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertEqual(191, state.attributes.get(ATTR_SUPPORTED_FEATURES)) + self.assertTrue(state.attributes.get(ATTR_ASSUMED_STATE)) + + light.turn_on(self.hass, 'light.test') + self.hass.block_till_done() + + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', '{"state": "ON"}', 2, False) + self.mock_publish.async_publish.reset_mock() + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + + light.turn_off(self.hass, 'light.test') + self.hass.block_till_done() + + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', '{"state": "OFF"}', 2, False) + self.mock_publish.async_publish.reset_mock() + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + light.turn_on(self.hass, 'light.test', + brightness=50, color_temp=155, effect='colorloop', + white_value=170) + self.hass.block_till_done() + + self.assertEqual('test_light_rgb/set', + self.mock_publish.async_publish.mock_calls[0][1][0]) + self.assertEqual(2, + self.mock_publish.async_publish.mock_calls[0][1][2]) + self.assertEqual(False, + self.mock_publish.async_publish.mock_calls[0][1][3]) + # Get the sent message + message_json = json.loads( + self.mock_publish.async_publish.mock_calls[0][1][1]) + self.assertEqual(50, message_json["brightness"]) + self.assertEqual(155, message_json["color_temp"]) + self.assertEqual('colorloop', message_json["effect"]) + self.assertEqual(170, message_json["white_value"]) + self.assertEqual("ON", message_json["state"]) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual(50, state.attributes['brightness']) + self.assertEqual(155, state.attributes['color_temp']) + self.assertEqual('colorloop', state.attributes['effect']) + self.assertEqual(170, state.attributes['white_value']) + + def test_flash_short_and_long(self): \ + # pylint: disable=invalid-name + """Test for flash length being sent when included.""" + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'flash_time_short': 5, + 'flash_time_long': 15, + 'qos': 0 + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertEqual(40, state.attributes.get(ATTR_SUPPORTED_FEATURES)) + + light.turn_on(self.hass, 'light.test', flash="short") + self.hass.block_till_done() + + self.assertEqual('test_light_rgb/set', + self.mock_publish.async_publish.mock_calls[0][1][0]) + self.assertEqual(0, + self.mock_publish.async_publish.mock_calls[0][1][2]) + self.assertEqual(False, + self.mock_publish.async_publish.mock_calls[0][1][3]) + # Get the sent message + message_json = json.loads( + self.mock_publish.async_publish.mock_calls[0][1][1]) + self.assertEqual(5, message_json["flash"]) + self.assertEqual("ON", message_json["state"]) + + self.mock_publish.async_publish.reset_mock() + light.turn_on(self.hass, 'light.test', flash="long") + self.hass.block_till_done() + + self.assertEqual('test_light_rgb/set', + self.mock_publish.async_publish.mock_calls[0][1][0]) + self.assertEqual(0, + self.mock_publish.async_publish.mock_calls[0][1][2]) + self.assertEqual(False, + self.mock_publish.async_publish.mock_calls[0][1][3]) + # Get the sent message + message_json = json.loads( + self.mock_publish.async_publish.mock_calls[0][1][1]) + self.assertEqual(15, message_json["flash"]) + self.assertEqual("ON", message_json["state"]) + + def test_transition(self): + """Test for transition time being sent when included.""" + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'qos': 0 + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertEqual(40, state.attributes.get(ATTR_SUPPORTED_FEATURES)) + + light.turn_on(self.hass, 'light.test', transition=10) + self.hass.block_till_done() + + self.assertEqual('test_light_rgb/set', + self.mock_publish.async_publish.mock_calls[0][1][0]) + self.assertEqual(0, + self.mock_publish.async_publish.mock_calls[0][1][2]) + self.assertEqual(False, + self.mock_publish.async_publish.mock_calls[0][1][3]) + # Get the sent message + message_json = json.loads( + self.mock_publish.async_publish.mock_calls[0][1][1]) + self.assertEqual(10, message_json["transition"]) + self.assertEqual("ON", message_json["state"]) + + # Transition back off + light.turn_off(self.hass, 'light.test', transition=10) + self.hass.block_till_done() + + self.assertEqual('test_light_rgb/set', + self.mock_publish.async_publish.mock_calls[1][1][0]) + self.assertEqual(0, + self.mock_publish.async_publish.mock_calls[1][1][2]) + self.assertEqual(False, + self.mock_publish.async_publish.mock_calls[1][1][3]) + # Get the sent message + message_json = json.loads( + self.mock_publish.async_publish.mock_calls[1][1][1]) + self.assertEqual(10, message_json["transition"]) + self.assertEqual("OFF", message_json["state"]) + + def test_brightness_scale(self): + """Test for brightness scaling.""" + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'state_topic': 'test_light_bright_scale', + 'command_topic': 'test_light_bright_scale/set', + 'brightness': True, + 'brightness_scale': 99 + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertIsNone(state.attributes.get('brightness')) + self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) + + # Turn on the light + fire_mqtt_message(self.hass, 'test_light_bright_scale', + '{"state":"ON"}') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual(255, state.attributes.get('brightness')) + + # Turn on the light with brightness + fire_mqtt_message(self.hass, 'test_light_bright_scale', + '{"state":"ON",' + '"brightness": 99}') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual(255, state.attributes.get('brightness')) + + def test_invalid_color_brightness_and_white_values(self): \ + # pylint: disable=invalid-name + """Test that invalid color/brightness/white values are ignored.""" + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'brightness': True, + 'rgb': True, + 'white_value': True, + 'qos': '0' + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertEqual(185, state.attributes.get(ATTR_SUPPORTED_FEATURES)) + self.assertIsNone(state.attributes.get('rgb_color')) + self.assertIsNone(state.attributes.get('brightness')) + self.assertIsNone(state.attributes.get('white_value')) + self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) + + # Turn on the light + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + '"color":{"r":255,"g":255,"b":255},' + '"brightness": 255,' + '"white_value": 255}') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) + self.assertEqual(255, state.attributes.get('brightness')) + self.assertEqual(255, state.attributes.get('white_value')) + + # Bad color values + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + '"color":{"r":"bad","g":"val","b":"test"}}') + self.hass.block_till_done() + + # Color should not have changed + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) + + # Bad brightness values + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + '"brightness": "badValue"}') + self.hass.block_till_done() + + # Brightness should not have changed + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual(255, state.attributes.get('brightness')) + + # Bad white value + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + '"white_value": "badValue"}') + self.hass.block_till_done() + + # White value should not have changed + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual(255, state.attributes.get('white_value')) + + def test_default_availability_payload(self): + """Test availability by default payload with defined topic.""" + self.assertTrue(setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'availability_topic': 'availability-topic' + } + })) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'online') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'offline') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + def test_custom_availability_payload(self): + """Test availability by custom payload with defined topic.""" + self.assertTrue(setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'availability_topic': 'availability-topic', + 'payload_available': 'good', + 'payload_not_available': 'nogood' + } + })) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'good') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'nogood') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) diff --git a/tests/components/light/test_mqtt_template.py b/tests/components/light/test_mqtt_template.py index 0df9d8136e1..62947c05227 100644 --- a/tests/components/light/test_mqtt_template.py +++ b/tests/components/light/test_mqtt_template.py @@ -1,524 +1,498 @@ -"""The tests for the MQTT Template light platform. - -Configuration example with all features: - -light: - platform: mqtt_template - name: mqtt_template_light_1 - state_topic: 'home/rgb1' - command_topic: 'home/rgb1/set' - command_on_template: > - on,{{ brightness|d }},{{ red|d }}-{{ green|d }}-{{ blue|d }} - command_off_template: 'off' - state_template: '{{ value.split(",")[0] }}' - brightness_template: '{{ value.split(",")[1] }}' - color_temp_template: '{{ value.split(",")[2] }}' - white_value_template: '{{ value.split(",")[3] }}' - red_template: '{{ value.split(",")[4].split("-")[0] }}' - green_template: '{{ value.split(",")[4].split("-")[1] }}' - blue_template: '{{ value.split(",")[4].split("-")[2] }}' - -If your light doesn't support brightness feature, omit `brightness_template`. - -If your light doesn't support color temp feature, omit `color_temp_template`. - -If your light doesn't support white value feature, omit `white_value_template`. - -If your light doesn't support RGB feature, omit `(red|green|blue)_template`. -""" -import unittest - -from homeassistant.setup import setup_component -from homeassistant.const import ( - STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE) -import homeassistant.components.light as light -from tests.common import ( - get_test_home_assistant, mock_mqtt_component, fire_mqtt_message, - assert_setup_component) - - -class TestLightMQTTTemplate(unittest.TestCase): - """Test the MQTT Template light.""" - - def setUp(self): # pylint: disable=invalid-name - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.mock_publish = mock_mqtt_component(self.hass) - - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that was started.""" - self.hass.stop() - - def test_setup_fails(self): \ - # pylint: disable=invalid-name - """Test that setup fails with missing required configuration items.""" - with assert_setup_component(0, light.DOMAIN): - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_template', - 'name': 'test', - } - }) - self.assertIsNone(self.hass.states.get('light.test')) - - def test_state_change_via_topic(self): \ - # pylint: disable=invalid-name - """Test state change via topic.""" - with assert_setup_component(1, light.DOMAIN): - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_template', - 'name': 'test', - 'state_topic': 'test_light_rgb', - 'command_topic': 'test_light_rgb/set', - 'command_on_template': 'on,' - '{{ brightness|d }},' - '{{ color_temp|d }},' - '{{ white_value|d }},' - '{{ red|d }}-' - '{{ green|d }}-' - '{{ blue|d }}', - 'command_off_template': 'off', - 'state_template': '{{ value.split(",")[0] }}' - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - self.assertIsNone(state.attributes.get('rgb_color')) - self.assertIsNone(state.attributes.get('brightness')) - self.assertIsNone(state.attributes.get('color_temp')) - self.assertIsNone(state.attributes.get('white_value')) - self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) - - fire_mqtt_message(self.hass, 'test_light_rgb', 'on') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertIsNone(state.attributes.get('rgb_color')) - self.assertIsNone(state.attributes.get('brightness')) - self.assertIsNone(state.attributes.get('color_temp')) - self.assertIsNone(state.attributes.get('white_value')) - - def test_state_brightness_color_effect_temp_white_change_via_topic(self): \ - # pylint: disable=invalid-name - """Test state, bri, color, effect, color temp, white val change.""" - with assert_setup_component(1, light.DOMAIN): - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_template', - 'name': 'test', - 'effect_list': ['rainbow', 'colorloop'], - 'state_topic': 'test_light_rgb', - 'command_topic': 'test_light_rgb/set', - 'command_on_template': 'on,' - '{{ brightness|d }},' - '{{ color_temp|d }},' - '{{ white_value|d }},' - '{{ red|d }}-' - '{{ green|d }}-' - '{{ blue|d }},' - '{{ effect|d }}', - 'command_off_template': 'off', - 'state_template': '{{ value.split(",")[0] }}', - 'brightness_template': '{{ value.split(",")[1] }}', - 'color_temp_template': '{{ value.split(",")[2] }}', - 'white_value_template': '{{ value.split(",")[3] }}', - 'red_template': '{{ value.split(",")[4].' - 'split("-")[0] }}', - 'green_template': '{{ value.split(",")[4].' - 'split("-")[1] }}', - 'blue_template': '{{ value.split(",")[4].' - 'split("-")[2] }}', - 'effect_template': '{{ value.split(",")[5] }}' - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - self.assertIsNone(state.attributes.get('rgb_color')) - self.assertIsNone(state.attributes.get('brightness')) - self.assertIsNone(state.attributes.get('effect')) - self.assertIsNone(state.attributes.get('color_temp')) - self.assertIsNone(state.attributes.get('white_value')) - self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) - - # turn on the light, full white - fire_mqtt_message(self.hass, 'test_light_rgb', - 'on,255,145,123,255-128-64,') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertEqual([255, 128, 64], state.attributes.get('rgb_color')) - self.assertEqual(255, state.attributes.get('brightness')) - self.assertEqual(145, state.attributes.get('color_temp')) - self.assertEqual(123, state.attributes.get('white_value')) - self.assertIsNone(state.attributes.get('effect')) - - # turn the light off - fire_mqtt_message(self.hass, 'test_light_rgb', 'off') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - - # lower the brightness - fire_mqtt_message(self.hass, 'test_light_rgb', 'on,100') - self.hass.block_till_done() - - light_state = self.hass.states.get('light.test') - self.hass.block_till_done() - self.assertEqual(100, light_state.attributes['brightness']) - - # change the color temp - fire_mqtt_message(self.hass, 'test_light_rgb', 'on,,195') - self.hass.block_till_done() - - light_state = self.hass.states.get('light.test') - self.hass.block_till_done() - self.assertEqual(195, light_state.attributes['color_temp']) - - # change the color - fire_mqtt_message(self.hass, 'test_light_rgb', 'on,,,,41-42-43') - self.hass.block_till_done() - - light_state = self.hass.states.get('light.test') - self.assertEqual([41, 42, 43], light_state.attributes.get('rgb_color')) - - # change the white value - fire_mqtt_message(self.hass, 'test_light_rgb', 'on,,,134') - self.hass.block_till_done() - - light_state = self.hass.states.get('light.test') - self.hass.block_till_done() - self.assertEqual(134, light_state.attributes['white_value']) - - # change the effect - fire_mqtt_message(self.hass, 'test_light_rgb', - 'on,,,,41-42-43,rainbow') - self.hass.block_till_done() - - light_state = self.hass.states.get('light.test') - self.assertEqual('rainbow', light_state.attributes.get('effect')) - - def test_optimistic(self): \ - # pylint: disable=invalid-name - """Test optimistic mode.""" - with assert_setup_component(1, light.DOMAIN): - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_template', - 'name': 'test', - 'command_topic': 'test_light_rgb/set', - 'command_on_template': 'on,' - '{{ brightness|d }},' - '{{ color_temp|d }},' - '{{ white_value|d }},' - '{{ red|d }}-' - '{{ green|d }}-' - '{{ blue|d }}', - 'command_off_template': 'off', - 'qos': 2 - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - self.assertTrue(state.attributes.get(ATTR_ASSUMED_STATE)) - - # turn on the light - light.turn_on(self.hass, 'light.test') - self.hass.block_till_done() - - self.assertEqual(('test_light_rgb/set', 'on,,,,--', 2, False), - self.mock_publish.mock_calls[-2][1]) - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - - # turn the light off - light.turn_off(self.hass, 'light.test') - self.hass.block_till_done() - - self.assertEqual(('test_light_rgb/set', 'off', 2, False), - self.mock_publish.mock_calls[-2][1]) - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - - # turn on the light with brightness, color - light.turn_on(self.hass, 'light.test', brightness=50, - rgb_color=[75, 75, 75]) - self.hass.block_till_done() - - self.assertEqual('test_light_rgb/set', - self.mock_publish.mock_calls[-2][1][0]) - - # check the payload - payload = self.mock_publish.mock_calls[-2][1][1] - self.assertEqual('on,50,,,75-75-75', payload) - - # turn on the light with color temp and white val - light.turn_on(self.hass, 'light.test', color_temp=200, white_value=139) - self.hass.block_till_done() - - payload = self.mock_publish.mock_calls[-2][1][1] - self.assertEqual('on,,200,139,--', payload) - - self.assertEqual(2, self.mock_publish.mock_calls[-2][1][2]) - self.assertEqual(False, self.mock_publish.mock_calls[-2][1][3]) - - # check the state - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertEqual((75, 75, 75), state.attributes['rgb_color']) - self.assertEqual(50, state.attributes['brightness']) - self.assertEqual(200, state.attributes['color_temp']) - self.assertEqual(139, state.attributes['white_value']) - - def test_flash(self): \ - # pylint: disable=invalid-name - """Test flash.""" - with assert_setup_component(1, light.DOMAIN): - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_template', - 'name': 'test', - 'command_topic': 'test_light_rgb/set', - 'command_on_template': 'on,{{ flash }}', - 'command_off_template': 'off', - 'qos': 0 - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - - # short flash - light.turn_on(self.hass, 'light.test', flash='short') - self.hass.block_till_done() - - self.assertEqual('test_light_rgb/set', - self.mock_publish.mock_calls[-2][1][0]) - self.assertEqual(0, self.mock_publish.mock_calls[-2][1][2]) - self.assertEqual(False, self.mock_publish.mock_calls[-2][1][3]) - - # check the payload - payload = self.mock_publish.mock_calls[-2][1][1] - self.assertEqual('on,short', payload) - - # long flash - light.turn_on(self.hass, 'light.test', flash='long') - self.hass.block_till_done() - - self.assertEqual('test_light_rgb/set', - self.mock_publish.mock_calls[-2][1][0]) - self.assertEqual(0, self.mock_publish.mock_calls[-2][1][2]) - self.assertEqual(False, self.mock_publish.mock_calls[-2][1][3]) - - # check the payload - payload = self.mock_publish.mock_calls[-2][1][1] - self.assertEqual('on,long', payload) - - def test_transition(self): - """Test for transition time being sent when included.""" - with assert_setup_component(1, light.DOMAIN): - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_template', - 'name': 'test', - 'command_topic': 'test_light_rgb/set', - 'command_on_template': 'on,{{ transition }}', - 'command_off_template': 'off,{{ transition|d }}' - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - - # transition on - light.turn_on(self.hass, 'light.test', transition=10) - self.hass.block_till_done() - - self.assertEqual('test_light_rgb/set', - self.mock_publish.mock_calls[-2][1][0]) - self.assertEqual(0, self.mock_publish.mock_calls[-2][1][2]) - self.assertEqual(False, self.mock_publish.mock_calls[-2][1][3]) - - # check the payload - payload = self.mock_publish.mock_calls[-2][1][1] - self.assertEqual('on,10', payload) - - # transition off - light.turn_off(self.hass, 'light.test', transition=4) - self.hass.block_till_done() - - self.assertEqual('test_light_rgb/set', - self.mock_publish.mock_calls[-2][1][0]) - self.assertEqual(0, self.mock_publish.mock_calls[-2][1][2]) - self.assertEqual(False, self.mock_publish.mock_calls[-2][1][3]) - - # check the payload - payload = self.mock_publish.mock_calls[-2][1][1] - self.assertEqual('off,4', payload) - - def test_invalid_values(self): \ - # pylint: disable=invalid-name - """Test that invalid values are ignored.""" - with assert_setup_component(1, light.DOMAIN): - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_template', - 'name': 'test', - 'effect_list': ['rainbow', 'colorloop'], - 'state_topic': 'test_light_rgb', - 'command_topic': 'test_light_rgb/set', - 'command_on_template': 'on,' - '{{ brightness|d }},' - '{{ color_temp|d }},' - '{{ red|d }}-' - '{{ green|d }}-' - '{{ blue|d }},' - '{{ effect|d }}', - 'command_off_template': 'off', - 'state_template': '{{ value.split(",")[0] }}', - 'brightness_template': '{{ value.split(",")[1] }}', - 'color_temp_template': '{{ value.split(",")[2] }}', - 'white_value_template': '{{ value.split(",")[3] }}', - 'red_template': '{{ value.split(",")[4].' - 'split("-")[0] }}', - 'green_template': '{{ value.split(",")[4].' - 'split("-")[1] }}', - 'blue_template': '{{ value.split(",")[4].' - 'split("-")[2] }}', - 'effect_template': '{{ value.split(",")[5] }}', - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - self.assertIsNone(state.attributes.get('rgb_color')) - self.assertIsNone(state.attributes.get('brightness')) - self.assertIsNone(state.attributes.get('color_temp')) - self.assertIsNone(state.attributes.get('effect')) - self.assertIsNone(state.attributes.get('white_value')) - self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) - - # turn on the light, full white - fire_mqtt_message(self.hass, 'test_light_rgb', - 'on,255,215,222,255-255-255,rainbow') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertEqual(255, state.attributes.get('brightness')) - self.assertEqual(215, state.attributes.get('color_temp')) - self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) - self.assertEqual(222, state.attributes.get('white_value')) - self.assertEqual('rainbow', state.attributes.get('effect')) - - # bad state value - fire_mqtt_message(self.hass, 'test_light_rgb', 'offf') - self.hass.block_till_done() - - # state should not have changed - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - - # bad brightness values - fire_mqtt_message(self.hass, 'test_light_rgb', 'on,off,255-255-255') - self.hass.block_till_done() - - # brightness should not have changed - state = self.hass.states.get('light.test') - self.assertEqual(255, state.attributes.get('brightness')) - - # bad color temp values - fire_mqtt_message(self.hass, 'test_light_rgb', 'on,,off,255-255-255') - self.hass.block_till_done() - - # color temp should not have changed - state = self.hass.states.get('light.test') - self.assertEqual(215, state.attributes.get('color_temp')) - - # bad color values - fire_mqtt_message(self.hass, 'test_light_rgb', 'on,255,a-b-c') - self.hass.block_till_done() - - # color should not have changed - state = self.hass.states.get('light.test') - self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) - - # bad white value values - fire_mqtt_message(self.hass, 'test_light_rgb', 'on,,,off,255-255-255') - self.hass.block_till_done() - - # white value should not have changed - state = self.hass.states.get('light.test') - self.assertEqual(222, state.attributes.get('white_value')) - - # bad effect value - fire_mqtt_message(self.hass, 'test_light_rgb', 'on,255,a-b-c,white') - self.hass.block_till_done() - - # effect should not have changed - state = self.hass.states.get('light.test') - self.assertEqual('rainbow', state.attributes.get('effect')) - - def test_default_availability_payload(self): - """Test availability by default payload with defined topic.""" - self.assertTrue(setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_template', - 'name': 'test', - 'command_topic': 'test_light_rgb/set', - 'command_on_template': 'on,{{ transition }}', - 'command_off_template': 'off,{{ transition|d }}', - 'availability_topic': 'availability-topic' - } - })) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_UNAVAILABLE, state.state) - - fire_mqtt_message(self.hass, 'availability-topic', 'online') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertNotEqual(STATE_UNAVAILABLE, state.state) - - fire_mqtt_message(self.hass, 'availability-topic', 'offline') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_UNAVAILABLE, state.state) - - def test_custom_availability_payload(self): - """Test availability by custom payload with defined topic.""" - self.assertTrue(setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_template', - 'name': 'test', - 'command_topic': 'test_light_rgb/set', - 'command_on_template': 'on,{{ transition }}', - 'command_off_template': 'off,{{ transition|d }}', - 'availability_topic': 'availability-topic', - 'payload_available': 'good', - 'payload_not_available': 'nogood' - } - })) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_UNAVAILABLE, state.state) - - fire_mqtt_message(self.hass, 'availability-topic', 'good') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertNotEqual(STATE_UNAVAILABLE, state.state) - - fire_mqtt_message(self.hass, 'availability-topic', 'nogood') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_UNAVAILABLE, state.state) +"""The tests for the MQTT Template light platform. + +Configuration example with all features: + +light: + platform: mqtt_template + name: mqtt_template_light_1 + state_topic: 'home/rgb1' + command_topic: 'home/rgb1/set' + command_on_template: > + on,{{ brightness|d }},{{ red|d }}-{{ green|d }}-{{ blue|d }} + command_off_template: 'off' + state_template: '{{ value.split(",")[0] }}' + brightness_template: '{{ value.split(",")[1] }}' + color_temp_template: '{{ value.split(",")[2] }}' + white_value_template: '{{ value.split(",")[3] }}' + red_template: '{{ value.split(",")[4].split("-")[0] }}' + green_template: '{{ value.split(",")[4].split("-")[1] }}' + blue_template: '{{ value.split(",")[4].split("-")[2] }}' + +If your light doesn't support brightness feature, omit `brightness_template`. + +If your light doesn't support color temp feature, omit `color_temp_template`. + +If your light doesn't support white value feature, omit `white_value_template`. + +If your light doesn't support RGB feature, omit `(red|green|blue)_template`. +""" +import unittest + +from homeassistant.setup import setup_component +from homeassistant.const import ( + STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE) +import homeassistant.components.light as light +from tests.common import ( + get_test_home_assistant, mock_mqtt_component, fire_mqtt_message, + assert_setup_component) + + +class TestLightMQTTTemplate(unittest.TestCase): + """Test the MQTT Template light.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.mock_publish = mock_mqtt_component(self.hass) + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + def test_setup_fails(self): \ + # pylint: disable=invalid-name + """Test that setup fails with missing required configuration items.""" + with assert_setup_component(0, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + } + }) + self.assertIsNone(self.hass.states.get('light.test')) + + def test_state_change_via_topic(self): \ + # pylint: disable=invalid-name + """Test state change via topic.""" + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,' + '{{ brightness|d }},' + '{{ color_temp|d }},' + '{{ white_value|d }},' + '{{ red|d }}-' + '{{ green|d }}-' + '{{ blue|d }}', + 'command_off_template': 'off', + 'state_template': '{{ value.split(",")[0] }}' + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertIsNone(state.attributes.get('rgb_color')) + self.assertIsNone(state.attributes.get('brightness')) + self.assertIsNone(state.attributes.get('color_temp')) + self.assertIsNone(state.attributes.get('white_value')) + self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) + + fire_mqtt_message(self.hass, 'test_light_rgb', 'on') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('rgb_color')) + self.assertIsNone(state.attributes.get('brightness')) + self.assertIsNone(state.attributes.get('color_temp')) + self.assertIsNone(state.attributes.get('white_value')) + + def test_state_brightness_color_effect_temp_white_change_via_topic(self): \ + # pylint: disable=invalid-name + """Test state, bri, color, effect, color temp, white val change.""" + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'effect_list': ['rainbow', 'colorloop'], + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,' + '{{ brightness|d }},' + '{{ color_temp|d }},' + '{{ white_value|d }},' + '{{ red|d }}-' + '{{ green|d }}-' + '{{ blue|d }},' + '{{ effect|d }}', + 'command_off_template': 'off', + 'state_template': '{{ value.split(",")[0] }}', + 'brightness_template': '{{ value.split(",")[1] }}', + 'color_temp_template': '{{ value.split(",")[2] }}', + 'white_value_template': '{{ value.split(",")[3] }}', + 'red_template': '{{ value.split(",")[4].' + 'split("-")[0] }}', + 'green_template': '{{ value.split(",")[4].' + 'split("-")[1] }}', + 'blue_template': '{{ value.split(",")[4].' + 'split("-")[2] }}', + 'effect_template': '{{ value.split(",")[5] }}' + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertIsNone(state.attributes.get('rgb_color')) + self.assertIsNone(state.attributes.get('brightness')) + self.assertIsNone(state.attributes.get('effect')) + self.assertIsNone(state.attributes.get('color_temp')) + self.assertIsNone(state.attributes.get('white_value')) + self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) + + # turn on the light, full white + fire_mqtt_message(self.hass, 'test_light_rgb', + 'on,255,145,123,255-128-64,') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual([255, 128, 64], state.attributes.get('rgb_color')) + self.assertEqual(255, state.attributes.get('brightness')) + self.assertEqual(145, state.attributes.get('color_temp')) + self.assertEqual(123, state.attributes.get('white_value')) + self.assertIsNone(state.attributes.get('effect')) + + # turn the light off + fire_mqtt_message(self.hass, 'test_light_rgb', 'off') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + # lower the brightness + fire_mqtt_message(self.hass, 'test_light_rgb', 'on,100') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.hass.block_till_done() + self.assertEqual(100, light_state.attributes['brightness']) + + # change the color temp + fire_mqtt_message(self.hass, 'test_light_rgb', 'on,,195') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.hass.block_till_done() + self.assertEqual(195, light_state.attributes['color_temp']) + + # change the color + fire_mqtt_message(self.hass, 'test_light_rgb', 'on,,,,41-42-43') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.assertEqual([41, 42, 43], light_state.attributes.get('rgb_color')) + + # change the white value + fire_mqtt_message(self.hass, 'test_light_rgb', 'on,,,134') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.hass.block_till_done() + self.assertEqual(134, light_state.attributes['white_value']) + + # change the effect + fire_mqtt_message(self.hass, 'test_light_rgb', + 'on,,,,41-42-43,rainbow') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.assertEqual('rainbow', light_state.attributes.get('effect')) + + def test_optimistic(self): \ + # pylint: disable=invalid-name + """Test optimistic mode.""" + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,' + '{{ brightness|d }},' + '{{ color_temp|d }},' + '{{ white_value|d }},' + '{{ red|d }}-' + '{{ green|d }}-' + '{{ blue|d }}', + 'command_off_template': 'off', + 'qos': 2 + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertTrue(state.attributes.get(ATTR_ASSUMED_STATE)) + + # turn on the light + light.turn_on(self.hass, 'light.test') + self.hass.block_till_done() + + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'on,,,,--', 2, False) + self.mock_publish.async_publish.reset_mock() + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + + # turn the light off + light.turn_off(self.hass, 'light.test') + self.hass.block_till_done() + + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'off', 2, False) + self.mock_publish.async_publish.reset_mock() + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + # turn on the light with brightness, color + light.turn_on(self.hass, 'light.test', brightness=50, + rgb_color=[75, 75, 75]) + self.hass.block_till_done() + + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'on,50,,,75-75-75', 2, False) + self.mock_publish.async_publish.reset_mock() + + # turn on the light with color temp and white val + light.turn_on(self.hass, 'light.test', color_temp=200, white_value=139) + self.hass.block_till_done() + + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'on,,200,139,--', 2, False) + + # check the state + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual((75, 75, 75), state.attributes['rgb_color']) + self.assertEqual(50, state.attributes['brightness']) + self.assertEqual(200, state.attributes['color_temp']) + self.assertEqual(139, state.attributes['white_value']) + + def test_flash(self): \ + # pylint: disable=invalid-name + """Test flash.""" + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,{{ flash }}', + 'command_off_template': 'off', + 'qos': 0 + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + # short flash + light.turn_on(self.hass, 'light.test', flash='short') + self.hass.block_till_done() + + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'on,short', 0, False) + self.mock_publish.async_publish.reset_mock() + + # long flash + light.turn_on(self.hass, 'light.test', flash='long') + self.hass.block_till_done() + + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'on,long', 0, False) + + def test_transition(self): + """Test for transition time being sent when included.""" + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,{{ transition }}', + 'command_off_template': 'off,{{ transition|d }}' + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + # transition on + light.turn_on(self.hass, 'light.test', transition=10) + self.hass.block_till_done() + + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'on,10', 0, False) + self.mock_publish.async_publish.reset_mock() + + # transition off + light.turn_off(self.hass, 'light.test', transition=4) + self.hass.block_till_done() + + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'off,4', 0, False) + + def test_invalid_values(self): \ + # pylint: disable=invalid-name + """Test that invalid values are ignored.""" + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'effect_list': ['rainbow', 'colorloop'], + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,' + '{{ brightness|d }},' + '{{ color_temp|d }},' + '{{ red|d }}-' + '{{ green|d }}-' + '{{ blue|d }},' + '{{ effect|d }}', + 'command_off_template': 'off', + 'state_template': '{{ value.split(",")[0] }}', + 'brightness_template': '{{ value.split(",")[1] }}', + 'color_temp_template': '{{ value.split(",")[2] }}', + 'white_value_template': '{{ value.split(",")[3] }}', + 'red_template': '{{ value.split(",")[4].' + 'split("-")[0] }}', + 'green_template': '{{ value.split(",")[4].' + 'split("-")[1] }}', + 'blue_template': '{{ value.split(",")[4].' + 'split("-")[2] }}', + 'effect_template': '{{ value.split(",")[5] }}', + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertIsNone(state.attributes.get('rgb_color')) + self.assertIsNone(state.attributes.get('brightness')) + self.assertIsNone(state.attributes.get('color_temp')) + self.assertIsNone(state.attributes.get('effect')) + self.assertIsNone(state.attributes.get('white_value')) + self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) + + # turn on the light, full white + fire_mqtt_message(self.hass, 'test_light_rgb', + 'on,255,215,222,255-255-255,rainbow') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual(255, state.attributes.get('brightness')) + self.assertEqual(215, state.attributes.get('color_temp')) + self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) + self.assertEqual(222, state.attributes.get('white_value')) + self.assertEqual('rainbow', state.attributes.get('effect')) + + # bad state value + fire_mqtt_message(self.hass, 'test_light_rgb', 'offf') + self.hass.block_till_done() + + # state should not have changed + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + + # bad brightness values + fire_mqtt_message(self.hass, 'test_light_rgb', 'on,off,255-255-255') + self.hass.block_till_done() + + # brightness should not have changed + state = self.hass.states.get('light.test') + self.assertEqual(255, state.attributes.get('brightness')) + + # bad color temp values + fire_mqtt_message(self.hass, 'test_light_rgb', 'on,,off,255-255-255') + self.hass.block_till_done() + + # color temp should not have changed + state = self.hass.states.get('light.test') + self.assertEqual(215, state.attributes.get('color_temp')) + + # bad color values + fire_mqtt_message(self.hass, 'test_light_rgb', 'on,255,a-b-c') + self.hass.block_till_done() + + # color should not have changed + state = self.hass.states.get('light.test') + self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) + + # bad white value values + fire_mqtt_message(self.hass, 'test_light_rgb', 'on,,,off,255-255-255') + self.hass.block_till_done() + + # white value should not have changed + state = self.hass.states.get('light.test') + self.assertEqual(222, state.attributes.get('white_value')) + + # bad effect value + fire_mqtt_message(self.hass, 'test_light_rgb', 'on,255,a-b-c,white') + self.hass.block_till_done() + + # effect should not have changed + state = self.hass.states.get('light.test') + self.assertEqual('rainbow', state.attributes.get('effect')) + + def test_default_availability_payload(self): + """Test availability by default payload with defined topic.""" + self.assertTrue(setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,{{ transition }}', + 'command_off_template': 'off,{{ transition|d }}', + 'availability_topic': 'availability-topic' + } + })) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'online') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'offline') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + def test_custom_availability_payload(self): + """Test availability by custom payload with defined topic.""" + self.assertTrue(setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,{{ transition }}', + 'command_off_template': 'off,{{ transition|d }}', + 'availability_topic': 'availability-topic', + 'payload_available': 'good', + 'payload_not_available': 'nogood' + } + })) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'good') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'nogood') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) diff --git a/tests/components/lock/test_mqtt.py b/tests/components/lock/test_mqtt.py index 0f4df75d1a2..f87b8f8b74b 100644 --- a/tests/components/lock/test_mqtt.py +++ b/tests/components/lock/test_mqtt.py @@ -70,16 +70,17 @@ class TestLockMQTT(unittest.TestCase): lock.lock(self.hass, 'lock.test') self.hass.block_till_done() - self.assertEqual(('command-topic', 'LOCK', 2, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'command-topic', 'LOCK', 2, False) + self.mock_publish.async_publish.reset_mock() state = self.hass.states.get('lock.test') self.assertEqual(STATE_LOCKED, state.state) lock.unlock(self.hass, 'lock.test') self.hass.block_till_done() - self.assertEqual(('command-topic', 'UNLOCK', 2, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'command-topic', 'UNLOCK', 2, False) state = self.hass.states.get('lock.test') self.assertEqual(STATE_UNLOCKED, state.state) diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index d0704aac227..995f7e891f9 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -18,7 +18,7 @@ def test_subscribing_config_topic(hass, mqtt_mock): assert mqtt_mock.async_subscribe.called call_args = mqtt_mock.async_subscribe.mock_calls[0][1] assert call_args[0] == discovery_topic + '/#' - assert call_args[1] == 0 + assert call_args[2] == 0 @asyncio.coroutine diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 55ff0e9ff05..a1edff8333d 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -1,6 +1,5 @@ """The tests for the MQTT component.""" import asyncio -from collections import namedtuple, OrderedDict import unittest from unittest import mock import socket @@ -9,26 +8,27 @@ import ssl import voluptuous as vol from homeassistant.core import callback -from homeassistant.setup import setup_component, async_setup_component +from homeassistant.setup import async_setup_component import homeassistant.components.mqtt as mqtt -from homeassistant.const import ( - EVENT_CALL_SERVICE, ATTR_DOMAIN, ATTR_SERVICE, EVENT_HOMEASSISTANT_STOP) -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.const import (EVENT_CALL_SERVICE, ATTR_DOMAIN, ATTR_SERVICE, + EVENT_HOMEASSISTANT_STOP) -from tests.common import ( - get_test_home_assistant, mock_mqtt_component, fire_mqtt_message, mock_coro) +from tests.common import (get_test_home_assistant, mock_coro, + mock_mqtt_component, + threadsafe_coroutine_factory, fire_mqtt_message, + async_fire_mqtt_message) @asyncio.coroutine -def mock_mqtt_client(hass, config=None): +def async_mock_mqtt_client(hass, config=None): """Mock the MQTT paho client.""" if config is None: - config = { - mqtt.CONF_BROKER: 'mock-broker' - } + config = {mqtt.CONF_BROKER: 'mock-broker'} with mock.patch('paho.mqtt.client.Client') as mock_client: - mock_client().connect = lambda *args: 0 + mock_client().connect.return_value = 0 + mock_client().subscribe.return_value = (0, 0) + mock_client().publish.return_value = (0, 0) result = yield from async_setup_component(hass, mqtt.DOMAIN, { mqtt.DOMAIN: config }) @@ -36,8 +36,11 @@ def mock_mqtt_client(hass, config=None): return mock_client() +mock_mqtt_client = threadsafe_coroutine_factory(async_mock_mqtt_client) + + # pylint: disable=invalid-name -class TestMQTT(unittest.TestCase): +class TestMQTTComponent(unittest.TestCase): """Test the MQTT component.""" def setUp(self): # pylint: disable=invalid-name @@ -55,12 +58,8 @@ class TestMQTT(unittest.TestCase): """Helper for recording calls.""" self.calls.append(args) - def test_client_starts_on_home_assistant_mqtt_setup(self): - """Test if client is connect after mqtt init on bootstrap.""" - assert self.hass.data['mqtt'].async_connect.called - def test_client_stops_on_home_assistant_start(self): - """Test if client stops on HA launch.""" + """Test if client stops on HA stop.""" self.hass.bus.fire(EVENT_HOMEASSISTANT_STOP) self.hass.block_till_done() self.assertTrue(self.hass.data['mqtt'].async_disconnect.called) @@ -131,6 +130,48 @@ class TestMQTT(unittest.TestCase): self.hass.data['mqtt'].async_publish.call_args[0][2], 2) self.assertFalse(self.hass.data['mqtt'].async_publish.call_args[0][3]) + def test_invalid_mqtt_topics(self): + """Test invalid topics.""" + self.assertRaises(vol.Invalid, mqtt.valid_publish_topic, 'bad+topic') + self.assertRaises(vol.Invalid, mqtt.valid_subscribe_topic, 'bad\0one') + + +# pylint: disable=invalid-name +class TestMQTTCallbacks(unittest.TestCase): + """Test the MQTT callbacks.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + mock_mqtt_client(self.hass) + self.calls = [] + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + @callback + def record_calls(self, *args): + """Helper for recording calls.""" + self.calls.append(args) + + def test_client_starts_on_home_assistant_mqtt_setup(self): + """Test if client is connected after mqtt init on bootstrap.""" + self.assertEqual(self.hass.data['mqtt']._mqttc.connect.call_count, 1) + + def test_receiving_non_utf8_message_gets_logged(self): + """Test receiving a non utf8 encoded message.""" + mqtt.subscribe(self.hass, 'test-topic', self.record_calls) + + with self.assertLogs(level='WARNING') as test_handle: + fire_mqtt_message(self.hass, 'test-topic', b'\x9a') + + self.hass.block_till_done() + self.assertIn( + "WARNING:homeassistant.components.mqtt:Can't decode payload " + "b'\\x9a' on test-topic with encoding utf-8", + test_handle.output[0]) + def test_subscribe_topic(self): """Test the subscription of a topic.""" unsub = mqtt.subscribe(self.hass, 'test-topic', self.record_calls) @@ -296,82 +337,6 @@ class TestMQTT(unittest.TestCase): self.assertEqual(topic, self.calls[0][0]) self.assertEqual(payload, self.calls[0][1]) - def test_subscribe_binary_topic(self): - """Test the subscription to a binary topic.""" - mqtt.subscribe(self.hass, 'test-topic', self.record_calls, - 0, None) - - fire_mqtt_message(self.hass, 'test-topic', 0x9a) - - self.hass.block_till_done() - self.assertEqual(1, len(self.calls)) - self.assertEqual('test-topic', self.calls[0][0]) - self.assertEqual(0x9a, self.calls[0][1]) - - def test_receiving_non_utf8_message_gets_logged(self): - """Test receiving a non utf8 encoded message.""" - mqtt.subscribe(self.hass, 'test-topic', self.record_calls) - - with self.assertLogs(level='ERROR') as test_handle: - fire_mqtt_message(self.hass, 'test-topic', 0x9a) - self.hass.block_till_done() - self.assertIn( - "ERROR:homeassistant.components.mqtt:Illegal payload " - "encoding utf-8 from MQTT " - "topic: test-topic, Payload: 154", - test_handle.output[0]) - - -class TestMQTTCallbacks(unittest.TestCase): - """Test the MQTT callbacks.""" - - def setUp(self): # pylint: disable=invalid-name - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - with mock.patch('paho.mqtt.client.Client') as client: - client().connect = lambda *args: 0 - assert setup_component(self.hass, mqtt.DOMAIN, { - mqtt.DOMAIN: { - mqtt.CONF_BROKER: 'mock-broker', - } - }) - - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that was started.""" - self.hass.stop() - - def test_receiving_mqtt_message_fires_hass_event(self): - """Test if receiving triggers an event.""" - calls = [] - - @callback - def record(topic, payload, qos): - """Helper to record calls.""" - data = { - 'topic': topic, - 'payload': payload, - 'qos': qos, - } - calls.append(data) - - async_dispatcher_connect( - self.hass, mqtt.SIGNAL_MQTT_MESSAGE_RECEIVED, record) - - MQTTMessage = namedtuple('MQTTMessage', ['topic', 'qos', 'payload']) - message = MQTTMessage('test_topic', 1, 'Hello World!'.encode('utf-8')) - - self.hass.data['mqtt']._mqtt_on_message( - None, {'hass': self.hass}, message) - self.hass.block_till_done() - - self.assertEqual(1, len(calls)) - last_event = calls[0] - self.assertEqual(bytearray('Hello World!', 'utf-8'), - last_event['payload']) - self.assertEqual(message.topic, last_event['topic']) - self.assertEqual(message.qos, last_event['qos']) - def test_mqtt_failed_connection_results_in_disconnect(self): """Test if connection failure leads to disconnect.""" for result_code in range(1, 6): @@ -388,16 +353,11 @@ class TestMQTTCallbacks(unittest.TestCase): @mock.patch('homeassistant.components.mqtt.time.sleep') def test_mqtt_disconnect_tries_reconnect(self, mock_sleep): """Test the re-connect tries.""" - self.hass.data['mqtt'].subscribed_topics = { - 'test/topic': 1, - } - self.hass.data['mqtt'].wanted_topics = { - 'test/progress': 0, - 'test/topic': 2, - } - self.hass.data['mqtt'].progress = { - 1: 'test/progress' - } + self.hass.data['mqtt'].subscriptions = [ + mqtt.Subscription('test/progress', None, 0), + mqtt.Subscription('test/progress', None, 1), + mqtt.Subscription('test/topic', None, 2), + ] self.hass.data['mqtt']._mqttc.reconnect.side_effect = [1, 1, 1, 0] self.hass.data['mqtt']._mqtt_on_disconnect(None, None, 1) self.assertTrue(self.hass.data['mqtt']._mqttc.reconnect.called) @@ -406,15 +366,77 @@ class TestMQTTCallbacks(unittest.TestCase): self.assertEqual([1, 2, 4], [call[1][0] for call in mock_sleep.mock_calls]) - self.assertEqual({'test/topic': 2, 'test/progress': 0}, - self.hass.data['mqtt'].wanted_topics) - self.assertEqual({}, self.hass.data['mqtt'].subscribed_topics) - self.assertEqual({}, self.hass.data['mqtt'].progress) + def test_retained_message_on_subscribe_received(self): + """Test every subscriber receives retained message on subscribe.""" + def side_effect(*args): + async_fire_mqtt_message(self.hass, 'test/state', 'online') + return 0, 0 - def test_invalid_mqtt_topics(self): - """Test invalid topics.""" - self.assertRaises(vol.Invalid, mqtt.valid_publish_topic, 'bad+topic') - self.assertRaises(vol.Invalid, mqtt.valid_subscribe_topic, 'bad\0one') + self.hass.data['mqtt']._mqttc.subscribe.side_effect = side_effect + + calls_a = mock.MagicMock() + mqtt.subscribe(self.hass, 'test/state', calls_a) + self.hass.block_till_done() + self.assertTrue(calls_a.called) + + calls_b = mock.MagicMock() + mqtt.subscribe(self.hass, 'test/state', calls_b) + self.hass.block_till_done() + self.assertTrue(calls_b.called) + + def test_not_calling_unsubscribe_with_active_subscribers(self): + """Test not calling unsubscribe() when other subscribers are active.""" + unsub = mqtt.subscribe(self.hass, 'test/state', None) + mqtt.subscribe(self.hass, 'test/state', None) + self.hass.block_till_done() + self.assertTrue(self.hass.data['mqtt']._mqttc.subscribe.called) + + unsub() + self.hass.block_till_done() + self.assertFalse(self.hass.data['mqtt']._mqttc.unsubscribe.called) + + def test_restore_subscriptions_on_reconnect(self): + """Test subscriptions are restored on reconnect.""" + mqtt.subscribe(self.hass, 'test/state', None) + self.hass.block_till_done() + self.assertEqual(self.hass.data['mqtt']._mqttc.subscribe.call_count, 1) + + self.hass.data['mqtt']._mqtt_on_disconnect(None, None, 0) + self.hass.data['mqtt']._mqtt_on_connect(None, None, None, 0) + self.hass.block_till_done() + self.assertEqual(self.hass.data['mqtt']._mqttc.subscribe.call_count, 2) + + def test_restore_all_active_subscriptions_on_reconnect(self): + """Test active subscriptions are restored correctly on reconnect.""" + self.hass.data['mqtt']._mqttc.subscribe.side_effect = ( + (0, 1), (0, 2), (0, 3), (0, 4) + ) + + unsub = mqtt.subscribe(self.hass, 'test/state', None, qos=2) + mqtt.subscribe(self.hass, 'test/state', None) + mqtt.subscribe(self.hass, 'test/state', None, qos=1) + self.hass.block_till_done() + + expected = [ + mock.call('test/state', 2), + mock.call('test/state', 0), + mock.call('test/state', 1) + ] + self.assertEqual(self.hass.data['mqtt']._mqttc.subscribe.mock_calls, + expected) + + unsub() + self.hass.block_till_done() + self.assertEqual(self.hass.data['mqtt']._mqttc.unsubscribe.call_count, + 0) + + self.hass.data['mqtt']._mqtt_on_disconnect(None, None, 0) + self.hass.data['mqtt']._mqtt_on_connect(None, None, None, 0) + self.hass.block_till_done() + + expected.append(mock.call('test/state', 1)) + self.assertEqual(self.hass.data['mqtt']._mqttc.subscribe.mock_calls, + expected) @asyncio.coroutine @@ -426,7 +448,7 @@ def test_setup_embedded_starts_with_no_config(hass): return_value=mock_coro( return_value=(True, client_config)) ) as _start: - yield from mock_mqtt_client(hass, {}) + yield from async_mock_mqtt_client(hass, {}) assert _start.call_count == 1 @@ -440,7 +462,7 @@ def test_setup_embedded_with_embedded(hass): return_value=(True, client_config)) ) as _start: _start.return_value = mock_coro(return_value=(True, client_config)) - yield from mock_mqtt_client(hass, {'embedded': None}) + yield from async_mock_mqtt_client(hass, {'embedded': None}) assert _start.call_count == 1 @@ -544,13 +566,13 @@ def test_setup_with_tls_config_of_v1_under_python36_only_uses_v1(hass): @asyncio.coroutine def test_birth_message(hass): """Test sending birth message.""" - mqtt_client = yield from mock_mqtt_client(hass, { + mqtt_client = yield from async_mock_mqtt_client(hass, { mqtt.CONF_BROKER: 'mock-broker', mqtt.CONF_BIRTH_MESSAGE: {mqtt.ATTR_TOPIC: 'birth', mqtt.ATTR_PAYLOAD: 'birth'} }) calls = [] - mqtt_client.publish = lambda *args: calls.append(args) + mqtt_client.publish.side_effect = lambda *args: calls.append(args) hass.data['mqtt']._mqtt_on_connect(None, None, 0, 0) yield from hass.async_block_till_done() assert calls[-1] == ('birth', 'birth', 0, False) @@ -559,30 +581,26 @@ def test_birth_message(hass): @asyncio.coroutine def test_mqtt_subscribes_topics_on_connect(hass): """Test subscription to topic on connect.""" - mqtt_client = yield from mock_mqtt_client(hass) + mqtt_client = yield from async_mock_mqtt_client(hass) - subscribed_topics = OrderedDict() - subscribed_topics['topic/test'] = 1 - subscribed_topics['home/sensor'] = 2 - - wanted_topics = subscribed_topics.copy() - wanted_topics['still/pending'] = 0 - - hass.data['mqtt'].wanted_topics = wanted_topics - hass.data['mqtt'].subscribed_topics = subscribed_topics - hass.data['mqtt'].progress = {1: 'still/pending'} - - # Return values for subscribe calls (rc, mid) - mqtt_client.subscribe.side_effect = ((0, 2), (0, 3)) + hass.data['mqtt'].subscriptions = [ + mqtt.Subscription('topic/test', None), + mqtt.Subscription('home/sensor', None, 2), + mqtt.Subscription('still/pending', None), + mqtt.Subscription('still/pending', None, 1), + ] hass.add_job = mock.MagicMock() hass.data['mqtt']._mqtt_on_connect(None, None, 0, 0) yield from hass.async_block_till_done() - assert not mqtt_client.disconnect.called + assert mqtt_client.disconnect.call_count == 0 - expected = [(topic, qos) for topic, qos in wanted_topics.items()] - - assert [call[1][1:] for call in hass.add_job.mock_calls] == expected - assert hass.data['mqtt'].progress == {} + expected = { + 'topic/test': 0, + 'home/sensor': 2, + 'still/pending': 1 + } + calls = {call[1][1]: call[1][2] for call in hass.add_job.mock_calls} + assert calls == expected diff --git a/tests/components/switch/test_mqtt.py b/tests/components/switch/test_mqtt.py index 661f570e698..f79d0706321 100644 --- a/tests/components/switch/test_mqtt.py +++ b/tests/components/switch/test_mqtt.py @@ -70,16 +70,17 @@ class TestSwitchMQTT(unittest.TestCase): switch.turn_on(self.hass, 'switch.test') self.hass.block_till_done() - self.assertEqual(('command-topic', 'beer on', 2, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'command-topic', 'beer on', 2, False) + self.mock_publish.async_publish.reset_mock() state = self.hass.states.get('switch.test') self.assertEqual(STATE_ON, state.state) switch.turn_off(self.hass, 'switch.test') self.hass.block_till_done() - self.assertEqual(('command-topic', 'beer off', 2, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'command-topic', 'beer off', 2, False) state = self.hass.states.get('switch.test') self.assertEqual(STATE_OFF, state.state) diff --git a/tests/components/vacuum/test_mqtt.py b/tests/components/vacuum/test_mqtt.py index 8c3b5fa4eeb..ba2288e3fc6 100644 --- a/tests/components/vacuum/test_mqtt.py +++ b/tests/components/vacuum/test_mqtt.py @@ -71,52 +71,56 @@ class TestVacuumMQTT(unittest.TestCase): vacuum.turn_on(self.hass, 'vacuum.mqtttest') self.hass.block_till_done() - self.assertEqual(('vacuum/command', 'turn_on', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'vacuum/command', 'turn_on', 0, False) + self.mock_publish.async_publish.reset_mock() vacuum.turn_off(self.hass, 'vacuum.mqtttest') self.hass.block_till_done() - self.assertEqual(('vacuum/command', 'turn_off', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'vacuum/command', 'turn_off', 0, False) + self.mock_publish.async_publish.reset_mock() vacuum.stop(self.hass, 'vacuum.mqtttest') self.hass.block_till_done() - self.assertEqual(('vacuum/command', 'stop', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'vacuum/command', 'stop', 0, False) + self.mock_publish.async_publish.reset_mock() vacuum.clean_spot(self.hass, 'vacuum.mqtttest') self.hass.block_till_done() - self.assertEqual(('vacuum/command', 'clean_spot', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'vacuum/command', 'clean_spot', 0, False) + self.mock_publish.async_publish.reset_mock() vacuum.locate(self.hass, 'vacuum.mqtttest') self.hass.block_till_done() - self.assertEqual(('vacuum/command', 'locate', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'vacuum/command', 'locate', 0, False) + self.mock_publish.async_publish.reset_mock() vacuum.start_pause(self.hass, 'vacuum.mqtttest') self.hass.block_till_done() - self.assertEqual(('vacuum/command', 'start_pause', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'vacuum/command', 'start_pause', 0, False) + self.mock_publish.async_publish.reset_mock() vacuum.return_to_base(self.hass, 'vacuum.mqtttest') self.hass.block_till_done() - self.assertEqual(('vacuum/command', 'return_to_base', 0, False), - self.mock_publish.mock_calls[-2][1]) + self.mock_publish.async_publish.assert_called_once_with( + 'vacuum/command', 'return_to_base', 0, False) + self.mock_publish.async_publish.reset_mock() vacuum.set_fan_speed(self.hass, 'high', 'vacuum.mqtttest') self.hass.block_till_done() - self.assertEqual( - ('vacuum/set_fan_speed', 'high', 0, False), - self.mock_publish.mock_calls[-2][1] - ) + self.mock_publish.async_publish.assert_called_once_with( + 'vacuum/set_fan_speed', 'high', 0, False) + self.mock_publish.async_publish.reset_mock() vacuum.send_command(self.hass, '44 FE 93', entity_id='vacuum.mqtttest') self.hass.block_till_done() - self.assertEqual( - ('vacuum/send_command', '44 FE 93', 0, False), - self.mock_publish.mock_calls[-2][1] - ) + self.mock_publish.async_publish.assert_called_once_with( + 'vacuum/send_command', '44 FE 93', 0, False) def test_status(self): """Test status updates from the vacuum.""" diff --git a/tests/conftest.py b/tests/conftest.py index f1947a61ad0..989785e72d5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,11 +8,11 @@ from unittest.mock import patch, MagicMock import pytest import requests_mock as _requests_mock -from homeassistant import util, setup +from homeassistant import util from homeassistant.util import location -from homeassistant.components import mqtt -from tests.common import async_test_home_assistant, mock_coro, INSTANCES +from tests.common import async_test_home_assistant, INSTANCES, \ + async_mock_mqtt_component from tests.test_util.aiohttp import mock_aiohttp_client from tests.mock.zwave import MockNetwork, MockOption @@ -85,17 +85,9 @@ def aioclient_mock(): @pytest.fixture def mqtt_mock(loop, hass): """Fixture to mock MQTT.""" - with patch('homeassistant.components.mqtt.MQTT') as mock_mqtt: - mock_mqtt().async_connect.return_value = mock_coro(True) - assert loop.run_until_complete(setup.async_setup_component( - hass, mqtt.DOMAIN, { - mqtt.DOMAIN: { - mqtt.CONF_BROKER: 'mock-broker', - } - })) - client = mock_mqtt() - client.reset_mock() - return client + client = loop.run_until_complete(async_mock_mqtt_component(hass)) + client.reset_mock() + return client @pytest.fixture From 2edebfee0ae655d9431fac509bef5849398cb54b Mon Sep 17 00:00:00 2001 From: Rene Nulsch <33263735+ReneNulschDE@users.noreply.github.com> Date: Sun, 11 Feb 2018 18:19:00 +0100 Subject: [PATCH 012/173] Fix config error for FTP links, add test (#12294) --- homeassistant/components/weblink.py | 3 ++- tests/components/test_weblink.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/weblink.py b/homeassistant/components/weblink.py index a20b0fc9b0c..cd87bd838fa 100644 --- a/homeassistant/components/weblink.py +++ b/homeassistant/components/weblink.py @@ -22,9 +22,10 @@ CONF_RELATIVE_URL_REGEX = r'\A/' DOMAIN = 'weblink' ENTITIES_SCHEMA = vol.Schema({ + # pylint: disable=no-value-for-parameter vol.Required(CONF_URL): vol.Any( vol.Match(CONF_RELATIVE_URL_REGEX, msg=CONF_RELATIVE_URL_ERROR_MSG), - cv.url), + vol.Url()), vol.Required(CONF_NAME): cv.string, vol.Optional(CONF_ICON): cv.icon, }) diff --git a/tests/components/test_weblink.py b/tests/components/test_weblink.py index 249e81d37af..f35398e034c 100644 --- a/tests/components/test_weblink.py +++ b/tests/components/test_weblink.py @@ -91,6 +91,19 @@ class TestComponentWeblink(unittest.TestCase): } })) + def test_good_config_ftp_link(self): + """Test if new entity is created.""" + self.assertTrue(setup_component(self.hass, 'weblink', { + 'weblink': { + 'entities': [ + { + weblink.CONF_NAME: 'My FTP URL', + weblink.CONF_URL: 'ftp://somehost/' + }, + ], + } + })) + def test_entities_get_created(self): """Test if new entity is created.""" self.assertTrue(setup_component(self.hass, weblink.DOMAIN, { From 64c5d26a845733183f54d932266b8b6f0d0c7764 Mon Sep 17 00:00:00 2001 From: Rene Nulsch <33263735+ReneNulschDE@users.noreply.github.com> Date: Sun, 11 Feb 2018 18:19:31 +0100 Subject: [PATCH 013/173] Fix Panel_IFrame - FTP URL not allowed in 0.63 (#12295) --- homeassistant/components/panel_iframe.py | 3 ++- tests/components/test_panel_iframe.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/panel_iframe.py b/homeassistant/components/panel_iframe.py index 6ddf00cf7d4..4574437bac9 100644 --- a/homeassistant/components/panel_iframe.py +++ b/homeassistant/components/panel_iframe.py @@ -23,13 +23,14 @@ CONF_RELATIVE_URL_REGEX = r'\A/' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ cv.slug: { + # pylint: disable=no-value-for-parameter vol.Optional(CONF_TITLE): cv.string, vol.Optional(CONF_ICON): cv.icon, vol.Required(CONF_URL): vol.Any( vol.Match( CONF_RELATIVE_URL_REGEX, msg=CONF_RELATIVE_URL_ERROR_MSG), - cv.url), + vol.Url()), }})}, extra=vol.ALLOW_EXTRA) diff --git a/tests/components/test_panel_iframe.py b/tests/components/test_panel_iframe.py index ef702b96f4b..91a07511787 100644 --- a/tests/components/test_panel_iframe.py +++ b/tests/components/test_panel_iframe.py @@ -55,6 +55,11 @@ class TestPanelIframe(unittest.TestCase): 'title': 'Api', 'url': '/api', }, + 'ftp': { + 'icon': 'mdi:weather', + 'title': 'FTP', + 'url': 'ftp://some/ftp', + }, }, }) @@ -86,3 +91,12 @@ class TestPanelIframe(unittest.TestCase): 'url': '/frontend_es5/panels/ha-panel-iframe-md5md5.html', 'url_path': 'api', } + + assert panels.get('ftp').to_response(self.hass, None) == { + 'component_name': 'iframe', + 'config': {'url': 'ftp://some/ftp'}, + 'icon': 'mdi:weather', + 'title': 'FTP', + 'url': '/frontend_es5/panels/ha-panel-iframe-md5md5.html', + 'url_path': 'ftp', + } From 678f284015a2c52f96a7687979cfd9f785e4527a Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Sun, 11 Feb 2018 18:20:28 +0100 Subject: [PATCH 014/173] Upgrade pylint to 1.8.2 (#12274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Upgrade pylint to 1.8.1 * Fix no-else-return * Fix bad-whitespace * Fix too-many-nested-blocks * Fix raising-format-tuple See https://github.com/PyCQA/pylint/blob/master/doc/whatsnew/1.8.rst * Fix len-as-condition * Fix logging-not-lazy Not sure about that TEMP_CELSIUS though, but internally it's probably just like if you concatenated any other (variable) string * Fix stop-iteration-return * Fix useless-super-delegation * Fix trailing-comma-tuple Both of these seem to simply be bugs: * Nest: The value of self._humidity never seems to be used anywhere * Dovado: The called API method seems to expect a "normal" number * Fix redefined-argument-from-local * Fix consider-using-enumerate * Fix wrong-import-order * Fix arguments-differ * Fix missed no-else-return * Fix no-member and related * Fix signatures-differ * Revert "Upgrade pylint to 1.8.1" This reverts commit af78aa00f125a7d34add97b9d50c14db48412211. * Fix arguments-differ * except for device_tracker * Cleanup * Fix test using positional argument * Fix line too long I forgot to run flake8 - shame on me... 🙃 * Fix bad-option-value for 1.6.5 * Fix arguments-differ for device_tracker * Upgrade pylint to 1.8.2 * 👕 Fix missed no-member --- homeassistant/bootstrap.py | 46 ++++++++-------- homeassistant/components/abode.py | 2 +- .../components/alarm_control_panel/canary.py | 3 +- .../components/alarm_control_panel/manual.py | 8 +-- .../alarm_control_panel/manual_mqtt.py | 8 +-- .../components/binary_sensor/envisalink.py | 2 +- homeassistant/components/binary_sensor/ihc.py | 2 +- homeassistant/components/calendar/todoist.py | 2 +- homeassistant/components/camera/mjpeg.py | 2 + homeassistant/components/camera/uvc.py | 2 +- homeassistant/components/camera/xeoma.py | 3 +- homeassistant/components/climate/__init__.py | 6 +- homeassistant/components/climate/daikin.py | 4 +- homeassistant/components/climate/demo.py | 10 ++-- homeassistant/components/climate/ephember.py | 3 +- .../components/climate/eq3btsmart.py | 2 +- homeassistant/components/climate/flexit.py | 4 +- .../components/climate/generic_thermostat.py | 8 +-- homeassistant/components/climate/melissa.py | 24 ++++---- homeassistant/components/climate/mqtt.py | 12 ++-- homeassistant/components/climate/mysensors.py | 6 +- homeassistant/components/climate/nest.py | 6 +- homeassistant/components/climate/nuheat.py | 2 +- .../components/climate/radiotherm.py | 7 +-- homeassistant/components/climate/sensibo.py | 8 +-- homeassistant/components/climate/tado.py | 1 + homeassistant/components/climate/tesla.py | 3 +- homeassistant/components/climate/venstar.py | 22 +++----- homeassistant/components/climate/vera.py | 8 +-- homeassistant/components/climate/wink.py | 15 +++-- homeassistant/components/climate/zwave.py | 4 +- homeassistant/components/cloud/auth_api.py | 2 + homeassistant/components/cover/demo.py | 9 ++- homeassistant/components/cover/garadget.py | 6 +- homeassistant/components/cover/isy994.py | 7 +-- homeassistant/components/cover/myq.py | 4 +- homeassistant/components/cover/opengarage.py | 4 +- homeassistant/components/cover/rpi_gpio.py | 4 +- homeassistant/components/cover/tahoma.py | 9 ++- homeassistant/components/cover/vera.py | 7 ++- homeassistant/components/cover/wink.py | 11 ++-- .../components/cover/xiaomi_aqara.py | 5 +- homeassistant/components/cover/zwave.py | 15 ++--- .../components/device_tracker/__init__.py | 55 ++++++++++--------- .../components/device_tracker/automatic.py | 7 +-- .../components/device_tracker/bbox.py | 6 +- .../device_tracker/bluetooth_le_tracker.py | 2 +- .../device_tracker/bluetooth_tracker.py | 2 +- .../components/device_tracker/fritz.py | 4 +- .../components/device_tracker/geofency.py | 3 +- .../components/device_tracker/hitron_coda.py | 6 +- .../device_tracker/huawei_router.py | 1 + .../device_tracker/keenetic_ndms2.py | 6 +- .../components/device_tracker/linksys_ap.py | 2 +- .../device_tracker/linksys_smart.py | 4 +- .../components/device_tracker/meraki.py | 5 +- .../components/device_tracker/mikrotik.py | 4 +- .../components/device_tracker/netgear.py | 6 +- .../components/device_tracker/nmap_tracker.py | 6 +- .../components/device_tracker/tado.py | 6 +- .../components/device_tracker/ubus.py | 4 +- .../components/device_tracker/unifi.py | 6 +- homeassistant/components/dominos.py | 28 +++++----- homeassistant/components/fan/__init__.py | 21 ++++--- homeassistant/components/fan/comfoconnect.py | 14 ++--- homeassistant/components/fan/demo.py | 4 +- homeassistant/components/fan/dyson.py | 2 +- homeassistant/components/fan/insteon_local.py | 2 +- homeassistant/components/fan/isy994.py | 8 +-- homeassistant/components/fan/mqtt.py | 4 +- homeassistant/components/fan/velbus.py | 4 +- homeassistant/components/fan/wink.py | 2 +- homeassistant/components/fan/xiaomi_miio.py | 8 +-- homeassistant/components/http/ban.py | 2 +- homeassistant/components/ihc/ihcdevice.py | 2 +- homeassistant/components/isy994.py | 49 +++++++++-------- homeassistant/components/light/avion.py | 6 +- homeassistant/components/light/blinkt.py | 2 +- homeassistant/components/light/decora.py | 4 +- homeassistant/components/light/decora_wifi.py | 5 +- homeassistant/components/light/hive.py | 2 +- homeassistant/components/light/hue.py | 2 +- homeassistant/components/light/ihc.py | 2 +- homeassistant/components/light/isy994.py | 5 +- .../components/light/limitlessled.py | 4 ++ homeassistant/components/light/mysensors.py | 2 +- homeassistant/components/light/tplink.py | 2 +- homeassistant/components/light/wink.py | 2 +- homeassistant/components/light/xiaomi_miio.py | 14 +---- homeassistant/components/lirc.py | 2 +- homeassistant/components/lock/isy994.py | 3 +- homeassistant/components/media_extractor.py | 4 +- .../components/media_player/aquostv.py | 4 +- .../components/media_player/bluesound.py | 13 ++--- .../components/media_player/hdmi_cec.py | 2 +- homeassistant/components/media_player/mpd.py | 3 +- .../components/media_player/soundtouch.py | 20 +++---- .../components/media_player/webostv.py | 3 +- homeassistant/components/notify/html5.py | 2 +- .../components/notify/llamalab_automate.py | 2 +- homeassistant/components/plant.py | 5 +- homeassistant/components/python_script.py | 3 +- homeassistant/components/raspihats.py | 5 +- homeassistant/components/recorder/__init__.py | 2 +- homeassistant/components/remote/harmony.py | 1 + .../components/remote/xiaomi_miio.py | 3 +- homeassistant/components/rflink.py | 4 +- homeassistant/components/rfxtrx.py | 4 +- homeassistant/components/ring.py | 10 ++-- homeassistant/components/scene/deconz.py | 2 +- homeassistant/components/scene/litejet.py | 2 +- .../components/scene/lutron_caseta.py | 2 +- homeassistant/components/scene/velux.py | 2 +- homeassistant/components/scene/vera.py | 2 +- homeassistant/components/scene/wink.py | 2 +- homeassistant/components/sensor/bme680.py | 2 +- homeassistant/components/sensor/cups.py | 2 +- homeassistant/components/sensor/dovado.py | 2 +- homeassistant/components/sensor/dsmr.py | 3 +- .../components/sensor/dwd_weather_warnings.py | 6 +- .../components/sensor/fritzbox_netmonitor.py | 3 +- homeassistant/components/sensor/hp_ilo.py | 2 +- homeassistant/components/sensor/ihc.py | 2 +- .../components/sensor/irish_rail_transport.py | 6 +- homeassistant/components/sensor/isy994.py | 8 --- homeassistant/components/sensor/lacrosse.py | 4 -- .../components/sensor/linux_battery.py | 35 ++++++------ .../components/sensor/mold_indicator.py | 6 +- .../sensor/nederlandse_spoorwegen.py | 2 +- homeassistant/components/sensor/qnap.py | 4 +- homeassistant/components/sensor/skybeacon.py | 2 +- homeassistant/components/sensor/statistics.py | 2 +- homeassistant/components/sensor/teksavvy.py | 34 ++++++------ .../components/sensor/viaggiatreno.py | 5 +- .../components/sensor/volvooncall.py | 9 +-- .../components/sensor/worldtidesinfo.py | 4 +- homeassistant/components/sensor/yr.py | 4 +- homeassistant/components/sensor/zigbee.py | 2 +- homeassistant/components/sleepiq.py | 2 +- .../components/switch/acer_projector.py | 4 +- .../components/switch/anel_pwrctrl.py | 4 +- homeassistant/components/switch/arduino.py | 4 +- homeassistant/components/switch/dlink.py | 2 +- homeassistant/components/switch/edimax.py | 2 +- homeassistant/components/switch/fritzdect.py | 2 +- homeassistant/components/switch/gc100.py | 4 +- homeassistant/components/switch/hdmi_cec.py | 2 +- homeassistant/components/switch/ihc.py | 2 +- homeassistant/components/switch/isy994.py | 4 -- homeassistant/components/switch/netio.py | 4 +- homeassistant/components/switch/pilight.py | 4 +- homeassistant/components/switch/rachio.py | 4 +- homeassistant/components/switch/raincloud.py | 4 +- homeassistant/components/switch/raspihats.py | 4 +- homeassistant/components/switch/rpi_pfio.py | 4 +- homeassistant/components/switch/rpi_rf.py | 6 +- homeassistant/components/switch/snmp.py | 4 +- homeassistant/components/switch/toon.py | 2 +- homeassistant/components/switch/tplink.py | 2 +- homeassistant/components/switch/verisure.py | 4 +- homeassistant/components/switch/vultr.py | 4 +- .../components/switch/wake_on_lan.py | 4 +- homeassistant/components/switch/wink.py | 2 +- .../components/switch/xiaomi_aqara.py | 2 +- homeassistant/components/switch/zoneminder.py | 4 +- .../components/telegram_bot/polling.py | 2 +- homeassistant/components/tellduslive.py | 6 +- homeassistant/components/tts/__init__.py | 2 +- homeassistant/components/upnp.py | 2 +- homeassistant/components/vacuum/roomba.py | 2 +- .../components/vacuum/xiaomi_miio.py | 12 ++-- homeassistant/components/volvooncall.py | 7 +-- .../components/weather/buienradar.py | 4 +- homeassistant/config.py | 6 +- homeassistant/helpers/condition.py | 6 +- homeassistant/helpers/entity.py | 8 +-- homeassistant/helpers/entityfilter.py | 6 +- homeassistant/helpers/icon.py | 4 +- homeassistant/helpers/script.py | 6 +- homeassistant/helpers/template.py | 3 +- homeassistant/remote.py | 7 ++- homeassistant/scripts/credstash.py | 2 +- homeassistant/scripts/db_migrator.py | 5 +- homeassistant/scripts/influxdb_import.py | 5 +- homeassistant/scripts/influxdb_migrator.py | 5 +- homeassistant/setup.py | 4 +- homeassistant/util/__init__.py | 4 +- homeassistant/util/color.py | 4 +- homeassistant/util/dt.py | 2 +- homeassistant/util/location.py | 2 +- homeassistant/util/package.py | 6 +- homeassistant/util/yaml.py | 3 +- pylintrc | 2 + requirements_test.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/cover/test_zwave.py | 2 +- 196 files changed, 541 insertions(+), 593 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 64ad88f8c8b..c5b01916d8c 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -35,13 +35,13 @@ FIRST_INIT_COMPONENT = set(( def from_config_dict(config: Dict[str, Any], - hass: Optional[core.HomeAssistant]=None, - config_dir: Optional[str]=None, - enable_log: bool=True, - verbose: bool=False, - skip_pip: bool=False, - log_rotate_days: Any=None, - log_file: Any=None) \ + hass: Optional[core.HomeAssistant] = None, + config_dir: Optional[str] = None, + enable_log: bool = True, + verbose: bool = False, + skip_pip: bool = False, + log_rotate_days: Any = None, + log_file: Any = None) \ -> Optional[core.HomeAssistant]: """Try to configure Home Assistant from a configuration dictionary. @@ -68,12 +68,12 @@ def from_config_dict(config: Dict[str, Any], @asyncio.coroutine def async_from_config_dict(config: Dict[str, Any], hass: core.HomeAssistant, - config_dir: Optional[str]=None, - enable_log: bool=True, - verbose: bool=False, - skip_pip: bool=False, - log_rotate_days: Any=None, - log_file: Any=None) \ + config_dir: Optional[str] = None, + enable_log: bool = True, + verbose: bool = False, + skip_pip: bool = False, + log_rotate_days: Any = None, + log_file: Any = None) \ -> Optional[core.HomeAssistant]: """Try to configure Home Assistant from a configuration dictionary. @@ -163,11 +163,11 @@ def async_from_config_dict(config: Dict[str, Any], def from_config_file(config_path: str, - hass: Optional[core.HomeAssistant]=None, - verbose: bool=False, - skip_pip: bool=True, - log_rotate_days: Any=None, - log_file: Any=None): + hass: Optional[core.HomeAssistant] = None, + verbose: bool = False, + skip_pip: bool = True, + log_rotate_days: Any = None, + log_file: Any = None): """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter if given, @@ -188,10 +188,10 @@ def from_config_file(config_path: str, @asyncio.coroutine def async_from_config_file(config_path: str, hass: core.HomeAssistant, - verbose: bool=False, - skip_pip: bool=True, - log_rotate_days: Any=None, - log_file: Any=None): + verbose: bool = False, + skip_pip: bool = True, + log_rotate_days: Any = None, + log_file: Any = None): """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter. @@ -219,7 +219,7 @@ def async_from_config_file(config_path: str, @core.callback -def async_enable_logging(hass: core.HomeAssistant, verbose: bool=False, +def async_enable_logging(hass: core.HomeAssistant, verbose: bool = False, log_rotate_days=None, log_file=None) -> None: """Set up the logging. diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index cbfee2ae215..fde21a265b0 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -7,6 +7,7 @@ https://home-assistant.io/components/abode/ import asyncio import logging from functools import partial +from requests.exceptions import HTTPError, ConnectTimeout import voluptuous as vol @@ -17,7 +18,6 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -from requests.exceptions import HTTPError, ConnectTimeout REQUIREMENTS = ['abodepy==0.12.2'] diff --git a/homeassistant/components/alarm_control_panel/canary.py b/homeassistant/components/alarm_control_panel/canary.py index fb5c4c37e8d..2e0e9994e10 100644 --- a/homeassistant/components/alarm_control_panel/canary.py +++ b/homeassistant/components/alarm_control_panel/canary.py @@ -59,8 +59,7 @@ class CanaryAlarm(AlarmControlPanel): return STATE_ALARM_ARMED_HOME elif mode.name == LOCATION_MODE_NIGHT: return STATE_ALARM_ARMED_NIGHT - else: - return None + return None @property def device_state_attributes(self): diff --git a/homeassistant/components/alarm_control_panel/manual.py b/homeassistant/components/alarm_control_panel/manual.py index 35b255d4b57..5beb5261607 100644 --- a/homeassistant/components/alarm_control_panel/manual.py +++ b/homeassistant/components/alarm_control_panel/manual.py @@ -172,9 +172,8 @@ class ManualAlarm(alarm.AlarmControlPanel): trigger_time) < dt_util.utcnow(): if self._disarm_after_trigger: return STATE_ALARM_DISARMED - else: - self._state = self._previous_state - return self._state + self._state = self._previous_state + return self._state if self._state in SUPPORTED_PENDING_STATES and \ self._within_pending_time(self._state): @@ -187,8 +186,7 @@ class ManualAlarm(alarm.AlarmControlPanel): """Get the current state.""" if self.state == STATE_ALARM_PENDING: return self._previous_state - else: - return self._state + return self._state def _pending_time(self, state): """Get the pending time.""" diff --git a/homeassistant/components/alarm_control_panel/manual_mqtt.py b/homeassistant/components/alarm_control_panel/manual_mqtt.py index ef12cbe365f..4b08ad67292 100644 --- a/homeassistant/components/alarm_control_panel/manual_mqtt.py +++ b/homeassistant/components/alarm_control_panel/manual_mqtt.py @@ -208,9 +208,8 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): trigger_time) < dt_util.utcnow(): if self._disarm_after_trigger: return STATE_ALARM_DISARMED - else: - self._state = self._previous_state - return self._state + self._state = self._previous_state + return self._state if self._state in SUPPORTED_PENDING_STATES and \ self._within_pending_time(self._state): @@ -223,8 +222,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): """Get the current state.""" if self.state == STATE_ALARM_PENDING: return self._previous_state - else: - return self._state + return self._state def _pending_time(self, state): """Get the pending time.""" diff --git a/homeassistant/components/binary_sensor/envisalink.py b/homeassistant/components/binary_sensor/envisalink.py index 7d35c0c9e94..0aadcc247ea 100644 --- a/homeassistant/components/binary_sensor/envisalink.py +++ b/homeassistant/components/binary_sensor/envisalink.py @@ -50,7 +50,7 @@ class EnvisalinkBinarySensor(EnvisalinkDevice, BinarySensorDevice): self._zone_type = zone_type self._zone_number = zone_number - _LOGGER.debug('Setting up zone: ' + zone_name) + _LOGGER.debug('Setting up zone: %s', zone_name) super().__init__(zone_name, info, controller) @asyncio.coroutine diff --git a/homeassistant/components/binary_sensor/ihc.py b/homeassistant/components/binary_sensor/ihc.py index 04f8c0d00dd..97de176753f 100644 --- a/homeassistant/components/binary_sensor/ihc.py +++ b/homeassistant/components/binary_sensor/ihc.py @@ -70,7 +70,7 @@ class IHCBinarySensor(IHCDevice, BinarySensorDevice): def __init__(self, ihc_controller, name, ihc_id: int, info: bool, sensor_type: str, inverting: bool, - product: Element=None) -> None: + product: Element = None) -> None: """Initialize the IHC binary sensor.""" super().__init__(ihc_controller, name, ihc_id, info, product) self._state = None diff --git a/homeassistant/components/calendar/todoist.py b/homeassistant/components/calendar/todoist.py index f1c80612f3b..c5ae1dd3c11 100644 --- a/homeassistant/components/calendar/todoist.py +++ b/homeassistant/components/calendar/todoist.py @@ -498,7 +498,7 @@ class TodoistProjectData(object): # Organize the best tasks (so users can see all the tasks # they have, organized) - while len(project_tasks) > 0: + while project_tasks: best_task = self.select_best_task(project_tasks) _LOGGER.debug("Found Todoist Task: %s", best_task[SUMMARY]) project_tasks.remove(best_task) diff --git a/homeassistant/components/camera/mjpeg.py b/homeassistant/components/camera/mjpeg.py index 6168eb81939..35d30104f6e 100644 --- a/homeassistant/components/camera/mjpeg.py +++ b/homeassistant/components/camera/mjpeg.py @@ -119,6 +119,8 @@ class MjpegCamera(Camera): else: req = requests.get(self._mjpeg_url, stream=True, timeout=10) + # https://github.com/PyCQA/pylint/issues/1437 + # pylint: disable=no-member with closing(req) as response: return extract_image_from_mjpeg(response.iter_content(102400)) diff --git a/homeassistant/components/camera/uvc.py b/homeassistant/components/camera/uvc.py index f7dc4cfd973..20dceb8a1c5 100644 --- a/homeassistant/components/camera/uvc.py +++ b/homeassistant/components/camera/uvc.py @@ -188,7 +188,7 @@ class UnifiVideoCamera(Camera): self._nvr.set_recordmode(self._uuid, set_mode) self._motion_status = mode except NvrError as err: - _LOGGER.error("Unable to set recordmode to " + set_mode) + _LOGGER.error("Unable to set recordmode to %s", set_mode) _LOGGER.debug(err) def enable_motion_detection(self): diff --git a/homeassistant/components/camera/xeoma.py b/homeassistant/components/camera/xeoma.py index b4bcad0064d..23342b94cdc 100644 --- a/homeassistant/components/camera/xeoma.py +++ b/homeassistant/components/camera/xeoma.py @@ -42,7 +42,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine -# pylint: disable=unused-argument def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Discover and setup Xeoma Cameras.""" from pyxeoma.xeoma import Xeoma, XeomaError @@ -69,6 +68,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): ] for cam in config[CONF_CAMERAS]: + # https://github.com/PyCQA/pylint/issues/1830 + # pylint: disable=stop-iteration-return camera = next( (dc for dc in discovered_cameras if dc[CONF_IMAGE_NAME] == cam[CONF_IMAGE_NAME]), None) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index ce656eb96e8..e1a5f71af83 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -669,16 +669,16 @@ class ClimateDevice(Entity): """ return self.hass.async_add_job(self.set_humidity, humidity) - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Set new target fan mode.""" raise NotImplementedError() - def async_set_fan_mode(self, fan): + def async_set_fan_mode(self, fan_mode): """Set new target fan mode. This method must be run in the event loop and returns a coroutine. """ - return self.hass.async_add_job(self.set_fan_mode, fan) + return self.hass.async_add_job(self.set_fan_mode, fan_mode) def set_operation_mode(self, operation_mode): """Set new target operation mode.""" diff --git a/homeassistant/components/climate/daikin.py b/homeassistant/components/climate/daikin.py index 0ed4ebe8942..531abd4b581 100644 --- a/homeassistant/components/climate/daikin.py +++ b/homeassistant/components/climate/daikin.py @@ -236,9 +236,9 @@ class DaikinClimate(ClimateDevice): """Return the fan setting.""" return self.get(ATTR_FAN_MODE) - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Set fan mode.""" - self.set({ATTR_FAN_MODE: fan}) + self.set({ATTR_FAN_MODE: fan_mode}) @property def fan_list(self): diff --git a/homeassistant/components/climate/demo.py b/homeassistant/components/climate/demo.py index 102155babea..44491b8cd21 100644 --- a/homeassistant/components/climate/demo.py +++ b/homeassistant/components/climate/demo.py @@ -195,9 +195,9 @@ class DemoClimate(ClimateDevice): self._current_swing_mode = swing_mode self.schedule_update_ha_state() - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Set new target temperature.""" - self._current_fan_mode = fan + self._current_fan_mode = fan_mode self.schedule_update_ha_state() def set_operation_mode(self, operation_mode): @@ -225,9 +225,9 @@ class DemoClimate(ClimateDevice): self._away = False self.schedule_update_ha_state() - def set_hold_mode(self, hold): - """Update hold mode on.""" - self._hold = hold + def set_hold_mode(self, hold_mode): + """Update hold_mode on.""" + self._hold = hold_mode self.schedule_update_ha_state() def turn_aux_heat_on(self): diff --git a/homeassistant/components/climate/ephember.py b/homeassistant/components/climate/ephember.py index e1f1ab7d448..419237b4645 100644 --- a/homeassistant/components/climate/ephember.py +++ b/homeassistant/components/climate/ephember.py @@ -98,8 +98,7 @@ class EphEmberThermostat(ClimateDevice): """Return current operation ie. heat, cool, idle.""" if self._zone['isCurrentlyActive']: return STATE_HEAT - else: - return STATE_IDLE + return STATE_IDLE @property def is_aux_heat_on(self): diff --git a/homeassistant/components/climate/eq3btsmart.py b/homeassistant/components/climate/eq3btsmart.py index 9c712c632e6..4a402887864 100644 --- a/homeassistant/components/climate/eq3btsmart.py +++ b/homeassistant/components/climate/eq3btsmart.py @@ -53,7 +53,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices(devices) -# pylint: disable=import-error +# pylint: disable=import-error, no-name-in-module class EQ3BTSmartThermostat(ClimateDevice): """Representation of an eQ-3 Bluetooth Smart thermostat.""" diff --git a/homeassistant/components/climate/flexit.py b/homeassistant/components/climate/flexit.py index 98c03217509..565e913319f 100644 --- a/homeassistant/components/climate/flexit.py +++ b/homeassistant/components/climate/flexit.py @@ -152,6 +152,6 @@ class Flexit(ClimateDevice): self._target_temperature = kwargs.get(ATTR_TEMPERATURE) self.unit.set_temp(self._target_temperature) - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Set new fan mode.""" - self.unit.set_fan_speed(self._fan_list.index(fan)) + self.unit.set_fan_speed(self._fan_list.index(fan_mode)) diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index c66e611c8e9..b97dc221298 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -190,11 +190,9 @@ class GenericThermostat(ClimateDevice): """Return the current state.""" if self._is_device_active: return self.current_operation - else: - if self._enabled: - return STATE_IDLE - else: - return STATE_OFF + if self._enabled: + return STATE_IDLE + return STATE_OFF @property def should_poll(self): diff --git a/homeassistant/components/climate/melissa.py b/homeassistant/components/climate/melissa.py index 2b3b3bfbab1..96bd66d05a5 100644 --- a/homeassistant/components/climate/melissa.py +++ b/homeassistant/components/climate/melissa.py @@ -146,10 +146,10 @@ class MelissaClimate(ClimateDevice): temp = kwargs.get(ATTR_TEMPERATURE) self.send({self._api.TEMP: temp}) - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Set fan mode.""" - fan_mode = self.hass_fan_to_melissa(fan) - self.send({self._api.FAN: fan_mode}) + melissa_fan_mode = self.hass_fan_to_melissa(fan_mode) + self.send({self._api.FAN: melissa_fan_mode}) def set_operation_mode(self, operation_mode): """Set operation mode.""" @@ -174,8 +174,7 @@ class MelissaClimate(ClimateDevice): if not self._api.send(self._serial_number, self._cur_settings): self._cur_settings = old_value return False - else: - return True + return True def update(self): """Get latest data from Melissa.""" @@ -196,8 +195,7 @@ class MelissaClimate(ClimateDevice): return STATE_OFF elif state == self._api.STATE_IDLE: return STATE_IDLE - else: - return None + return None def melissa_op_to_hass(self, mode): """Translate Melissa modes to hass states.""" @@ -211,10 +209,9 @@ class MelissaClimate(ClimateDevice): return STATE_DRY elif mode == self._api.MODE_FAN: return STATE_FAN_ONLY - else: - _LOGGER.warning( - "Operation mode %s could not be mapped to hass", mode) - return None + _LOGGER.warning( + "Operation mode %s could not be mapped to hass", mode) + return None def melissa_fan_to_hass(self, fan): """Translate Melissa fan modes to hass modes.""" @@ -226,9 +223,8 @@ class MelissaClimate(ClimateDevice): return SPEED_MEDIUM elif fan == self._api.FAN_HIGH: return SPEED_HIGH - else: - _LOGGER.warning("Fan mode %s could not be mapped to hass", fan) - return None + _LOGGER.warning("Fan mode %s could not be mapped to hass", fan) + return None def hass_mode_to_melissa(self, mode): """Translate hass states to melissa modes.""" diff --git a/homeassistant/components/climate/mqtt.py b/homeassistant/components/climate/mqtt.py index 5929cec3b05..1d98a5733f7 100644 --- a/homeassistant/components/climate/mqtt.py +++ b/homeassistant/components/climate/mqtt.py @@ -482,15 +482,15 @@ class MqttClimate(MqttAvailability, ClimateDevice): self.async_schedule_update_ha_state() @asyncio.coroutine - def async_set_fan_mode(self, fan): + def async_set_fan_mode(self, fan_mode): """Set new target temperature.""" if self._send_if_off or self._current_operation != STATE_OFF: mqtt.async_publish( self.hass, self._topic[CONF_FAN_MODE_COMMAND_TOPIC], - fan, self._qos, self._retain) + fan_mode, self._qos, self._retain) if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None: - self._current_fan_mode = fan + self._current_fan_mode = fan_mode self.async_schedule_update_ha_state() @asyncio.coroutine @@ -552,15 +552,15 @@ class MqttClimate(MqttAvailability, ClimateDevice): self.async_schedule_update_ha_state() @asyncio.coroutine - def async_set_hold_mode(self, hold): + def async_set_hold_mode(self, hold_mode): """Update hold mode on.""" if self._topic[CONF_HOLD_COMMAND_TOPIC] is not None: mqtt.async_publish(self.hass, self._topic[CONF_HOLD_COMMAND_TOPIC], - hold, self._qos, self._retain) + hold_mode, self._qos, self._retain) if self._topic[CONF_HOLD_STATE_TOPIC] is None: - self._hold = hold + self._hold = hold_mode self.async_schedule_update_ha_state() @asyncio.coroutine diff --git a/homeassistant/components/climate/mysensors.py b/homeassistant/components/climate/mysensors.py index 5553db70f0d..b526d8b066c 100644 --- a/homeassistant/components/climate/mysensors.py +++ b/homeassistant/components/climate/mysensors.py @@ -143,14 +143,14 @@ class MySensorsHVAC(mysensors.MySensorsEntity, ClimateDevice): self._values[value_type] = value self.schedule_update_ha_state() - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Set new target temperature.""" set_req = self.gateway.const.SetReq self.gateway.set_child_value( - self.node_id, self.child_id, set_req.V_HVAC_SPEED, fan) + self.node_id, self.child_id, set_req.V_HVAC_SPEED, fan_mode) if self.gateway.optimistic: # Optimistically assume that device has changed state - self._values[set_req.V_HVAC_SPEED] = fan + self._values[set_req.V_HVAC_SPEED] = fan_mode self.schedule_update_ha_state() def set_operation_mode(self, operation_mode): diff --git a/homeassistant/components/climate/nest.py b/homeassistant/components/climate/nest.py index d8d7d6c901a..0427514a7b5 100644 --- a/homeassistant/components/climate/nest.py +++ b/homeassistant/components/climate/nest.py @@ -207,9 +207,9 @@ class NestThermostat(ClimateDevice): """List of available fan modes.""" return self._fan_list - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Turn fan on/off.""" - self.device.fan = fan.lower() + self.device.fan = fan_mode.lower() @property def min_temp(self): @@ -225,7 +225,7 @@ class NestThermostat(ClimateDevice): """Cache value from Python-nest.""" self._location = self.device.where self._name = self.device.name - self._humidity = self.device.humidity, + self._humidity = self.device.humidity self._temperature = self.device.temperature self._mode = self.device.mode self._target_temperature = self.device.target diff --git a/homeassistant/components/climate/nuheat.py b/homeassistant/components/climate/nuheat.py index f41812dbaae..39c66ff94f2 100644 --- a/homeassistant/components/climate/nuheat.py +++ b/homeassistant/components/climate/nuheat.py @@ -185,7 +185,7 @@ class NuHeatThermostat(ClimateDevice): self._thermostat.resume_schedule() self._force_update = True - def set_hold_mode(self, hold_mode, **kwargs): + def set_hold_mode(self, hold_mode): """Update the hold mode of the thermostat.""" if hold_mode == MODE_AUTO: schedule_mode = SCHEDULE_RUN diff --git a/homeassistant/components/climate/radiotherm.py b/homeassistant/components/climate/radiotherm.py index 2b31ca93d22..032d85637ef 100644 --- a/homeassistant/components/climate/radiotherm.py +++ b/homeassistant/components/climate/radiotherm.py @@ -183,17 +183,16 @@ class RadioThermostat(ClimateDevice): """List of available fan modes.""" if self._is_model_ct80: return CT80_FAN_OPERATION_LIST - else: - return CT30_FAN_OPERATION_LIST + return CT30_FAN_OPERATION_LIST @property def current_fan_mode(self): """Return whether the fan is on.""" return self._fmode - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Turn fan on/off.""" - code = FAN_MODE_TO_CODE.get(fan, None) + code = FAN_MODE_TO_CODE.get(fan_mode, None) if code is not None: self.device.fmode = code diff --git a/homeassistant/components/climate/sensibo.py b/homeassistant/components/climate/sensibo.py index 67113e7c48a..b49d379592f 100644 --- a/homeassistant/components/climate/sensibo.py +++ b/homeassistant/components/climate/sensibo.py @@ -240,13 +240,13 @@ class SensiboClimate(ClimateDevice): def min_temp(self): """Return the minimum temperature.""" return self._temperatures_list[0] \ - if len(self._temperatures_list) else super().min_temp() + if self._temperatures_list else super().min_temp() @property def max_temp(self): """Return the maximum temperature.""" return self._temperatures_list[-1] \ - if len(self._temperatures_list) else super().max_temp() + if self._temperatures_list else super().max_temp() @asyncio.coroutine def async_set_temperature(self, **kwargs): @@ -273,11 +273,11 @@ class SensiboClimate(ClimateDevice): self._id, 'targetTemperature', temperature, self._ac_states) @asyncio.coroutine - def async_set_fan_mode(self, fan): + def async_set_fan_mode(self, fan_mode): """Set new target fan mode.""" with async_timeout.timeout(TIMEOUT): yield from self._client.async_set_ac_state_property( - self._id, 'fanLevel', fan, self._ac_states) + self._id, 'fanLevel', fan_mode, self._ac_states) @asyncio.coroutine def async_set_operation_mode(self, operation_mode): diff --git a/homeassistant/components/climate/tado.py b/homeassistant/components/climate/tado.py index 868511c0ac4..437c8ec3371 100644 --- a/homeassistant/components/climate/tado.py +++ b/homeassistant/components/climate/tado.py @@ -213,6 +213,7 @@ class TadoClimate(ClimateDevice): self._target_temp = temperature self._control_heating() + # pylint: disable=arguments-differ def set_operation_mode(self, readable_operation_mode): """Set new operation mode.""" operation_mode = CONST_MODE_SMART_SCHEDULE diff --git a/homeassistant/components/climate/tesla.py b/homeassistant/components/climate/tesla.py index 459d9c666fd..225c13d975d 100644 --- a/homeassistant/components/climate/tesla.py +++ b/homeassistant/components/climate/tesla.py @@ -51,8 +51,7 @@ class TeslaThermostat(TeslaDevice, ClimateDevice): mode = self.tesla_device.is_hvac_enabled() if mode: return OPERATION_LIST[0] # On - else: - return OPERATION_LIST[1] # Off + return OPERATION_LIST[1] # Off @property def operation_list(self): diff --git a/homeassistant/components/climate/venstar.py b/homeassistant/components/climate/venstar.py index 6db1d53bc50..6e63cc4092b 100644 --- a/homeassistant/components/climate/venstar.py +++ b/homeassistant/components/climate/venstar.py @@ -111,8 +111,7 @@ class VenstarThermostat(ClimateDevice): """Return the unit of measurement, as defined by the API.""" if self._client.tempunits == self._client.TEMPUNITS_F: return TEMP_FAHRENHEIT - else: - return TEMP_CELSIUS + return TEMP_CELSIUS @property def fan_list(self): @@ -143,16 +142,14 @@ class VenstarThermostat(ClimateDevice): return STATE_COOL elif self._client.mode == self._client.MODE_AUTO: return STATE_AUTO - else: - return STATE_OFF + return STATE_OFF @property def current_fan_mode(self): """Return the fan setting.""" if self._client.fan == self._client.FAN_AUTO: return STATE_AUTO - else: - return STATE_ON + return STATE_ON @property def device_state_attributes(self): @@ -169,24 +166,21 @@ class VenstarThermostat(ClimateDevice): return self._client.heattemp elif self._client.mode == self._client.MODE_COOL: return self._client.cooltemp - else: - return None + return None @property def target_temperature_low(self): """Return the lower bound temp if auto mode is on.""" if self._client.mode == self._client.MODE_AUTO: return self._client.heattemp - else: - return None + return None @property def target_temperature_high(self): """Return the upper bound temp if auto mode is on.""" if self._client.mode == self._client.MODE_AUTO: return self._client.cooltemp - else: - return None + return None @property def target_humidity(self): @@ -245,9 +239,9 @@ class VenstarThermostat(ClimateDevice): if not success: _LOGGER.error("Failed to change the temperature") - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Set new target fan mode.""" - if fan == STATE_ON: + if fan_mode == STATE_ON: success = self._client.set_fan(self._client.FAN_ON) else: success = self._client.set_fan(self._client.FAN_AUTO) diff --git a/homeassistant/components/climate/vera.py b/homeassistant/components/climate/vera.py index c9d22e41d81..6fb6bc0ff48 100644 --- a/homeassistant/components/climate/vera.py +++ b/homeassistant/components/climate/vera.py @@ -85,13 +85,13 @@ class VeraThermostat(VeraDevice, ClimateDevice): """Return a list of available fan modes.""" return FAN_OPERATION_LIST - def set_fan_mode(self, mode): + def set_fan_mode(self, fan_mode): """Set new target temperature.""" - if mode == FAN_OPERATION_LIST[0]: + if fan_mode == FAN_OPERATION_LIST[0]: self.vera_device.fan_on() - elif mode == FAN_OPERATION_LIST[1]: + elif fan_mode == FAN_OPERATION_LIST[1]: self.vera_device.fan_auto() - elif mode == FAN_OPERATION_LIST[2]: + elif fan_mode == FAN_OPERATION_LIST[2]: return self.vera_device.fan_cycle() @property diff --git a/homeassistant/components/climate/wink.py b/homeassistant/components/climate/wink.py index 50374a32807..8c66567a4aa 100644 --- a/homeassistant/components/climate/wink.py +++ b/homeassistant/components/climate/wink.py @@ -324,9 +324,9 @@ class WinkThermostat(WinkDevice, ClimateDevice): return self.wink.fan_modes() return None - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Turn fan on/off.""" - self.wink.set_fan_mode(fan.lower()) + self.wink.set_fan_mode(fan_mode.lower()) def turn_aux_heat_on(self): """Turn auxiliary heater on.""" @@ -486,26 +486,25 @@ class WinkAC(WinkDevice, ClimateDevice): return SPEED_LOW elif speed <= 0.66: return SPEED_MEDIUM - else: - return SPEED_HIGH + return SPEED_HIGH @property def fan_list(self): """Return a list of available fan modes.""" return [SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """ Set fan speed. The official Wink app only supports 3 modes [low, medium, high] which are equal to [0.33, 0.66, 1.0] respectively. """ - if fan == SPEED_LOW: + if fan_mode == SPEED_LOW: speed = 0.33 - elif fan == SPEED_MEDIUM: + elif fan_mode == SPEED_MEDIUM: speed = 0.66 - elif fan == SPEED_HIGH: + elif fan_mode == SPEED_HIGH: speed = 1.0 self.wink.set_ac_fan_speed(speed) diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py index acc3eda1194..1eec9c82f3c 100644 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -198,10 +198,10 @@ class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice): self.values.primary.data = temperature - def set_fan_mode(self, fan): + def set_fan_mode(self, fan_mode): """Set new target fan mode.""" if self.values.fan_mode: - self.values.fan_mode.data = fan + self.values.fan_mode.data = fan_mode def set_operation_mode(self, operation_mode): """Set new target operation mode.""" diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py index e96f2a2d8a5..c7679c0f262 100644 --- a/homeassistant/components/cloud/auth_api.py +++ b/homeassistant/components/cloud/auth_api.py @@ -31,6 +31,8 @@ class InvalidCode(CloudError): class PasswordChangeRequired(CloudError): """Raised when a password change is required.""" + # https://github.com/PyCQA/pylint/issues/1085 + # pylint: disable=useless-super-delegation def __init__(self, message='Password change required.'): """Initialize a password change required error.""" super().__init__(message) diff --git a/homeassistant/components/cover/demo.py b/homeassistant/components/cover/demo.py index 827b50c8af9..70e681f1120 100644 --- a/homeassistant/components/cover/demo.py +++ b/homeassistant/components/cover/demo.py @@ -5,7 +5,8 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ from homeassistant.components.cover import ( - CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE) + CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE, ATTR_POSITION, + ATTR_TILT_POSITION) from homeassistant.helpers.event import track_utc_time_change @@ -137,8 +138,9 @@ class DemoCover(CoverDevice): self._listen_cover_tilt() self._requested_closing_tilt = False - def set_cover_position(self, position, **kwargs): + def set_cover_position(self, **kwargs): """Move the cover to a specific position.""" + position = kwargs.get(ATTR_POSITION) self._set_position = round(position, -1) if self._position == position: return @@ -146,8 +148,9 @@ class DemoCover(CoverDevice): self._listen_cover() self._requested_closing = position < self._position - def set_cover_tilt_position(self, tilt_position, **kwargs): + def set_cover_tilt_position(self, **kwargs): """Move the cover til to a specific position.""" + tilt_position = kwargs.get(ATTR_TILT_POSITION) self._set_tilt_position = round(tilt_position, -1) if self._tilt_position == tilt_position: return diff --git a/homeassistant/components/cover/garadget.py b/homeassistant/components/cover/garadget.py index 22f5fd889a2..c19aa69c8f0 100644 --- a/homeassistant/components/cover/garadget.py +++ b/homeassistant/components/cover/garadget.py @@ -201,21 +201,21 @@ class GaradgetCover(CoverDevice): """Check the state of the service during an operation.""" self.schedule_update_ha_state(True) - def close_cover(self): + def close_cover(self, **kwargs): """Close the cover.""" if self._state not in ['close', 'closing']: ret = self._put_command('setState', 'close') self._start_watcher('close') return ret.get('return_value') == 1 - def open_cover(self): + def open_cover(self, **kwargs): """Open the cover.""" if self._state not in ['open', 'opening']: ret = self._put_command('setState', 'open') self._start_watcher('open') return ret.get('return_value') == 1 - def stop_cover(self): + def stop_cover(self, **kwargs): """Stop the door where it is.""" if self._state not in ['stopped']: ret = self._put_command('setState', 'stop') diff --git a/homeassistant/components/cover/isy994.py b/homeassistant/components/cover/isy994.py index 7d77b1bc3be..82ca60e84e6 100644 --- a/homeassistant/components/cover/isy994.py +++ b/homeassistant/components/cover/isy994.py @@ -42,10 +42,6 @@ def setup_platform(hass, config: ConfigType, class ISYCoverDevice(ISYDevice, CoverDevice): """Representation of an ISY994 cover device.""" - def __init__(self, node: object) -> None: - """Initialize the ISY994 cover device.""" - super().__init__(node) - @property def current_cover_position(self) -> int: """Return the current cover position.""" @@ -61,8 +57,7 @@ class ISYCoverDevice(ISYDevice, CoverDevice): """Get the state of the ISY994 cover device.""" if self.is_unknown(): return None - else: - return VALUE_TO_STATE.get(self.value, STATE_OPEN) + return VALUE_TO_STATE.get(self.value, STATE_OPEN) def open_cover(self, **kwargs) -> None: """Send the open cover command to the ISY994 cover device.""" diff --git a/homeassistant/components/cover/myq.py b/homeassistant/components/cover/myq.py index 8d59a90278c..f07d3849fae 100644 --- a/homeassistant/components/cover/myq.py +++ b/homeassistant/components/cover/myq.py @@ -84,11 +84,11 @@ class MyQDevice(CoverDevice): """Return true if cover is closed, else False.""" return self._status == STATE_CLOSED - def close_cover(self): + def close_cover(self, **kwargs): """Issue close command to cover.""" self.myq.close_device(self.device_id) - def open_cover(self): + def open_cover(self, **kwargs): """Issue open command to cover.""" self.myq.open_device(self.device_id) diff --git a/homeassistant/components/cover/opengarage.py b/homeassistant/components/cover/opengarage.py index d98c71e25fb..38fbaf0acdb 100644 --- a/homeassistant/components/cover/opengarage.py +++ b/homeassistant/components/cover/opengarage.py @@ -119,14 +119,14 @@ class OpenGarageCover(CoverDevice): return None return self._state in [STATE_CLOSED, STATE_OPENING] - def close_cover(self): + def close_cover(self, **kwargs): """Close the cover.""" if self._state not in [STATE_CLOSED, STATE_CLOSING]: self._state_before_move = self._state self._state = STATE_CLOSING self._push_button() - def open_cover(self): + def open_cover(self, **kwargs): """Open the cover.""" if self._state not in [STATE_OPEN, STATE_OPENING]: self._state_before_move = self._state diff --git a/homeassistant/components/cover/rpi_gpio.py b/homeassistant/components/cover/rpi_gpio.py index 981312140eb..77cd0b0f7e2 100644 --- a/homeassistant/components/cover/rpi_gpio.py +++ b/homeassistant/components/cover/rpi_gpio.py @@ -109,12 +109,12 @@ class RPiGPIOCover(CoverDevice): sleep(self._relay_time) rpi_gpio.write_output(self._relay_pin, not self._invert_relay) - def close_cover(self): + def close_cover(self, **kwargs): """Close the cover.""" if not self.is_closed: self._trigger() - def open_cover(self): + def open_cover(self, **kwargs): """Open the cover.""" if self.is_closed: self._trigger() diff --git a/homeassistant/components/cover/tahoma.py b/homeassistant/components/cover/tahoma.py index 19bd9f01417..6fb8e92e051 100644 --- a/homeassistant/components/cover/tahoma.py +++ b/homeassistant/components/cover/tahoma.py @@ -6,7 +6,7 @@ https://home-assistant.io/components/cover.tahoma/ """ import logging -from homeassistant.components.cover import CoverDevice +from homeassistant.components.cover import CoverDevice, ATTR_POSITION from homeassistant.components.tahoma import ( DOMAIN as TAHOMA_DOMAIN, TahomaDevice) @@ -49,9 +49,9 @@ class TahomaCover(TahomaDevice, CoverDevice): except KeyError: return None - def set_cover_position(self, position, **kwargs): + def set_cover_position(self, **kwargs): """Move the cover to a specific position.""" - self.apply_action('setPosition', 100 - position) + self.apply_action('setPosition', 100 - kwargs.get(ATTR_POSITION)) @property def is_closed(self): @@ -64,8 +64,7 @@ class TahomaCover(TahomaDevice, CoverDevice): """Return the class of the device.""" if self.tahoma_device.type == 'io:WindowOpenerVeluxIOComponent': return 'window' - else: - return None + return None def open_cover(self, **kwargs): """Open the cover.""" diff --git a/homeassistant/components/cover/vera.py b/homeassistant/components/cover/vera.py index 6cf269b75b3..ff9ba6f762b 100644 --- a/homeassistant/components/cover/vera.py +++ b/homeassistant/components/cover/vera.py @@ -6,7 +6,8 @@ https://home-assistant.io/components/cover.vera/ """ import logging -from homeassistant.components.cover import CoverDevice, ENTITY_ID_FORMAT +from homeassistant.components.cover import CoverDevice, ENTITY_ID_FORMAT, \ + ATTR_POSITION from homeassistant.components.vera import ( VERA_CONTROLLER, VERA_DEVICES, VeraDevice) @@ -44,9 +45,9 @@ class VeraCover(VeraDevice, CoverDevice): return 100 return position - def set_cover_position(self, position, **kwargs): + def set_cover_position(self, **kwargs): """Move the cover to a specific position.""" - self.vera_device.set_level(position) + self.vera_device.set_level(kwargs.get(ATTR_POSITION)) self.schedule_update_ha_state() @property diff --git a/homeassistant/components/cover/wink.py b/homeassistant/components/cover/wink.py index 35f14e80b5b..093ccd43473 100644 --- a/homeassistant/components/cover/wink.py +++ b/homeassistant/components/cover/wink.py @@ -6,7 +6,8 @@ https://home-assistant.io/components/cover.wink/ """ import asyncio -from homeassistant.components.cover import CoverDevice, STATE_UNKNOWN +from homeassistant.components.cover import CoverDevice, STATE_UNKNOWN, \ + ATTR_POSITION from homeassistant.components.wink import WinkDevice, DOMAIN DEPENDENCIES = ['wink'] @@ -42,17 +43,17 @@ class WinkCoverDevice(WinkDevice, CoverDevice): """Open the cover.""" self.wink.set_state(1) - def set_cover_position(self, position, **kwargs): + def set_cover_position(self, **kwargs): """Move the cover shutter to a specific position.""" - self.wink.set_state(float(position)/100) + position = kwargs.get(ATTR_POSITION) + self.wink.set_state(position/100) @property def current_cover_position(self): """Return the current position of cover shutter.""" if self.wink.state() is not None: return int(self.wink.state()*100) - else: - return STATE_UNKNOWN + return STATE_UNKNOWN @property def is_closed(self): diff --git a/homeassistant/components/cover/xiaomi_aqara.py b/homeassistant/components/cover/xiaomi_aqara.py index 29cb707fef5..14321149148 100644 --- a/homeassistant/components/cover/xiaomi_aqara.py +++ b/homeassistant/components/cover/xiaomi_aqara.py @@ -1,7 +1,7 @@ """Support for Xiaomi curtain.""" import logging -from homeassistant.components.cover import CoverDevice +from homeassistant.components.cover import CoverDevice, ATTR_POSITION from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, XiaomiDevice) @@ -55,8 +55,9 @@ class XiaomiGenericCover(XiaomiDevice, CoverDevice): """Stop the cover.""" self._write_to_hub(self._sid, **{self._data_key['status']: 'stop'}) - def set_cover_position(self, position, **kwargs): + def set_cover_position(self, **kwargs): """Move the cover to a specific position.""" + position = kwargs.get(ATTR_POSITION) self._write_to_hub(self._sid, **{self._data_key['pos']: str(position)}) def parse_data(self, data, raw_data): diff --git a/homeassistant/components/cover/zwave.py b/homeassistant/components/cover/zwave.py index 15100957242..6f4a11684bd 100644 --- a/homeassistant/components/cover/zwave.py +++ b/homeassistant/components/cover/zwave.py @@ -8,7 +8,7 @@ https://home-assistant.io/components/cover.zwave/ # pylint: disable=import-error import logging from homeassistant.components.cover import ( - DOMAIN, SUPPORT_OPEN, SUPPORT_CLOSE) + DOMAIN, SUPPORT_OPEN, SUPPORT_CLOSE, ATTR_POSITION) from homeassistant.components.zwave import ZWaveDeviceEntity from homeassistant.components import zwave from homeassistant.components.zwave import async_setup_platform # noqa # pylint: disable=unused-import @@ -97,9 +97,10 @@ class ZwaveRollershutter(zwave.ZWaveDeviceEntity, CoverDevice): """Move the roller shutter down.""" self._network.manager.pressButton(self._close_id) - def set_cover_position(self, position, **kwargs): + def set_cover_position(self, **kwargs): """Move the roller shutter to a specific position.""" - self.node.set_dimmer(self.values.primary.value_id, position) + self.node.set_dimmer(self.values.primary.value_id, + kwargs.get(ATTR_POSITION)) def stop_cover(self, **kwargs): """Stop the roller shutter.""" @@ -139,11 +140,11 @@ class ZwaveGarageDoorSwitch(ZwaveGarageDoorBase): """Return the current position of Zwave garage door.""" return not self._state - def close_cover(self): + def close_cover(self, **kwargs): """Close the garage door.""" self.values.primary.data = False - def open_cover(self): + def open_cover(self, **kwargs): """Open the garage door.""" self.values.primary.data = True @@ -166,10 +167,10 @@ class ZwaveGarageDoorBarrier(ZwaveGarageDoorBase): """Return the current position of Zwave garage door.""" return self._state == "Closed" - def close_cover(self): + def close_cover(self, **kwargs): """Close the garage door.""" self.values.primary.data = "Closed" - def open_cover(self): + def open_cover(self, **kwargs): """Open the garage door.""" self.values.primary.data = "Opened" diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 2adee1e2330..3fa87ad697a 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -99,17 +99,17 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ @bind_hass -def is_on(hass: HomeAssistantType, entity_id: str=None): +def is_on(hass: HomeAssistantType, entity_id: str = None): """Return the state if any or a specified device is home.""" entity = entity_id or ENTITY_ID_ALL_DEVICES return hass.states.is_state(entity, STATE_HOME) -def see(hass: HomeAssistantType, mac: str=None, dev_id: str=None, - host_name: str=None, location_name: str=None, - gps: GPSType=None, gps_accuracy=None, - battery=None, attributes: dict=None): +def see(hass: HomeAssistantType, mac: str = None, dev_id: str = None, + host_name: str = None, location_name: str = None, + gps: GPSType = None, gps_accuracy=None, + battery=None, attributes: dict = None): """Call service to notify you see device.""" data = {key: value for key, value in ((ATTR_MAC, mac), @@ -239,11 +239,11 @@ class DeviceTracker(object): _LOGGER.warning('Duplicate device MAC addresses detected %s', dev.mac) - def see(self, mac: str=None, dev_id: str=None, host_name: str=None, - location_name: str=None, gps: GPSType=None, gps_accuracy=None, - battery: str=None, attributes: dict=None, - source_type: str=SOURCE_TYPE_GPS, picture: str=None, - icon: str=None): + def see(self, mac: str = None, dev_id: str = None, host_name: str = None, + location_name: str = None, gps: GPSType = None, gps_accuracy=None, + battery: str = None, attributes: dict = None, + source_type: str = SOURCE_TYPE_GPS, picture: str = None, + icon: str = None): """Notify the device tracker that you see a device.""" self.hass.add_job( self.async_see(mac, dev_id, host_name, location_name, gps, @@ -252,11 +252,11 @@ class DeviceTracker(object): ) @asyncio.coroutine - def async_see(self, mac: str=None, dev_id: str=None, host_name: str=None, - location_name: str=None, gps: GPSType=None, - gps_accuracy=None, battery: str=None, attributes: dict=None, - source_type: str=SOURCE_TYPE_GPS, picture: str=None, - icon: str=None): + def async_see(self, mac: str = None, dev_id: str = None, + host_name: str = None, location_name: str = None, + gps: GPSType = None, gps_accuracy=None, battery: str = None, + attributes: dict = None, source_type: str = SOURCE_TYPE_GPS, + picture: str = None, icon: str = None): """Notify the device tracker that you see a device. This method is a coroutine. @@ -396,9 +396,9 @@ class Device(Entity): _state = STATE_NOT_HOME def __init__(self, hass: HomeAssistantType, consider_home: timedelta, - track: bool, dev_id: str, mac: str, name: str=None, - picture: str=None, gravatar: str=None, icon: str=None, - hide_if_away: bool=False, vendor: str=None) -> None: + track: bool, dev_id: str, mac: str, name: str = None, + picture: str = None, gravatar: str = None, icon: str = None, + hide_if_away: bool = False, vendor: str = None) -> None: """Initialize a device.""" self.hass = hass self.entity_id = ENTITY_ID_FORMAT.format(dev_id) @@ -475,9 +475,10 @@ class Device(Entity): return self.away_hide and self.state != STATE_HOME @asyncio.coroutine - def async_seen(self, host_name: str=None, location_name: str=None, - gps: GPSType=None, gps_accuracy=0, battery: str=None, - attributes: dict=None, source_type: str=SOURCE_TYPE_GPS): + def async_seen(self, host_name: str = None, location_name: str = None, + gps: GPSType = None, gps_accuracy=0, battery: str = None, + attributes: dict = None, + source_type: str = SOURCE_TYPE_GPS): """Mark the device as seen.""" self.source_type = source_type self.last_seen = dt_util.utcnow() @@ -504,7 +505,7 @@ class Device(Entity): # pylint: disable=not-an-iterable yield from self.async_update() - def stale(self, now: dt_util.dt.datetime=None): + def stale(self, now: dt_util.dt.datetime = None): """Return if device state is stale. Async friendly. @@ -621,16 +622,16 @@ class DeviceScanner(object): """ return self.hass.async_add_job(self.scan_devices) - def get_device_name(self, mac: str) -> str: - """Get device name from mac.""" + def get_device_name(self, device: str) -> str: + """Get the name of a device.""" raise NotImplementedError() - def async_get_device_name(self, mac: str) -> Any: - """Get device name from mac. + def async_get_device_name(self, device: str) -> Any: + """Get the name of a device. This method must be run in the event loop and returns a coroutine. """ - return self.hass.async_add_job(self.get_device_name, mac) + return self.hass.async_add_job(self.get_device_name, device) def load_config(path: str, hass: HomeAssistantType, consider_home: timedelta): diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index 5ad3995ad2a..9c04c6b40a5 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -177,10 +177,9 @@ class AutomaticAuthCallbackView(HomeAssistantView): _LOGGER.error( "Error authorizing Automatic: %s", params['error']) return response - else: - _LOGGER.error( - "Error authorizing Automatic. Invalid response returned") - return response + _LOGGER.error( + "Error authorizing Automatic. Invalid response returned") + return response if DATA_CONFIGURING not in hass.data or \ params['state'] not in hass.data[DATA_CONFIGURING]: diff --git a/homeassistant/components/device_tracker/bbox.py b/homeassistant/components/device_tracker/bbox.py index 23a94d093e2..6d870364dcb 100644 --- a/homeassistant/components/device_tracker/bbox.py +++ b/homeassistant/components/device_tracker/bbox.py @@ -45,10 +45,10 @@ class BboxDeviceScanner(DeviceScanner): return [device.mac for device in self.last_results] - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - filter_named = [device.name for device in self.last_results if - device.mac == mac] + filter_named = [result.name for result in self.last_results if + result.mac == device] if filter_named: return filter_named[0] diff --git a/homeassistant/components/device_tracker/bluetooth_le_tracker.py b/homeassistant/components/device_tracker/bluetooth_le_tracker.py index 19582822913..d9cda24b699 100644 --- a/homeassistant/components/device_tracker/bluetooth_le_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_le_tracker.py @@ -102,7 +102,7 @@ def setup_scanner(hass, config, see, discovery_info=None): """Lookup Bluetooth LE devices and update status.""" devs = discover_ble_devices() for mac in devs_to_track: - _LOGGER.debug("Checking " + mac) + _LOGGER.debug("Checking %s", mac) result = mac in devs if not result: # Could not lookup device name diff --git a/homeassistant/components/device_tracker/bluetooth_tracker.py b/homeassistant/components/device_tracker/bluetooth_tracker.py index a535d87105e..9d41611d9a2 100644 --- a/homeassistant/components/device_tracker/bluetooth_tracker.py +++ b/homeassistant/components/device_tracker/bluetooth_tracker.py @@ -41,7 +41,7 @@ def setup_scanner(hass, config, see, discovery_info=None): result = bluetooth.discover_devices( duration=8, lookup_names=True, flush_cache=True, lookup_class=False) - _LOGGER.debug("Bluetooth devices discovered = " + str(len(result))) + _LOGGER.debug("Bluetooth devices discovered = %d", len(result)) return result yaml_path = hass.config.path(YAML_DEVICES) diff --git a/homeassistant/components/device_tracker/fritz.py b/homeassistant/components/device_tracker/fritz.py index 58c23cb7d76..8c9d1988a71 100644 --- a/homeassistant/components/device_tracker/fritz.py +++ b/homeassistant/components/device_tracker/fritz.py @@ -75,9 +75,9 @@ class FritzBoxScanner(DeviceScanner): active_hosts.append(known_host['mac']) return active_hosts - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name of the given device or None if is not known.""" - ret = self.fritz_box.get_specific_host_entry(mac).get( + ret = self.fritz_box.get_specific_host_entry(device).get( 'NewHostName' ) if ret == {}: diff --git a/homeassistant/components/device_tracker/geofency.py b/homeassistant/components/device_tracker/geofency.py index 58d69f39a1d..adb5c6f6d28 100644 --- a/homeassistant/components/device_tracker/geofency.py +++ b/homeassistant/components/device_tracker/geofency.py @@ -120,8 +120,7 @@ class GeofencyView(HomeAssistantView): """Return name of device tracker.""" if 'beaconUUID' in data: return "{}_{}".format(BEACON_DEV_PREFIX, data['name']) - else: - return data['device'] + return data['device'] @asyncio.coroutine def _set_location(self, hass, data, location_name): diff --git a/homeassistant/components/device_tracker/hitron_coda.py b/homeassistant/components/device_tracker/hitron_coda.py index 17dc34d1040..aa437eeef86 100644 --- a/homeassistant/components/device_tracker/hitron_coda.py +++ b/homeassistant/components/device_tracker/hitron_coda.py @@ -60,11 +60,11 @@ class HitronCODADeviceScanner(DeviceScanner): return [device.mac for device in self.last_results] - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name of the device with the given MAC address.""" name = next(( - device.name for device in self.last_results - if device.mac == mac), None) + result.name for result in self.last_results + if result.mac == device), None) return name def _login(self): diff --git a/homeassistant/components/device_tracker/huawei_router.py b/homeassistant/components/device_tracker/huawei_router.py index 357dd0d36cf..775075b8a4a 100644 --- a/homeassistant/components/device_tracker/huawei_router.py +++ b/homeassistant/components/device_tracker/huawei_router.py @@ -86,6 +86,7 @@ class HuaweiDeviceScanner(DeviceScanner): active_clients = [client for client in data if client.state] self.last_results = active_clients + # pylint: disable=logging-not-lazy _LOGGER.debug("Active clients: " + "\n" .join((client.mac + " " + client.name) for client in active_clients)) diff --git a/homeassistant/components/device_tracker/keenetic_ndms2.py b/homeassistant/components/device_tracker/keenetic_ndms2.py index 5a7db36e479..36dc1182a92 100644 --- a/homeassistant/components/device_tracker/keenetic_ndms2.py +++ b/homeassistant/components/device_tracker/keenetic_ndms2.py @@ -67,10 +67,10 @@ class KeeneticNDMS2DeviceScanner(DeviceScanner): return [device.mac for device in self.last_results] - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - filter_named = [device.name for device in self.last_results - if device.mac == mac] + filter_named = [result.name for result in self.last_results + if result.mac == device] if filter_named: return filter_named[0] diff --git a/homeassistant/components/device_tracker/linksys_ap.py b/homeassistant/components/device_tracker/linksys_ap.py index 20dc9052e11..8837b628b32 100644 --- a/homeassistant/components/device_tracker/linksys_ap.py +++ b/homeassistant/components/device_tracker/linksys_ap.py @@ -62,7 +62,7 @@ class LinksysAPDeviceScanner(DeviceScanner): return self.last_results # pylint: disable=no-self-use - def get_device_name(self, mac): + def get_device_name(self, device): """ Return the name (if known) of the device. diff --git a/homeassistant/components/device_tracker/linksys_smart.py b/homeassistant/components/device_tracker/linksys_smart.py index 4bcbb600b8b..c92f940f526 100644 --- a/homeassistant/components/device_tracker/linksys_smart.py +++ b/homeassistant/components/device_tracker/linksys_smart.py @@ -45,9 +45,9 @@ class LinksysSmartWifiDeviceScanner(DeviceScanner): return self.last_results.keys() - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name (if known) of the device.""" - return self.last_results.get(mac) + return self.last_results.get(device) def _update_info(self): """Check for connected devices.""" diff --git a/homeassistant/components/device_tracker/meraki.py b/homeassistant/components/device_tracker/meraki.py index 9437486a0aa..9bbc6bf9ffe 100644 --- a/homeassistant/components/device_tracker/meraki.py +++ b/homeassistant/components/device_tracker/meraki.py @@ -85,7 +85,7 @@ class MerakiView(HomeAssistantView): return self.json_message('Invalid device type', HTTP_UNPROCESSABLE_ENTITY) _LOGGER.debug("Processing %s", data['type']) - if len(data["data"]["observations"]) == 0: + if not data["data"]["observations"]: _LOGGER.debug("No observations found") return self._handle(request.app['hass'], data) @@ -107,8 +107,7 @@ class MerakiView(HomeAssistantView): if lat == "NaN" or lng == "NaN": _LOGGER.debug( - "No coordinates received, skipping location for: " + mac - ) + "No coordinates received, skipping location for: %s", mac) gps_location = None accuracy = None else: diff --git a/homeassistant/components/device_tracker/mikrotik.py b/homeassistant/components/device_tracker/mikrotik.py index 1805559c252..1d9161c0d45 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -137,9 +137,9 @@ class MikrotikScanner(DeviceScanner): self._update_info() return [device for device in self.last_results] - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - return self.last_results.get(mac) + return self.last_results.get(device) def _update_info(self): """Retrieve latest information from the Mikrotik box.""" diff --git a/homeassistant/components/device_tracker/netgear.py b/homeassistant/components/device_tracker/netgear.py index d2b8bc274ca..25d5d38b2a7 100644 --- a/homeassistant/components/device_tracker/netgear.py +++ b/homeassistant/components/device_tracker/netgear.py @@ -70,11 +70,11 @@ class NetgearDeviceScanner(DeviceScanner): return (device.mac for device in self.last_results) - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" try: - return next(device.name for device in self.last_results - if device.mac == mac) + return next(result.name for result in self.last_results + if result.mac == device) except StopIteration: return None diff --git a/homeassistant/components/device_tracker/nmap_tracker.py b/homeassistant/components/device_tracker/nmap_tracker.py index e9d70142ad1..3c3fd954a73 100644 --- a/homeassistant/components/device_tracker/nmap_tracker.py +++ b/homeassistant/components/device_tracker/nmap_tracker.py @@ -85,10 +85,10 @@ class NmapDeviceScanner(DeviceScanner): return [device.mac for device in self.last_results] - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - filter_named = [device.name for device in self.last_results - if device.mac == mac] + filter_named = [result.name for result in self.last_results + if result.mac == device] if filter_named: return filter_named[0] diff --git a/homeassistant/components/device_tracker/tado.py b/homeassistant/components/device_tracker/tado.py index fca4998f7b5..dcf06036ea0 100644 --- a/homeassistant/components/device_tracker/tado.py +++ b/homeassistant/components/device_tracker/tado.py @@ -83,10 +83,10 @@ class TadoDeviceScanner(DeviceScanner): return [device.mac for device in self.last_results] @asyncio.coroutine - def async_get_device_name(self, mac): + def async_get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - filter_named = [device.name for device in self.last_results - if device.mac == mac] + filter_named = [result.name for result in self.last_results + if result.mac == device] if filter_named: return filter_named[0] diff --git a/homeassistant/components/device_tracker/ubus.py b/homeassistant/components/device_tracker/ubus.py index e66bb95a11a..946aae5fe56 100644 --- a/homeassistant/components/device_tracker/ubus.py +++ b/homeassistant/components/device_tracker/ubus.py @@ -96,11 +96,11 @@ class UbusDeviceScanner(DeviceScanner): raise NotImplementedError @_refresh_on_access_denied - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" if self.mac2name is None: self._generate_mac2name() - name = self.mac2name.get(mac.upper(), None) + name = self.mac2name.get(device.upper(), None) return name @_refresh_on_access_denied diff --git a/homeassistant/components/device_tracker/unifi.py b/homeassistant/components/device_tracker/unifi.py index d5b6b044f1f..59b538cd824 100644 --- a/homeassistant/components/device_tracker/unifi.py +++ b/homeassistant/components/device_tracker/unifi.py @@ -101,13 +101,13 @@ class UnifiScanner(DeviceScanner): self._update() return self._clients.keys() - def get_device_name(self, mac): + def get_device_name(self, device): """Return the name (if known) of the device. If a name has been set in Unifi, then return that, else return the hostname if it has been detected. """ - client = self._clients.get(mac, {}) + client = self._clients.get(device, {}) name = client.get('name') or client.get('hostname') - _LOGGER.debug("Device mac %s name %s", mac, name) + _LOGGER.debug("Device mac %s name %s", device, name) return name diff --git a/homeassistant/components/dominos.py b/homeassistant/components/dominos.py index 4bdb4c80add..2c9f763aaa8 100644 --- a/homeassistant/components/dominos.py +++ b/homeassistant/components/dominos.py @@ -140,21 +140,20 @@ class Dominos(): if self.closest_store is None: _LOGGER.warning('Cannot get menu. Store may be closed') return [] - else: - menu = self.closest_store.get_menu() - product_entries = [] + menu = self.closest_store.get_menu() + product_entries = [] - for product in menu.products: - item = {} - if isinstance(product.menu_data['Variants'], list): - variants = ', '.join(product.menu_data['Variants']) - else: - variants = product.menu_data['Variants'] - item['name'] = product.name - item['variants'] = variants - product_entries.append(item) + for product in menu.products: + item = {} + if isinstance(product.menu_data['Variants'], list): + variants = ', '.join(product.menu_data['Variants']) + else: + variants = product.menu_data['Variants'] + item['name'] = product.name + item['variants'] = variants + product_entries.append(item) - return product_entries + return product_entries class DominosProductListView(http.HomeAssistantView): @@ -203,8 +202,7 @@ class DominosOrder(Entity): """Return the state either closed, orderable or unorderable.""" if self.dominos.closest_store is None: return 'closed' - else: - return 'orderable' if self._orderable else 'unorderable' + return 'orderable' if self._orderable else 'unorderable' @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 6e6d377986d..66790d02687 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -118,7 +118,7 @@ SERVICE_TO_METHOD = { @bind_hass -def is_on(hass, entity_id: str=None) -> bool: +def is_on(hass, entity_id: str = None) -> bool: """Return if the fans are on based on the statemachine.""" entity_id = entity_id or ENTITY_ID_ALL_FANS state = hass.states.get(entity_id) @@ -126,7 +126,7 @@ def is_on(hass, entity_id: str=None) -> bool: @bind_hass -def turn_on(hass, entity_id: str=None, speed: str=None) -> None: +def turn_on(hass, entity_id: str = None, speed: str = None) -> None: """Turn all or specified fan on.""" data = { key: value for key, value in [ @@ -139,7 +139,7 @@ def turn_on(hass, entity_id: str=None, speed: str=None) -> None: @bind_hass -def turn_off(hass, entity_id: str=None) -> None: +def turn_off(hass, entity_id: str = None) -> None: """Turn all or specified fan off.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} @@ -147,7 +147,7 @@ def turn_off(hass, entity_id: str=None) -> None: @bind_hass -def toggle(hass, entity_id: str=None) -> None: +def toggle(hass, entity_id: str = None) -> None: """Toggle all or specified fans.""" data = { ATTR_ENTITY_ID: entity_id @@ -157,7 +157,8 @@ def toggle(hass, entity_id: str=None) -> None: @bind_hass -def oscillate(hass, entity_id: str=None, should_oscillate: bool=True) -> None: +def oscillate(hass, entity_id: str = None, + should_oscillate: bool = True) -> None: """Set oscillation on all or specified fan.""" data = { key: value for key, value in [ @@ -170,7 +171,7 @@ def oscillate(hass, entity_id: str=None, should_oscillate: bool=True) -> None: @bind_hass -def set_speed(hass, entity_id: str=None, speed: str=None) -> None: +def set_speed(hass, entity_id: str = None, speed: str = None) -> None: """Set speed for all or specified fan.""" data = { key: value for key, value in [ @@ -183,7 +184,7 @@ def set_speed(hass, entity_id: str=None, speed: str=None) -> None: @bind_hass -def set_direction(hass, entity_id: str=None, direction: str=None) -> None: +def set_direction(hass, entity_id: str = None, direction: str = None) -> None: """Set direction for all or specified fan.""" data = { key: value for key, value in [ @@ -258,11 +259,13 @@ class FanEntity(ToggleEntity): """ return self.hass.async_add_job(self.set_direction, direction) - def turn_on(self: ToggleEntity, speed: str=None, **kwargs) -> None: + # pylint: disable=arguments-differ + def turn_on(self: ToggleEntity, speed: str = None, **kwargs) -> None: """Turn on the fan.""" raise NotImplementedError() - def async_turn_on(self: ToggleEntity, speed: str=None, **kwargs): + # pylint: disable=arguments-differ + def async_turn_on(self: ToggleEntity, speed: str = None, **kwargs): """Turn on the fan. This method must be run in the event loop and returns a coroutine. diff --git a/homeassistant/components/fan/comfoconnect.py b/homeassistant/components/fan/comfoconnect.py index c6d1232801f..12dc0b1104f 100644 --- a/homeassistant/components/fan/comfoconnect.py +++ b/homeassistant/components/fan/comfoconnect.py @@ -87,7 +87,7 @@ class ComfoConnectFan(FanEntity): """List of available fan modes.""" return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - def turn_on(self, speed: str=None, **kwargs) -> None: + def turn_on(self, speed: str = None, **kwargs) -> None: """Turn on the fan.""" if speed is None: speed = SPEED_LOW @@ -97,21 +97,21 @@ class ComfoConnectFan(FanEntity): """Turn off the fan (to away).""" self.set_speed(SPEED_OFF) - def set_speed(self, mode): + def set_speed(self, speed: str): """Set fan speed.""" - _LOGGER.debug('Changing fan mode to %s.', mode) + _LOGGER.debug('Changing fan speed to %s.', speed) from pycomfoconnect import ( CMD_FAN_MODE_AWAY, CMD_FAN_MODE_LOW, CMD_FAN_MODE_MEDIUM, CMD_FAN_MODE_HIGH) - if mode == SPEED_OFF: + if speed == SPEED_OFF: self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_AWAY) - elif mode == SPEED_LOW: + elif speed == SPEED_LOW: self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_LOW) - elif mode == SPEED_MEDIUM: + elif speed == SPEED_MEDIUM: self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_MEDIUM) - elif mode == SPEED_HIGH: + elif speed == SPEED_HIGH: self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_HIGH) # Update current mode diff --git a/homeassistant/components/fan/demo.py b/homeassistant/components/fan/demo.py index bdb1b784c8b..b328ebb3101 100644 --- a/homeassistant/components/fan/demo.py +++ b/homeassistant/components/fan/demo.py @@ -59,13 +59,13 @@ class DemoFan(FanEntity): """Get the list of available speeds.""" return [STATE_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - def turn_on(self, speed: str=None) -> None: + def turn_on(self, speed: str = None, **kwargs) -> None: """Turn on the entity.""" if speed is None: speed = SPEED_MEDIUM self.set_speed(speed) - def turn_off(self) -> None: + def turn_off(self, **kwargs) -> None: """Turn off the entity.""" self.oscillate(False) self.set_speed(STATE_OFF) diff --git a/homeassistant/components/fan/dyson.py b/homeassistant/components/fan/dyson.py index c5e5b8736ae..5b689ece6ed 100644 --- a/homeassistant/components/fan/dyson.py +++ b/homeassistant/components/fan/dyson.py @@ -113,7 +113,7 @@ class DysonPureCoolLinkDevice(FanEntity): self._device.set_configuration( fan_mode=FanMode.FAN, fan_speed=fan_speed) - def turn_on(self: ToggleEntity, speed: str=None, **kwargs) -> None: + def turn_on(self: ToggleEntity, speed: str = None, **kwargs) -> None: """Turn on the fan.""" from libpurecoollink.const import FanSpeed, FanMode diff --git a/homeassistant/components/fan/insteon_local.py b/homeassistant/components/fan/insteon_local.py index e6f9424d852..b8a5c99add4 100644 --- a/homeassistant/components/fan/insteon_local.py +++ b/homeassistant/components/fan/insteon_local.py @@ -91,7 +91,7 @@ class InsteonLocalFanDevice(FanEntity): """Flag supported features.""" return SUPPORT_INSTEON_LOCAL - def turn_on(self: ToggleEntity, speed: str=None, **kwargs) -> None: + def turn_on(self: ToggleEntity, speed: str = None, **kwargs) -> None: """Turn device on.""" if speed is None: if ATTR_SPEED in kwargs: diff --git a/homeassistant/components/fan/isy994.py b/homeassistant/components/fan/isy994.py index 137bc400d0d..847ca3b325b 100644 --- a/homeassistant/components/fan/isy994.py +++ b/homeassistant/components/fan/isy994.py @@ -48,10 +48,6 @@ def setup_platform(hass, config: ConfigType, class ISYFanDevice(ISYDevice, FanEntity): """Representation of an ISY994 fan device.""" - def __init__(self, node) -> None: - """Initialize the ISY994 fan device.""" - super().__init__(node) - @property def speed(self) -> str: """Return the current speed.""" @@ -66,7 +62,7 @@ class ISYFanDevice(ISYDevice, FanEntity): """Send the set speed command to the ISY994 fan device.""" self._node.on(val=STATE_TO_VALUE.get(speed, 255)) - def turn_on(self, speed: str=None, **kwargs) -> None: + def turn_on(self, speed: str = None, **kwargs) -> None: """Send the turn on command to the ISY994 fan device.""" self.set_speed(speed) @@ -99,7 +95,7 @@ class ISYFanProgram(ISYFanDevice): if not self._actions.runThen(): _LOGGER.error("Unable to turn off the fan") - def turn_on(self, **kwargs) -> None: + def turn_on(self, speed: str = None, **kwargs) -> None: """Send the turn off command to ISY994 fan program.""" if not self._actions.runElse(): _LOGGER.error("Unable to turn on the fan") diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/fan/mqtt.py index 1ecbb12bcb4..95ff587c613 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/fan/mqtt.py @@ -252,7 +252,7 @@ class MqttFan(MqttAvailability, FanEntity): return self._oscillation @asyncio.coroutine - def async_turn_on(self, speed: str=None) -> None: + def async_turn_on(self, speed: str = None, **kwargs) -> None: """Turn on the entity. This method is a coroutine. @@ -264,7 +264,7 @@ class MqttFan(MqttAvailability, FanEntity): yield from self.async_set_speed(speed) @asyncio.coroutine - def async_turn_off(self) -> None: + def async_turn_off(self, **kwargs) -> None: """Turn off the entity. This method is a coroutine. diff --git a/homeassistant/components/fan/velbus.py b/homeassistant/components/fan/velbus.py index c0d125aa5ab..e8208d1c990 100644 --- a/homeassistant/components/fan/velbus.py +++ b/homeassistant/components/fan/velbus.py @@ -128,13 +128,13 @@ class VelbusFan(FanEntity): """Get the list of available speeds.""" return [STATE_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - def turn_on(self, speed, **kwargs): + def turn_on(self, speed=None, **kwargs): """Turn on the entity.""" if speed is None: speed = SPEED_MEDIUM self.set_speed(speed) - def turn_off(self): + def turn_off(self, **kwargs): """Turn off the entity.""" self.set_speed(STATE_OFF) diff --git a/homeassistant/components/fan/wink.py b/homeassistant/components/fan/wink.py index 827f134cc08..0cebd9cb9f8 100644 --- a/homeassistant/components/fan/wink.py +++ b/homeassistant/components/fan/wink.py @@ -47,7 +47,7 @@ class WinkFanDevice(WinkDevice, FanEntity): """Set the speed of the fan.""" self.wink.set_state(True, speed) - def turn_on(self: ToggleEntity, speed: str=None, **kwargs) -> None: + def turn_on(self: ToggleEntity, speed: str = None, **kwargs) -> None: """Turn on the fan.""" self.wink.set_state(True, speed) diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index 942aff4ec57..2749bf298c0 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -214,7 +214,7 @@ class XiaomiAirPurifier(FanEntity): return False @asyncio.coroutine - def async_turn_on(self: ToggleEntity, speed: str=None, **kwargs) -> None: + def async_turn_on(self: ToggleEntity, speed: str = None, **kwargs) -> None: """Turn the fan on.""" if speed: # If operation mode was set the device must not be turned on. @@ -283,7 +283,7 @@ class XiaomiAirPurifier(FanEntity): @asyncio.coroutine def async_set_speed(self: ToggleEntity, speed: str) -> None: """Set the speed of the fan.""" - _LOGGER.debug("Setting the operation mode to: " + speed) + _LOGGER.debug("Setting the operation mode to: %s", speed) from miio.airpurifier import OperationMode yield from self._try_command( @@ -333,7 +333,7 @@ class XiaomiAirPurifier(FanEntity): self._air_purifier.set_child_lock, False) @asyncio.coroutine - def async_set_led_brightness(self, brightness: int=2): + def async_set_led_brightness(self, brightness: int = 2): """Set the led brightness.""" from miio.airpurifier import LedBrightness @@ -342,7 +342,7 @@ class XiaomiAirPurifier(FanEntity): self._air_purifier.set_led_brightness, LedBrightness(brightness)) @asyncio.coroutine - def async_set_favorite_level(self, level: int=1): + def async_set_favorite_level(self, level: int = 1): """Set the favorite level.""" yield from self._try_command( "Setting the favorite level of the air purifier failed.", diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index f636ad80c36..8423c53716b 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -103,7 +103,7 @@ def process_wrong_login(request): class IpBan(object): """Represents banned IP address.""" - def __init__(self, ip_ban: str, banned_at: datetime=None) -> None: + def __init__(self, ip_ban: str, banned_at: datetime = None) -> None: """Initialize IP Ban object.""" self.ip_address = ip_address(ip_ban) self.banned_at = banned_at or datetime.utcnow() diff --git a/homeassistant/components/ihc/ihcdevice.py b/homeassistant/components/ihc/ihcdevice.py index 999dda42015..59f4d95f0a1 100644 --- a/homeassistant/components/ihc/ihcdevice.py +++ b/homeassistant/components/ihc/ihcdevice.py @@ -14,7 +14,7 @@ class IHCDevice(Entity): """ def __init__(self, ihc_controller, name, ihc_id: int, info: bool, - product: Element=None) -> None: + product: Element = None) -> None: """Initialize IHC attributes.""" self.ihc_controller = ihc_controller self._name = name diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py index d85883e472a..04437d7055c 100644 --- a/homeassistant/components/isy994.py +++ b/homeassistant/components/isy994.py @@ -135,7 +135,7 @@ WeatherNode = namedtuple('WeatherNode', ('status', 'name', 'uom')) def _check_for_node_def(hass: HomeAssistant, node, - single_domain: str=None) -> bool: + single_domain: str = None) -> bool: """Check if the node matches the node_def_id for any domains. This is only present on the 5.0 ISY firmware, and is the most reliable @@ -157,7 +157,7 @@ def _check_for_node_def(hass: HomeAssistant, node, def _check_for_insteon_type(hass: HomeAssistant, node, - single_domain: str=None) -> bool: + single_domain: str = None) -> bool: """Check if the node matches the Insteon type for any domains. This is for (presumably) every version of the ISY firmware, but only @@ -180,7 +180,8 @@ def _check_for_insteon_type(hass: HomeAssistant, node, def _check_for_uom_id(hass: HomeAssistant, node, - single_domain: str=None, uom_list: list=None) -> bool: + single_domain: str = None, + uom_list: list = None) -> bool: """Check if a node's uom matches any of the domains uom filter. This is used for versions of the ISY firmware that report uoms as a single @@ -207,8 +208,8 @@ def _check_for_uom_id(hass: HomeAssistant, node, def _check_for_states_in_uom(hass: HomeAssistant, node, - single_domain: str=None, - states_list: list=None) -> bool: + single_domain: str = None, + states_list: list = None) -> bool: """Check if a list of uoms matches two possible filters. This is for versions of the ISY firmware that report uoms as a list of all @@ -302,24 +303,25 @@ def _categorize_programs(hass: HomeAssistant, programs: dict) -> None: pass else: for dtype, _, node_id in folder.children: - if dtype == KEY_FOLDER: - entity_folder = folder[node_id] - try: - status = entity_folder[KEY_STATUS] - assert status.dtype == 'program', 'Not a program' - if domain != 'binary_sensor': - actions = entity_folder[KEY_ACTIONS] - assert actions.dtype == 'program', 'Not a program' - else: - actions = None - except (AttributeError, KeyError, AssertionError): - _LOGGER.warning("Program entity '%s' not loaded due " - "to invalid folder structure.", - entity_folder.name) - continue + if dtype != KEY_FOLDER: + continue + entity_folder = folder[node_id] + try: + status = entity_folder[KEY_STATUS] + assert status.dtype == 'program', 'Not a program' + if domain != 'binary_sensor': + actions = entity_folder[KEY_ACTIONS] + assert actions.dtype == 'program', 'Not a program' + else: + actions = None + except (AttributeError, KeyError, AssertionError): + _LOGGER.warning("Program entity '%s' not loaded due " + "to invalid folder structure.", + entity_folder.name) + continue - entity = (entity_folder.name, status, actions) - hass.data[ISY994_PROGRAMS][domain].append(entity) + entity = (entity_folder.name, status, actions) + hass.data[ISY994_PROGRAMS][domain].append(entity) def _categorize_weather(hass: HomeAssistant, climate) -> None: @@ -461,8 +463,7 @@ class ISYDevice(Entity): """Return the state of the ISY device.""" if self.is_unknown(): return None - else: - return super().state + return super().state @property def device_state_attributes(self) -> Dict: diff --git a/homeassistant/components/light/avion.py b/homeassistant/components/light/avion.py index 5344c3dce6d..b4b9f4e7775 100644 --- a/homeassistant/components/light/avion.py +++ b/homeassistant/components/light/avion.py @@ -37,7 +37,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up an Avion switch.""" - # pylint: disable=import-error + # pylint: disable=import-error, no-member import avion lights = [] @@ -70,7 +70,7 @@ class AvionLight(Light): def __init__(self, device): """Initialize the light.""" - # pylint: disable=import-error + # pylint: disable=import-error, no-member import avion self._name = device['name'] @@ -117,7 +117,7 @@ class AvionLight(Light): def set_state(self, brightness): """Set the state of this lamp to the provided brightness.""" - # pylint: disable=import-error + # pylint: disable=import-error, no-member import avion # Bluetooth LE is unreliable, and the connection may drop at any diff --git a/homeassistant/components/light/blinkt.py b/homeassistant/components/light/blinkt.py index e331fba32c2..db3171cf4cf 100644 --- a/homeassistant/components/light/blinkt.py +++ b/homeassistant/components/light/blinkt.py @@ -29,7 +29,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Blinkt Light platform.""" - # pylint: disable=import-error + # pylint: disable=import-error, no-member import blinkt # ensure that the lights are off when exiting diff --git a/homeassistant/components/light/decora.py b/homeassistant/components/light/decora.py index 03441dd8ea6..c7478b435ee 100644 --- a/homeassistant/components/light/decora.py +++ b/homeassistant/components/light/decora.py @@ -37,7 +37,7 @@ def retry(method): @wraps(method) def wrapper_retry(device, *args, **kwargs): """Try send command and retry on error.""" - # pylint: disable=import-error + # pylint: disable=import-error, no-member import decora import bluepy @@ -75,7 +75,7 @@ class DecoraLight(Light): def __init__(self, device): """Initialize the light.""" - # pylint: disable=import-error + # pylint: disable=import-error, no-member import decora self._name = device['name'] diff --git a/homeassistant/components/light/decora_wifi.py b/homeassistant/components/light/decora_wifi.py index 971ad21e84b..111d39f2019 100644 --- a/homeassistant/components/light/decora_wifi.py +++ b/homeassistant/components/light/decora_wifi.py @@ -36,7 +36,7 @@ NOTIFICATION_TITLE = 'myLeviton Decora Setup' def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Decora WiFi platform.""" - # pylint: disable=import-error + # pylint: disable=import-error, no-member, no-name-in-module from decora_wifi import DecoraWiFiSession from decora_wifi.models.person import Person from decora_wifi.models.residential_account import ResidentialAccount @@ -93,8 +93,7 @@ class DecoraWifiLight(Light): """Return supported features.""" if self._switch.canSetLevel: return SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION - else: - return 0 + return 0 @property def name(self): diff --git a/homeassistant/components/light/hive.py b/homeassistant/components/light/hive.py index 5ba162a20d2..e57bdf2c046 100644 --- a/homeassistant/components/light/hive.py +++ b/homeassistant/components/light/hive.py @@ -116,7 +116,7 @@ class HiveDeviceLight(Light): for entity in self.session.entities: entity.handle_update(self.data_updatesource) - def turn_off(self): + def turn_off(self, **kwargs): """Instruct the light to turn off.""" self.session.light.turn_off(self.node_id) for entity in self.session.entities: diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 07ba069d831..ffca48743e9 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -91,7 +91,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if discovery_info is None or 'bridge_id' not in discovery_info: return - if config is not None and len(config) > 0: + if config is not None and config: # Legacy configuration, will be removed in 0.60 config_str = yaml.dump([config]) # Indent so it renders in a fixed-width font diff --git a/homeassistant/components/light/ihc.py b/homeassistant/components/light/ihc.py index ead0f153562..c9ceda8651a 100644 --- a/homeassistant/components/light/ihc.py +++ b/homeassistant/components/light/ihc.py @@ -64,7 +64,7 @@ class IhcLight(IHCDevice, Light): """ def __init__(self, ihc_controller, name, ihc_id: int, info: bool, - dimmable=False, product: Element=None) -> None: + dimmable=False, product: Element = None) -> None: """Initialize the light.""" super().__init__(ihc_controller, name, ihc_id, info, product) self._brightness = 0 diff --git a/homeassistant/components/light/isy994.py b/homeassistant/components/light/isy994.py index cee8155c322..d2ed865892e 100644 --- a/homeassistant/components/light/isy994.py +++ b/homeassistant/components/light/isy994.py @@ -29,10 +29,6 @@ def setup_platform(hass, config: ConfigType, class ISYLightDevice(ISYDevice, Light): """Representation of an ISY994 light device.""" - def __init__(self, node: object) -> None: - """Initialize the ISY994 light device.""" - super().__init__(node) - @property def is_on(self) -> bool: """Get whether the ISY994 light is on.""" @@ -48,6 +44,7 @@ class ISYLightDevice(ISYDevice, Light): if not self._node.off(): _LOGGER.debug("Unable to turn off light") + # pylint: disable=arguments-differ def turn_on(self, brightness=None, **kwargs) -> None: """Send the turn on command to the ISY994 light device.""" if not self._node.on(val=brightness): diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index 0c6b1143bbd..94c02577a6b 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -198,6 +198,7 @@ class LimitlessLEDGroup(Light): """Return the brightness property.""" return self._brightness + # pylint: disable=arguments-differ @state(False) def turn_off(self, transition_time, pipeline, **kwargs): """Turn off a group.""" @@ -231,6 +232,7 @@ class LimitlessLEDWhiteGroup(LimitlessLEDGroup): """Flag supported features.""" return SUPPORT_LIMITLESSLED_WHITE + # pylint: disable=arguments-differ @state(True) def turn_on(self, transition_time, pipeline, **kwargs): """Turn on (or adjust property of) a group.""" @@ -271,6 +273,7 @@ class LimitlessLEDRGBWGroup(LimitlessLEDGroup): """Flag supported features.""" return SUPPORT_LIMITLESSLED_RGB + # pylint: disable=arguments-differ @state(True) def turn_on(self, transition_time, pipeline, **kwargs): """Turn on (or adjust property of) a group.""" @@ -337,6 +340,7 @@ class LimitlessLEDRGBWWGroup(LimitlessLEDGroup): """Flag supported features.""" return SUPPORT_LIMITLESSLED_RGBWW + # pylint: disable=arguments-differ @state(True) def turn_on(self, transition_time, pipeline, **kwargs): """Turn on (or adjust property of) a group.""" diff --git a/homeassistant/components/light/mysensors.py b/homeassistant/components/light/mysensors.py index 9a48b13ed3b..a37553017e7 100644 --- a/homeassistant/components/light/mysensors.py +++ b/homeassistant/components/light/mysensors.py @@ -130,7 +130,7 @@ class MySensorsLight(mysensors.MySensorsEntity, Light): self._white = white self._values[self.value_type] = hex_color - def turn_off(self): + def turn_off(self, **kwargs): """Turn the device off.""" value_type = self.gateway.const.SetReq.V_LIGHT self.gateway.set_child_value( diff --git a/homeassistant/components/light/tplink.py b/homeassistant/components/light/tplink.py index 6aee02ee914..f87d624b83a 100644 --- a/homeassistant/components/light/tplink.py +++ b/homeassistant/components/light/tplink.py @@ -118,7 +118,7 @@ class TPLinkSmartBulb(Light): rgb = kwargs.get(ATTR_RGB_COLOR) self.smartbulb.hsv = rgb_to_hsv(rgb) - def turn_off(self): + def turn_off(self, **kwargs): """Turn the light off.""" self.smartbulb.state = self.smartbulb.BULB_STATE_OFF diff --git a/homeassistant/components/light/wink.py b/homeassistant/components/light/wink.py index 02605d24faf..e329fa04837 100644 --- a/homeassistant/components/light/wink.py +++ b/homeassistant/components/light/wink.py @@ -118,6 +118,6 @@ class WinkLight(WinkDevice, Light): self.wink.set_state(True, **state_kwargs) - def turn_off(self): + def turn_off(self, **kwargs): """Turn the switch off.""" self.wink.set_state(False) diff --git a/homeassistant/components/light/xiaomi_miio.py b/homeassistant/components/light/xiaomi_miio.py index a3c5fa9f62e..06d585b8593 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -242,7 +242,7 @@ class XiaomiPhilipsGenericLight(Light): _LOGGER.error("Got exception while fetching the state: %s", ex) @asyncio.coroutine - def async_set_scene(self, scene: int=1): + def async_set_scene(self, scene: int = 1): """Set the fixed scene.""" yield from self._try_command( "Setting a fixed scene failed.", @@ -260,10 +260,6 @@ class XiaomiPhilipsGenericLight(Light): class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light): """Representation of a Xiaomi Philips Light Ball.""" - def __init__(self, name, light, device_info): - """Initialize the light device.""" - super().__init__(name, light, device_info) - @property def color_temp(self): """Return the color temperature.""" @@ -345,10 +341,6 @@ class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light): class XiaomiPhilipsCeilingLamp(XiaomiPhilipsLightBall, Light): """Representation of a Xiaomi Philips Ceiling Lamp.""" - def __init__(self, name, light, device_info): - """Initialize the light device.""" - super().__init__(name, light, device_info) - @property def min_mireds(self): """Return the coldest color_temp that this light supports.""" @@ -363,6 +355,4 @@ class XiaomiPhilipsCeilingLamp(XiaomiPhilipsLightBall, Light): class XiaomiPhilipsEyecareLamp(XiaomiPhilipsGenericLight, Light): """Representation of a Xiaomi Philips Eyecare Lamp 2.""" - def __init__(self, name, light, device_info): - """Initialize the light device.""" - super().__init__(name, light, device_info) + pass diff --git a/homeassistant/components/lirc.py b/homeassistant/components/lirc.py index ea4df658ef6..0cd49ab6c9a 100644 --- a/homeassistant/components/lirc.py +++ b/homeassistant/components/lirc.py @@ -4,7 +4,7 @@ LIRC interface to receive signals from an infrared remote control. For more details about this component, please refer to the documentation at https://home-assistant.io/components/lirc/ """ -# pylint: disable=import-error +# pylint: disable=import-error,no-member import threading import time import logging diff --git a/homeassistant/components/lock/isy994.py b/homeassistant/components/lock/isy994.py index 33e2a0bea25..50371fdc9ae 100644 --- a/homeassistant/components/lock/isy994.py +++ b/homeassistant/components/lock/isy994.py @@ -53,8 +53,7 @@ class ISYLockDevice(ISYDevice, LockDevice): """Get the state of the lock.""" if self.is_unknown(): return None - else: - return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN) + return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN) def lock(self, **kwargs) -> None: """Send the lock command to the ISY994 device.""" diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index f712007ccec..a2ec11cc948 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -85,7 +85,7 @@ class MediaExtractor(object): else: entities = self.get_entities() - if len(entities) == 0: + if not entities: self.call_media_player_service(stream_selector, None) for entity_id in entities: @@ -108,7 +108,7 @@ class MediaExtractor(object): _LOGGER.warning( "Playlists are not supported, looking for the first video") entries = list(all_media['entries']) - if len(entries) > 0: + if entries: selected_media = entries[0] else: _LOGGER.error("Playlist is empty") diff --git a/homeassistant/components/media_player/aquostv.py b/homeassistant/components/media_player/aquostv.py index ae6d9e04643..6933286f0fe 100644 --- a/homeassistant/components/media_player/aquostv.py +++ b/homeassistant/components/media_player/aquostv.py @@ -201,9 +201,9 @@ class SharpAquosTVDevice(MediaPlayerDevice): self._remote.volume(int(self._volume * 60) - 2) @_retry - def set_volume_level(self, level): + def set_volume_level(self, volume): """Set Volume media player.""" - self._remote.volume(int(level * 60)) + self._remote.volume(int(volume * 60)) @_retry def mute_volume(self, mute): diff --git a/homeassistant/components/media_player/bluesound.py b/homeassistant/components/media_player/bluesound.py index 848c6abe91f..d7664d68ce5 100644 --- a/homeassistant/components/media_player/bluesound.py +++ b/homeassistant/components/media_player/bluesound.py @@ -440,8 +440,7 @@ class BluesoundPlayer(MediaPlayerDevice): return STATE_PAUSED elif status == 'stream' or status == 'play': return STATE_PLAYING - else: - return STATE_IDLE + return STATE_IDLE @property def media_title(self): @@ -595,7 +594,7 @@ class BluesoundPlayer(MediaPlayerDevice): # But it works with radio service_items will catch playlists. items = [x for x in self._preset_items if 'url2' in x and parse.unquote(x['url2']) == stream_url] - if len(items) > 0: + if items: return items[0]['title'] # This could be a bit difficult to detect. Bluetooth could be named @@ -606,11 +605,11 @@ class BluesoundPlayer(MediaPlayerDevice): if title == 'bluetooth' or stream_url == 'Capture:hw:2,0/44100/16/2': items = [x for x in self._capture_items if x['url'] == "Capture%3Abluez%3Abluetooth"] - if len(items) > 0: + if items: return items[0]['title'] items = [x for x in self._capture_items if x['url'] == stream_url] - if len(items) > 0: + if items: return items[0]['title'] if stream_url[:8] == 'Capture:': @@ -631,12 +630,12 @@ class BluesoundPlayer(MediaPlayerDevice): items = [x for x in self._capture_items if x['name'] == current_service] - if len(items) > 0: + if items: return items[0]['title'] items = [x for x in self._services_items if x['name'] == current_service] - if len(items) > 0: + if items: return items[0]['title'] if self._status.get('streamUrl', '') != '': diff --git a/homeassistant/components/media_player/hdmi_cec.py b/homeassistant/components/media_player/hdmi_cec.py index e1fffefed18..f5b4cbd4854 100644 --- a/homeassistant/components/media_player/hdmi_cec.py +++ b/homeassistant/components/media_player/hdmi_cec.py @@ -87,7 +87,7 @@ class CecPlayerDevice(CecDevice, MediaPlayerDevice): self.send_keypress(KEY_STOP) self._state = STATE_IDLE - def play_media(self, media_type, media_id): + def play_media(self, media_type, media_id, **kwargs): """Not supported.""" raise NotImplementedError() diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index 4307b68e709..81a18ab93c5 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -182,8 +182,7 @@ class MpdDevice(MediaPlayerDevice): if name is None and title is None: if file_name is None: return "None" - else: - return os.path.basename(file_name) + return os.path.basename(file_name) elif name is None: return title elif title is None: diff --git a/homeassistant/components/media_player/soundtouch.py b/homeassistant/components/media_player/soundtouch.py index e4c3fa623c9..9c4a0e9fa17 100644 --- a/homeassistant/components/media_player/soundtouch.py +++ b/homeassistant/components/media_player/soundtouch.py @@ -296,7 +296,7 @@ class SoundTouchDevice(MediaPlayerDevice): def play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" - _LOGGER.debug("Starting media with media_id: " + str(media_id)) + _LOGGER.debug("Starting media with media_id: %s", media_id) if re.match(r'http://', str(media_id)): # URL _LOGGER.debug("Playing URL %s", str(media_id)) @@ -307,11 +307,10 @@ class SoundTouchDevice(MediaPlayerDevice): preset = next([preset for preset in presets if preset.preset_id == str(media_id)].__iter__(), None) if preset is not None: - _LOGGER.debug("Playing preset: " + preset.name) + _LOGGER.debug("Playing preset: %s", preset.name) self._device.select_preset(preset) else: - _LOGGER.warning( - "Unable to find preset with id " + str(media_id)) + _LOGGER.warning("Unable to find preset with id %s", media_id) def create_zone(self, slaves): """ @@ -323,8 +322,8 @@ class SoundTouchDevice(MediaPlayerDevice): if not slaves: _LOGGER.warning("Unable to create zone without slaves") else: - _LOGGER.info( - "Creating zone with master " + str(self.device.config.name)) + _LOGGER.info("Creating zone with master %s", + self.device.config.name) self.device.create_zone([slave.device for slave in slaves]) def remove_zone_slave(self, slaves): @@ -341,8 +340,8 @@ class SoundTouchDevice(MediaPlayerDevice): if not slaves: _LOGGER.warning("Unable to find slaves to remove") else: - _LOGGER.info("Removing slaves from zone with master " + - str(self.device.config.name)) + _LOGGER.info("Removing slaves from zone with master %s", + self.device.config.name) self.device.remove_zone_slave([slave.device for slave in slaves]) def add_zone_slave(self, slaves): @@ -357,7 +356,6 @@ class SoundTouchDevice(MediaPlayerDevice): if not slaves: _LOGGER.warning("Unable to find slaves to add") else: - _LOGGER.info( - "Adding slaves to zone with master " + str( - self.device.config.name)) + _LOGGER.info("Adding slaves to zone with master %s", + self.device.config.name) self.device.add_zone_slave([slave.device for slave in slaves]) diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py index 3ccd3c7dbe9..acd1ffad6eb 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -270,8 +270,7 @@ class LgWebOSDevice(MediaPlayerDevice): """Title of current playing media.""" if (self._channel is not None) and ('channelName' in self._channel): return self._channel['channelName'] - else: - return None + return None @property def media_image_url(self): diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index f2611cf65d3..5d41004ba1d 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -136,7 +136,7 @@ def _load_config(filename): class JSONBytesDecoder(json.JSONEncoder): """JSONEncoder to decode bytes objects to unicode.""" - # pylint: disable=method-hidden + # pylint: disable=method-hidden, arguments-differ def default(self, obj): """Decode object if it's a bytes object, else defer to base class.""" if isinstance(obj, bytes): diff --git a/homeassistant/components/notify/llamalab_automate.py b/homeassistant/components/notify/llamalab_automate.py index 606c0fafc8b..0ddcb450bcf 100644 --- a/homeassistant/components/notify/llamalab_automate.py +++ b/homeassistant/components/notify/llamalab_automate.py @@ -56,4 +56,4 @@ class AutomateNotificationService(BaseNotificationService): response = requests.post(_RESOURCE, json=data) if response.status_code != 200: - _LOGGER.error("Error sending message: " + str(response)) + _LOGGER.error("Error sending message: %s", response) diff --git a/homeassistant/components/plant.py b/homeassistant/components/plant.py index 24b8c682d02..048851e97f5 100644 --- a/homeassistant/components/plant.py +++ b/homeassistant/components/plant.py @@ -9,6 +9,7 @@ from datetime import datetime, timedelta from collections import deque import voluptuous as vol +from homeassistant.exceptions import HomeAssistantError from homeassistant.const import ( STATE_OK, STATE_PROBLEM, STATE_UNKNOWN, TEMP_CELSIUS, ATTR_TEMPERATURE, CONF_SENSORS, ATTR_UNIT_OF_MEASUREMENT) @@ -198,8 +199,8 @@ class Plant(Entity): self._brightness_history.add_measurement(self._brightness, new_state.last_updated) else: - raise _LOGGER.error("Unknown reading from sensor %s: %s", - entity_id, value) + raise HomeAssistantError( + "Unknown reading from sensor {}: {}".format(entity_id, value)) if ATTR_UNIT_OF_MEASUREMENT in new_state.attributes: self._unit_of_measurement[reading] = \ new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) diff --git a/homeassistant/components/python_script.py b/homeassistant/components/python_script.py index b49b280791a..dedc39ef3a2 100644 --- a/homeassistant/components/python_script.py +++ b/homeassistant/components/python_script.py @@ -208,5 +208,4 @@ class TimeWrapper: """Wrap to return callable method if callable.""" return attribute(*args, **kw) return wrapper - else: - return attribute + return attribute diff --git a/homeassistant/components/raspihats.py b/homeassistant/components/raspihats.py index e3c1ab8ff88..3bc45eab34e 100644 --- a/homeassistant/components/raspihats.py +++ b/homeassistant/components/raspihats.py @@ -4,6 +4,7 @@ Support for controlling raspihats boards. For more details about this component, please refer to the documentation at https://home-assistant.io/components/raspihats/ """ +# pylint: disable=import-error,no-name-in-module import logging import threading import time @@ -143,7 +144,6 @@ class I2CHatsManager(threading.Thread): def run(self): """Keep alive for I2C-HATs.""" - # pylint: disable=import-error from raspihats.i2c_hats import ResponseException _LOGGER.info(log_message(self, "starting")) @@ -206,7 +206,6 @@ class I2CHatsManager(threading.Thread): def read_di(self, address, channel): """Read a value from a I2C-HAT digital input.""" - # pylint: disable=import-error from raspihats.i2c_hats import ResponseException with self._lock: @@ -219,7 +218,6 @@ class I2CHatsManager(threading.Thread): def write_dq(self, address, channel, value): """Write a value to a I2C-HAT digital output.""" - # pylint: disable=import-error from raspihats.i2c_hats import ResponseException with self._lock: @@ -231,7 +229,6 @@ class I2CHatsManager(threading.Thread): def read_dq(self, address, channel): """Read a value from a I2C-HAT digital output.""" - # pylint: disable=import-error from raspihats.i2c_hats import ResponseException with self._lock: diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index b2628f954fc..09922665ae1 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -94,7 +94,7 @@ def wait_connection_ready(hass): return hass.data[DATA_INSTANCE].async_db_ready -def run_information(hass, point_in_time: Optional[datetime]=None): +def run_information(hass, point_in_time: Optional[datetime] = None): """Return information about current run. There is also the run that covers point_in_time. diff --git a/homeassistant/components/remote/harmony.py b/homeassistant/components/remote/harmony.py index 39f09ea66a2..ae48f269986 100644 --- a/homeassistant/components/remote/harmony.py +++ b/homeassistant/components/remote/harmony.py @@ -207,6 +207,7 @@ class HarmonyRemote(remote.RemoteDevice): """Start the PowerOff activity.""" self._client.power_off() + # pylint: disable=arguments-differ def send_command(self, commands, **kwargs): """Send a list of commands to one device.""" device = kwargs.get(ATTR_DEVICE) diff --git a/homeassistant/components/remote/xiaomi_miio.py b/homeassistant/components/remote/xiaomi_miio.py index 32fde57b61a..7561f584dc3 100644 --- a/homeassistant/components/remote/xiaomi_miio.py +++ b/homeassistant/components/remote/xiaomi_miio.py @@ -210,8 +210,7 @@ class XiaomiMiioRemote(RemoteDevice): """Hide remote by default.""" if self._is_hidden: return {'hidden': 'true'} - else: - return + return # pylint: disable=R0201 @asyncio.coroutine diff --git a/homeassistant/components/rflink.py b/homeassistant/components/rflink.py index d97d4f38f02..db35b8caf9f 100644 --- a/homeassistant/components/rflink.py +++ b/homeassistant/components/rflink.py @@ -8,9 +8,10 @@ import asyncio from collections import defaultdict import functools as ft import logging - import async_timeout +import voluptuous as vol + from homeassistant.const import ( ATTR_ENTITY_ID, CONF_COMMAND, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN) @@ -19,7 +20,6 @@ from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.deprecation import get_deprecated from homeassistant.helpers.entity import Entity -import voluptuous as vol REQUIREMENTS = ['rflink==0.0.34'] diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index de8a0c00d80..e7301836d7e 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -188,8 +188,8 @@ def find_possible_pt2262_device(device_id): for dev_id, device in RFX_DEVICES.items(): if hasattr(device, 'is_lighting4') and len(dev_id) == len(device_id): size = None - for i in range(0, len(dev_id)): - if dev_id[i] != device_id[i]: + for i, (char1, char2) in enumerate(zip(dev_id, device_id)): + if char1 != char2: break size = i diff --git a/homeassistant/components/ring.py b/homeassistant/components/ring.py index 62bd07d2c27..6e70ddb244d 100644 --- a/homeassistant/components/ring.py +++ b/homeassistant/components/ring.py @@ -5,13 +5,13 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/ring/ """ import logging -import voluptuous as vol -import homeassistant.helpers.config_validation as cv - -from homeassistant.const import CONF_USERNAME, CONF_PASSWORD - from requests.exceptions import HTTPError, ConnectTimeout +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD + REQUIREMENTS = ['ring_doorbell==0.1.8'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/scene/deconz.py b/homeassistant/components/scene/deconz.py index 067db1f93a3..b3400c306af 100644 --- a/homeassistant/components/scene/deconz.py +++ b/homeassistant/components/scene/deconz.py @@ -34,7 +34,7 @@ class DeconzScene(Scene): self._scene = scene @asyncio.coroutine - def async_activate(self, **kwargs): + def async_activate(self): """Activate the scene.""" yield from self._scene.async_set_state({}) diff --git a/homeassistant/components/scene/litejet.py b/homeassistant/components/scene/litejet.py index 432ce060774..b8f3a82c0e3 100644 --- a/homeassistant/components/scene/litejet.py +++ b/homeassistant/components/scene/litejet.py @@ -54,6 +54,6 @@ class LiteJetScene(Scene): ATTR_NUMBER: self._index } - def activate(self, **kwargs): + def activate(self): """Activate the scene.""" self._lj.activate_scene(self._index) diff --git a/homeassistant/components/scene/lutron_caseta.py b/homeassistant/components/scene/lutron_caseta.py index 53df0da7617..5f96e126321 100644 --- a/homeassistant/components/scene/lutron_caseta.py +++ b/homeassistant/components/scene/lutron_caseta.py @@ -53,6 +53,6 @@ class LutronCasetaScene(Scene): return False @asyncio.coroutine - def async_activate(self, **kwargs): + def async_activate(self): """Activate the scene.""" self._bridge.activate_scene(self._scene_id) diff --git a/homeassistant/components/scene/velux.py b/homeassistant/components/scene/velux.py index 9da7a662117..8c87b434471 100644 --- a/homeassistant/components/scene/velux.py +++ b/homeassistant/components/scene/velux.py @@ -48,6 +48,6 @@ class VeluxScene(Scene): """There is no way of detecting if a scene is active (yet).""" return False - def activate(self, **kwargs): + def activate(self): """Activate the scene.""" self.hass.async_add_job(self.scene.run()) diff --git a/homeassistant/components/scene/vera.py b/homeassistant/components/scene/vera.py index 3dbb68d214f..24dfaef1fb1 100644 --- a/homeassistant/components/scene/vera.py +++ b/homeassistant/components/scene/vera.py @@ -40,7 +40,7 @@ class VeraScene(Scene): """Update the scene status.""" self.vera_scene.refresh() - def activate(self, **kwargs): + def activate(self): """Activate the scene.""" self.vera_scene.activate() diff --git a/homeassistant/components/scene/wink.py b/homeassistant/components/scene/wink.py index 2d4a6d0621c..0f617511818 100644 --- a/homeassistant/components/scene/wink.py +++ b/homeassistant/components/scene/wink.py @@ -43,6 +43,6 @@ class WinkScene(WinkDevice, Scene): """Python-wink will always return False.""" return self.wink.state() - def activate(self, **kwargs): + def activate(self): """Activate the scene.""" self.wink.activate() diff --git a/homeassistant/components/sensor/bme680.py b/homeassistant/components/sensor/bme680.py index 470d7749ea2..2dbda26ac32 100644 --- a/homeassistant/components/sensor/bme680.py +++ b/homeassistant/components/sensor/bme680.py @@ -116,7 +116,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): return -# pylint: disable=import-error +# pylint: disable=import-error, no-member def _setup_bme680(config): """Set up and configure the BME680 sensor.""" from smbus import SMBus diff --git a/homeassistant/components/sensor/cups.py b/homeassistant/components/sensor/cups.py index f4d826c250d..7c1d9fc3d49 100644 --- a/homeassistant/components/sensor/cups.py +++ b/homeassistant/components/sensor/cups.py @@ -128,7 +128,7 @@ class CupsSensor(Entity): self._printer = self.data.printers.get(self._name) -# pylint: disable=import-error +# pylint: disable=import-error, no-name-in-module class CupsData(object): """Get the latest data from CUPS and update the state.""" diff --git a/homeassistant/components/sensor/dovado.py b/homeassistant/components/sensor/dovado.py index eba6596efc4..ee2292d4122 100644 --- a/homeassistant/components/sensor/dovado.py +++ b/homeassistant/components/sensor/dovado.py @@ -79,7 +79,7 @@ class Dovado: def send_sms(service): """Send SMS through the router.""" - number = service.data.get('number'), + number = service.data.get('number') message = service.data.get('message') _LOGGER.debug("message for %s: %s", number, message) self._dovado.send_sms(number, message) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 32c888bad3b..e712f5b3751 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -9,13 +9,14 @@ from datetime import timedelta from functools import partial import logging +import voluptuous as vol + from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN) from homeassistant.core import CoreState import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -import voluptuous as vol _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/dwd_weather_warnings.py b/homeassistant/components/sensor/dwd_weather_warnings.py index 0eeaa9424e8..d7183494181 100644 --- a/homeassistant/components/sensor/dwd_weather_warnings.py +++ b/homeassistant/components/sensor/dwd_weather_warnings.py @@ -137,11 +137,11 @@ class DwdWeatherWarningsSensor(Entity): data['warning_{}_name'.format(i)] = event['event'] data['warning_{}_level'.format(i)] = event['level'] data['warning_{}_type'.format(i)] = event['type'] - if len(event['headline']) > 0: + if event['headline']: data['warning_{}_headline'.format(i)] = event['headline'] - if len(event['description']) > 0: + if event['description']: data['warning_{}_description'.format(i)] = event['description'] - if len(event['instruction']) > 0: + if event['instruction']: data['warning_{}_instruction'.format(i)] = event['instruction'] if event['start'] is not None: diff --git a/homeassistant/components/sensor/fritzbox_netmonitor.py b/homeassistant/components/sensor/fritzbox_netmonitor.py index c7486b56c25..f4f774cad1e 100644 --- a/homeassistant/components/sensor/fritzbox_netmonitor.py +++ b/homeassistant/components/sensor/fritzbox_netmonitor.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/sensor.fritzbox_netmonitor/ """ import logging from datetime import timedelta +from requests.exceptions import RequestException import voluptuous as vol @@ -15,8 +16,6 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -from requests.exceptions import RequestException - REQUIREMENTS = ['fritzconnection==0.6.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/hp_ilo.py b/homeassistant/components/sensor/hp_ilo.py index 016d68b3b0e..387d0fae5a0 100644 --- a/homeassistant/components/sensor/hp_ilo.py +++ b/homeassistant/components/sensor/hp_ilo.py @@ -172,4 +172,4 @@ class HpIloData(object): password=self._password, port=self._port) except (hpilo.IloError, hpilo.IloCommunicationError, hpilo.IloLoginFailed) as error: - raise ValueError("Unable to init HP ILO, %s", error) + raise ValueError("Unable to init HP ILO, {}".format(error)) diff --git a/homeassistant/components/sensor/ihc.py b/homeassistant/components/sensor/ihc.py index b6440a407a4..b30a242c17c 100644 --- a/homeassistant/components/sensor/ihc.py +++ b/homeassistant/components/sensor/ihc.py @@ -62,7 +62,7 @@ class IHCSensor(IHCDevice, Entity): """Implementation of the IHC sensor.""" def __init__(self, ihc_controller, name, ihc_id: int, info: bool, - unit, product: Element=None) -> None: + unit, product: Element = None) -> None: """Initialize the IHC sensor.""" super().__init__(ihc_controller, name, ihc_id, info, product) self._state = None diff --git a/homeassistant/components/sensor/irish_rail_transport.py b/homeassistant/components/sensor/irish_rail_transport.py index 0c34a5f6ce8..fc012d9589a 100644 --- a/homeassistant/components/sensor/irish_rail_transport.py +++ b/homeassistant/components/sensor/irish_rail_transport.py @@ -92,7 +92,7 @@ class IrishRailTransportSensor(Entity): @property def device_state_attributes(self): """Return the state attributes.""" - if len(self._times) > 0: + if self._times: next_up = "None" if len(self._times) > 1: next_up = self._times[1][ATTR_ORIGIN] + " to " @@ -126,7 +126,7 @@ class IrishRailTransportSensor(Entity): """Get the latest data and update the states.""" self.data.update() self._times = self.data.info - if len(self._times) > 0: + if self._times: self._state = self._times[0][ATTR_DUE_IN] else: self._state = None @@ -164,7 +164,7 @@ class IrishRailTransportData(object): ATTR_TRAIN_TYPE: train.get('type')} self.info.append(train_data) - if not self.info or len(self.info) == 0: + if not self.info or not self.info: self.info = self._empty_train_data() def _empty_train_data(self): diff --git a/homeassistant/components/sensor/isy994.py b/homeassistant/components/sensor/isy994.py index 39c9d8a3b9d..c34a4a8fca7 100644 --- a/homeassistant/components/sensor/isy994.py +++ b/homeassistant/components/sensor/isy994.py @@ -254,10 +254,6 @@ def setup_platform(hass, config: ConfigType, class ISYSensorDevice(ISYDevice): """Representation of an ISY994 sensor device.""" - def __init__(self, node) -> None: - """Initialize the ISY994 sensor device.""" - super().__init__(node) - @property def raw_unit_of_measurement(self) -> str: """Get the raw unit of measurement for the ISY994 sensor device.""" @@ -313,10 +309,6 @@ class ISYSensorDevice(ISYDevice): class ISYWeatherDevice(ISYDevice): """Representation of an ISY994 weather device.""" - def __init__(self, node) -> None: - """Initialize the ISY994 weather device.""" - super().__init__(node) - @property def raw_units(self) -> str: """Return the raw unit of measurement.""" diff --git a/homeassistant/components/sensor/lacrosse.py b/homeassistant/components/sensor/lacrosse.py index b402fc5c70f..3e0a5af283f 100644 --- a/homeassistant/components/sensor/lacrosse.py +++ b/homeassistant/components/sensor/lacrosse.py @@ -133,10 +133,6 @@ class LaCrosseSensor(Entity): """Return the name of the sensor.""" return self._name - def update(self, *args): - """Get the latest data.""" - pass - @property def device_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/sensor/linux_battery.py b/homeassistant/components/sensor/linux_battery.py index 89647d258b4..3d28c44d606 100644 --- a/homeassistant/components/sensor/linux_battery.py +++ b/homeassistant/components/sensor/linux_battery.py @@ -119,24 +119,23 @@ class LinuxBatterySensor(Entity): ATTR_HEALTH: self._battery_stat.health, ATTR_STATUS: self._battery_stat.status, } - else: - return { - ATTR_NAME: self._battery_stat.name, - ATTR_PATH: self._battery_stat.path, - ATTR_ALARM: self._battery_stat.alarm, - ATTR_CAPACITY_LEVEL: self._battery_stat.capacity_level, - ATTR_CYCLE_COUNT: self._battery_stat.cycle_count, - ATTR_ENERGY_FULL: self._battery_stat.energy_full, - ATTR_ENERGY_FULL_DESIGN: self._battery_stat.energy_full_design, - ATTR_ENERGY_NOW: self._battery_stat.energy_now, - ATTR_MANUFACTURER: self._battery_stat.manufacturer, - ATTR_MODEL_NAME: self._battery_stat.model_name, - ATTR_POWER_NOW: self._battery_stat.power_now, - ATTR_SERIAL_NUMBER: self._battery_stat.serial_number, - ATTR_STATUS: self._battery_stat.status, - ATTR_VOLTAGE_MIN_DESIGN: self._battery_stat.voltage_min_design, - ATTR_VOLTAGE_NOW: self._battery_stat.voltage_now, - } + return { + ATTR_NAME: self._battery_stat.name, + ATTR_PATH: self._battery_stat.path, + ATTR_ALARM: self._battery_stat.alarm, + ATTR_CAPACITY_LEVEL: self._battery_stat.capacity_level, + ATTR_CYCLE_COUNT: self._battery_stat.cycle_count, + ATTR_ENERGY_FULL: self._battery_stat.energy_full, + ATTR_ENERGY_FULL_DESIGN: self._battery_stat.energy_full_design, + ATTR_ENERGY_NOW: self._battery_stat.energy_now, + ATTR_MANUFACTURER: self._battery_stat.manufacturer, + ATTR_MODEL_NAME: self._battery_stat.model_name, + ATTR_POWER_NOW: self._battery_stat.power_now, + ATTR_SERIAL_NUMBER: self._battery_stat.serial_number, + ATTR_STATUS: self._battery_stat.status, + ATTR_VOLTAGE_MIN_DESIGN: self._battery_stat.voltage_min_design, + ATTR_VOLTAGE_NOW: self._battery_stat.voltage_now, + } def update(self): """Get the latest data and updates the states.""" diff --git a/homeassistant/components/sensor/mold_indicator.py b/homeassistant/components/sensor/mold_indicator.py index b47367cafc8..057718400c4 100644 --- a/homeassistant/components/sensor/mold_indicator.py +++ b/homeassistant/components/sensor/mold_indicator.py @@ -174,7 +174,7 @@ class MoldIndicator(Entity): 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) + _LOGGER.debug("Dewpoint: %f %s", self._dewpoint, TEMP_CELSIUS) def _calc_moldindicator(self): """Calculate the humidity at the (cold) calibration point.""" @@ -192,8 +192,8 @@ class MoldIndicator(Entity): self._outdoor_temp + (self._indoor_temp - self._outdoor_temp) / \ self._calib_factor - _LOGGER.debug("Estimated Critical Temperature: %f " + - TEMP_CELSIUS, self._crit_temp) + _LOGGER.debug("Estimated Critical Temperature: %f %s", + self._crit_temp, TEMP_CELSIUS) # Then calculate the humidity at this point alpha = MAGNUS_K2 * self._crit_temp / (MAGNUS_K3 + self._crit_temp) diff --git a/homeassistant/components/sensor/nederlandse_spoorwegen.py b/homeassistant/components/sensor/nederlandse_spoorwegen.py index ec534047ccc..431a44c56e3 100644 --- a/homeassistant/components/sensor/nederlandse_spoorwegen.py +++ b/homeassistant/components/sensor/nederlandse_spoorwegen.py @@ -71,7 +71,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): NSDepartureSensor( nsapi, departure.get(CONF_NAME), departure.get(CONF_FROM), departure.get(CONF_TO), departure.get(CONF_VIA))) - if len(sensors): + if sensors: add_devices(sensors, True) diff --git a/homeassistant/components/sensor/qnap.py b/homeassistant/components/sensor/qnap.py index 3caebad2007..badec6624d7 100644 --- a/homeassistant/components/sensor/qnap.py +++ b/homeassistant/components/sensor/qnap.py @@ -7,6 +7,8 @@ https://home-assistant.io/components/sensor.qnap/ import logging from datetime import timedelta +import voluptuous as vol + from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity from homeassistant.const import ( @@ -15,8 +17,6 @@ from homeassistant.const import ( from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -import voluptuous as vol - REQUIREMENTS = ['qnapstats==0.2.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/skybeacon.py b/homeassistant/components/sensor/skybeacon.py index 3c14625202e..eabc33312b2 100644 --- a/homeassistant/components/sensor/skybeacon.py +++ b/homeassistant/components/sensor/skybeacon.py @@ -140,7 +140,7 @@ class Monitor(threading.Thread): def run(self): """Thread that keeps connection alive.""" - # pylint: disable=import-error + # pylint: disable=import-error, no-name-in-module, no-member import pygatt from pygatt.backends import Characteristic from pygatt.exceptions import ( diff --git a/homeassistant/components/sensor/statistics.py b/homeassistant/components/sensor/statistics.py index b26fd5cc804..7b2ae537d4b 100644 --- a/homeassistant/components/sensor/statistics.py +++ b/homeassistant/components/sensor/statistics.py @@ -173,7 +173,7 @@ class StatisticsSensor(Entity): """Remove states which are older than self._max_age.""" now = dt_util.utcnow() - while (len(self.ages) > 0) and (now - self.ages[0]) > self._max_age: + while self.ages and (now - self.ages[0]) > self._max_age: self.ages.popleft() self.states.popleft() diff --git a/homeassistant/components/sensor/teksavvy.py b/homeassistant/components/sensor/teksavvy.py index cb78caae095..33e5c0cf4ce 100644 --- a/homeassistant/components/sensor/teksavvy.py +++ b/homeassistant/components/sensor/teksavvy.py @@ -6,9 +6,11 @@ https://home-assistant.io/components/sensor.teksavvy/ """ from datetime import timedelta import logging - import asyncio import async_timeout + +import voluptuous as vol + from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_API_KEY, CONF_MONITORED_VARIABLES, CONF_NAME) @@ -16,7 +18,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -import voluptuous as vol _LOGGER = logging.getLogger(__name__) @@ -142,18 +143,17 @@ class TekSavvyData(object): if req.status != 200: _LOGGER.error("Request failed with status: %u", req.status) return False - else: - data = yield from req.json() - for (api, ha_name) in API_HA_MAP: - self.data[ha_name] = float(data["value"][0][api]) - on_peak_download = self.data["onpeak_download"] - on_peak_upload = self.data["onpeak_upload"] - off_peak_download = self.data["offpeak_download"] - off_peak_upload = self.data["offpeak_upload"] - limit = self.data["limit"] - self.data["usage"] = 100*on_peak_download/self.bandwidth_cap - self.data["usage_gb"] = on_peak_download - self.data["onpeak_total"] = on_peak_download + on_peak_upload - self.data["offpeak_total"] = off_peak_download + off_peak_upload - self.data["onpeak_remaining"] = limit - on_peak_download - return True + data = yield from req.json() + for (api, ha_name) in API_HA_MAP: + self.data[ha_name] = float(data["value"][0][api]) + on_peak_download = self.data["onpeak_download"] + on_peak_upload = self.data["onpeak_upload"] + off_peak_download = self.data["offpeak_download"] + off_peak_upload = self.data["offpeak_upload"] + limit = self.data["limit"] + self.data["usage"] = 100*on_peak_download/self.bandwidth_cap + self.data["usage_gb"] = on_peak_download + self.data["onpeak_total"] = on_peak_download + on_peak_upload + self.data["offpeak_total"] = off_peak_download + off_peak_upload + self.data["onpeak_remaining"] = limit - on_peak_download + return True diff --git a/homeassistant/components/sensor/viaggiatreno.py b/homeassistant/components/sensor/viaggiatreno.py index a7f4b070f2d..43ba80d2630 100644 --- a/homeassistant/components/sensor/viaggiatreno.py +++ b/homeassistant/components/sensor/viaggiatreno.py @@ -76,9 +76,8 @@ def async_http_request(hass, uri): req = yield from session.get(uri) if req.status != 200: return {'error': req.status} - else: - json_response = yield from req.json() - return json_response + json_response = yield from req.json() + return json_response except (asyncio.TimeoutError, aiohttp.ClientError) as exc: _LOGGER.error("Cannot connect to ViaggiaTreno API endpoint: %s", exc) except ValueError: diff --git a/homeassistant/components/sensor/volvooncall.py b/homeassistant/components/sensor/volvooncall.py index 32b228ca1f9..343bcdf2033 100644 --- a/homeassistant/components/sensor/volvooncall.py +++ b/homeassistant/components/sensor/volvooncall.py @@ -42,12 +42,10 @@ class VolvoSensor(VolvoEntity): val /= 10 # L/1000km -> L/100km if 'mil' in self.unit_of_measurement: return round(val, 2) - else: - return round(val, 1) + return round(val, 1) elif self._attribute == 'distance_to_empty': return int(floor(val)) - else: - return int(round(val)) + return int(round(val)) @property def unit_of_measurement(self): @@ -56,8 +54,7 @@ class VolvoSensor(VolvoEntity): if self._state.config[CONF_SCANDINAVIAN_MILES] and 'km' in unit: if self._attribute == 'average_fuel_consumption': return 'L/mil' - else: - return unit.replace('km', 'mil') + return unit.replace('km', 'mil') return unit @property diff --git a/homeassistant/components/sensor/worldtidesinfo.py b/homeassistant/components/sensor/worldtidesinfo.py index f23d244cf3a..8884d790eed 100644 --- a/homeassistant/components/sensor/worldtidesinfo.py +++ b/homeassistant/components/sensor/worldtidesinfo.py @@ -90,10 +90,8 @@ class WorldTidesInfoSensor(Entity): tidetime = time.strftime('%I:%M %p', time.localtime( self.data['extremes'][0]['dt'])) return "Low tide at %s" % (tidetime) - else: - return STATE_UNKNOWN - else: return STATE_UNKNOWN + return STATE_UNKNOWN def update(self): """Get the latest data from WorldTidesInfo API.""" diff --git a/homeassistant/components/sensor/yr.py b/homeassistant/components/sensor/yr.py index 71a4e60e8a6..244ad58eb9a 100644 --- a/homeassistant/components/sensor/yr.py +++ b/homeassistant/components/sensor/yr.py @@ -226,7 +226,7 @@ class YrData(object): # Update all devices tasks = [] - if len(ordered_entries) > 0: + if ordered_entries: for dev in self.devices: new_state = None @@ -258,5 +258,5 @@ class YrData(object): dev._state = new_state tasks.append(dev.async_update_ha_state()) - if len(tasks) > 0: + if tasks: yield from asyncio.wait(tasks, loop=self.hass.loop) diff --git a/homeassistant/components/sensor/zigbee.py b/homeassistant/components/sensor/zigbee.py index a1d549cb382..37cc6fabe2e 100644 --- a/homeassistant/components/sensor/zigbee.py +++ b/homeassistant/components/sensor/zigbee.py @@ -70,7 +70,7 @@ class ZigBeeTemperatureSensor(Entity): """Return the unit of measurement the value is expressed in.""" return TEMP_CELSIUS - def update(self, *args): + def update(self): """Get the latest data.""" try: self._temp = zigbee.DEVICE.get_temperature(self._config.address) diff --git a/homeassistant/components/sleepiq.py b/homeassistant/components/sleepiq.py index baf6d154c66..3b74b79b36b 100644 --- a/homeassistant/components/sleepiq.py +++ b/homeassistant/components/sleepiq.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/sleepiq/ """ import logging from datetime import timedelta +from requests.exceptions import HTTPError import voluptuous as vol @@ -14,7 +15,6 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity from homeassistant.const import CONF_USERNAME, CONF_PASSWORD from homeassistant.util import Throttle -from requests.exceptions import HTTPError DOMAIN = 'sleepiq' diff --git a/homeassistant/components/switch/acer_projector.py b/homeassistant/components/switch/acer_projector.py index 8fd70ec7ed8..527456d6d19 100644 --- a/homeassistant/components/switch/acer_projector.py +++ b/homeassistant/components/switch/acer_projector.py @@ -155,13 +155,13 @@ class AcerSwitch(SwitchDevice): awns = self._write_read_format(msg) self._attributes[key] = awns - def turn_on(self): + def turn_on(self, **kwargs): """Turn the projector on.""" msg = CMD_DICT[STATE_ON] self._write_read(msg) self._state = STATE_ON - def turn_off(self): + def turn_off(self, **kwargs): """Turn the projector off.""" msg = CMD_DICT[STATE_OFF] self._write_read(msg) diff --git a/homeassistant/components/switch/anel_pwrctrl.py b/homeassistant/components/switch/anel_pwrctrl.py index bfa6e2af976..9144222e5c7 100644 --- a/homeassistant/components/switch/anel_pwrctrl.py +++ b/homeassistant/components/switch/anel_pwrctrl.py @@ -101,11 +101,11 @@ class PwrCtrlSwitch(SwitchDevice): """Trigger update for all switches on the parent device.""" self._parent_device.update() - def turn_on(self): + def turn_on(self, **kwargs): """Turn the switch on.""" self._port.on() - def turn_off(self): + def turn_off(self, **kwargs): """Turn the switch off.""" self._port.off() diff --git a/homeassistant/components/switch/arduino.py b/homeassistant/components/switch/arduino.py index 3aa61feffc8..1547f4f1dee 100644 --- a/homeassistant/components/switch/arduino.py +++ b/homeassistant/components/switch/arduino.py @@ -83,12 +83,12 @@ class ArduinoSwitch(SwitchDevice): """Return true if pin is high/on.""" return self._state - def turn_on(self): + def turn_on(self, **kwargs): """Turn the pin to high/on.""" self._state = True self.turn_on_handler(self._pin) - def turn_off(self): + def turn_off(self, **kwargs): """Turn the pin to low/off.""" self._state = False self.turn_off_handler(self._pin) diff --git a/homeassistant/components/switch/dlink.py b/homeassistant/components/switch/dlink.py index f6ed6dac018..5d727e72138 100644 --- a/homeassistant/components/switch/dlink.py +++ b/homeassistant/components/switch/dlink.py @@ -117,7 +117,7 @@ class SmartPlugSwitch(SwitchDevice): """Turn the switch on.""" self.data.smartplug.state = 'ON' - def turn_off(self): + def turn_off(self, **kwargs): """Turn the switch off.""" self.data.smartplug.state = 'OFF' diff --git a/homeassistant/components/switch/edimax.py b/homeassistant/components/switch/edimax.py index c5973c3ee04..d4b02749c1b 100644 --- a/homeassistant/components/switch/edimax.py +++ b/homeassistant/components/switch/edimax.py @@ -77,7 +77,7 @@ class SmartPlugSwitch(SwitchDevice): """Turn the switch on.""" self.smartplug.state = 'ON' - def turn_off(self): + def turn_off(self, **kwargs): """Turn the switch off.""" self.smartplug.state = 'OFF' diff --git a/homeassistant/components/switch/fritzdect.py b/homeassistant/components/switch/fritzdect.py index 8ddfca05fb6..58ad745a2d2 100644 --- a/homeassistant/components/switch/fritzdect.py +++ b/homeassistant/components/switch/fritzdect.py @@ -130,7 +130,7 @@ class FritzDectSwitch(SwitchDevice): _LOGGER.error("Fritz!Box query failed, triggering relogin") self.data.is_online = False - def turn_off(self): + def turn_off(self, **kwargs): """Turn the switch off.""" if not self.data.is_online: _LOGGER.error("turn_off: Not online skipping request") diff --git a/homeassistant/components/switch/gc100.py b/homeassistant/components/switch/gc100.py index ed50c3f63f6..f4175926aa0 100644 --- a/homeassistant/components/switch/gc100.py +++ b/homeassistant/components/switch/gc100.py @@ -56,11 +56,11 @@ class GC100Switch(ToggleEntity): """Return the state of the entity.""" return self._state - def turn_on(self): + def turn_on(self, **kwargs): """Turn the device on.""" self._gc100.write_switch(self._port_addr, 1, self.set_state) - def turn_off(self): + def turn_off(self, **kwargs): """Turn the device off.""" self._gc100.write_switch(self._port_addr, 0, self.set_state) diff --git a/homeassistant/components/switch/hdmi_cec.py b/homeassistant/components/switch/hdmi_cec.py index 65a7a762c0f..e81c09894ab 100644 --- a/homeassistant/components/switch/hdmi_cec.py +++ b/homeassistant/components/switch/hdmi_cec.py @@ -47,7 +47,7 @@ class CecSwitchDevice(CecDevice, SwitchDevice): self._device.turn_off() self._state = STATE_ON - def toggle(self): + def toggle(self, **kwargs): """Toggle the entity.""" self._device.toggle() if self._state == STATE_ON: diff --git a/homeassistant/components/switch/ihc.py b/homeassistant/components/switch/ihc.py index eab88035c73..499a4ca53a7 100644 --- a/homeassistant/components/switch/ihc.py +++ b/homeassistant/components/switch/ihc.py @@ -53,7 +53,7 @@ class IHCSwitch(IHCDevice, SwitchDevice): """IHC Switch.""" def __init__(self, ihc_controller, name: str, ihc_id: int, - info: bool, product: Element=None) -> None: + info: bool, product: Element = None) -> None: """Initialize the IHC switch.""" super().__init__(ihc_controller, name, ihc_id, product) self._state = False diff --git a/homeassistant/components/switch/isy994.py b/homeassistant/components/switch/isy994.py index f0fd397710e..efdda6ed40c 100644 --- a/homeassistant/components/switch/isy994.py +++ b/homeassistant/components/switch/isy994.py @@ -33,10 +33,6 @@ def setup_platform(hass, config: ConfigType, class ISYSwitchDevice(ISYDevice, SwitchDevice): """Representation of an ISY994 switch device.""" - def __init__(self, node) -> None: - """Initialize the ISY994 switch device.""" - super().__init__(node) - @property def is_on(self) -> bool: """Get whether the ISY994 device is in the on state.""" diff --git a/homeassistant/components/switch/netio.py b/homeassistant/components/switch/netio.py index 2a72703c5df..365bbaa3679 100644 --- a/homeassistant/components/switch/netio.py +++ b/homeassistant/components/switch/netio.py @@ -141,11 +141,11 @@ class NetioSwitch(SwitchDevice): """Return true if entity is available.""" return not hasattr(self, 'telnet') - def turn_on(self): + def turn_on(self, **kwargs): """Turn switch on.""" self._set(True) - def turn_off(self): + def turn_off(self, **kwargs): """Turn switch off.""" self._set(False) diff --git a/homeassistant/components/switch/pilight.py b/homeassistant/components/switch/pilight.py index 1ce599366a1..57fa4b00c98 100644 --- a/homeassistant/components/switch/pilight.py +++ b/homeassistant/components/switch/pilight.py @@ -188,10 +188,10 @@ class PilightSwitch(SwitchDevice): self._state = turn_on self.schedule_update_ha_state() - def turn_on(self): + def turn_on(self, **kwargs): """Turn the switch on by calling pilight.send service with on code.""" self.set_state(turn_on=True) - def turn_off(self): + def turn_off(self, **kwargs): """Turn the switch on by calling pilight.send service with off code.""" self.set_state(turn_on=False) diff --git a/homeassistant/components/switch/rachio.py b/homeassistant/components/switch/rachio.py index d8d424be361..dc661c3e5bf 100644 --- a/homeassistant/components/switch/rachio.py +++ b/homeassistant/components/switch/rachio.py @@ -216,7 +216,7 @@ class RachioZone(SwitchDevice): _LOGGER.debug("Updated %s", str(self)) - def turn_on(self): + def turn_on(self, **kwargs): """Start the zone.""" # Stop other zones first self.turn_off() @@ -224,7 +224,7 @@ class RachioZone(SwitchDevice): _LOGGER.info("Watering %s for %d s", self.name, self._manual_run_secs) self.rachio.zone.start(self.zone_id, self._manual_run_secs) - def turn_off(self): + def turn_off(self, **kwargs): """Stop all zones.""" _LOGGER.info("Stopping watering of all zones") self.rachio.device.stopWater(self._device.device_id) diff --git a/homeassistant/components/switch/raincloud.py b/homeassistant/components/switch/raincloud.py index a18d6544acc..8a5c4347cf7 100644 --- a/homeassistant/components/switch/raincloud.py +++ b/homeassistant/components/switch/raincloud.py @@ -59,7 +59,7 @@ class RainCloudSwitch(RainCloudEntity, SwitchDevice): """Return true if device is on.""" return self._state - def turn_on(self): + def turn_on(self, **kwargs): """Turn the device on.""" if self._sensor_type == 'manual_watering': self.data.watering_time = self._default_watering_timer @@ -67,7 +67,7 @@ class RainCloudSwitch(RainCloudEntity, SwitchDevice): self.data.auto_watering = True self._state = True - def turn_off(self): + def turn_off(self, **kwargs): """Turn the device off.""" if self._sensor_type == 'manual_watering': self.data.watering_time = 'off' diff --git a/homeassistant/components/switch/raspihats.py b/homeassistant/components/switch/raspihats.py index 183ee6edb77..a8177c01792 100644 --- a/homeassistant/components/switch/raspihats.py +++ b/homeassistant/components/switch/raspihats.py @@ -121,7 +121,7 @@ class I2CHatSwitch(ToggleEntity): _LOGGER.error(self._log_message("Is ON check failed, " + str(ex))) return False - def turn_on(self): + def turn_on(self, **kwargs): """Turn the device on.""" try: state = True if self._invert_logic is False else False @@ -130,7 +130,7 @@ class I2CHatSwitch(ToggleEntity): except I2CHatsException as ex: _LOGGER.error(self._log_message("Turn ON failed, " + str(ex))) - def turn_off(self): + def turn_off(self, **kwargs): """Turn the device off.""" try: state = False if self._invert_logic is False else True diff --git a/homeassistant/components/switch/rpi_pfio.py b/homeassistant/components/switch/rpi_pfio.py index a493a8e9589..bd964e3d2ad 100644 --- a/homeassistant/components/switch/rpi_pfio.py +++ b/homeassistant/components/switch/rpi_pfio.py @@ -75,13 +75,13 @@ class RPiPFIOSwitch(ToggleEntity): """Return true if device is on.""" return self._state - def turn_on(self): + def turn_on(self, **kwargs): """Turn the device on.""" rpi_pfio.write_output(self._port, 0 if self._invert_logic else 1) self._state = True self.schedule_update_ha_state() - def turn_off(self): + def turn_off(self, **kwargs): """Turn the device off.""" rpi_pfio.write_output(self._port, 1 if self._invert_logic else 0) self._state = False diff --git a/homeassistant/components/switch/rpi_rf.py b/homeassistant/components/switch/rpi_rf.py index 94a61314d1d..40200f05806 100644 --- a/homeassistant/components/switch/rpi_rf.py +++ b/homeassistant/components/switch/rpi_rf.py @@ -44,7 +44,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -# pylint: disable=unused-argument, import-error +# pylint: disable=unused-argument, import-error, no-member def setup_platform(hass, config, add_devices, discovery_info=None): """Find and return switches controlled by a generic RF device via GPIO.""" import rpi_rf @@ -117,13 +117,13 @@ class RPiRFSwitch(SwitchDevice): self._rfdevice.tx_code(code, protocol, pulselength) return True - def turn_on(self): + def turn_on(self, **kwargs): """Turn the switch on.""" if self._send_code(self._code_on, self._protocol, self._pulselength): self._state = True self.schedule_update_ha_state() - def turn_off(self): + def turn_off(self, **kwargs): """Turn the switch off.""" if self._send_code(self._code_off, self._protocol, self._pulselength): self._state = False diff --git a/homeassistant/components/switch/snmp.py b/homeassistant/components/switch/snmp.py index 8aa9744b3da..b0c192cdafa 100644 --- a/homeassistant/components/switch/snmp.py +++ b/homeassistant/components/switch/snmp.py @@ -95,13 +95,13 @@ class SnmpSwitch(SwitchDevice): self._payload_on = payload_on self._payload_off = payload_off - def turn_on(self): + def turn_on(self, **kwargs): """Turn on the switch.""" from pyasn1.type.univ import (Integer) self._set(Integer(self._command_payload_on)) - def turn_off(self): + def turn_off(self, **kwargs): """Turn off the switch.""" from pyasn1.type.univ import (Integer) diff --git a/homeassistant/components/switch/toon.py b/homeassistant/components/switch/toon.py index 09dc45c6587..94086d819e2 100644 --- a/homeassistant/components/switch/toon.py +++ b/homeassistant/components/switch/toon.py @@ -64,7 +64,7 @@ class EnecoSmartPlug(SwitchDevice): """Turn the switch on.""" return self.smartplug.turn_on() - def turn_off(self): + def turn_off(self, **kwargs): """Turn the switch off.""" return self.smartplug.turn_off() diff --git a/homeassistant/components/switch/tplink.py b/homeassistant/components/switch/tplink.py index 14faa98fb59..1eca5284f76 100644 --- a/homeassistant/components/switch/tplink.py +++ b/homeassistant/components/switch/tplink.py @@ -75,7 +75,7 @@ class SmartPlugSwitch(SwitchDevice): """Turn the switch on.""" self.smartplug.turn_on() - def turn_off(self): + def turn_off(self, **kwargs): """Turn the switch off.""" self.smartplug.turn_off() diff --git a/homeassistant/components/switch/verisure.py b/homeassistant/components/switch/verisure.py index 710580c2ec6..810946a5058 100644 --- a/homeassistant/components/switch/verisure.py +++ b/homeassistant/components/switch/verisure.py @@ -60,13 +60,13 @@ class VerisureSmartplug(SwitchDevice): "$.smartPlugs[?(@.deviceLabel == '%s')]", self._device_label) is not None - def turn_on(self): + def turn_on(self, **kwargs): """Set smartplug status on.""" hub.session.set_smartplug_state(self._device_label, True) self._state = True self._change_timestamp = time() - def turn_off(self): + def turn_off(self, **kwargs): """Set smartplug status off.""" hub.session.set_smartplug_state(self._device_label, False) self._state = False diff --git a/homeassistant/components/switch/vultr.py b/homeassistant/components/switch/vultr.py index a044fca2972..fe3d67470d7 100644 --- a/homeassistant/components/switch/vultr.py +++ b/homeassistant/components/switch/vultr.py @@ -90,12 +90,12 @@ class VultrSwitch(SwitchDevice): ATTR_VCPUS: self.data.get('vcpu_count'), } - def turn_on(self): + def turn_on(self, **kwargs): """Boot-up the subscription.""" if self.data['power_status'] != 'running': self._vultr.start(self.subscription) - def turn_off(self): + def turn_off(self, **kwargs): """Halt the subscription.""" if self.data['power_status'] == 'running': self._vultr.halt(self.subscription) diff --git a/homeassistant/components/switch/wake_on_lan.py b/homeassistant/components/switch/wake_on_lan.py index ecaff14e2e2..80102621f7d 100644 --- a/homeassistant/components/switch/wake_on_lan.py +++ b/homeassistant/components/switch/wake_on_lan.py @@ -78,7 +78,7 @@ class WOLSwitch(SwitchDevice): """Return the name of the switch.""" return self._name - def turn_on(self): + def turn_on(self, **kwargs): """Turn the device on.""" if self._broadcast_address: self._wol.send_magic_packet( @@ -86,7 +86,7 @@ class WOLSwitch(SwitchDevice): else: self._wol.send_magic_packet(self._mac_address) - def turn_off(self): + def turn_off(self, **kwargs): """Turn the device off if an off action is present.""" if self._off_script is not None: self._off_script.run() diff --git a/homeassistant/components/switch/wink.py b/homeassistant/components/switch/wink.py index 5a43de9425c..6a244615065 100644 --- a/homeassistant/components/switch/wink.py +++ b/homeassistant/components/switch/wink.py @@ -54,7 +54,7 @@ class WinkToggleDevice(WinkDevice, ToggleEntity): """Turn the device on.""" self.wink.set_state(True) - def turn_off(self): + def turn_off(self, **kwargs): """Turn the device off.""" self.wink.set_state(False) diff --git a/homeassistant/components/switch/xiaomi_aqara.py b/homeassistant/components/switch/xiaomi_aqara.py index 578036a1677..1688b6b89e1 100644 --- a/homeassistant/components/switch/xiaomi_aqara.py +++ b/homeassistant/components/switch/xiaomi_aqara.py @@ -101,7 +101,7 @@ class XiaomiGenericSwitch(XiaomiDevice, SwitchDevice): self._state = True self.schedule_update_ha_state() - def turn_off(self): + def turn_off(self, **kwargs): """Turn the switch off.""" if self._write_to_hub(self._sid, **{self._data_key: 'off'}): self._state = False diff --git a/homeassistant/components/switch/zoneminder.py b/homeassistant/components/switch/zoneminder.py index 5dffd99c324..adf3bf2d9bd 100644 --- a/homeassistant/components/switch/zoneminder.py +++ b/homeassistant/components/switch/zoneminder.py @@ -75,14 +75,14 @@ class ZMSwitchMonitors(SwitchDevice): """Return True if entity is on.""" return self._state - def turn_on(self): + def turn_on(self, **kwargs): """Turn the entity on.""" zoneminder.change_state( 'api/monitors/%i.json' % self._monitor_id, {'Monitor[Function]': self._on_state} ) - def turn_off(self): + def turn_off(self, **kwargs): """Turn the entity off.""" zoneminder.change_state( 'api/monitors/%i.json' % self._monitor_id, diff --git a/homeassistant/components/telegram_bot/polling.py b/homeassistant/components/telegram_bot/polling.py index 0ce11441843..bec239ba1dd 100644 --- a/homeassistant/components/telegram_bot/polling.py +++ b/homeassistant/components/telegram_bot/polling.py @@ -93,7 +93,7 @@ class TelegramPoll(BaseTelegramBotEntity): _json = yield from resp.json() return _json else: - raise WrongHttpStatus('wrong status %s', resp.status) + raise WrongHttpStatus('wrong status {}'.format(resp.status)) finally: if resp is not None: yield from resp.release() diff --git a/homeassistant/components/tellduslive.py b/homeassistant/components/tellduslive.py index 28bf65bc4c5..dfb4b1e5fa9 100644 --- a/homeassistant/components/tellduslive.py +++ b/homeassistant/components/tellduslive.py @@ -7,6 +7,8 @@ https://home-assistant.io/components/tellduslive/ from datetime import datetime, timedelta import logging +import voluptuous as vol + from homeassistant.const import ( ATTR_BATTERY_LEVEL, DEVICE_DEFAULT_NAME, CONF_TOKEN, CONF_HOST, @@ -18,7 +20,6 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.util.dt import utcnow from homeassistant.util.json import load_json, save_json -import voluptuous as vol APPLICATION_NAME = 'Home Assistant' @@ -352,8 +353,7 @@ class TelldusLiveEntity(Entity): return None elif self.device.battery == BATTERY_OK: return 100 - else: - return self.device.battery # Percentage + return self.device.battery # Percentage @property def _last_updated(self): diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 532b4529eca..17aa66ea825 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -411,7 +411,7 @@ class SpeechManager(object): if key not in self.mem_cache: if key not in self.file_cache: - raise HomeAssistantError("%s not in cache!", key) + raise HomeAssistantError("{} not in cache!".format(key)) yield from self.async_file_to_mem(key) content, _ = mimetypes.guess_type(filename) diff --git a/homeassistant/components/upnp.py b/homeassistant/components/upnp.py index 87990495cf4..960d8f3780e 100644 --- a/homeassistant/components/upnp.py +++ b/homeassistant/components/upnp.py @@ -51,7 +51,7 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -# pylint: disable=import-error, no-member, broad-except +# pylint: disable=import-error, no-member, broad-except, c-extension-no-member def setup(hass, config): """Register a port mapping for Home Assistant via UPnP.""" config = config[DOMAIN] diff --git a/homeassistant/components/vacuum/roomba.py b/homeassistant/components/vacuum/roomba.py index 500b98420fc..6485f0025e2 100644 --- a/homeassistant/components/vacuum/roomba.py +++ b/homeassistant/components/vacuum/roomba.py @@ -242,7 +242,7 @@ class RoombaVacuum(VacuumDevice): self.vacuum.set_preference, 'vacHigh', str(high_perf)) @asyncio.coroutine - def async_send_command(self, command, params, **kwargs): + def async_send_command(self, command, params=None, **kwargs): """Send raw command.""" _LOGGER.debug("async_send_command %s (%s), %s", command, params, kwargs) diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index d64f7a754ee..3f194b7eeac 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -341,9 +341,9 @@ class MiroboVacuum(VacuumDevice): @asyncio.coroutine def async_remote_control_move(self, - rotation: int=0, - velocity: float=0.3, - duration: int=1500): + rotation: int = 0, + velocity: float = 0.3, + duration: int = 1500): """Move vacuum with remote control mode.""" yield from self._try_command( "Unable to move with remote control the vacuum: %s", @@ -352,9 +352,9 @@ class MiroboVacuum(VacuumDevice): @asyncio.coroutine def async_remote_control_move_step(self, - rotation: int=0, - velocity: float=0.2, - duration: int=1500): + rotation: int = 0, + velocity: float = 0.2, + duration: int = 1500): """Move vacuum one step with remote control mode.""" yield from self._try_command( "Unable to remote control the vacuum: %s", diff --git a/homeassistant/components/volvooncall.py b/homeassistant/components/volvooncall.py index 3e36d0a3028..6557be2fb1b 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -4,10 +4,11 @@ Support for Volvo On Call. For more details about this component, please refer to the documentation at https://home-assistant.io/components/volvooncall/ """ - from datetime import timedelta import logging +import voluptuous as vol + from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, CONF_NAME, CONF_RESOURCES) from homeassistant.helpers import discovery @@ -16,7 +17,6 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.util.dt import utcnow -import voluptuous as vol DOMAIN = 'volvooncall' @@ -143,8 +143,7 @@ class VolvoData: return vehicle.registration_number elif vehicle.vin: return vehicle.vin - else: - return '' + return '' class VolvoEntity(Entity): diff --git a/homeassistant/components/weather/buienradar.py b/homeassistant/components/weather/buienradar.py index f37914b3b0f..b06ae4dcea1 100644 --- a/homeassistant/components/weather/buienradar.py +++ b/homeassistant/components/weather/buienradar.py @@ -6,6 +6,9 @@ https://home-assistant.io/components/weather.buienradar/ """ import logging import asyncio + +import voluptuous as vol + from homeassistant.components.weather import ( WeatherEntity, PLATFORM_SCHEMA, ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME) from homeassistant.const import \ @@ -14,7 +17,6 @@ from homeassistant.helpers import config_validation as cv # Reuse data and API logic from the sensor implementation from homeassistant.components.sensor.buienradar import ( BrData) -import voluptuous as vol REQUIREMENTS = ['buienradar==0.9'] diff --git a/homeassistant/config.py b/homeassistant/config.py index 5e82ef1baa0..f48f93b39fa 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -166,7 +166,7 @@ def get_default_config_dir() -> str: return os.path.join(data_dir, CONFIG_DIR_NAME) -def ensure_config_exists(config_dir: str, detect_location: bool=True) -> str: +def ensure_config_exists(config_dir: str, detect_location: bool = True) -> str: """Ensure a configuration file exists in given configuration directory. Creating a default one if needed. @@ -677,7 +677,7 @@ def async_check_ha_config_file(hass): @callback -def async_notify_setup_error(hass, component, link=False): +def async_notify_setup_error(hass, component, display_link=False): """Print a persistent notification. This method must be run in the event loop. @@ -689,7 +689,7 @@ def async_notify_setup_error(hass, component, link=False): if errors is None: errors = hass.data[DATA_PERSISTENT_ERRORS] = {} - errors[component] = errors.get(component) or link + errors[component] = errors.get(component) or display_link message = 'The following components and platforms could not be set up:\n\n' diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 87b84a80815..382a7c27d78 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -47,7 +47,7 @@ def _threaded_factory(async_factory): return factory -def async_from_config(config: ConfigType, config_validation: bool=True): +def async_from_config(config: ConfigType, config_validation: bool = True): """Turn a condition configuration into a method. Should be run on the event loop. @@ -70,7 +70,7 @@ def async_from_config(config: ConfigType, config_validation: bool=True): from_config = _threaded_factory(async_from_config) -def async_and_from_config(config: ConfigType, config_validation: bool=True): +def async_and_from_config(config: ConfigType, config_validation: bool = True): """Create multi condition matcher using 'AND'.""" if config_validation: config = cv.AND_CONDITION_SCHEMA(config) @@ -101,7 +101,7 @@ def async_and_from_config(config: ConfigType, config_validation: bool=True): and_from_config = _threaded_factory(async_and_from_config) -def async_or_from_config(config: ConfigType, config_validation: bool=True): +def async_or_from_config(config: ConfigType, config_validation: bool = True): """Create multi condition matcher using 'OR'.""" if config_validation: config = cv.OR_CONDITION_SCHEMA(config) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 6b882d2fdad..04719e89187 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -22,8 +22,8 @@ SLOW_UPDATE_WARNING = 10 def generate_entity_id(entity_id_format: str, name: Optional[str], - current_ids: Optional[List[str]]=None, - hass: Optional[HomeAssistant]=None) -> str: + current_ids: Optional[List[str]] = None, + hass: Optional[HomeAssistant] = None) -> str: """Generate a unique entity ID based on given entity IDs or used IDs.""" if current_ids is None: if hass is None: @@ -42,8 +42,8 @@ def generate_entity_id(entity_id_format: str, name: Optional[str], @callback def async_generate_entity_id(entity_id_format: str, name: Optional[str], - current_ids: Optional[List[str]]=None, - hass: Optional[HomeAssistant]=None) -> str: + current_ids: Optional[List[str]] = None, + hass: Optional[HomeAssistant] = None) -> str: """Generate a unique entity ID based on given entity IDs or used IDs.""" if current_ids is None: if hass is None: diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index f78c70e57d3..c9554488aa7 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -74,8 +74,7 @@ def generate_filter(include_domains, include_entities, domain = split_entity_id(entity_id)[0] if domain in include_d: return entity_id not in exclude_e - else: - return entity_id in include_e + return entity_id in include_e return entity_filter_4a @@ -88,8 +87,7 @@ def generate_filter(include_domains, include_entities, domain = split_entity_id(entity_id)[0] if domain in exclude_d: return entity_id in include_e - else: - return entity_id not in exclude_e + return entity_id not in exclude_e return entity_filter_4b diff --git a/homeassistant/helpers/icon.py b/homeassistant/helpers/icon.py index e4c78fcbed2..e3fb983f691 100644 --- a/homeassistant/helpers/icon.py +++ b/homeassistant/helpers/icon.py @@ -2,8 +2,8 @@ from typing import Optional -def icon_for_battery_level(battery_level: Optional[int]=None, - charging: bool=False) -> str: +def icon_for_battery_level(battery_level: Optional[int] = None, + charging: bool = False) -> str: """Return a battery icon valid identifier.""" icon = 'mdi:battery' if battery_level is None: diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 1ef9aa15674..7a989267572 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -33,7 +33,7 @@ CONF_WAIT_TEMPLATE = 'wait_template' def call_from_config(hass: HomeAssistant, config: ConfigType, - variables: Optional[Sequence]=None) -> None: + variables: Optional[Sequence] = None) -> None: """Call a script based on a config entry.""" Script(hass, cv.SCRIPT_SCHEMA(config)).run(variables) @@ -41,7 +41,7 @@ def call_from_config(hass: HomeAssistant, config: ConfigType, class Script(): """Representation of a script.""" - def __init__(self, hass: HomeAssistant, sequence, name: str=None, + def __init__(self, hass: HomeAssistant, sequence, name: str = None, change_listener=None) -> None: """Initialize the script.""" self.hass = hass @@ -69,7 +69,7 @@ class Script(): self.async_run(variables), self.hass.loop).result() @asyncio.coroutine - def async_run(self, variables: Optional[Sequence]=None) -> None: + def async_run(self, variables: Optional[Sequence] = None) -> None: """Run script. This method is a coroutine. diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index b381e1c2b0e..6fab1c6c844 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -271,8 +271,7 @@ class TemplateState(State): """Return an attribute of the state.""" if name in TemplateState.__dict__: return object.__getattribute__(self, name) - else: - return getattr(object.__getattribute__(self, '_state'), name) + return getattr(object.__getattribute__(self, '_state'), name) def __repr__(self): """Representation of Template State.""" diff --git a/homeassistant/remote.py b/homeassistant/remote.py index 7d032303548..566f37a621a 100644 --- a/homeassistant/remote.py +++ b/homeassistant/remote.py @@ -45,8 +45,9 @@ class APIStatus(enum.Enum): class API(object): """Object to pass around Home Assistant API location and credentials.""" - def __init__(self, host: str, api_password: Optional[str]=None, - port: Optional[int]=SERVER_PORT, use_ssl: bool=False) -> None: + def __init__(self, host: str, api_password: Optional[str] = None, + port: Optional[int] = SERVER_PORT, + use_ssl: bool = False) -> None: """Init the API.""" self.host = host self.port = port @@ -68,7 +69,7 @@ class API(object): if api_password is not None: self._headers[HTTP_HEADER_HA_AUTH] = api_password - def validate_api(self, force_validate: bool=False) -> bool: + def validate_api(self, force_validate: bool = False) -> bool: """Test if we can communicate with the API.""" if self.status is None or force_validate: self.status = validate_api(self) diff --git a/homeassistant/scripts/credstash.py b/homeassistant/scripts/credstash.py index 12516e55c7d..84ba20619d8 100644 --- a/homeassistant/scripts/credstash.py +++ b/homeassistant/scripts/credstash.py @@ -24,7 +24,7 @@ def run(args): 'value', help="The value to save when putting a secret", nargs='?', default=None) - # pylint: disable=import-error + # pylint: disable=import-error, no-member import credstash import botocore diff --git a/homeassistant/scripts/db_migrator.py b/homeassistant/scripts/db_migrator.py index bf4dddc94fe..419f1138bf0 100644 --- a/homeassistant/scripts/db_migrator.py +++ b/homeassistant/scripts/db_migrator.py @@ -23,8 +23,9 @@ def ts_to_dt(timestamp: Optional[float]) -> Optional[datetime]: # Based on code at # http://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console -def print_progress(iteration: int, total: int, prefix: str='', suffix: str='', - decimals: int=2, bar_length: int=68) -> None: +def print_progress(iteration: int, total: int, prefix: str = '', + suffix: str = '', decimals: int = 2, + bar_length: int = 68) -> None: """Print progress bar. Call in a loop to create terminal progress bar diff --git a/homeassistant/scripts/influxdb_import.py b/homeassistant/scripts/influxdb_import.py index e91aeb8a0d7..421e84d503a 100644 --- a/homeassistant/scripts/influxdb_import.py +++ b/homeassistant/scripts/influxdb_import.py @@ -257,8 +257,9 @@ def run(script_args: List) -> int: # Based on code at # http://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console -def print_progress(iteration: int, total: int, prefix: str='', suffix: str='', - decimals: int=2, bar_length: int=68) -> None: +def print_progress(iteration: int, total: int, prefix: str = '', + suffix: str = '', decimals: int = 2, + bar_length: int = 68) -> None: """Print progress bar. Call in a loop to create terminal progress bar diff --git a/homeassistant/scripts/influxdb_migrator.py b/homeassistant/scripts/influxdb_migrator.py index f41240bad74..a4c0df74b09 100644 --- a/homeassistant/scripts/influxdb_migrator.py +++ b/homeassistant/scripts/influxdb_migrator.py @@ -8,8 +8,9 @@ from typing import List # Based on code at # http://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console -def print_progress(iteration: int, total: int, prefix: str='', suffix: str='', - decimals: int=2, bar_length: int=68) -> None: +def print_progress(iteration: int, total: int, prefix: str = '', + suffix: str = '', decimals: int = 2, + bar_length: int = 68) -> None: """Print progress bar. Call in a loop to create terminal progress bar diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 3221ea35d48..364bbc94230 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -24,7 +24,7 @@ SLOW_SETUP_WARNING = 10 def setup_component(hass: core.HomeAssistant, domain: str, - config: Optional[Dict]=None) -> bool: + config: Optional[Dict] = None) -> bool: """Set up a component and all its dependencies.""" return run_coroutine_threadsafe( async_setup_component(hass, domain, config), loop=hass.loop).result() @@ -32,7 +32,7 @@ def setup_component(hass: core.HomeAssistant, domain: str, @asyncio.coroutine def async_setup_component(hass: core.HomeAssistant, domain: str, - config: Optional[Dict]=None) -> bool: + config: Optional[Dict] = None) -> bool: """Set up a component and all its dependencies. This method is a coroutine. diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index c4fea2846c5..75721a37466 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -61,7 +61,7 @@ def repr_helper(inp: Any) -> str: def convert(value: T, to_type: Callable[[T], U], - default: Optional[U]=None) -> Optional[U]: + default: Optional[U] = None) -> Optional[U]: """Convert value to to_type, returns default if fails.""" try: return default if value is None else to_type(value) @@ -164,6 +164,7 @@ class OrderedSet(MutableSet): """Check if key is in set.""" return key in self.map + # pylint: disable=arguments-differ def add(self, key): """Add an element to the end of the set.""" if key not in self.map: @@ -180,6 +181,7 @@ class OrderedSet(MutableSet): curr = begin[1] curr[2] = begin[1] = self.map[key] = [key, curr, begin] + # pylint: disable=arguments-differ def discard(self, key): """Discard an element from the set.""" if key in self.map: diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 9c7fa0d70e7..089e1e733ed 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -392,8 +392,8 @@ def color_temperature_to_rgb(color_temperature_kelvin): return (red, green, blue) -def _bound(color_component: float, minimum: float=0, - maximum: float=255) -> float: +def _bound(color_component: float, minimum: float = 0, + maximum: float = 255) -> float: """ Bound the given color component value between the given min and max values. diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index c3400bac9be..7b5b996a3a3 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -51,7 +51,7 @@ def utcnow() -> dt.datetime: return dt.datetime.now(UTC) -def now(time_zone: dt.tzinfo=None) -> dt.datetime: +def now(time_zone: dt.tzinfo = None) -> dt.datetime: """Get now in specified time zone.""" return dt.datetime.now(time_zone or DEFAULT_TIME_ZONE) diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py index 35b266cb104..0cd0b14d3ab 100644 --- a/homeassistant/util/location.py +++ b/homeassistant/util/location.py @@ -84,7 +84,7 @@ def elevation(latitude, longitude): # License: https://github.com/maurycyp/vincenty/blob/master/LICENSE # pylint: disable=invalid-name, unused-variable, invalid-sequence-index def vincenty(point1: Tuple[float, float], point2: Tuple[float, float], - miles: bool=False) -> Optional[float]: + miles: bool = False) -> Optional[float]: """ Vincenty formula (inverse method) to calculate the distance. diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index a82a50f4e02..e8149a85262 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -17,9 +17,9 @@ _LOGGER = logging.getLogger(__name__) INSTALL_LOCK = threading.Lock() -def install_package(package: str, upgrade: bool=True, - target: Optional[str]=None, - constraints: Optional[str]=None) -> bool: +def install_package(package: str, upgrade: bool = True, + target: Optional[str] = None, + constraints: Optional[str] = None) -> bool: """Install a package on PyPi. Accepts pip compatible package strings. Return boolean if install successful. diff --git a/homeassistant/util/yaml.py b/homeassistant/util/yaml.py index d0d5199e0f4..8ac8d096b99 100644 --- a/homeassistant/util/yaml.py +++ b/homeassistant/util/yaml.py @@ -13,7 +13,7 @@ except ImportError: keyring = None try: - import credstash # pylint: disable=import-error + import credstash # pylint: disable=import-error, no-member except ImportError: credstash = None @@ -276,6 +276,7 @@ def _secret_yaml(loader: SafeLineLoader, global credstash # pylint: disable=invalid-name if credstash: + # pylint: disable=no-member try: pwd = credstash.getSecret(node.value, table=_SECRET_NAMESPACE) if pwd: diff --git a/pylintrc b/pylintrc index 1ed8d2af336..85a44782af1 100644 --- a/pylintrc +++ b/pylintrc @@ -13,6 +13,7 @@ reports=no # too-many-* - are not enforced for the sake of readability # too-few-* - same as too-many-* # abstract-method - with intro of async there are always methods missing +# inconsistent-return-statements - doesn't handle raise generated-members=botocore.errorfactory @@ -23,6 +24,7 @@ disable= cyclic-import, duplicate-code, global-statement, + inconsistent-return-statements, locally-disabled, not-context-manager, redefined-variable-type, diff --git a/requirements_test.txt b/requirements_test.txt index cddf11a34b8..d56a7085c74 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,7 +2,7 @@ # make new things fail. Manually update these pins when pulling in a # new version flake8==3.5 -pylint==1.6.5 +pylint==1.8.2 mypy==0.560 pydocstyle==1.1.1 coveralls==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1ae1b9f2e14..ecb478a29cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -3,7 +3,7 @@ # make new things fail. Manually update these pins when pulling in a # new version flake8==3.5 -pylint==1.6.5 +pylint==1.8.2 mypy==0.560 pydocstyle==1.1.1 coveralls==1.2.0 diff --git a/tests/components/cover/test_zwave.py b/tests/components/cover/test_zwave.py index 4b9be9f5e00..b870075d39f 100644 --- a/tests/components/cover/test_zwave.py +++ b/tests/components/cover/test_zwave.py @@ -118,7 +118,7 @@ def test_roller_commands(hass, mock_openzwave): device = zwave.get_device(hass=hass, node=node, values=values, node_config={}) - device.set_cover_position(25) + device.set_cover_position(position=25) assert node.set_dimmer.called value_id, brightness = node.set_dimmer.mock_calls[0][1] assert value_id == value.value_id From 26209de2f2266cdc3525dde1841e57c517087861 Mon Sep 17 00:00:00 2001 From: Tod Schmidt Date: Sun, 11 Feb 2018 12:33:19 -0500 Subject: [PATCH 015/173] Move HassIntent handler code into helpers/intent (#12181) * Moved TurnOn/Off Intents to component * Removed unused import * Lint fix which my local runs dont catch apparently... * Moved hass intent code into intent * Added test for toggle to conversation. * Fixed toggle tests * Update intent.py * Added homeassistant.helpers to gen_requirements script. * Update intent.py * Update intent.py * Changed return value for _match_entity * Moved consts and requirements * Removed unused import * Removed http view * Removed http import * Removed fuzzywuzzy dependency * woof * A few cleanups * Added domain filtering to entities * Clarified class doc string * Added doc string * Added test in test_init * woof * Cleanup entity matching * Update intent.py * removed uneeded setup from tests --- homeassistant/components/__init__.py | 7 ++ homeassistant/components/conversation.py | 96 ++---------------------- homeassistant/helpers/intent.py | 74 +++++++++++++++++- requirements_all.txt | 3 - requirements_test_all.txt | 3 - script/gen_requirements_all.py | 1 - tests/components/test_conversation.py | 42 +++++++++++ tests/components/test_init.py | 94 +++++++++++++++++++++++ 8 files changed, 221 insertions(+), 99 deletions(-) diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index a1c6811afe7..6b306adad5b 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -15,6 +15,7 @@ import homeassistant.core as ha import homeassistant.config as conf_util from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.service import extract_entity_ids +from homeassistant.helpers import intent from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART, @@ -154,6 +155,12 @@ def async_setup(hass, config): ha.DOMAIN, SERVICE_TURN_ON, async_handle_turn_service) hass.services.async_register( ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service) + hass.helpers.intent.async_register(intent.ServiceIntentHandler( + intent.INTENT_TURN_ON, ha.DOMAIN, SERVICE_TURN_ON, "Turned on {}")) + hass.helpers.intent.async_register(intent.ServiceIntentHandler( + intent.INTENT_TURN_OFF, ha.DOMAIN, SERVICE_TURN_OFF, "Turned off {}")) + hass.helpers.intent.async_register(intent.ServiceIntentHandler( + intent.INTENT_TOGGLE, ha.DOMAIN, SERVICE_TOGGLE, "Toggled {}")) @asyncio.coroutine def async_handle_core_service(call): diff --git a/homeassistant/components/conversation.py b/homeassistant/components/conversation.py index 5187b4782ef..c1dd89d31cd 100644 --- a/homeassistant/components/conversation.py +++ b/homeassistant/components/conversation.py @@ -7,19 +7,15 @@ https://home-assistant.io/components/conversation/ import asyncio import logging import re -import warnings import voluptuous as vol from homeassistant import core from homeassistant.components import http -from homeassistant.const import ( - ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON) from homeassistant.helpers import config_validation as cv from homeassistant.helpers import intent -from homeassistant.loader import bind_hass -REQUIREMENTS = ['fuzzywuzzy==0.16.0'] +from homeassistant.loader import bind_hass _LOGGER = logging.getLogger(__name__) @@ -28,9 +24,6 @@ ATTR_TEXT = 'text' DEPENDENCIES = ['http'] DOMAIN = 'conversation' -INTENT_TURN_OFF = 'HassTurnOff' -INTENT_TURN_ON = 'HassTurnOn' - REGEX_TURN_COMMAND = re.compile(r'turn (?P(?: |\w)+) (?P\w+)') REGEX_TYPE = type(re.compile('')) @@ -50,7 +43,7 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({ @core.callback @bind_hass def async_register(hass, intent_type, utterances): - """Register an intent. + """Register utterances and any custom intents. Registrations don't require conversations to be loaded. They will become active once the conversation component is loaded. @@ -75,8 +68,6 @@ def async_register(hass, intent_type, utterances): @asyncio.coroutine def async_setup(hass, config): """Register the process service.""" - warnings.filterwarnings('ignore', module='fuzzywuzzy') - config = config.get(DOMAIN, {}) intents = hass.data.get(DOMAIN) @@ -102,12 +93,12 @@ def async_setup(hass, config): hass.http.register_view(ConversationProcessView) - hass.helpers.intent.async_register(TurnOnIntent()) - hass.helpers.intent.async_register(TurnOffIntent()) - async_register(hass, INTENT_TURN_ON, + async_register(hass, intent.INTENT_TURN_ON, ['Turn {name} on', 'Turn on {name}']) - async_register(hass, INTENT_TURN_OFF, [ - 'Turn {name} off', 'Turn off {name}']) + async_register(hass, intent.INTENT_TURN_OFF, + ['Turn {name} off', 'Turn off {name}']) + async_register(hass, intent.INTENT_TOGGLE, + ['Toggle {name}', '{name} toggle']) return True @@ -151,79 +142,6 @@ def _process(hass, text): return response -@core.callback -def _match_entity(hass, name): - """Match a name to an entity.""" - from fuzzywuzzy import process as fuzzyExtract - entities = {state.entity_id: state.name for state - in hass.states.async_all()} - entity_id = fuzzyExtract.extractOne( - name, entities, score_cutoff=65)[2] - return hass.states.get(entity_id) if entity_id else None - - -class TurnOnIntent(intent.IntentHandler): - """Handle turning item on intents.""" - - intent_type = INTENT_TURN_ON - slot_schema = { - 'name': cv.string, - } - - @asyncio.coroutine - def async_handle(self, intent_obj): - """Handle turn on intent.""" - hass = intent_obj.hass - slots = self.async_validate_slots(intent_obj.slots) - name = slots['name']['value'] - entity = _match_entity(hass, name) - - if not entity: - _LOGGER.error("Could not find entity id for %s", name) - return None - - yield from hass.services.async_call( - core.DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity.entity_id, - }, blocking=True) - - response = intent_obj.create_response() - response.async_set_speech( - 'Turned on {}'.format(entity.name)) - return response - - -class TurnOffIntent(intent.IntentHandler): - """Handle turning item off intents.""" - - intent_type = INTENT_TURN_OFF - slot_schema = { - 'name': cv.string, - } - - @asyncio.coroutine - def async_handle(self, intent_obj): - """Handle turn off intent.""" - hass = intent_obj.hass - slots = self.async_validate_slots(intent_obj.slots) - name = slots['name']['value'] - entity = _match_entity(hass, name) - - if not entity: - _LOGGER.error("Could not find entity id for %s", name) - return None - - yield from hass.services.async_call( - core.DOMAIN, SERVICE_TURN_OFF, { - ATTR_ENTITY_ID: entity.entity_id, - }, blocking=True) - - response = intent_obj.create_response() - response.async_set_speech( - 'Turned off {}'.format(entity.name)) - return response - - class ConversationProcessView(http.HomeAssistantView): """View to retrieve shopping list content.""" diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 0cf9d83863f..bf2773d32b8 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -1,20 +1,27 @@ """Module to coordinate user intentions.""" import asyncio import logging +import re import voluptuous as vol from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.loader import bind_hass +from homeassistant.const import ATTR_ENTITY_ID - -DATA_KEY = 'intent' _LOGGER = logging.getLogger(__name__) +INTENT_TURN_OFF = 'HassTurnOff' +INTENT_TURN_ON = 'HassTurnOn' +INTENT_TOGGLE = 'HassToggle' + SLOT_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) +DATA_KEY = 'intent' + SPEECH_TYPE_PLAIN = 'plain' SPEECH_TYPE_SSML = 'ssml' @@ -87,7 +94,7 @@ class IntentHandler: intent_type = None slot_schema = None _slot_schema = None - platforms = None + platforms = [] @callback def async_can_handle(self, intent_obj): @@ -117,6 +124,67 @@ class IntentHandler: return '<{} - {}>'.format(self.__class__.__name__, self.intent_type) +def fuzzymatch(name, entities): + """Fuzzy matching function.""" + matches = [] + pattern = '.*?'.join(name) + regex = re.compile(pattern, re.IGNORECASE) + for entity_id, entity_name in entities.items(): + match = regex.search(entity_name) + if match: + matches.append((len(match.group()), match.start(), entity_id)) + return [x for _, _, x in sorted(matches)] + + +class ServiceIntentHandler(IntentHandler): + """Service Intent handler registration. + + Service specific intent handler that calls a service by name/entity_id. + """ + + slot_schema = { + 'name': cv.string, + } + + def __init__(self, intent_type, domain, service, speech): + """Create Service Intent Handler.""" + self.intent_type = intent_type + self.domain = domain + self.service = service + self.speech = speech + + @asyncio.coroutine + def async_handle(self, intent_obj): + """Handle the hass intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + response = intent_obj.create_response() + + name = slots['name']['value'] + entities = {state.entity_id: state.name for state + in hass.states.async_all()} + + matches = fuzzymatch(name, entities) + entity_id = matches[0] if matches else None + _LOGGER.debug("%s matched entity: %s", name, entity_id) + + response = intent_obj.create_response() + if not entity_id: + response.async_set_speech( + "Could not find entity id matching {}.".format(name)) + _LOGGER.error("Could not find entity id matching %s", name) + return response + + yield from hass.services.async_call( + self.domain, self.service, { + ATTR_ENTITY_ID: entity_id + }) + + response.async_set_speech( + self.speech.format(name)) + return response + + class Intent: """Hold the intent.""" diff --git a/requirements_all.txt b/requirements_all.txt index 308a81e16bb..21ae23ccc3d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -295,9 +295,6 @@ fritzhome==1.0.4 # homeassistant.components.media_player.frontier_silicon fsapi==0.0.7 -# homeassistant.components.conversation -fuzzywuzzy==0.16.0 - # homeassistant.components.tts.google gTTS-token==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ecb478a29cc..efb9eb52ce4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -56,9 +56,6 @@ evohomeclient==0.2.5 # homeassistant.components.sensor.geo_rss_events feedparser==5.2.1 -# homeassistant.components.conversation -fuzzywuzzy==0.16.0 - # homeassistant.components.tts.google gTTS-token==1.1.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index dcd201667dd..9c510d8339e 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -46,7 +46,6 @@ TEST_REQUIREMENTS = ( 'ephem', 'evohomeclient', 'feedparser', - 'fuzzywuzzy', 'gTTS-token', 'ha-ffmpeg', 'haversine', diff --git a/tests/components/test_conversation.py b/tests/components/test_conversation.py index fab1e24d8e7..8d629321853 100644 --- a/tests/components/test_conversation.py +++ b/tests/components/test_conversation.py @@ -6,6 +6,7 @@ import pytest from homeassistant.setup import async_setup_component from homeassistant.components import conversation +import homeassistant.components as component from homeassistant.helpers import intent from tests.common import async_mock_intent, async_mock_service @@ -16,6 +17,9 @@ def test_calling_intent(hass): """Test calling an intent from a conversation.""" intents = async_mock_intent(hass, 'OrderBeer') + result = yield from component.async_setup(hass, {}) + assert result + result = yield from async_setup_component(hass, 'conversation', { 'conversation': { 'intents': { @@ -145,6 +149,9 @@ def test_http_processing_intent(hass, test_client): @pytest.mark.parametrize('sentence', ('turn on kitchen', 'turn kitchen on')) def test_turn_on_intent(hass, sentence): """Test calling the turn on intent.""" + result = yield from component.async_setup(hass, {}) + assert result + result = yield from async_setup_component(hass, 'conversation', {}) assert result @@ -168,6 +175,9 @@ def test_turn_on_intent(hass, sentence): @pytest.mark.parametrize('sentence', ('turn off kitchen', 'turn kitchen off')) def test_turn_off_intent(hass, sentence): """Test calling the turn on intent.""" + result = yield from component.async_setup(hass, {}) + assert result + result = yield from async_setup_component(hass, 'conversation', {}) assert result @@ -187,9 +197,38 @@ def test_turn_off_intent(hass, sentence): assert call.data == {'entity_id': 'light.kitchen'} +@asyncio.coroutine +@pytest.mark.parametrize('sentence', ('toggle kitchen', 'kitchen toggle')) +def test_toggle_intent(hass, sentence): + """Test calling the turn on intent.""" + result = yield from component.async_setup(hass, {}) + assert result + + result = yield from async_setup_component(hass, 'conversation', {}) + assert result + + hass.states.async_set('light.kitchen', 'on') + calls = async_mock_service(hass, 'homeassistant', 'toggle') + + yield from hass.services.async_call( + 'conversation', 'process', { + conversation.ATTR_TEXT: sentence + }) + yield from hass.async_block_till_done() + + assert len(calls) == 1 + call = calls[0] + assert call.domain == 'homeassistant' + assert call.service == 'toggle' + assert call.data == {'entity_id': 'light.kitchen'} + + @asyncio.coroutine def test_http_api(hass, test_client): """Test the HTTP conversation API.""" + result = yield from component.async_setup(hass, {}) + assert result + result = yield from async_setup_component(hass, 'conversation', {}) assert result @@ -212,6 +251,9 @@ def test_http_api(hass, test_client): @asyncio.coroutine def test_http_api_wrong_data(hass, test_client): """Test the HTTP conversation API.""" + result = yield from component.async_setup(hass, {}) + assert result + result = yield from async_setup_component(hass, 'conversation', {}) assert result diff --git a/tests/components/test_init.py b/tests/components/test_init.py index dde141b6495..fff3b74c831 100644 --- a/tests/components/test_init.py +++ b/tests/components/test_init.py @@ -11,6 +11,7 @@ from homeassistant import config from homeassistant.const import ( STATE_ON, STATE_OFF, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE) import homeassistant.components as comps +import homeassistant.helpers.intent as intent from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity from homeassistant.util.async import run_coroutine_threadsafe @@ -195,3 +196,96 @@ class TestComponentsCore(unittest.TestCase): self.hass.block_till_done() assert mock_check.called assert not mock_stop.called + + +@asyncio.coroutine +def test_turn_on_intent(hass): + """Test HassTurnOn intent.""" + result = yield from comps.async_setup(hass, {}) + assert result + + hass.states.async_set('light.test_light', 'off') + calls = async_mock_service(hass, 'light', SERVICE_TURN_ON) + + response = yield from intent.async_handle( + hass, 'test', 'HassTurnOn', {'name': {'value': 'test light'}} + ) + yield from hass.async_block_till_done() + + assert response.speech['plain']['speech'] == 'Turned on test light' + assert len(calls) == 1 + call = calls[0] + assert call.domain == 'light' + assert call.service == 'turn_on' + assert call.data == {'entity_id': ['light.test_light']} + + +@asyncio.coroutine +def test_turn_off_intent(hass): + """Test HassTurnOff intent.""" + result = yield from comps.async_setup(hass, {}) + assert result + + hass.states.async_set('light.test_light', 'on') + calls = async_mock_service(hass, 'light', SERVICE_TURN_OFF) + + response = yield from intent.async_handle( + hass, 'test', 'HassTurnOff', {'name': {'value': 'test light'}} + ) + yield from hass.async_block_till_done() + + assert response.speech['plain']['speech'] == 'Turned off test light' + assert len(calls) == 1 + call = calls[0] + assert call.domain == 'light' + assert call.service == 'turn_off' + assert call.data == {'entity_id': ['light.test_light']} + + +@asyncio.coroutine +def test_toggle_intent(hass): + """Test HassToggle intent.""" + result = yield from comps.async_setup(hass, {}) + assert result + + hass.states.async_set('light.test_light', 'off') + calls = async_mock_service(hass, 'light', SERVICE_TOGGLE) + + response = yield from intent.async_handle( + hass, 'test', 'HassToggle', {'name': {'value': 'test light'}} + ) + yield from hass.async_block_till_done() + + assert response.speech['plain']['speech'] == 'Toggled test light' + assert len(calls) == 1 + call = calls[0] + assert call.domain == 'light' + assert call.service == 'toggle' + assert call.data == {'entity_id': ['light.test_light']} + + +@asyncio.coroutine +def test_turn_on_multiple_intent(hass): + """Test HassTurnOn intent with multiple similar entities. + + This tests that matching finds the proper entity among similar names. + """ + result = yield from comps.async_setup(hass, {}) + assert result + + hass.states.async_set('light.test_light', 'off') + hass.states.async_set('light.test_lights_2', 'off') + hass.states.async_set('light.test_lighter', 'off') + calls = async_mock_service(hass, 'light', SERVICE_TURN_ON) + + response = yield from intent.async_handle( + hass, 'test', 'HassTurnOn', {'name': {'value': 'test lights'}} + ) + yield from hass.async_block_till_done() + + assert response.speech['plain']['speech'] == 'Turned on test lights' + assert len(calls) == 1 + call = calls[0] + assert call.domain == 'light' + assert call.service == 'turn_on' + assert call.data == {'entity_id': ['light.test_lights_2']} From 6c358fa6a387093a20a839fcfb61d2982c43a329 Mon Sep 17 00:00:00 2001 From: Jerad Meisner Date: Sun, 11 Feb 2018 09:33:56 -0800 Subject: [PATCH 016/173] Migrated SABnzbd sensor to asyncio and switched to pypi library (#12290) * Migrated SABnzbd sensor to asyncio and switched to pypi library * bumped pysabnzbd version and refactored imports --- homeassistant/components/sensor/sabnzbd.py | 120 +++++++++++---------- requirements_all.txt | 6 +- 2 files changed, 64 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/sensor/sabnzbd.py b/homeassistant/components/sensor/sabnzbd.py index 9ce2da09451..632e1ed5c1d 100644 --- a/homeassistant/components/sensor/sabnzbd.py +++ b/homeassistant/components/sensor/sabnzbd.py @@ -4,6 +4,7 @@ Support for monitoring an SABnzbd NZB client. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.sabnzbd/ """ +import asyncio import logging from datetime import timedelta @@ -18,13 +19,10 @@ from homeassistant.util import Throttle from homeassistant.util.json import load_json, save_json import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['https://github.com/jamespcole/home-assistant-nzb-clients/' - 'archive/616cad59154092599278661af17e2a9f2cf5e2a9.zip' - '#python-sabnzbd==0.1'] +REQUIREMENTS = ['pysabnzbd==0.0.3'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) -_THROTTLED_REFRESH = None CONFIG_FILE = 'sabnzbd.conf' DEFAULT_NAME = 'SABnzbd' @@ -54,38 +52,42 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def _check_sabnzbd(sab_api, base_url, api_key): +@asyncio.coroutine +def async_check_sabnzbd(sab_api, base_url, api_key): """Check if we can reach SABnzbd.""" from pysabnzbd import SabnzbdApiException sab_api = sab_api(base_url, api_key) try: - sab_api.check_available() + yield from sab_api.check_available() except SabnzbdApiException: _LOGGER.error("Connection to SABnzbd API failed") return False return True -def setup_sabnzbd(base_url, apikey, name, hass, config, add_devices, sab_api): +def setup_sabnzbd(base_url, apikey, name, config, + async_add_devices, sab_api): """Set up polling from SABnzbd and sensors.""" sab_api = sab_api(base_url, apikey) - # Add minimal info to the front end - monitored = config.get(CONF_MONITORED_VARIABLES, ['current_status']) - - # pylint: disable=global-statement - global _THROTTLED_REFRESH - _THROTTLED_REFRESH = Throttle( - MIN_TIME_BETWEEN_UPDATES)(sab_api.refresh_queue) - - devices = [] - for variable in monitored: - devices.append(SabnzbdSensor(variable, sab_api, name)) - - add_devices(devices) + monitored = config.get(CONF_MONITORED_VARIABLES) + async_add_devices([SabnzbdSensor(variable, sab_api, name) + for variable in monitored]) -def request_configuration(host, name, hass, config, add_devices, sab_api): +@asyncio.coroutine +@Throttle(MIN_TIME_BETWEEN_UPDATES) +def async_update_queue(sab_api): + """ + Throttled function to update SABnzbd queue. + + This ensures that the queue info only gets updated once for all sensors + """ + yield from sab_api.refresh_queue() + + +def request_configuration(host, name, hass, config, async_add_devices, + sab_api): """Request configuration steps from the user.""" configurator = hass.components.configurator # We got an error if this method is called while we are configuring @@ -95,12 +97,13 @@ def request_configuration(host, name, hass, config, add_devices, sab_api): return - def sabnzbd_configuration_callback(data): + @asyncio.coroutine + def async_configuration_callback(data): """Handle configuration changes.""" api_key = data.get('api_key') - if _check_sabnzbd(sab_api, host, api_key): - setup_sabnzbd(host, api_key, name, - hass, config, add_devices, sab_api) + if (yield from async_check_sabnzbd(sab_api, host, api_key)): + setup_sabnzbd(host, api_key, name, config, + async_add_devices, sab_api) def success(): """Set up was successful.""" @@ -108,23 +111,21 @@ def request_configuration(host, name, hass, config, add_devices, sab_api): conf[host] = {'api_key': api_key} save_json(hass.config.path(CONFIG_FILE), conf) req_config = _CONFIGURING.pop(host) - hass.async_add_job(configurator.request_done, req_config) + configurator.async_request_done(req_config) hass.async_add_job(success) - _CONFIGURING[host] = configurator.request_config( + _CONFIGURING[host] = configurator.async_request_config( DEFAULT_NAME, - sabnzbd_configuration_callback, - description=('Enter the API Key'), + async_configuration_callback, + description='Enter the API Key', submit_caption='Confirm', - fields=[{ - 'id': 'api_key', - 'name': 'API Key', - 'type': ''}] + fields=[{'id': 'api_key', 'name': 'API Key', 'type': ''}] ) -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the SABnzbd platform.""" from pysabnzbd import SabnzbdApi @@ -139,31 +140,32 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = config.get(CONF_NAME, DEFAULT_NAME) use_ssl = config.get(CONF_SSL) + api_key = config.get(CONF_API_KEY) + uri_scheme = 'https://' if use_ssl else 'http://' base_url = "{}{}:{}/".format(uri_scheme, host, port) - api_key = config.get(CONF_API_KEY) if not api_key: conf = load_json(hass.config.path(CONFIG_FILE)) if conf.get(base_url, {}).get('api_key'): api_key = conf[base_url]['api_key'] - if not _check_sabnzbd(SabnzbdApi, base_url, api_key): + if not (yield from async_check_sabnzbd(SabnzbdApi, base_url, api_key)): request_configuration(base_url, name, hass, config, - add_devices, SabnzbdApi) + async_add_devices, SabnzbdApi) return - setup_sabnzbd(base_url, api_key, name, hass, - config, add_devices, SabnzbdApi) + setup_sabnzbd(base_url, api_key, name, config, + async_add_devices, SabnzbdApi) class SabnzbdSensor(Entity): """Representation of an SABnzbd sensor.""" - def __init__(self, sensor_type, sabnzb_client, client_name): + def __init__(self, sensor_type, sabnzbd_api, client_name): """Initialize the sensor.""" self._name = SENSOR_TYPES[sensor_type][0] - self.sabnzb_client = sabnzb_client + self.sabnzbd_api = sabnzbd_api self.type = sensor_type self.client_name = client_name self._state = None @@ -184,35 +186,35 @@ class SabnzbdSensor(Entity): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement - # pylint: disable=no-self-use - def refresh_sabnzbd_data(self): + @asyncio.coroutine + def async_refresh_sabnzbd_data(self): """Call the throttled SABnzbd refresh method.""" - if _THROTTLED_REFRESH is not None: - from pysabnzbd import SabnzbdApiException - try: - _THROTTLED_REFRESH() - except SabnzbdApiException: - _LOGGER.exception("Connection to SABnzbd API failed") + from pysabnzbd import SabnzbdApiException + try: + yield from async_update_queue(self.sabnzbd_api) + except SabnzbdApiException: + _LOGGER.exception("Connection to SABnzbd API failed") - def update(self): + @asyncio.coroutine + def async_update(self): """Get the latest data and updates the states.""" - self.refresh_sabnzbd_data() + yield from self.async_refresh_sabnzbd_data() - if self.sabnzb_client.queue: + if self.sabnzbd_api.queue: if self.type == 'current_status': - self._state = self.sabnzb_client.queue.get('status') + self._state = self.sabnzbd_api.queue.get('status') elif self.type == 'speed': - mb_spd = float(self.sabnzb_client.queue.get('kbpersec')) / 1024 + mb_spd = float(self.sabnzbd_api.queue.get('kbpersec')) / 1024 self._state = round(mb_spd, 1) elif self.type == 'queue_size': - self._state = self.sabnzb_client.queue.get('mb') + self._state = self.sabnzbd_api.queue.get('mb') elif self.type == 'queue_remaining': - self._state = self.sabnzb_client.queue.get('mbleft') + self._state = self.sabnzbd_api.queue.get('mbleft') elif self.type == 'disk_size': - self._state = self.sabnzb_client.queue.get('diskspacetotal1') + self._state = self.sabnzbd_api.queue.get('diskspacetotal1') elif self.type == 'disk_free': - self._state = self.sabnzb_client.queue.get('diskspace1') + self._state = self.sabnzbd_api.queue.get('diskspace1') elif self.type == 'queue_count': - self._state = self.sabnzb_client.queue.get('noofslots_total') + self._state = self.sabnzbd_api.queue.get('noofslots_total') else: self._state = 'Unknown' diff --git a/requirements_all.txt b/requirements_all.txt index 21ae23ccc3d..5af332fe65b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -373,9 +373,6 @@ https://github.com/jabesq/netatmo-api-python/archive/v0.9.2.1.zip#lnetatmo==0.9. # homeassistant.components.neato https://github.com/jabesq/pybotvac/archive/v0.0.5.zip#pybotvac==0.0.5 -# homeassistant.components.sensor.sabnzbd -https://github.com/jamespcole/home-assistant-nzb-clients/archive/616cad59154092599278661af17e2a9f2cf5e2a9.zip#python-sabnzbd==0.1 - # homeassistant.components.switch.anel_pwrctrl https://github.com/mweinelt/anel-pwrctrl/archive/ed26e8830e28a2bfa4260a9002db23ce3e7e63d7.zip#anel_pwrctrl==0.0.1 @@ -846,6 +843,9 @@ pyqwikswitch==0.4 # homeassistant.components.rainbird pyrainbird==0.1.3 +# homeassistant.components.sensor.sabnzbd +pysabnzbd==0.0.3 + # homeassistant.components.climate.sensibo pysensibo==1.0.2 From a71d5f4614c841f78cc375cf0b9f87dd9c2cd6ee Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 11 Feb 2018 09:45:21 -0800 Subject: [PATCH 017/173] Bump frontend to 20180211.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index eedd33478a7..c426a775fc5 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20180209.0', 'user-agents==1.1.0'] +REQUIREMENTS = ['home-assistant-frontend==20180211.0', 'user-agents==1.1.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 5af332fe65b..ededa49454b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -350,7 +350,7 @@ hipnotify==1.0.8 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180209.0 +home-assistant-frontend==20180211.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index efb9eb52ce4..ecde5a5fc9e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -72,7 +72,7 @@ hbmqtt==0.9.1 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180209.0 +home-assistant-frontend==20180211.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From e4a826d1c18e73df0dad4fd55f9290c7d9523174 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Sun, 11 Feb 2018 20:01:44 +0100 Subject: [PATCH 018/173] =?UTF-8?q?=F0=9F=93=9D=20Fix=20fixture=20encoding?= =?UTF-8?q?=20(#12296)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/common.py b/tests/common.py index cf896008d85..d194ad4d040 100644 --- a/tests/common.py +++ b/tests/common.py @@ -245,7 +245,7 @@ def fire_service_discovered(hass, service, info): def load_fixture(filename): """Load a fixture.""" path = os.path.join(os.path.dirname(__file__), 'fixtures', filename) - with open(path) as fptr: + with open(path, encoding='utf-8') as fptr: return fptr.read() From 767d3c6206916d1ae2236cce62d0e313ad32fcdf Mon Sep 17 00:00:00 2001 From: Richard Lucas Date: Sun, 11 Feb 2018 11:25:05 -0800 Subject: [PATCH 019/173] Fix Alexa Step Volume (#12314) --- homeassistant/components/alexa/smart_home.py | 2 +- tests/components/alexa/test_smart_home.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 354a612c4b8..e9b1b845d3d 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -1168,7 +1168,7 @@ def async_api_adjust_volume(hass, config, request, entity): @asyncio.coroutine def async_api_adjust_volume_step(hass, config, request, entity): """Process an adjust volume step request.""" - volume_step = round(float(request[API_PAYLOAD]['volume'] / 100), 2) + volume_step = round(float(request[API_PAYLOAD]['volumeSteps'] / 100), 2) current_level = entity.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 71485231150..487ff301c18 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -511,14 +511,14 @@ def test_media_player(hass): 'Alexa.StepSpeaker', 'AdjustVolume', 'media_player#test', 'media_player.volume_set', hass, - payload={'volume': 20}) + payload={'volumeSteps': 20}) assert call.data['volume_level'] == 0.95 call, _ = yield from assert_request_calls_service( 'Alexa.StepSpeaker', 'AdjustVolume', 'media_player#test', 'media_player.volume_set', hass, - payload={'volume': -20}) + payload={'volumeSteps': -20}) assert call.data['volume_level'] == 0.55 From 47bfef96405e4fd93ff1fde31be9b813eaa48dc1 Mon Sep 17 00:00:00 2001 From: Thijs de Jong Date: Sun, 11 Feb 2018 20:36:04 +0100 Subject: [PATCH 020/173] Clarify tahoma errrors (#12307) --- homeassistant/components/tahoma.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tahoma.py b/homeassistant/components/tahoma.py index 28a54f40d56..00ebc78a40b 100644 --- a/homeassistant/components/tahoma.py +++ b/homeassistant/components/tahoma.py @@ -57,14 +57,14 @@ def setup(hass, config): try: api = TahomaApi(username, password) except RequestException: - _LOGGER.exception("Error communicating with Tahoma API") + _LOGGER.exception("Error when trying to log in to the Tahoma API") return False try: api.get_setup() devices = api.get_devices() except RequestException: - _LOGGER.exception("Cannot fetch information from Tahoma API") + _LOGGER.exception("Error when getting devices from the Tahoma API") return False hass.data[DOMAIN] = { From 219ed7331c7f06d8c86854268cd472245acbb87f Mon Sep 17 00:00:00 2001 From: NovapaX Date: Sun, 11 Feb 2018 21:12:30 +0100 Subject: [PATCH 021/173] add friendly_name_template to template sensor (#12268) * add friendly_name_template to template sensor. If set, overrides friendly_name setting. * Add test --- homeassistant/components/sensor/template.py | 14 ++++++++--- homeassistant/const.py | 1 + tests/components/sensor/test_template.py | 27 +++++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/template.py b/homeassistant/components/sensor/template.py index b347439e08d..582bc3a0150 100644 --- a/homeassistant/components/sensor/template.py +++ b/homeassistant/components/sensor/template.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ENTITY_ID_FORMAT, PLATFORM_SCHEMA from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, CONF_ICON_TEMPLATE, CONF_ENTITY_PICTURE_TEMPLATE, ATTR_ENTITY_ID, - CONF_SENSORS, EVENT_HOMEASSISTANT_START) + CONF_SENSORS, EVENT_HOMEASSISTANT_START, CONF_FRIENDLY_NAME_TEMPLATE) from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity, async_generate_entity_id @@ -26,6 +26,7 @@ SENSOR_SCHEMA = vol.Schema({ vol.Required(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_ICON_TEMPLATE): cv.template, vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template, + vol.Optional(CONF_FRIENDLY_NAME_TEMPLATE): cv.template, vol.Optional(ATTR_FRIENDLY_NAME): cv.string, vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids @@ -50,6 +51,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): entity_ids = (device_config.get(ATTR_ENTITY_ID) or state_template.extract_entities()) friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) + friendly_name_template = device_config.get(CONF_FRIENDLY_NAME_TEMPLATE) unit_of_measurement = device_config.get(ATTR_UNIT_OF_MEASUREMENT) state_template.hass = hass @@ -60,11 +62,15 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if entity_picture_template is not None: entity_picture_template.hass = hass + if friendly_name_template is not None: + friendly_name_template.hass = hass + sensors.append( SensorTemplate( hass, device, friendly_name, + friendly_name_template, unit_of_measurement, state_template, icon_template, @@ -82,7 +88,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class SensorTemplate(Entity): """Representation of a Template Sensor.""" - def __init__(self, hass, device_id, friendly_name, + def __init__(self, hass, device_id, friendly_name, friendly_name_template, unit_of_measurement, state_template, icon_template, entity_picture_template, entity_ids): """Initialize the sensor.""" @@ -90,6 +96,7 @@ class SensorTemplate(Entity): self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) self._name = friendly_name + self._friendly_name_template = friendly_name_template self._unit_of_measurement = unit_of_measurement self._template = state_template self._state = None @@ -165,7 +172,8 @@ class SensorTemplate(Entity): for property_name, template in ( ('_icon', self._icon_template), - ('_entity_picture', self._entity_picture_template)): + ('_entity_picture', self._entity_picture_template), + ('_name', self._friendly_name_template)): if template is None: continue diff --git a/homeassistant/const.py b/homeassistant/const.py index b7b6061e757..10c29d19107 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -76,6 +76,7 @@ CONF_FILENAME = 'filename' CONF_FOR = 'for' CONF_FORCE_UPDATE = 'force_update' CONF_FRIENDLY_NAME = 'friendly_name' +CONF_FRIENDLY_NAME_TEMPLATE = 'friendly_name_template' CONF_HEADERS = 'headers' CONF_HOST = 'host' CONF_HOSTS = 'hosts' diff --git a/tests/components/sensor/test_template.py b/tests/components/sensor/test_template.py index 3033b41b142..5e258bc9245 100644 --- a/tests/components/sensor/test_template.py +++ b/tests/components/sensor/test_template.py @@ -104,6 +104,33 @@ class TestTemplateSensor: state = self.hass.states.get('sensor.test_template_sensor') assert state.attributes['entity_picture'] == '/local/sensor.png' + def test_friendly_name_template(self): + """Test friendly_name template.""" + with assert_setup_component(1): + assert setup_component(self.hass, 'sensor', { + 'sensor': { + 'platform': 'template', + 'sensors': { + 'test_template_sensor': { + 'value_template': "State", + 'friendly_name_template': + "It {{ states.sensor.test_state.state }}." + } + } + } + }) + + self.hass.start() + self.hass.block_till_done() + + state = self.hass.states.get('sensor.test_template_sensor') + assert state.attributes.get('friendly_name') == 'It .' + + self.hass.states.set('sensor.test_state', 'Works') + self.hass.block_till_done() + state = self.hass.states.get('sensor.test_template_sensor') + assert state.attributes['friendly_name'] == 'It Works.' + def test_template_syntax_error(self): """Test templating syntax error.""" with assert_setup_component(0): From 247edf1b694411e250e8e8fa170000bf13b7eca6 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sun, 11 Feb 2018 22:22:59 +0100 Subject: [PATCH 022/173] Purge recorder data by default (#12271) --- homeassistant/components/recorder/__init__.py | 31 +++++---- homeassistant/components/recorder/purge.py | 7 +- .../components/recorder/services.yaml | 3 + homeassistant/config.py | 4 -- tests/components/recorder/test_purge.py | 66 ++++++++++++------- 5 files changed, 63 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 09922665ae1..ddf98c1420b 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -43,10 +43,12 @@ DOMAIN = 'recorder' SERVICE_PURGE = 'purge' ATTR_KEEP_DAYS = 'keep_days' +ATTR_REPACK = 'repack' SERVICE_PURGE_SCHEMA = vol.Schema({ - vol.Required(ATTR_KEEP_DAYS): - vol.All(vol.Coerce(int), vol.Range(min=0)) + vol.Optional(ATTR_KEEP_DAYS): + vol.All(vol.Coerce(int), vol.Range(min=0)), + vol.Optional(ATTR_REPACK, default=False): cv.boolean }) DEFAULT_URL = 'sqlite:///{hass_config_path}' @@ -76,7 +78,7 @@ FILTER_SCHEMA = vol.Schema({ CONFIG_SCHEMA = vol.Schema({ DOMAIN: FILTER_SCHEMA.extend({ - vol.Optional(CONF_PURGE_KEEP_DAYS): + vol.Optional(CONF_PURGE_KEEP_DAYS, default=10): vol.All(vol.Coerce(int), vol.Range(min=1)), vol.Optional(CONF_PURGE_INTERVAL, default=1): vol.All(vol.Coerce(int), vol.Range(min=0)), @@ -122,12 +124,6 @@ def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: keep_days = conf.get(CONF_PURGE_KEEP_DAYS) purge_interval = conf.get(CONF_PURGE_INTERVAL) - if keep_days is None and purge_interval != 0: - _LOGGER.warning( - "From version 0.64.0 the 'recorder' component will by default " - "purge data older than 10 days. To keep data longer you must " - "configure 'purge_keep_days' or 'purge_interval'.") - db_url = conf.get(CONF_DB_URL, None) if not db_url: db_url = DEFAULT_URL.format( @@ -144,7 +140,7 @@ def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @asyncio.coroutine def async_handle_purge_service(service): """Handle calls to the purge service.""" - instance.do_adhoc_purge(service.data[ATTR_KEEP_DAYS]) + instance.do_adhoc_purge(**service.data) hass.services.async_register( DOMAIN, SERVICE_PURGE, async_handle_purge_service, @@ -153,7 +149,7 @@ def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return (yield from instance.async_db_ready) -PurgeTask = namedtuple('PurgeTask', ['keep_days']) +PurgeTask = namedtuple('PurgeTask', ['keep_days', 'repack']) class Recorder(threading.Thread): @@ -188,10 +184,12 @@ class Recorder(threading.Thread): """Initialize the recorder.""" self.hass.bus.async_listen(MATCH_ALL, self.event_listener) - def do_adhoc_purge(self, keep_days): + def do_adhoc_purge(self, **kwargs): """Trigger an adhoc purge retaining keep_days worth of data.""" - if keep_days is not None: - self.queue.put(PurgeTask(keep_days)) + keep_days = kwargs.get(ATTR_KEEP_DAYS, self.keep_days) + repack = kwargs.get(ATTR_REPACK) + + self.queue.put(PurgeTask(keep_days, repack)) def run(self): """Start processing events to save.""" @@ -261,7 +259,8 @@ class Recorder(threading.Thread): @callback def async_purge(now): """Trigger the purge and schedule the next run.""" - self.queue.put(PurgeTask(self.keep_days)) + self.queue.put( + PurgeTask(self.keep_days, repack=not self.did_vacuum)) self.hass.helpers.event.async_track_point_in_time( async_purge, now + timedelta(days=self.purge_interval)) @@ -294,7 +293,7 @@ class Recorder(threading.Thread): self.queue.task_done() return elif isinstance(event, PurgeTask): - purge.purge_old_data(self, event.keep_days) + purge.purge_old_data(self, event.keep_days, event.repack) self.queue.task_done() continue elif event.event_type == EVENT_TIME_CHANGED: diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 06bd81c2309..d4b07232436 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -9,13 +9,14 @@ from .util import session_scope _LOGGER = logging.getLogger(__name__) -def purge_old_data(instance, purge_days): +def purge_old_data(instance, purge_days, repack): """Purge events and states older than purge_days ago.""" from .models import States, Events from sqlalchemy import orm from sqlalchemy.sql import exists purge_before = dt_util.utcnow() - timedelta(days=purge_days) + _LOGGER.debug("Purging events before %s", purge_before) with session_scope(session=instance.get_session()) as session: # For each entity, the most recent state is protected from deletion @@ -55,10 +56,10 @@ def purge_old_data(instance, purge_days): # Execute sqlite vacuum command to free up space on disk _LOGGER.debug("DB engine driver: %s", instance.engine.driver) - if instance.engine.driver == 'pysqlite' and not instance.did_vacuum: + if repack and instance.engine.driver == 'pysqlite': from sqlalchemy import exc - _LOGGER.info("Vacuuming SQLite to free space") + _LOGGER.debug("Vacuuming SQLite to free space") try: instance.engine.execute("VACUUM") instance.did_vacuum = True diff --git a/homeassistant/components/recorder/services.yaml b/homeassistant/components/recorder/services.yaml index a2a8c9eab8d..512807c9f69 100644 --- a/homeassistant/components/recorder/services.yaml +++ b/homeassistant/components/recorder/services.yaml @@ -6,3 +6,6 @@ purge: keep_days: description: Number of history days to keep in database after purge. Value >= 0. example: 2 + repack: + description: Attempt to save disk space by rewriting the entire database file. + example: true diff --git a/homeassistant/config.py b/homeassistant/config.py index f48f93b39fa..6507e2a74f6 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -95,10 +95,6 @@ conversation: # Enables support for tracking state changes over time history: -# Tracked history is kept for 10 days -recorder: - purge_keep_days: 10 - # View all events in a logbook logbook: diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index c429ee2fbbb..2ae039b6712 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -16,9 +16,8 @@ class TestRecorderPurge(unittest.TestCase): def setUp(self): # pylint: disable=invalid-name """Setup things to be run when tests are started.""" - config = {'purge_keep_days': 4, 'purge_interval': 2} self.hass = get_test_home_assistant() - init_recorder_component(self.hass, config) + init_recorder_component(self.hass) self.hass.start() def tearDown(self): # pylint: disable=invalid-name @@ -29,14 +28,18 @@ class TestRecorderPurge(unittest.TestCase): """Add multiple states to the db for testing.""" now = datetime.now() five_days_ago = now - timedelta(days=5) + eleven_days_ago = now - timedelta(days=11) attributes = {'test_attr': 5, 'test_attr_10': 'nice'} self.hass.block_till_done() self.hass.data[DATA_INSTANCE].block_till_done() with recorder.session_scope(hass=self.hass) as session: - for event_id in range(5): - if event_id < 3: + for event_id in range(6): + if event_id < 2: + timestamp = eleven_days_ago + state = 'autopurgeme' + elif event_id < 4: timestamp = five_days_ago state = 'purgeme' else: @@ -65,9 +68,9 @@ class TestRecorderPurge(unittest.TestCase): domain='sensor', state='iamprotected', attributes=json.dumps(attributes), - last_changed=five_days_ago, - last_updated=five_days_ago, - created=five_days_ago, + last_changed=eleven_days_ago, + last_updated=eleven_days_ago, + created=eleven_days_ago, event_id=protected_event_id )) @@ -75,14 +78,18 @@ class TestRecorderPurge(unittest.TestCase): """Add a few events for testing.""" now = datetime.now() five_days_ago = now - timedelta(days=5) + eleven_days_ago = now - timedelta(days=11) event_data = {'test_attr': 5, 'test_attr_10': 'nice'} self.hass.block_till_done() self.hass.data[DATA_INSTANCE].block_till_done() with recorder.session_scope(hass=self.hass) as session: - for event_id in range(5): + for event_id in range(6): if event_id < 2: + timestamp = eleven_days_ago + event_type = 'EVENT_TEST_AUTOPURGE' + elif event_id < 4: timestamp = five_days_ago event_type = 'EVENT_TEST_PURGE' else: @@ -102,8 +109,8 @@ class TestRecorderPurge(unittest.TestCase): event_type='EVENT_TEST_FOR_PROTECTED', event_data=json.dumps(event_data), origin='LOCAL', - created=five_days_ago, - time_fired=five_days_ago, + created=eleven_days_ago, + time_fired=eleven_days_ago, ) session.add(protected_event) session.flush() @@ -113,13 +120,13 @@ class TestRecorderPurge(unittest.TestCase): def test_purge_old_states(self): """Test deleting old states.""" self._add_test_states() - # make sure we start with 6 states + # make sure we start with 7 states with session_scope(hass=self.hass) as session: states = session.query(States) - self.assertEqual(states.count(), 6) + self.assertEqual(states.count(), 7) # run purge_old_data() - purge_old_data(self.hass.data[DATA_INSTANCE], 4) + purge_old_data(self.hass.data[DATA_INSTANCE], 4, repack=False) # we should only have 3 states left after purging self.assertEqual(states.count(), 3) @@ -131,13 +138,13 @@ class TestRecorderPurge(unittest.TestCase): with session_scope(hass=self.hass) as session: events = session.query(Events).filter( Events.event_type.like("EVENT_TEST%")) - self.assertEqual(events.count(), 6) + self.assertEqual(events.count(), 7) # run purge_old_data() - purge_old_data(self.hass.data[DATA_INSTANCE], 4) + purge_old_data(self.hass.data[DATA_INSTANCE], 4, repack=False) - # now we should only have 3 events left - self.assertEqual(events.count(), 3) + # no state to protect, now we should only have 2 events left + self.assertEqual(events.count(), 2) def test_purge_method(self): """Test purge method.""" @@ -148,24 +155,24 @@ class TestRecorderPurge(unittest.TestCase): # make sure we start with 6 states with session_scope(hass=self.hass) as session: states = session.query(States) - self.assertEqual(states.count(), 6) + self.assertEqual(states.count(), 7) events = session.query(Events).filter( Events.event_type.like("EVENT_TEST%")) - self.assertEqual(events.count(), 6) + self.assertEqual(events.count(), 7) self.hass.data[DATA_INSTANCE].block_till_done() - # run purge method - no service data, should not work + # run purge method - no service data, use defaults self.hass.services.call('recorder', 'purge') self.hass.async_block_till_done() # Small wait for recorder thread self.hass.data[DATA_INSTANCE].block_till_done() - # we should still have everything from before - self.assertEqual(states.count(), 6) - self.assertEqual(events.count(), 6) + # only purged old events + self.assertEqual(states.count(), 5) + self.assertEqual(events.count(), 5) # run purge method - correct service data self.hass.services.call('recorder', 'purge', @@ -182,11 +189,20 @@ class TestRecorderPurge(unittest.TestCase): self.assertTrue('iamprotected' in ( state.state for state in states)) - # now we should only have 4 events left - self.assertEqual(events.count(), 4) + # now we should only have 3 events left + self.assertEqual(events.count(), 3) # and the protected event is among them self.assertTrue('EVENT_TEST_FOR_PROTECTED' in ( event.event_type for event in events.all())) self.assertFalse('EVENT_TEST_PURGE' in ( event.event_type for event in events.all())) + + # run purge method - correct service data, with repack + service_data['repack'] = True + self.assertFalse(self.hass.data[DATA_INSTANCE].did_vacuum) + self.hass.services.call('recorder', 'purge', + service_data=service_data) + self.hass.async_block_till_done() + self.hass.data[DATA_INSTANCE].block_till_done() + self.assertTrue(self.hass.data[DATA_INSTANCE].did_vacuum) From 3e150bb2b3511c8211c631e972b6af54974ca369 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 11 Feb 2018 13:49:16 -0800 Subject: [PATCH 023/173] Protect bloomsky platform setup (#12316) --- homeassistant/components/binary_sensor/bloomsky.py | 4 ++++ homeassistant/components/camera/bloomsky.py | 4 ++++ homeassistant/components/sensor/bloomsky.py | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/homeassistant/components/binary_sensor/bloomsky.py b/homeassistant/components/binary_sensor/bloomsky.py index 1d0849b255e..add78864394 100644 --- a/homeassistant/components/binary_sensor/bloomsky.py +++ b/homeassistant/components/binary_sensor/bloomsky.py @@ -31,6 +31,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the available BloomSky weather binary sensors.""" + # Protect against people having setup the bloomsky platforms + if discovery_info is None: + return + bloomsky = get_component('bloomsky') # Default needed in case of discovery sensors = config.get(CONF_MONITORED_CONDITIONS, SENSOR_TYPES) diff --git a/homeassistant/components/camera/bloomsky.py b/homeassistant/components/camera/bloomsky.py index c3b4775b593..49240354b64 100644 --- a/homeassistant/components/camera/bloomsky.py +++ b/homeassistant/components/camera/bloomsky.py @@ -17,6 +17,10 @@ DEPENDENCIES = ['bloomsky'] # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up access to BloomSky cameras.""" + # Protect against people having setup the bloomsky platforms + if discovery_info is None: + return + bloomsky = get_component('bloomsky') for device in bloomsky.BLOOMSKY.devices.values(): add_devices([BloomSkyCamera(bloomsky.BLOOMSKY, device)]) diff --git a/homeassistant/components/sensor/bloomsky.py b/homeassistant/components/sensor/bloomsky.py index ce44abdb087..f0d7c059b4a 100644 --- a/homeassistant/components/sensor/bloomsky.py +++ b/homeassistant/components/sensor/bloomsky.py @@ -45,6 +45,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the available BloomSky weather sensors.""" + # Protect against people having setup the bloomsky platforms + if discovery_info is None: + return + bloomsky = get_component('bloomsky') # Default needed in case of discovery sensors = config.get(CONF_MONITORED_CONDITIONS, SENSOR_TYPES) From 28ed304c93d29b3920de77e095699799b4636c91 Mon Sep 17 00:00:00 2001 From: Russell Cloran Date: Sun, 11 Feb 2018 14:45:47 -0800 Subject: [PATCH 024/173] zha: Update zigpy-xbee to 0.0.2 0.0.2 implements auto_form, so that configuring the radio to be a controller is done automatically. --- homeassistant/components/zha/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 58d44b31994..3729ce8a153 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -18,7 +18,7 @@ from homeassistant.util import slugify REQUIREMENTS = [ 'bellows==0.5.0', 'zigpy==0.0.1', - 'zigpy-xbee==0.0.1', + 'zigpy-xbee==0.0.2', ] DOMAIN = 'zha' diff --git a/requirements_all.txt b/requirements_all.txt index ededa49454b..82c98817aff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1273,7 +1273,7 @@ zeroconf==0.19.1 ziggo-mediabox-xl==1.0.0 # homeassistant.components.zha -zigpy-xbee==0.0.1 +zigpy-xbee==0.0.2 # homeassistant.components.zha zigpy==0.0.1 From ed1d6f102791183fe53e0d317c1fefb34312a270 Mon Sep 17 00:00:00 2001 From: ChristianKuehnel Date: Sun, 11 Feb 2018 23:51:10 +0100 Subject: [PATCH 025/173] Removed default sensor configuration (#12252) * removed default configuration, added warning if no sensor was configured * fixed typo in currency-try icon * changed if statement --- .../components/sensor/alpha_vantage.py | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/sensor/alpha_vantage.py b/homeassistant/components/sensor/alpha_vantage.py index 6b224492ffb..fce82f7eda5 100644 --- a/homeassistant/components/sensor/alpha_vantage.py +++ b/homeassistant/components/sensor/alpha_vantage.py @@ -31,25 +31,13 @@ CONF_SYMBOL = 'symbol' CONF_SYMBOLS = 'symbols' CONF_TO = 'to' -DEFAULT_SYMBOL = { - CONF_CURRENCY: 'USD', - CONF_NAME: 'Google', - CONF_SYMBOL: 'GOOGL', -} - -DEFAULT_CURRENCY = { - CONF_FROM: 'BTC', - CONF_NAME: 'Bitcon', - CONF_TO: 'USD', -} - ICONS = { 'BTC': 'mdi:currency-btc', 'EUR': 'mdi:currency-eur', 'GBP': 'mdi:currency-gbp', 'INR': 'mdi:currency-inr', 'RUB': 'mdi:currency-rub', - 'TRY': 'mdi: currency-try', + 'TRY': 'mdi:currency-try', 'USD': 'mdi:currency-usd', } @@ -69,9 +57,9 @@ CURRENCY_SCHEMA = vol.Schema({ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_FOREIGN_EXCHANGE, default=[DEFAULT_CURRENCY]): + vol.Optional(CONF_FOREIGN_EXCHANGE): vol.All(cv.ensure_list, [CURRENCY_SCHEMA]), - vol.Optional(CONF_SYMBOLS, default=[DEFAULT_SYMBOL]): + vol.Optional(CONF_SYMBOLS): vol.All(cv.ensure_list, [SYMBOL_SCHEMA]), }) @@ -83,6 +71,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): api_key = config.get(CONF_API_KEY) symbols = config.get(CONF_SYMBOLS) + conversions = config.get(CONF_FOREIGN_EXCHANGE) + + if not symbols and not conversions: + msg = 'Warning: No symbols or currencies configured.' + hass.components.persistent_notification.create( + msg, 'Sensor alpha_vantage') + _LOGGER.warning(msg) + return timeseries = TimeSeries(key=api_key) @@ -98,7 +94,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev.append(AlphaVantageSensor(timeseries, symbol)) forex = ForeignExchange(key=api_key) - for conversion in config.get(CONF_FOREIGN_EXCHANGE): + for conversion in conversions: from_cur = conversion.get(CONF_FROM) to_cur = conversion.get(CONF_TO) try: From f28fa7447ef4d7f746d2d53c9f66c28fcd3a8449 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Mon, 12 Feb 2018 05:23:16 +0200 Subject: [PATCH 026/173] Force LF line endings for Windows (#12266) * Force LF line endings for Windows * include all --- .gitattributes | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.gitattributes b/.gitattributes index 214efef6e4d..1e4e6c6a091 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,9 @@ # Ensure Docker script files uses LF to support Docker for Windows. -setup_docker_prereqs eol=lf -/virtualization/Docker/scripts/* eol=lf \ No newline at end of file +# Ensure "git config --global core.autocrlf input" before you clone +* text eol=lf +*.py whitespace=error + +*.ico binary +*.jpg binary +*.png binary +*.zip binary From 2e3524147cf1c20a7f043dfad94b9e52b359abb6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 11 Feb 2018 19:33:37 -0800 Subject: [PATCH 027/173] Remove unique ID from netatmo (#12317) * Remove unique ID from netatmo * Shame platform in error message --- homeassistant/components/binary_sensor/netatmo.py | 7 ------- homeassistant/components/camera/netatmo.py | 7 ------- homeassistant/components/sensor/netatmo.py | 6 ------ homeassistant/helpers/entity_platform.py | 6 +++++- 4 files changed, 5 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/binary_sensor/netatmo.py b/homeassistant/components/binary_sensor/netatmo.py index 4d8aaa7d0d9..dd7e0ee8d50 100644 --- a/homeassistant/components/binary_sensor/netatmo.py +++ b/homeassistant/components/binary_sensor/netatmo.py @@ -131,8 +131,6 @@ class NetatmoBinarySensor(BinarySensorDevice): self._name += ' / ' + module_name self._sensor_name = sensor self._name += ' ' + sensor - self._unique_id = data.camera_data.cameraByName( - camera=camera_name, home=home)['id'] self._cameratype = camera_type self._state = None @@ -141,11 +139,6 @@ class NetatmoBinarySensor(BinarySensorDevice): """Return the name of the Netatmo device and this sensor.""" return self._name - @property - def unique_id(self): - """Return the unique ID for this sensor.""" - return self._unique_id - @property def device_class(self): """Return the class of this sensor, from DEVICE_CLASSES.""" diff --git a/homeassistant/components/camera/netatmo.py b/homeassistant/components/camera/netatmo.py index 0a9a3fbdca4..48f2710ce2e 100644 --- a/homeassistant/components/camera/netatmo.py +++ b/homeassistant/components/camera/netatmo.py @@ -67,8 +67,6 @@ class NetatmoCamera(Camera): self._vpnurl, self._localurl = self._data.camera_data.cameraUrls( camera=camera_name ) - self._unique_id = data.camera_data.cameraByName( - camera=camera_name, home=home)['id'] self._cameratype = camera_type def camera_image(self): @@ -112,8 +110,3 @@ class NetatmoCamera(Camera): elif self._cameratype == "NACamera": return "Welcome" return None - - @property - def unique_id(self): - """Return the unique ID for this camera.""" - return self._unique_id diff --git a/homeassistant/components/sensor/netatmo.py b/homeassistant/components/sensor/netatmo.py index c20e0a59408..8ec6de60fb9 100644 --- a/homeassistant/components/sensor/netatmo.py +++ b/homeassistant/components/sensor/netatmo.py @@ -113,18 +113,12 @@ class NetAtmoSensor(Entity): module_id = self.netatmo_data.\ station_data.moduleByName(module=module_name)['_id'] self.module_id = module_id[1] - self._unique_id = '{}-{}'.format(self.module_id, self.type) @property def name(self): """Return the name of the sensor.""" return self._name - @property - def unique_id(self): - """Return the unique ID for this sensor.""" - return self._unique_id - @property def icon(self): """Icon to use in the frontend, if any.""" diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 5c1d437c7cf..9035973e015 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -240,8 +240,12 @@ class EntityPlatform(object): raise HomeAssistantError( 'Invalid entity id: {}'.format(entity.entity_id)) elif entity.entity_id in component_entities: + msg = 'Entity id already exists: {}'.format(entity.entity_id) + if entity.unique_id is not None: + msg += '. Platform {} does not generate unique IDs'.format( + self.platform_name) raise HomeAssistantError( - 'Entity id already exists: {}'.format(entity.entity_id)) + msg) self.entities[entity.entity_id] = entity component_entities.add(entity.entity_id) From c193d80ec5393f9f343a9ac0e69585c385ec0cfa Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sun, 11 Feb 2018 20:55:51 -0700 Subject: [PATCH 028/173] Updated RainMachine to play better with the entity registry (#12315) * Updated RainMachine to play better with the entity registry * Owner-requested changes --- .../components/switch/rainmachine.py | 131 ++++++++---------- 1 file changed, 60 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/switch/rainmachine.py b/homeassistant/components/switch/rainmachine.py index 3147ded96bd..99d41bdd9c3 100644 --- a/homeassistant/components/switch/rainmachine.py +++ b/homeassistant/components/switch/rainmachine.py @@ -32,22 +32,16 @@ PLATFORM_SCHEMA = vol.Schema( vol.All( cv.has_at_least_one_key(CONF_IP_ADDRESS, CONF_EMAIL), { - vol.Required(CONF_PLATFORM): - cv.string, - vol.Optional(CONF_SCAN_INTERVAL): - cv.time_period, - vol.Exclusive(CONF_IP_ADDRESS, 'auth'): - cv.string, + vol.Required(CONF_PLATFORM): cv.string, + vol.Optional(CONF_SCAN_INTERVAL): cv.time_period, + vol.Exclusive(CONF_IP_ADDRESS, 'auth'): cv.string, vol.Exclusive(CONF_EMAIL, 'auth'): - vol.Email(), # pylint: disable=no-value-for-parameter - vol.Required(CONF_PASSWORD): - cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): - cv.port, - vol.Optional(CONF_SSL, default=DEFAULT_SSL): - cv.boolean, + vol.Email(), # pylint: disable=no-value-for-parameter + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, vol.Optional(CONF_ZONE_RUN_TIME, default=DEFAULT_ZONE_RUN_SECONDS): - cv.positive_int + cv.positive_int }), extra=vol.ALLOW_EXTRA) @@ -56,27 +50,19 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set this component up under its platform.""" import regenmaschine as rm - ip_address = config.get(CONF_IP_ADDRESS) - _LOGGER.debug('IP address: %s', ip_address) + _LOGGER.debug('Config data: %s', config) - email_address = config.get(CONF_EMAIL) - _LOGGER.debug('Email address: %s', email_address) - - password = config.get(CONF_PASSWORD) - _LOGGER.debug('Password: %s', password) - - zone_run_time = config.get(CONF_ZONE_RUN_TIME) - _LOGGER.debug('Zone run time: %s', zone_run_time) + ip_address = config.get(CONF_IP_ADDRESS, None) + email_address = config.get(CONF_EMAIL, None) + password = config[CONF_PASSWORD] + zone_run_time = config[CONF_ZONE_RUN_TIME] try: if ip_address: - port = config.get(CONF_PORT) - _LOGGER.debug('Port: %s', port) - - ssl = config.get(CONF_SSL) - _LOGGER.debug('SSL: %s', ssl) - _LOGGER.debug('Configuring local API') + + port = config[CONF_PORT] + ssl = config[CONF_SSL] auth = rm.Authenticator.create_local( ip_address, password, port=port, https=ssl) elif email_address: @@ -85,32 +71,27 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.debug('Querying against: %s', auth.url) - _LOGGER.debug('Instantiating RainMachine client') client = rm.Client(auth) - - rainmachine_device_name = client.provision.device_name().get('name') + device_name = client.provision.device_name()['name'] + device_mac = client.provision.wifi()['macAddress'] entities = [] - for program in client.programs.all().get('programs'): + for program in client.programs.all().get('programs', {}): if not program.get('active'): continue _LOGGER.debug('Adding program: %s', program) entities.append( - RainMachineProgram( - client, program, device_name=rainmachine_device_name)) + RainMachineProgram(client, device_name, device_mac, program)) - for zone in client.zones.all().get('zones'): + for zone in client.zones.all().get('zones', {}): if not zone.get('active'): continue _LOGGER.debug('Adding zone: %s', zone) entities.append( - RainMachineZone( - client, - zone, - zone_run_time, - device_name=rainmachine_device_name, )) + RainMachineZone(client, device_name, device_mac, zone, + zone_run_time)) add_devices(entities) except rm.exceptions.HTTPError as exc_info: @@ -149,16 +130,17 @@ def aware_throttle(api_type): class RainMachineEntity(SwitchDevice): """A class to represent a generic RainMachine entity.""" - def __init__(self, client, entity_json, **kwargs): + def __init__(self, client, device_name, device_mac, entity_json): """Initialize a generic RainMachine entity.""" self._api_type = 'remote' if client.auth.using_remote_api else 'local' self._client = client - self._device_name = kwargs.get('device_name') self._entity_json = entity_json + self.device_mac = device_mac + self.device_name = device_name self._attrs = { ATTR_ATTRIBUTION: '© RainMachine', - ATTR_DEVICE_CLASS: self._device_name + ATTR_DEVICE_CLASS: self.device_name } @property @@ -173,15 +155,10 @@ class RainMachineEntity(SwitchDevice): return self._entity_json.get('active') @property - def rainmachine_id(self) -> int: + def rainmachine_entity_id(self) -> int: """Return the RainMachine ID for this entity.""" return self._entity_json.get('uid') - @property - def unique_id(self) -> str: - """Return a unique, HASS-friendly identifier for this entity.""" - return self.rainmachine_id - @aware_throttle('local') def _local_update(self) -> None: """Call an update with scan times appropriate for the local API.""" @@ -217,17 +194,22 @@ class RainMachineProgram(RainMachineEntity): """Return the name of the program.""" return 'Program: {}'.format(self._entity_json.get('name')) + @property + def unique_id(self) -> str: + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_program_{1}'.format( + self.device_mac.replace(':', ''), self.rainmachine_entity_id) + def turn_off(self, **kwargs) -> None: """Turn the program off.""" import regenmaschine.exceptions as exceptions try: - self._client.programs.stop(self.rainmachine_id) + self._client.programs.stop(self.rainmachine_entity_id) except exceptions.BrokenAPICall: _LOGGER.error('programs.stop currently broken in remote API') except exceptions.HTTPError as exc_info: - _LOGGER.error('Unable to turn off program "%s"', - self.rainmachine_id) + _LOGGER.error('Unable to turn off program "%s"', self.unique_id) _LOGGER.debug(exc_info) def turn_on(self, **kwargs) -> None: @@ -235,12 +217,11 @@ class RainMachineProgram(RainMachineEntity): import regenmaschine.exceptions as exceptions try: - self._client.programs.start(self.rainmachine_id) + self._client.programs.start(self.rainmachine_entity_id) except exceptions.BrokenAPICall: _LOGGER.error('programs.start currently broken in remote API') except exceptions.HTTPError as exc_info: - _LOGGER.error('Unable to turn on program "%s"', - self.rainmachine_id) + _LOGGER.error('Unable to turn on program "%s"', self.unique_id) _LOGGER.debug(exc_info) def _update(self) -> None: @@ -248,25 +229,25 @@ class RainMachineProgram(RainMachineEntity): import regenmaschine.exceptions as exceptions try: - self._entity_json = self._client.programs.get(self.rainmachine_id) + self._entity_json = self._client.programs.get( + self.rainmachine_entity_id) except exceptions.HTTPError as exc_info: _LOGGER.error('Unable to update info for program "%s"', - self.rainmachine_id) + self.unique_id) _LOGGER.debug(exc_info) class RainMachineZone(RainMachineEntity): """A RainMachine zone.""" - def __init__(self, client, zone_json, zone_run_time, **kwargs): + def __init__(self, client, device_name, device_mac, zone_json, + zone_run_time): """Initialize a RainMachine zone.""" - super().__init__(client, zone_json, **kwargs) + super().__init__(client, device_name, device_mac, zone_json) self._run_time = zone_run_time self._attrs.update({ - ATTR_CYCLES: - self._entity_json.get('noOfCycles'), - ATTR_TOTAL_DURATION: - self._entity_json.get('userDuration') + ATTR_CYCLES: self._entity_json.get('noOfCycles'), + ATTR_TOTAL_DURATION: self._entity_json.get('userDuration') }) @property @@ -279,14 +260,20 @@ class RainMachineZone(RainMachineEntity): """Return the name of the zone.""" return 'Zone: {}'.format(self._entity_json.get('name')) + @property + def unique_id(self) -> str: + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_zone_{1}'.format( + self.device_mac.replace(':', ''), self.rainmachine_entity_id) + def turn_off(self, **kwargs) -> None: """Turn the zone off.""" import regenmaschine.exceptions as exceptions try: - self._client.zones.stop(self.rainmachine_id) + self._client.zones.stop(self.rainmachine_entity_id) except exceptions.HTTPError as exc_info: - _LOGGER.error('Unable to turn off zone "%s"', self.rainmachine_id) + _LOGGER.error('Unable to turn off zone "%s"', self.unique_id) _LOGGER.debug(exc_info) def turn_on(self, **kwargs) -> None: @@ -294,9 +281,10 @@ class RainMachineZone(RainMachineEntity): import regenmaschine.exceptions as exceptions try: - self._client.zones.start(self.rainmachine_id, self._run_time) + self._client.zones.start(self.rainmachine_entity_id, + self._run_time) except exceptions.HTTPError as exc_info: - _LOGGER.error('Unable to turn on zone "%s"', self.rainmachine_id) + _LOGGER.error('Unable to turn on zone "%s"', self.unique_id) _LOGGER.debug(exc_info) def _update(self) -> None: @@ -304,8 +292,9 @@ class RainMachineZone(RainMachineEntity): import regenmaschine.exceptions as exceptions try: - self._entity_json = self._client.zones.get(self.rainmachine_id) + self._entity_json = self._client.zones.get( + self.rainmachine_entity_id) except exceptions.HTTPError as exc_info: _LOGGER.error('Unable to update info for zone "%s"', - self.rainmachine_id) + self.unique_id) _LOGGER.debug(exc_info) From 7e9dcfa4c95bb80db859be9114c066bb926e066c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 11 Feb 2018 20:33:08 -0800 Subject: [PATCH 029/173] Revert #12316 (#12329) --- homeassistant/components/binary_sensor/bloomsky.py | 4 ---- homeassistant/components/camera/bloomsky.py | 4 ---- homeassistant/components/sensor/bloomsky.py | 4 ---- 3 files changed, 12 deletions(-) diff --git a/homeassistant/components/binary_sensor/bloomsky.py b/homeassistant/components/binary_sensor/bloomsky.py index add78864394..1d0849b255e 100644 --- a/homeassistant/components/binary_sensor/bloomsky.py +++ b/homeassistant/components/binary_sensor/bloomsky.py @@ -31,10 +31,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the available BloomSky weather binary sensors.""" - # Protect against people having setup the bloomsky platforms - if discovery_info is None: - return - bloomsky = get_component('bloomsky') # Default needed in case of discovery sensors = config.get(CONF_MONITORED_CONDITIONS, SENSOR_TYPES) diff --git a/homeassistant/components/camera/bloomsky.py b/homeassistant/components/camera/bloomsky.py index 49240354b64..c3b4775b593 100644 --- a/homeassistant/components/camera/bloomsky.py +++ b/homeassistant/components/camera/bloomsky.py @@ -17,10 +17,6 @@ DEPENDENCIES = ['bloomsky'] # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up access to BloomSky cameras.""" - # Protect against people having setup the bloomsky platforms - if discovery_info is None: - return - bloomsky = get_component('bloomsky') for device in bloomsky.BLOOMSKY.devices.values(): add_devices([BloomSkyCamera(bloomsky.BLOOMSKY, device)]) diff --git a/homeassistant/components/sensor/bloomsky.py b/homeassistant/components/sensor/bloomsky.py index f0d7c059b4a..ce44abdb087 100644 --- a/homeassistant/components/sensor/bloomsky.py +++ b/homeassistant/components/sensor/bloomsky.py @@ -45,10 +45,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the available BloomSky weather sensors.""" - # Protect against people having setup the bloomsky platforms - if discovery_info is None: - return - bloomsky = get_component('bloomsky') # Default needed in case of discovery sensors = config.get(CONF_MONITORED_CONDITIONS, SENSOR_TYPES) From 3b3050434aa4a278739955b66f09078b01a16b01 Mon Sep 17 00:00:00 2001 From: Russell Cloran Date: Sun, 11 Feb 2018 20:34:19 -0800 Subject: [PATCH 030/173] zha: Add remove service (#11683) * zha: Add remove service * Lint --- homeassistant/components/zha/__init__.py | 37 +++++++++++++++++++--- homeassistant/components/zha/services.yaml | 7 ++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 3729ce8a153..71a04338023 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -5,6 +5,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/zha/ """ import asyncio +import collections import enum import logging @@ -55,13 +56,18 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) ATTR_DURATION = 'duration' +ATTR_IEEE = 'ieee_address' SERVICE_PERMIT = 'permit' +SERVICE_REMOVE = 'remove' SERVICE_SCHEMAS = { SERVICE_PERMIT: vol.Schema({ vol.Optional(ATTR_DURATION, default=60): vol.All(vol.Coerce(int), vol.Range(1, 254)), }), + SERVICE_REMOVE: vol.Schema({ + vol.Required(ATTR_IEEE): cv.string, + }), } @@ -116,6 +122,18 @@ def async_setup(hass, config): hass.services.async_register(DOMAIN, SERVICE_PERMIT, permit, schema=SERVICE_SCHEMAS[SERVICE_PERMIT]) + @asyncio.coroutine + def remove(service): + """Remove a node from the network.""" + from bellows.types import EmberEUI64, uint8_t + ieee = service.data.get(ATTR_IEEE) + ieee = EmberEUI64([uint8_t(p, base=16) for p in ieee.split(':')]) + _LOGGER.info("Removing node %s", ieee) + yield from APPLICATION_CONTROLLER.remove(ieee) + + hass.services.async_register(DOMAIN, SERVICE_REMOVE, remove, + schema=SERVICE_SCHEMAS[SERVICE_REMOVE]) + return True @@ -126,6 +144,7 @@ class ApplicationListener: """Initialize the listener.""" self._hass = hass self._config = config + self._device_registry = collections.defaultdict(list) hass.data[DISCOVERY_KEY] = hass.data.get(DISCOVERY_KEY, {}) def device_joined(self, device): @@ -147,7 +166,8 @@ class ApplicationListener: def device_removed(self, device): """Handle device being removed from the network.""" - pass + for device_entity in self._device_registry[device.ieee]: + self._hass.async_add_job(device_entity.async_remove()) @asyncio.coroutine def async_device_initialized(self, device, join): @@ -189,6 +209,7 @@ class ApplicationListener: for c in profile_clusters[1] if c in endpoint.out_clusters] discovery_info = { + 'application_listener': self, 'endpoint': endpoint, 'in_clusters': {c.cluster_id: c for c in in_clusters}, 'out_clusters': {c.cluster_id: c for c in out_clusters}, @@ -214,6 +235,7 @@ class ApplicationListener: component = zha_const.SINGLE_CLUSTER_DEVICE_CLASS[cluster_type] discovery_info = { + 'application_listener': self, 'endpoint': endpoint, 'in_clusters': {cluster.cluster_id: cluster}, 'out_clusters': {}, @@ -231,6 +253,10 @@ class ApplicationListener: self._config, ) + def register_entity(self, ieee, entity_obj): + """Record the creation of a hass entity associated with ieee.""" + self._device_registry[ieee].append(entity_obj) + class Entity(entity.Entity): """A base class for ZHA entities.""" @@ -238,12 +264,11 @@ class Entity(entity.Entity): _domain = None # Must be overridden by subclasses def __init__(self, endpoint, in_clusters, out_clusters, manufacturer, - model, **kwargs): + model, application_listener, **kwargs): """Init ZHA entity.""" self._device_state_attributes = {} - ieeetail = ''.join([ - '%02x' % (o, ) for o in endpoint.device.ieee[-4:] - ]) + ieee = endpoint.device.ieee + ieeetail = ''.join(['%02x' % (o, ) for o in ieee[-4:]]) if manufacturer and model is not None: self.entity_id = '%s.%s_%s_%s_%s' % ( self._domain, @@ -271,6 +296,8 @@ class Entity(entity.Entity): self._out_clusters = out_clusters self._state = ha_const.STATE_UNKNOWN + application_listener.register_entity(ieee, self) + def attribute_updated(self, attribute, value): """Handle an attribute updated on this cluster.""" pass diff --git a/homeassistant/components/zha/services.yaml b/homeassistant/components/zha/services.yaml index a9ad0e7a1ca..4b1122b8167 100644 --- a/homeassistant/components/zha/services.yaml +++ b/homeassistant/components/zha/services.yaml @@ -6,3 +6,10 @@ permit: duration: description: Time to permit joins, in seconds example: 60 + +remove: + description: Remove a node from the ZigBee network. + fields: + ieee_address: + description: IEEE address of the node to remove + example: "00:0d:6f:00:05:7d:2d:34" From eb7adc74ef91f032f4948b7075bdd819ffbb0b51 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 11 Feb 2018 20:55:38 -0800 Subject: [PATCH 031/173] Respect entity namespace for entity registry (#12313) * Respect entity namespace for entity registry * Lint --- homeassistant/helpers/entity_platform.py | 4 +++ tests/helpers/test_entity_platform.py | 36 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 9035973e015..6cf58212c8e 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -209,6 +209,10 @@ class EntityPlatform(object): else: suggested_object_id = entity.name + if self.entity_namespace is not None: + suggested_object_id = '{} {}'.format( + self.entity_namespace, suggested_object_id) + entry = registry.async_get_or_create( self.domain, self.platform_name, entity.unique_id, suggested_object_id=suggested_object_id) diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index e398349cf7a..a54a6de511a 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -21,6 +21,32 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "test_domain" +class MockEntityPlatform(entity_platform.EntityPlatform): + """Mock class with some mock defaults.""" + + def __init__( + self, *, hass, + logger=None, + domain='test', + platform_name='test_platform', + scan_interval=timedelta(seconds=15), + parallel_updates=0, + entity_namespace=None, + async_entities_added_callback=lambda: None + ): + """Initialize a mock entity platform.""" + super().__init__( + hass=hass, + logger=logger, + domain=domain, + platform_name=platform_name, + scan_interval=scan_interval, + parallel_updates=parallel_updates, + entity_namespace=entity_namespace, + async_entities_added_callback=async_entities_added_callback, + ) + + class TestHelpersEntityPlatform(unittest.TestCase): """Test homeassistant.helpers.entity_component module.""" @@ -454,3 +480,13 @@ def test_overriding_name_from_registry(hass): state = hass.states.get('test_domain.world') assert state is not None assert state.name == 'Overridden' + + +@asyncio.coroutine +def test_registry_respect_entity_namespace(hass): + """Test that the registry respects entity namespace.""" + mock_registry(hass) + platform = MockEntityPlatform(hass=hass, entity_namespace='ns') + entity = MockEntity(unique_id='1234', name='Device Name') + yield from platform.async_add_entities([entity]) + assert entity.entity_id == 'test.ns_device_name' From 669929de0662ce6badd9b6a9bf57409211254e3c Mon Sep 17 00:00:00 2001 From: Richard Lucas Date: Sun, 11 Feb 2018 22:36:22 -0800 Subject: [PATCH 032/173] Fix Report State for Alexa Brightness Controller (#12318) * Fix Report State for Alexa Brightness Controller * Lint --- homeassistant/components/alexa/smart_home.py | 5 +++-- tests/components/alexa/test_smart_home.py | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index e9b1b845d3d..c0a42ef8aa6 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -328,8 +328,9 @@ class _AlexaBrightnessController(_AlexaInterface): def get_property(self, name): if name != 'brightness': raise _UnsupportedProperty(name) - - return round(self.entity.attributes['brightness'] / 255.0 * 100) + if 'brightness' in self.entity.attributes: + return round(self.entity.attributes['brightness'] / 255.0 * 100) + return 0 class _AlexaColorController(_AlexaInterface): diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 487ff301c18..96e16544438 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1095,6 +1095,23 @@ def test_report_lock_state(hass): properties.assert_equal('Alexa.LockController', 'lockState', 'JAMMED') +@asyncio.coroutine +def test_report_dimmable_light_state(hass): + """Test BrightnessController reports brightness correctly.""" + hass.states.async_set( + 'light.test_on', 'on', {'friendly_name': "Test light On", + 'brightness': 128, 'supported_features': 1}) + hass.states.async_set( + 'light.test_off', 'off', {'friendly_name': "Test light Off", + 'supported_features': 1}) + + properties = yield from reported_properties(hass, 'light.test_on') + properties.assert_equal('Alexa.BrightnessController', 'brightness', 50) + + properties = yield from reported_properties(hass, 'light.test_off') + properties.assert_equal('Alexa.BrightnessController', 'brightness', 0) + + @asyncio.coroutine def reported_properties(hass, endpoint): """Use ReportState to get properties and return them. @@ -1118,7 +1135,7 @@ class _ReportedProperties(object): for prop in self.properties: if prop['namespace'] == namespace and prop['name'] == name: assert prop['value'] == value - return prop + return prop assert False, 'property %s:%s not in %r' % ( namespace, From 5d15b257c43d9295171ac87ca66717bccc33d305 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 11 Feb 2018 23:07:28 -0800 Subject: [PATCH 033/173] Fix line endings [skipci] (#12333) --- .gitattributes | 1 + .../alarm_control_panel/services.yaml | 142 +- homeassistant/components/cover/services.yaml | 126 +- tests/components/light/test_mqtt_json.py | 1194 ++++++++--------- tests/components/light/test_mqtt_template.py | 996 +++++++------- tests/fixtures/pushbullet_devices.json | 86 +- tests/fixtures/yahooweather.json | 276 ++-- 7 files changed, 1411 insertions(+), 1410 deletions(-) diff --git a/.gitattributes b/.gitattributes index 1e4e6c6a091..caff2fc5c1f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,3 +7,4 @@ *.jpg binary *.png binary *.zip binary +*.mp3 binary diff --git a/homeassistant/components/alarm_control_panel/services.yaml b/homeassistant/components/alarm_control_panel/services.yaml index bfd38c902d0..72784c8178c 100644 --- a/homeassistant/components/alarm_control_panel/services.yaml +++ b/homeassistant/components/alarm_control_panel/services.yaml @@ -1,71 +1,71 @@ -# Describes the format for available alarm control panel services - -alarm_disarm: - description: Send the alarm the command for disarm. - fields: - entity_id: - description: Name of alarm control panel to disarm. - example: 'alarm_control_panel.downstairs' - code: - description: An optional code to disarm the alarm control panel with. - example: 1234 - -alarm_arm_home: - description: Send the alarm the command for arm home. - fields: - entity_id: - description: Name of alarm control panel to arm home. - example: 'alarm_control_panel.downstairs' - code: - description: An optional code to arm home the alarm control panel with. - example: 1234 - -alarm_arm_away: - description: Send the alarm the command for arm away. - fields: - entity_id: - description: Name of alarm control panel to arm away. - example: 'alarm_control_panel.downstairs' - code: - description: An optional code to arm away the alarm control panel with. - example: 1234 - -alarm_arm_night: - description: Send the alarm the command for arm night. - fields: - entity_id: - description: Name of alarm control panel to arm night. - example: 'alarm_control_panel.downstairs' - code: - description: An optional code to arm night the alarm control panel with. - example: 1234 - -alarm_trigger: - description: Send the alarm the command for trigger. - fields: - entity_id: - description: Name of alarm control panel to trigger. - example: 'alarm_control_panel.downstairs' - code: - description: An optional code to trigger the alarm control panel with. - example: 1234 - -envisalink_alarm_keypress: - description: Send custom keypresses to the alarm. - fields: - entity_id: - description: Name of the alarm control panel to trigger. - example: 'alarm_control_panel.downstairs' - keypress: - description: 'String to send to the alarm panel (1-6 characters).' - example: '*71' - -alarmdecoder_alarm_toggle_chime: - description: Send the alarm the toggle chime command. - fields: - entity_id: - description: Name of the alarm control panel to trigger. - example: 'alarm_control_panel.downstairs' - code: - description: A required code to toggle the alarm control panel chime with. - example: 1234 +# Describes the format for available alarm control panel services + +alarm_disarm: + description: Send the alarm the command for disarm. + fields: + entity_id: + description: Name of alarm control panel to disarm. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to disarm the alarm control panel with. + example: 1234 + +alarm_arm_home: + description: Send the alarm the command for arm home. + fields: + entity_id: + description: Name of alarm control panel to arm home. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to arm home the alarm control panel with. + example: 1234 + +alarm_arm_away: + description: Send the alarm the command for arm away. + fields: + entity_id: + description: Name of alarm control panel to arm away. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to arm away the alarm control panel with. + example: 1234 + +alarm_arm_night: + description: Send the alarm the command for arm night. + fields: + entity_id: + description: Name of alarm control panel to arm night. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to arm night the alarm control panel with. + example: 1234 + +alarm_trigger: + description: Send the alarm the command for trigger. + fields: + entity_id: + description: Name of alarm control panel to trigger. + example: 'alarm_control_panel.downstairs' + code: + description: An optional code to trigger the alarm control panel with. + example: 1234 + +envisalink_alarm_keypress: + description: Send custom keypresses to the alarm. + fields: + entity_id: + description: Name of the alarm control panel to trigger. + example: 'alarm_control_panel.downstairs' + keypress: + description: 'String to send to the alarm panel (1-6 characters).' + example: '*71' + +alarmdecoder_alarm_toggle_chime: + description: Send the alarm the toggle chime command. + fields: + entity_id: + description: Name of the alarm control panel to trigger. + example: 'alarm_control_panel.downstairs' + code: + description: A required code to toggle the alarm control panel chime with. + example: 1234 diff --git a/homeassistant/components/cover/services.yaml b/homeassistant/components/cover/services.yaml index 1a3e020ed87..79f00180a89 100644 --- a/homeassistant/components/cover/services.yaml +++ b/homeassistant/components/cover/services.yaml @@ -1,63 +1,63 @@ -# Describes the format for available cover services - -open_cover: - description: Open all or specified cover. - fields: - entity_id: - description: Name(s) of cover(s) to open. - example: 'cover.living_room' - -close_cover: - description: Close all or specified cover. - fields: - entity_id: - description: Name(s) of cover(s) to close. - example: 'cover.living_room' - -set_cover_position: - description: Move to specific position all or specified cover. - fields: - entity_id: - description: Name(s) of cover(s) to set cover position. - example: 'cover.living_room' - position: - description: Position of the cover (0 to 100). - example: 30 - -stop_cover: - description: Stop all or specified cover. - fields: - entity_id: - description: Name(s) of cover(s) to stop. - example: 'cover.living_room' - -open_cover_tilt: - description: Open all or specified cover tilt. - fields: - entity_id: - description: Name(s) of cover(s) tilt to open. - example: 'cover.living_room' - -close_cover_tilt: - description: Close all or specified cover tilt. - fields: - entity_id: - description: Name(s) of cover(s) to close tilt. - example: 'cover.living_room' - -set_cover_tilt_position: - description: Move to specific position all or specified cover tilt. - fields: - entity_id: - description: Name(s) of cover(s) to set cover tilt position. - example: 'cover.living_room' - tilt_position: - description: Tilt position of the cover (0 to 100). - example: 30 - -stop_cover_tilt: - description: Stop all or specified cover. - fields: - entity_id: - description: Name(s) of cover(s) to stop. - example: 'cover.living_room' +# Describes the format for available cover services + +open_cover: + description: Open all or specified cover. + fields: + entity_id: + description: Name(s) of cover(s) to open. + example: 'cover.living_room' + +close_cover: + description: Close all or specified cover. + fields: + entity_id: + description: Name(s) of cover(s) to close. + example: 'cover.living_room' + +set_cover_position: + description: Move to specific position all or specified cover. + fields: + entity_id: + description: Name(s) of cover(s) to set cover position. + example: 'cover.living_room' + position: + description: Position of the cover (0 to 100). + example: 30 + +stop_cover: + description: Stop all or specified cover. + fields: + entity_id: + description: Name(s) of cover(s) to stop. + example: 'cover.living_room' + +open_cover_tilt: + description: Open all or specified cover tilt. + fields: + entity_id: + description: Name(s) of cover(s) tilt to open. + example: 'cover.living_room' + +close_cover_tilt: + description: Close all or specified cover tilt. + fields: + entity_id: + description: Name(s) of cover(s) to close tilt. + example: 'cover.living_room' + +set_cover_tilt_position: + description: Move to specific position all or specified cover tilt. + fields: + entity_id: + description: Name(s) of cover(s) to set cover tilt position. + example: 'cover.living_room' + tilt_position: + description: Tilt position of the cover (0 to 100). + example: 30 + +stop_cover_tilt: + description: Stop all or specified cover. + fields: + entity_id: + description: Name(s) of cover(s) to stop. + example: 'cover.living_room' diff --git a/tests/components/light/test_mqtt_json.py b/tests/components/light/test_mqtt_json.py index d7eb80980ca..ba306a81a34 100644 --- a/tests/components/light/test_mqtt_json.py +++ b/tests/components/light/test_mqtt_json.py @@ -1,597 +1,597 @@ -"""The tests for the MQTT JSON light platform. - -Configuration with RGB, brightness, color temp, effect, white value and XY: - -light: - platform: mqtt_json - name: mqtt_json_light_1 - state_topic: "home/rgb1" - command_topic: "home/rgb1/set" - brightness: true - color_temp: true - effect: true - rgb: true - white_value: true - xy: true - -Configuration with RGB, brightness, color temp, effect, white value: - -light: - platform: mqtt_json - name: mqtt_json_light_1 - state_topic: "home/rgb1" - command_topic: "home/rgb1/set" - brightness: true - color_temp: true - effect: true - rgb: true - white_value: true - -Configuration with RGB, brightness, color temp and effect: - -light: - platform: mqtt_json - name: mqtt_json_light_1 - state_topic: "home/rgb1" - command_topic: "home/rgb1/set" - brightness: true - color_temp: true - effect: true - rgb: true - -Configuration with RGB, brightness and color temp: - -light: - platform: mqtt_json - name: mqtt_json_light_1 - state_topic: "home/rgb1" - command_topic: "home/rgb1/set" - brightness: true - rgb: true - color_temp: true - -Configuration with RGB, brightness: - -light: - platform: mqtt_json - name: mqtt_json_light_1 - state_topic: "home/rgb1" - command_topic: "home/rgb1/set" - brightness: true - rgb: true - -Config without RGB: - -light: - platform: mqtt_json - name: mqtt_json_light_1 - state_topic: "home/rgb1" - command_topic: "home/rgb1/set" - brightness: true - -Config without RGB and brightness: - -light: - platform: mqtt_json - name: mqtt_json_light_1 - state_topic: "home/rgb1" - command_topic: "home/rgb1/set" - -Config with brightness and scale: - -light: - platform: mqtt_json - name: test - state_topic: "mqtt_json_light_1" - command_topic: "mqtt_json_light_1/set" - brightness: true - brightness_scale: 99 -""" - -import json -import unittest - -from homeassistant.setup import setup_component -from homeassistant.const import ( - STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE, - ATTR_SUPPORTED_FEATURES) -import homeassistant.components.light as light -from tests.common import ( - get_test_home_assistant, mock_mqtt_component, fire_mqtt_message, - assert_setup_component) - - -class TestLightMQTTJSON(unittest.TestCase): - """Test the MQTT JSON light.""" - - def setUp(self): # pylint: disable=invalid-name - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.mock_publish = mock_mqtt_component(self.hass) - - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that was started.""" - self.hass.stop() - - def test_fail_setup_if_no_command_topic(self): \ - # pylint: disable=invalid-name - """Test if setup fails with no command topic.""" - with assert_setup_component(0, light.DOMAIN): - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_json', - 'name': 'test', - } - }) - self.assertIsNone(self.hass.states.get('light.test')) - - def test_no_color_brightness_color_temp_white_val_if_no_topics(self): \ - # pylint: disable=invalid-name - """Test for no RGB, brightness, color temp, effect, white val or XY.""" - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_json', - 'name': 'test', - 'state_topic': 'test_light_rgb', - 'command_topic': 'test_light_rgb/set', - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - self.assertEqual(40, state.attributes.get(ATTR_SUPPORTED_FEATURES)) - self.assertIsNone(state.attributes.get('rgb_color')) - self.assertIsNone(state.attributes.get('brightness')) - self.assertIsNone(state.attributes.get('color_temp')) - self.assertIsNone(state.attributes.get('effect')) - self.assertIsNone(state.attributes.get('white_value')) - self.assertIsNone(state.attributes.get('xy_color')) - - fire_mqtt_message(self.hass, 'test_light_rgb', '{"state":"ON"}') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertIsNone(state.attributes.get('rgb_color')) - self.assertIsNone(state.attributes.get('brightness')) - self.assertIsNone(state.attributes.get('color_temp')) - self.assertIsNone(state.attributes.get('effect')) - self.assertIsNone(state.attributes.get('white_value')) - self.assertIsNone(state.attributes.get('xy_color')) - - def test_controlling_state_via_topic(self): \ - # pylint: disable=invalid-name - """Test the controlling of the state via topic.""" - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_json', - 'name': 'test', - 'state_topic': 'test_light_rgb', - 'command_topic': 'test_light_rgb/set', - 'brightness': True, - 'color_temp': True, - 'effect': True, - 'rgb': True, - 'white_value': True, - 'xy': True, - 'qos': '0' - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - self.assertEqual(255, state.attributes.get(ATTR_SUPPORTED_FEATURES)) - self.assertIsNone(state.attributes.get('rgb_color')) - self.assertIsNone(state.attributes.get('brightness')) - self.assertIsNone(state.attributes.get('color_temp')) - self.assertIsNone(state.attributes.get('effect')) - self.assertIsNone(state.attributes.get('white_value')) - self.assertIsNone(state.attributes.get('xy_color')) - self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) - - # Turn on the light, full white - fire_mqtt_message(self.hass, 'test_light_rgb', - '{"state":"ON",' - '"color":{"r":255,"g":255,"b":255,' - '"x":0.123,"y":0.123},' - '"brightness":255,' - '"color_temp":155,' - '"effect":"colorloop",' - '"white_value":150}') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) - self.assertEqual(255, state.attributes.get('brightness')) - self.assertEqual(155, state.attributes.get('color_temp')) - self.assertEqual('colorloop', state.attributes.get('effect')) - self.assertEqual(150, state.attributes.get('white_value')) - self.assertEqual([0.123, 0.123], state.attributes.get('xy_color')) - - # Turn the light off - fire_mqtt_message(self.hass, 'test_light_rgb', '{"state":"OFF"}') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - - fire_mqtt_message(self.hass, 'test_light_rgb', - '{"state":"ON",' - '"brightness":100}') - self.hass.block_till_done() - - light_state = self.hass.states.get('light.test') - self.hass.block_till_done() - self.assertEqual(100, - light_state.attributes['brightness']) - - fire_mqtt_message(self.hass, 'test_light_rgb', - '{"state":"ON",' - '"color":{"r":125,"g":125,"b":125}}') - self.hass.block_till_done() - - light_state = self.hass.states.get('light.test') - self.assertEqual([125, 125, 125], - light_state.attributes.get('rgb_color')) - - fire_mqtt_message(self.hass, 'test_light_rgb', - '{"state":"ON",' - '"color":{"x":0.135,"y":0.135}}') - self.hass.block_till_done() - - light_state = self.hass.states.get('light.test') - self.assertEqual([0.135, 0.135], - light_state.attributes.get('xy_color')) - - fire_mqtt_message(self.hass, 'test_light_rgb', - '{"state":"ON",' - '"color_temp":155}') - self.hass.block_till_done() - - light_state = self.hass.states.get('light.test') - self.assertEqual(155, light_state.attributes.get('color_temp')) - - fire_mqtt_message(self.hass, 'test_light_rgb', - '{"state":"ON",' - '"effect":"colorloop"}') - self.hass.block_till_done() - - light_state = self.hass.states.get('light.test') - self.assertEqual('colorloop', light_state.attributes.get('effect')) - - fire_mqtt_message(self.hass, 'test_light_rgb', - '{"state":"ON",' - '"white_value":155}') - self.hass.block_till_done() - - light_state = self.hass.states.get('light.test') - self.assertEqual(155, light_state.attributes.get('white_value')) - - def test_sending_mqtt_commands_and_optimistic(self): \ - # pylint: disable=invalid-name - """Test the sending of command in optimistic mode.""" - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_json', - 'name': 'test', - 'command_topic': 'test_light_rgb/set', - 'brightness': True, - 'color_temp': True, - 'effect': True, - 'rgb': True, - 'white_value': True, - 'qos': 2 - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - self.assertEqual(191, state.attributes.get(ATTR_SUPPORTED_FEATURES)) - self.assertTrue(state.attributes.get(ATTR_ASSUMED_STATE)) - - light.turn_on(self.hass, 'light.test') - self.hass.block_till_done() - - self.mock_publish.async_publish.assert_called_once_with( - 'test_light_rgb/set', '{"state": "ON"}', 2, False) - self.mock_publish.async_publish.reset_mock() - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - - light.turn_off(self.hass, 'light.test') - self.hass.block_till_done() - - self.mock_publish.async_publish.assert_called_once_with( - 'test_light_rgb/set', '{"state": "OFF"}', 2, False) - self.mock_publish.async_publish.reset_mock() - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - - light.turn_on(self.hass, 'light.test', - brightness=50, color_temp=155, effect='colorloop', - white_value=170) - self.hass.block_till_done() - - self.assertEqual('test_light_rgb/set', - self.mock_publish.async_publish.mock_calls[0][1][0]) - self.assertEqual(2, - self.mock_publish.async_publish.mock_calls[0][1][2]) - self.assertEqual(False, - self.mock_publish.async_publish.mock_calls[0][1][3]) - # Get the sent message - message_json = json.loads( - self.mock_publish.async_publish.mock_calls[0][1][1]) - self.assertEqual(50, message_json["brightness"]) - self.assertEqual(155, message_json["color_temp"]) - self.assertEqual('colorloop', message_json["effect"]) - self.assertEqual(170, message_json["white_value"]) - self.assertEqual("ON", message_json["state"]) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertEqual(50, state.attributes['brightness']) - self.assertEqual(155, state.attributes['color_temp']) - self.assertEqual('colorloop', state.attributes['effect']) - self.assertEqual(170, state.attributes['white_value']) - - def test_flash_short_and_long(self): \ - # pylint: disable=invalid-name - """Test for flash length being sent when included.""" - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_json', - 'name': 'test', - 'state_topic': 'test_light_rgb', - 'command_topic': 'test_light_rgb/set', - 'flash_time_short': 5, - 'flash_time_long': 15, - 'qos': 0 - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - self.assertEqual(40, state.attributes.get(ATTR_SUPPORTED_FEATURES)) - - light.turn_on(self.hass, 'light.test', flash="short") - self.hass.block_till_done() - - self.assertEqual('test_light_rgb/set', - self.mock_publish.async_publish.mock_calls[0][1][0]) - self.assertEqual(0, - self.mock_publish.async_publish.mock_calls[0][1][2]) - self.assertEqual(False, - self.mock_publish.async_publish.mock_calls[0][1][3]) - # Get the sent message - message_json = json.loads( - self.mock_publish.async_publish.mock_calls[0][1][1]) - self.assertEqual(5, message_json["flash"]) - self.assertEqual("ON", message_json["state"]) - - self.mock_publish.async_publish.reset_mock() - light.turn_on(self.hass, 'light.test', flash="long") - self.hass.block_till_done() - - self.assertEqual('test_light_rgb/set', - self.mock_publish.async_publish.mock_calls[0][1][0]) - self.assertEqual(0, - self.mock_publish.async_publish.mock_calls[0][1][2]) - self.assertEqual(False, - self.mock_publish.async_publish.mock_calls[0][1][3]) - # Get the sent message - message_json = json.loads( - self.mock_publish.async_publish.mock_calls[0][1][1]) - self.assertEqual(15, message_json["flash"]) - self.assertEqual("ON", message_json["state"]) - - def test_transition(self): - """Test for transition time being sent when included.""" - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_json', - 'name': 'test', - 'state_topic': 'test_light_rgb', - 'command_topic': 'test_light_rgb/set', - 'qos': 0 - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - self.assertEqual(40, state.attributes.get(ATTR_SUPPORTED_FEATURES)) - - light.turn_on(self.hass, 'light.test', transition=10) - self.hass.block_till_done() - - self.assertEqual('test_light_rgb/set', - self.mock_publish.async_publish.mock_calls[0][1][0]) - self.assertEqual(0, - self.mock_publish.async_publish.mock_calls[0][1][2]) - self.assertEqual(False, - self.mock_publish.async_publish.mock_calls[0][1][3]) - # Get the sent message - message_json = json.loads( - self.mock_publish.async_publish.mock_calls[0][1][1]) - self.assertEqual(10, message_json["transition"]) - self.assertEqual("ON", message_json["state"]) - - # Transition back off - light.turn_off(self.hass, 'light.test', transition=10) - self.hass.block_till_done() - - self.assertEqual('test_light_rgb/set', - self.mock_publish.async_publish.mock_calls[1][1][0]) - self.assertEqual(0, - self.mock_publish.async_publish.mock_calls[1][1][2]) - self.assertEqual(False, - self.mock_publish.async_publish.mock_calls[1][1][3]) - # Get the sent message - message_json = json.loads( - self.mock_publish.async_publish.mock_calls[1][1][1]) - self.assertEqual(10, message_json["transition"]) - self.assertEqual("OFF", message_json["state"]) - - def test_brightness_scale(self): - """Test for brightness scaling.""" - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_json', - 'name': 'test', - 'state_topic': 'test_light_bright_scale', - 'command_topic': 'test_light_bright_scale/set', - 'brightness': True, - 'brightness_scale': 99 - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - self.assertIsNone(state.attributes.get('brightness')) - self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) - - # Turn on the light - fire_mqtt_message(self.hass, 'test_light_bright_scale', - '{"state":"ON"}') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertEqual(255, state.attributes.get('brightness')) - - # Turn on the light with brightness - fire_mqtt_message(self.hass, 'test_light_bright_scale', - '{"state":"ON",' - '"brightness": 99}') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertEqual(255, state.attributes.get('brightness')) - - def test_invalid_color_brightness_and_white_values(self): \ - # pylint: disable=invalid-name - """Test that invalid color/brightness/white values are ignored.""" - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_json', - 'name': 'test', - 'state_topic': 'test_light_rgb', - 'command_topic': 'test_light_rgb/set', - 'brightness': True, - 'rgb': True, - 'white_value': True, - 'qos': '0' - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - self.assertEqual(185, state.attributes.get(ATTR_SUPPORTED_FEATURES)) - self.assertIsNone(state.attributes.get('rgb_color')) - self.assertIsNone(state.attributes.get('brightness')) - self.assertIsNone(state.attributes.get('white_value')) - self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) - - # Turn on the light - fire_mqtt_message(self.hass, 'test_light_rgb', - '{"state":"ON",' - '"color":{"r":255,"g":255,"b":255},' - '"brightness": 255,' - '"white_value": 255}') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) - self.assertEqual(255, state.attributes.get('brightness')) - self.assertEqual(255, state.attributes.get('white_value')) - - # Bad color values - fire_mqtt_message(self.hass, 'test_light_rgb', - '{"state":"ON",' - '"color":{"r":"bad","g":"val","b":"test"}}') - self.hass.block_till_done() - - # Color should not have changed - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) - - # Bad brightness values - fire_mqtt_message(self.hass, 'test_light_rgb', - '{"state":"ON",' - '"brightness": "badValue"}') - self.hass.block_till_done() - - # Brightness should not have changed - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertEqual(255, state.attributes.get('brightness')) - - # Bad white value - fire_mqtt_message(self.hass, 'test_light_rgb', - '{"state":"ON",' - '"white_value": "badValue"}') - self.hass.block_till_done() - - # White value should not have changed - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertEqual(255, state.attributes.get('white_value')) - - def test_default_availability_payload(self): - """Test availability by default payload with defined topic.""" - self.assertTrue(setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_json', - 'name': 'test', - 'state_topic': 'test_light_rgb', - 'command_topic': 'test_light_rgb/set', - 'availability_topic': 'availability-topic' - } - })) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_UNAVAILABLE, state.state) - - fire_mqtt_message(self.hass, 'availability-topic', 'online') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertNotEqual(STATE_UNAVAILABLE, state.state) - - fire_mqtt_message(self.hass, 'availability-topic', 'offline') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_UNAVAILABLE, state.state) - - def test_custom_availability_payload(self): - """Test availability by custom payload with defined topic.""" - self.assertTrue(setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_json', - 'name': 'test', - 'state_topic': 'test_light_rgb', - 'command_topic': 'test_light_rgb/set', - 'availability_topic': 'availability-topic', - 'payload_available': 'good', - 'payload_not_available': 'nogood' - } - })) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_UNAVAILABLE, state.state) - - fire_mqtt_message(self.hass, 'availability-topic', 'good') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertNotEqual(STATE_UNAVAILABLE, state.state) - - fire_mqtt_message(self.hass, 'availability-topic', 'nogood') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_UNAVAILABLE, state.state) +"""The tests for the MQTT JSON light platform. + +Configuration with RGB, brightness, color temp, effect, white value and XY: + +light: + platform: mqtt_json + name: mqtt_json_light_1 + state_topic: "home/rgb1" + command_topic: "home/rgb1/set" + brightness: true + color_temp: true + effect: true + rgb: true + white_value: true + xy: true + +Configuration with RGB, brightness, color temp, effect, white value: + +light: + platform: mqtt_json + name: mqtt_json_light_1 + state_topic: "home/rgb1" + command_topic: "home/rgb1/set" + brightness: true + color_temp: true + effect: true + rgb: true + white_value: true + +Configuration with RGB, brightness, color temp and effect: + +light: + platform: mqtt_json + name: mqtt_json_light_1 + state_topic: "home/rgb1" + command_topic: "home/rgb1/set" + brightness: true + color_temp: true + effect: true + rgb: true + +Configuration with RGB, brightness and color temp: + +light: + platform: mqtt_json + name: mqtt_json_light_1 + state_topic: "home/rgb1" + command_topic: "home/rgb1/set" + brightness: true + rgb: true + color_temp: true + +Configuration with RGB, brightness: + +light: + platform: mqtt_json + name: mqtt_json_light_1 + state_topic: "home/rgb1" + command_topic: "home/rgb1/set" + brightness: true + rgb: true + +Config without RGB: + +light: + platform: mqtt_json + name: mqtt_json_light_1 + state_topic: "home/rgb1" + command_topic: "home/rgb1/set" + brightness: true + +Config without RGB and brightness: + +light: + platform: mqtt_json + name: mqtt_json_light_1 + state_topic: "home/rgb1" + command_topic: "home/rgb1/set" + +Config with brightness and scale: + +light: + platform: mqtt_json + name: test + state_topic: "mqtt_json_light_1" + command_topic: "mqtt_json_light_1/set" + brightness: true + brightness_scale: 99 +""" + +import json +import unittest + +from homeassistant.setup import setup_component +from homeassistant.const import ( + STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE, + ATTR_SUPPORTED_FEATURES) +import homeassistant.components.light as light +from tests.common import ( + get_test_home_assistant, mock_mqtt_component, fire_mqtt_message, + assert_setup_component) + + +class TestLightMQTTJSON(unittest.TestCase): + """Test the MQTT JSON light.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.mock_publish = mock_mqtt_component(self.hass) + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + def test_fail_setup_if_no_command_topic(self): \ + # pylint: disable=invalid-name + """Test if setup fails with no command topic.""" + with assert_setup_component(0, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + } + }) + self.assertIsNone(self.hass.states.get('light.test')) + + def test_no_color_brightness_color_temp_white_val_if_no_topics(self): \ + # pylint: disable=invalid-name + """Test for no RGB, brightness, color temp, effect, white val or XY.""" + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertEqual(40, state.attributes.get(ATTR_SUPPORTED_FEATURES)) + self.assertIsNone(state.attributes.get('rgb_color')) + self.assertIsNone(state.attributes.get('brightness')) + self.assertIsNone(state.attributes.get('color_temp')) + self.assertIsNone(state.attributes.get('effect')) + self.assertIsNone(state.attributes.get('white_value')) + self.assertIsNone(state.attributes.get('xy_color')) + + fire_mqtt_message(self.hass, 'test_light_rgb', '{"state":"ON"}') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('rgb_color')) + self.assertIsNone(state.attributes.get('brightness')) + self.assertIsNone(state.attributes.get('color_temp')) + self.assertIsNone(state.attributes.get('effect')) + self.assertIsNone(state.attributes.get('white_value')) + self.assertIsNone(state.attributes.get('xy_color')) + + def test_controlling_state_via_topic(self): \ + # pylint: disable=invalid-name + """Test the controlling of the state via topic.""" + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'brightness': True, + 'color_temp': True, + 'effect': True, + 'rgb': True, + 'white_value': True, + 'xy': True, + 'qos': '0' + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertEqual(255, state.attributes.get(ATTR_SUPPORTED_FEATURES)) + self.assertIsNone(state.attributes.get('rgb_color')) + self.assertIsNone(state.attributes.get('brightness')) + self.assertIsNone(state.attributes.get('color_temp')) + self.assertIsNone(state.attributes.get('effect')) + self.assertIsNone(state.attributes.get('white_value')) + self.assertIsNone(state.attributes.get('xy_color')) + self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) + + # Turn on the light, full white + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + '"color":{"r":255,"g":255,"b":255,' + '"x":0.123,"y":0.123},' + '"brightness":255,' + '"color_temp":155,' + '"effect":"colorloop",' + '"white_value":150}') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) + self.assertEqual(255, state.attributes.get('brightness')) + self.assertEqual(155, state.attributes.get('color_temp')) + self.assertEqual('colorloop', state.attributes.get('effect')) + self.assertEqual(150, state.attributes.get('white_value')) + self.assertEqual([0.123, 0.123], state.attributes.get('xy_color')) + + # Turn the light off + fire_mqtt_message(self.hass, 'test_light_rgb', '{"state":"OFF"}') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + '"brightness":100}') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.hass.block_till_done() + self.assertEqual(100, + light_state.attributes['brightness']) + + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + '"color":{"r":125,"g":125,"b":125}}') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.assertEqual([125, 125, 125], + light_state.attributes.get('rgb_color')) + + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + '"color":{"x":0.135,"y":0.135}}') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.assertEqual([0.135, 0.135], + light_state.attributes.get('xy_color')) + + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + '"color_temp":155}') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.assertEqual(155, light_state.attributes.get('color_temp')) + + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + '"effect":"colorloop"}') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.assertEqual('colorloop', light_state.attributes.get('effect')) + + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + '"white_value":155}') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.assertEqual(155, light_state.attributes.get('white_value')) + + def test_sending_mqtt_commands_and_optimistic(self): \ + # pylint: disable=invalid-name + """Test the sending of command in optimistic mode.""" + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'brightness': True, + 'color_temp': True, + 'effect': True, + 'rgb': True, + 'white_value': True, + 'qos': 2 + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertEqual(191, state.attributes.get(ATTR_SUPPORTED_FEATURES)) + self.assertTrue(state.attributes.get(ATTR_ASSUMED_STATE)) + + light.turn_on(self.hass, 'light.test') + self.hass.block_till_done() + + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', '{"state": "ON"}', 2, False) + self.mock_publish.async_publish.reset_mock() + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + + light.turn_off(self.hass, 'light.test') + self.hass.block_till_done() + + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', '{"state": "OFF"}', 2, False) + self.mock_publish.async_publish.reset_mock() + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + light.turn_on(self.hass, 'light.test', + brightness=50, color_temp=155, effect='colorloop', + white_value=170) + self.hass.block_till_done() + + self.assertEqual('test_light_rgb/set', + self.mock_publish.async_publish.mock_calls[0][1][0]) + self.assertEqual(2, + self.mock_publish.async_publish.mock_calls[0][1][2]) + self.assertEqual(False, + self.mock_publish.async_publish.mock_calls[0][1][3]) + # Get the sent message + message_json = json.loads( + self.mock_publish.async_publish.mock_calls[0][1][1]) + self.assertEqual(50, message_json["brightness"]) + self.assertEqual(155, message_json["color_temp"]) + self.assertEqual('colorloop', message_json["effect"]) + self.assertEqual(170, message_json["white_value"]) + self.assertEqual("ON", message_json["state"]) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual(50, state.attributes['brightness']) + self.assertEqual(155, state.attributes['color_temp']) + self.assertEqual('colorloop', state.attributes['effect']) + self.assertEqual(170, state.attributes['white_value']) + + def test_flash_short_and_long(self): \ + # pylint: disable=invalid-name + """Test for flash length being sent when included.""" + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'flash_time_short': 5, + 'flash_time_long': 15, + 'qos': 0 + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertEqual(40, state.attributes.get(ATTR_SUPPORTED_FEATURES)) + + light.turn_on(self.hass, 'light.test', flash="short") + self.hass.block_till_done() + + self.assertEqual('test_light_rgb/set', + self.mock_publish.async_publish.mock_calls[0][1][0]) + self.assertEqual(0, + self.mock_publish.async_publish.mock_calls[0][1][2]) + self.assertEqual(False, + self.mock_publish.async_publish.mock_calls[0][1][3]) + # Get the sent message + message_json = json.loads( + self.mock_publish.async_publish.mock_calls[0][1][1]) + self.assertEqual(5, message_json["flash"]) + self.assertEqual("ON", message_json["state"]) + + self.mock_publish.async_publish.reset_mock() + light.turn_on(self.hass, 'light.test', flash="long") + self.hass.block_till_done() + + self.assertEqual('test_light_rgb/set', + self.mock_publish.async_publish.mock_calls[0][1][0]) + self.assertEqual(0, + self.mock_publish.async_publish.mock_calls[0][1][2]) + self.assertEqual(False, + self.mock_publish.async_publish.mock_calls[0][1][3]) + # Get the sent message + message_json = json.loads( + self.mock_publish.async_publish.mock_calls[0][1][1]) + self.assertEqual(15, message_json["flash"]) + self.assertEqual("ON", message_json["state"]) + + def test_transition(self): + """Test for transition time being sent when included.""" + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'qos': 0 + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertEqual(40, state.attributes.get(ATTR_SUPPORTED_FEATURES)) + + light.turn_on(self.hass, 'light.test', transition=10) + self.hass.block_till_done() + + self.assertEqual('test_light_rgb/set', + self.mock_publish.async_publish.mock_calls[0][1][0]) + self.assertEqual(0, + self.mock_publish.async_publish.mock_calls[0][1][2]) + self.assertEqual(False, + self.mock_publish.async_publish.mock_calls[0][1][3]) + # Get the sent message + message_json = json.loads( + self.mock_publish.async_publish.mock_calls[0][1][1]) + self.assertEqual(10, message_json["transition"]) + self.assertEqual("ON", message_json["state"]) + + # Transition back off + light.turn_off(self.hass, 'light.test', transition=10) + self.hass.block_till_done() + + self.assertEqual('test_light_rgb/set', + self.mock_publish.async_publish.mock_calls[1][1][0]) + self.assertEqual(0, + self.mock_publish.async_publish.mock_calls[1][1][2]) + self.assertEqual(False, + self.mock_publish.async_publish.mock_calls[1][1][3]) + # Get the sent message + message_json = json.loads( + self.mock_publish.async_publish.mock_calls[1][1][1]) + self.assertEqual(10, message_json["transition"]) + self.assertEqual("OFF", message_json["state"]) + + def test_brightness_scale(self): + """Test for brightness scaling.""" + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'state_topic': 'test_light_bright_scale', + 'command_topic': 'test_light_bright_scale/set', + 'brightness': True, + 'brightness_scale': 99 + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertIsNone(state.attributes.get('brightness')) + self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) + + # Turn on the light + fire_mqtt_message(self.hass, 'test_light_bright_scale', + '{"state":"ON"}') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual(255, state.attributes.get('brightness')) + + # Turn on the light with brightness + fire_mqtt_message(self.hass, 'test_light_bright_scale', + '{"state":"ON",' + '"brightness": 99}') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual(255, state.attributes.get('brightness')) + + def test_invalid_color_brightness_and_white_values(self): \ + # pylint: disable=invalid-name + """Test that invalid color/brightness/white values are ignored.""" + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'brightness': True, + 'rgb': True, + 'white_value': True, + 'qos': '0' + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertEqual(185, state.attributes.get(ATTR_SUPPORTED_FEATURES)) + self.assertIsNone(state.attributes.get('rgb_color')) + self.assertIsNone(state.attributes.get('brightness')) + self.assertIsNone(state.attributes.get('white_value')) + self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) + + # Turn on the light + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + '"color":{"r":255,"g":255,"b":255},' + '"brightness": 255,' + '"white_value": 255}') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) + self.assertEqual(255, state.attributes.get('brightness')) + self.assertEqual(255, state.attributes.get('white_value')) + + # Bad color values + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + '"color":{"r":"bad","g":"val","b":"test"}}') + self.hass.block_till_done() + + # Color should not have changed + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) + + # Bad brightness values + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + '"brightness": "badValue"}') + self.hass.block_till_done() + + # Brightness should not have changed + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual(255, state.attributes.get('brightness')) + + # Bad white value + fire_mqtt_message(self.hass, 'test_light_rgb', + '{"state":"ON",' + '"white_value": "badValue"}') + self.hass.block_till_done() + + # White value should not have changed + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual(255, state.attributes.get('white_value')) + + def test_default_availability_payload(self): + """Test availability by default payload with defined topic.""" + self.assertTrue(setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'availability_topic': 'availability-topic' + } + })) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'online') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'offline') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + def test_custom_availability_payload(self): + """Test availability by custom payload with defined topic.""" + self.assertTrue(setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'availability_topic': 'availability-topic', + 'payload_available': 'good', + 'payload_not_available': 'nogood' + } + })) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'good') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'nogood') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) diff --git a/tests/components/light/test_mqtt_template.py b/tests/components/light/test_mqtt_template.py index 62947c05227..5a01aa15fa2 100644 --- a/tests/components/light/test_mqtt_template.py +++ b/tests/components/light/test_mqtt_template.py @@ -1,498 +1,498 @@ -"""The tests for the MQTT Template light platform. - -Configuration example with all features: - -light: - platform: mqtt_template - name: mqtt_template_light_1 - state_topic: 'home/rgb1' - command_topic: 'home/rgb1/set' - command_on_template: > - on,{{ brightness|d }},{{ red|d }}-{{ green|d }}-{{ blue|d }} - command_off_template: 'off' - state_template: '{{ value.split(",")[0] }}' - brightness_template: '{{ value.split(",")[1] }}' - color_temp_template: '{{ value.split(",")[2] }}' - white_value_template: '{{ value.split(",")[3] }}' - red_template: '{{ value.split(",")[4].split("-")[0] }}' - green_template: '{{ value.split(",")[4].split("-")[1] }}' - blue_template: '{{ value.split(",")[4].split("-")[2] }}' - -If your light doesn't support brightness feature, omit `brightness_template`. - -If your light doesn't support color temp feature, omit `color_temp_template`. - -If your light doesn't support white value feature, omit `white_value_template`. - -If your light doesn't support RGB feature, omit `(red|green|blue)_template`. -""" -import unittest - -from homeassistant.setup import setup_component -from homeassistant.const import ( - STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE) -import homeassistant.components.light as light -from tests.common import ( - get_test_home_assistant, mock_mqtt_component, fire_mqtt_message, - assert_setup_component) - - -class TestLightMQTTTemplate(unittest.TestCase): - """Test the MQTT Template light.""" - - def setUp(self): # pylint: disable=invalid-name - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.mock_publish = mock_mqtt_component(self.hass) - - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that was started.""" - self.hass.stop() - - def test_setup_fails(self): \ - # pylint: disable=invalid-name - """Test that setup fails with missing required configuration items.""" - with assert_setup_component(0, light.DOMAIN): - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_template', - 'name': 'test', - } - }) - self.assertIsNone(self.hass.states.get('light.test')) - - def test_state_change_via_topic(self): \ - # pylint: disable=invalid-name - """Test state change via topic.""" - with assert_setup_component(1, light.DOMAIN): - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_template', - 'name': 'test', - 'state_topic': 'test_light_rgb', - 'command_topic': 'test_light_rgb/set', - 'command_on_template': 'on,' - '{{ brightness|d }},' - '{{ color_temp|d }},' - '{{ white_value|d }},' - '{{ red|d }}-' - '{{ green|d }}-' - '{{ blue|d }}', - 'command_off_template': 'off', - 'state_template': '{{ value.split(",")[0] }}' - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - self.assertIsNone(state.attributes.get('rgb_color')) - self.assertIsNone(state.attributes.get('brightness')) - self.assertIsNone(state.attributes.get('color_temp')) - self.assertIsNone(state.attributes.get('white_value')) - self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) - - fire_mqtt_message(self.hass, 'test_light_rgb', 'on') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertIsNone(state.attributes.get('rgb_color')) - self.assertIsNone(state.attributes.get('brightness')) - self.assertIsNone(state.attributes.get('color_temp')) - self.assertIsNone(state.attributes.get('white_value')) - - def test_state_brightness_color_effect_temp_white_change_via_topic(self): \ - # pylint: disable=invalid-name - """Test state, bri, color, effect, color temp, white val change.""" - with assert_setup_component(1, light.DOMAIN): - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_template', - 'name': 'test', - 'effect_list': ['rainbow', 'colorloop'], - 'state_topic': 'test_light_rgb', - 'command_topic': 'test_light_rgb/set', - 'command_on_template': 'on,' - '{{ brightness|d }},' - '{{ color_temp|d }},' - '{{ white_value|d }},' - '{{ red|d }}-' - '{{ green|d }}-' - '{{ blue|d }},' - '{{ effect|d }}', - 'command_off_template': 'off', - 'state_template': '{{ value.split(",")[0] }}', - 'brightness_template': '{{ value.split(",")[1] }}', - 'color_temp_template': '{{ value.split(",")[2] }}', - 'white_value_template': '{{ value.split(",")[3] }}', - 'red_template': '{{ value.split(",")[4].' - 'split("-")[0] }}', - 'green_template': '{{ value.split(",")[4].' - 'split("-")[1] }}', - 'blue_template': '{{ value.split(",")[4].' - 'split("-")[2] }}', - 'effect_template': '{{ value.split(",")[5] }}' - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - self.assertIsNone(state.attributes.get('rgb_color')) - self.assertIsNone(state.attributes.get('brightness')) - self.assertIsNone(state.attributes.get('effect')) - self.assertIsNone(state.attributes.get('color_temp')) - self.assertIsNone(state.attributes.get('white_value')) - self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) - - # turn on the light, full white - fire_mqtt_message(self.hass, 'test_light_rgb', - 'on,255,145,123,255-128-64,') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertEqual([255, 128, 64], state.attributes.get('rgb_color')) - self.assertEqual(255, state.attributes.get('brightness')) - self.assertEqual(145, state.attributes.get('color_temp')) - self.assertEqual(123, state.attributes.get('white_value')) - self.assertIsNone(state.attributes.get('effect')) - - # turn the light off - fire_mqtt_message(self.hass, 'test_light_rgb', 'off') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - - # lower the brightness - fire_mqtt_message(self.hass, 'test_light_rgb', 'on,100') - self.hass.block_till_done() - - light_state = self.hass.states.get('light.test') - self.hass.block_till_done() - self.assertEqual(100, light_state.attributes['brightness']) - - # change the color temp - fire_mqtt_message(self.hass, 'test_light_rgb', 'on,,195') - self.hass.block_till_done() - - light_state = self.hass.states.get('light.test') - self.hass.block_till_done() - self.assertEqual(195, light_state.attributes['color_temp']) - - # change the color - fire_mqtt_message(self.hass, 'test_light_rgb', 'on,,,,41-42-43') - self.hass.block_till_done() - - light_state = self.hass.states.get('light.test') - self.assertEqual([41, 42, 43], light_state.attributes.get('rgb_color')) - - # change the white value - fire_mqtt_message(self.hass, 'test_light_rgb', 'on,,,134') - self.hass.block_till_done() - - light_state = self.hass.states.get('light.test') - self.hass.block_till_done() - self.assertEqual(134, light_state.attributes['white_value']) - - # change the effect - fire_mqtt_message(self.hass, 'test_light_rgb', - 'on,,,,41-42-43,rainbow') - self.hass.block_till_done() - - light_state = self.hass.states.get('light.test') - self.assertEqual('rainbow', light_state.attributes.get('effect')) - - def test_optimistic(self): \ - # pylint: disable=invalid-name - """Test optimistic mode.""" - with assert_setup_component(1, light.DOMAIN): - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_template', - 'name': 'test', - 'command_topic': 'test_light_rgb/set', - 'command_on_template': 'on,' - '{{ brightness|d }},' - '{{ color_temp|d }},' - '{{ white_value|d }},' - '{{ red|d }}-' - '{{ green|d }}-' - '{{ blue|d }}', - 'command_off_template': 'off', - 'qos': 2 - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - self.assertTrue(state.attributes.get(ATTR_ASSUMED_STATE)) - - # turn on the light - light.turn_on(self.hass, 'light.test') - self.hass.block_till_done() - - self.mock_publish.async_publish.assert_called_once_with( - 'test_light_rgb/set', 'on,,,,--', 2, False) - self.mock_publish.async_publish.reset_mock() - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - - # turn the light off - light.turn_off(self.hass, 'light.test') - self.hass.block_till_done() - - self.mock_publish.async_publish.assert_called_once_with( - 'test_light_rgb/set', 'off', 2, False) - self.mock_publish.async_publish.reset_mock() - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - - # turn on the light with brightness, color - light.turn_on(self.hass, 'light.test', brightness=50, - rgb_color=[75, 75, 75]) - self.hass.block_till_done() - - self.mock_publish.async_publish.assert_called_once_with( - 'test_light_rgb/set', 'on,50,,,75-75-75', 2, False) - self.mock_publish.async_publish.reset_mock() - - # turn on the light with color temp and white val - light.turn_on(self.hass, 'light.test', color_temp=200, white_value=139) - self.hass.block_till_done() - - self.mock_publish.async_publish.assert_called_once_with( - 'test_light_rgb/set', 'on,,200,139,--', 2, False) - - # check the state - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertEqual((75, 75, 75), state.attributes['rgb_color']) - self.assertEqual(50, state.attributes['brightness']) - self.assertEqual(200, state.attributes['color_temp']) - self.assertEqual(139, state.attributes['white_value']) - - def test_flash(self): \ - # pylint: disable=invalid-name - """Test flash.""" - with assert_setup_component(1, light.DOMAIN): - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_template', - 'name': 'test', - 'command_topic': 'test_light_rgb/set', - 'command_on_template': 'on,{{ flash }}', - 'command_off_template': 'off', - 'qos': 0 - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - - # short flash - light.turn_on(self.hass, 'light.test', flash='short') - self.hass.block_till_done() - - self.mock_publish.async_publish.assert_called_once_with( - 'test_light_rgb/set', 'on,short', 0, False) - self.mock_publish.async_publish.reset_mock() - - # long flash - light.turn_on(self.hass, 'light.test', flash='long') - self.hass.block_till_done() - - self.mock_publish.async_publish.assert_called_once_with( - 'test_light_rgb/set', 'on,long', 0, False) - - def test_transition(self): - """Test for transition time being sent when included.""" - with assert_setup_component(1, light.DOMAIN): - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_template', - 'name': 'test', - 'command_topic': 'test_light_rgb/set', - 'command_on_template': 'on,{{ transition }}', - 'command_off_template': 'off,{{ transition|d }}' - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - - # transition on - light.turn_on(self.hass, 'light.test', transition=10) - self.hass.block_till_done() - - self.mock_publish.async_publish.assert_called_once_with( - 'test_light_rgb/set', 'on,10', 0, False) - self.mock_publish.async_publish.reset_mock() - - # transition off - light.turn_off(self.hass, 'light.test', transition=4) - self.hass.block_till_done() - - self.mock_publish.async_publish.assert_called_once_with( - 'test_light_rgb/set', 'off,4', 0, False) - - def test_invalid_values(self): \ - # pylint: disable=invalid-name - """Test that invalid values are ignored.""" - with assert_setup_component(1, light.DOMAIN): - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_template', - 'name': 'test', - 'effect_list': ['rainbow', 'colorloop'], - 'state_topic': 'test_light_rgb', - 'command_topic': 'test_light_rgb/set', - 'command_on_template': 'on,' - '{{ brightness|d }},' - '{{ color_temp|d }},' - '{{ red|d }}-' - '{{ green|d }}-' - '{{ blue|d }},' - '{{ effect|d }}', - 'command_off_template': 'off', - 'state_template': '{{ value.split(",")[0] }}', - 'brightness_template': '{{ value.split(",")[1] }}', - 'color_temp_template': '{{ value.split(",")[2] }}', - 'white_value_template': '{{ value.split(",")[3] }}', - 'red_template': '{{ value.split(",")[4].' - 'split("-")[0] }}', - 'green_template': '{{ value.split(",")[4].' - 'split("-")[1] }}', - 'blue_template': '{{ value.split(",")[4].' - 'split("-")[2] }}', - 'effect_template': '{{ value.split(",")[5] }}', - } - }) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) - self.assertIsNone(state.attributes.get('rgb_color')) - self.assertIsNone(state.attributes.get('brightness')) - self.assertIsNone(state.attributes.get('color_temp')) - self.assertIsNone(state.attributes.get('effect')) - self.assertIsNone(state.attributes.get('white_value')) - self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) - - # turn on the light, full white - fire_mqtt_message(self.hass, 'test_light_rgb', - 'on,255,215,222,255-255-255,rainbow') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - self.assertEqual(255, state.attributes.get('brightness')) - self.assertEqual(215, state.attributes.get('color_temp')) - self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) - self.assertEqual(222, state.attributes.get('white_value')) - self.assertEqual('rainbow', state.attributes.get('effect')) - - # bad state value - fire_mqtt_message(self.hass, 'test_light_rgb', 'offf') - self.hass.block_till_done() - - # state should not have changed - state = self.hass.states.get('light.test') - self.assertEqual(STATE_ON, state.state) - - # bad brightness values - fire_mqtt_message(self.hass, 'test_light_rgb', 'on,off,255-255-255') - self.hass.block_till_done() - - # brightness should not have changed - state = self.hass.states.get('light.test') - self.assertEqual(255, state.attributes.get('brightness')) - - # bad color temp values - fire_mqtt_message(self.hass, 'test_light_rgb', 'on,,off,255-255-255') - self.hass.block_till_done() - - # color temp should not have changed - state = self.hass.states.get('light.test') - self.assertEqual(215, state.attributes.get('color_temp')) - - # bad color values - fire_mqtt_message(self.hass, 'test_light_rgb', 'on,255,a-b-c') - self.hass.block_till_done() - - # color should not have changed - state = self.hass.states.get('light.test') - self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) - - # bad white value values - fire_mqtt_message(self.hass, 'test_light_rgb', 'on,,,off,255-255-255') - self.hass.block_till_done() - - # white value should not have changed - state = self.hass.states.get('light.test') - self.assertEqual(222, state.attributes.get('white_value')) - - # bad effect value - fire_mqtt_message(self.hass, 'test_light_rgb', 'on,255,a-b-c,white') - self.hass.block_till_done() - - # effect should not have changed - state = self.hass.states.get('light.test') - self.assertEqual('rainbow', state.attributes.get('effect')) - - def test_default_availability_payload(self): - """Test availability by default payload with defined topic.""" - self.assertTrue(setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_template', - 'name': 'test', - 'command_topic': 'test_light_rgb/set', - 'command_on_template': 'on,{{ transition }}', - 'command_off_template': 'off,{{ transition|d }}', - 'availability_topic': 'availability-topic' - } - })) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_UNAVAILABLE, state.state) - - fire_mqtt_message(self.hass, 'availability-topic', 'online') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertNotEqual(STATE_UNAVAILABLE, state.state) - - fire_mqtt_message(self.hass, 'availability-topic', 'offline') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_UNAVAILABLE, state.state) - - def test_custom_availability_payload(self): - """Test availability by custom payload with defined topic.""" - self.assertTrue(setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_template', - 'name': 'test', - 'command_topic': 'test_light_rgb/set', - 'command_on_template': 'on,{{ transition }}', - 'command_off_template': 'off,{{ transition|d }}', - 'availability_topic': 'availability-topic', - 'payload_available': 'good', - 'payload_not_available': 'nogood' - } - })) - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_UNAVAILABLE, state.state) - - fire_mqtt_message(self.hass, 'availability-topic', 'good') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertNotEqual(STATE_UNAVAILABLE, state.state) - - fire_mqtt_message(self.hass, 'availability-topic', 'nogood') - self.hass.block_till_done() - - state = self.hass.states.get('light.test') - self.assertEqual(STATE_UNAVAILABLE, state.state) +"""The tests for the MQTT Template light platform. + +Configuration example with all features: + +light: + platform: mqtt_template + name: mqtt_template_light_1 + state_topic: 'home/rgb1' + command_topic: 'home/rgb1/set' + command_on_template: > + on,{{ brightness|d }},{{ red|d }}-{{ green|d }}-{{ blue|d }} + command_off_template: 'off' + state_template: '{{ value.split(",")[0] }}' + brightness_template: '{{ value.split(",")[1] }}' + color_temp_template: '{{ value.split(",")[2] }}' + white_value_template: '{{ value.split(",")[3] }}' + red_template: '{{ value.split(",")[4].split("-")[0] }}' + green_template: '{{ value.split(",")[4].split("-")[1] }}' + blue_template: '{{ value.split(",")[4].split("-")[2] }}' + +If your light doesn't support brightness feature, omit `brightness_template`. + +If your light doesn't support color temp feature, omit `color_temp_template`. + +If your light doesn't support white value feature, omit `white_value_template`. + +If your light doesn't support RGB feature, omit `(red|green|blue)_template`. +""" +import unittest + +from homeassistant.setup import setup_component +from homeassistant.const import ( + STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE) +import homeassistant.components.light as light +from tests.common import ( + get_test_home_assistant, mock_mqtt_component, fire_mqtt_message, + assert_setup_component) + + +class TestLightMQTTTemplate(unittest.TestCase): + """Test the MQTT Template light.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.mock_publish = mock_mqtt_component(self.hass) + + def tearDown(self): # pylint: disable=invalid-name + """Stop everything that was started.""" + self.hass.stop() + + def test_setup_fails(self): \ + # pylint: disable=invalid-name + """Test that setup fails with missing required configuration items.""" + with assert_setup_component(0, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + } + }) + self.assertIsNone(self.hass.states.get('light.test')) + + def test_state_change_via_topic(self): \ + # pylint: disable=invalid-name + """Test state change via topic.""" + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,' + '{{ brightness|d }},' + '{{ color_temp|d }},' + '{{ white_value|d }},' + '{{ red|d }}-' + '{{ green|d }}-' + '{{ blue|d }}', + 'command_off_template': 'off', + 'state_template': '{{ value.split(",")[0] }}' + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertIsNone(state.attributes.get('rgb_color')) + self.assertIsNone(state.attributes.get('brightness')) + self.assertIsNone(state.attributes.get('color_temp')) + self.assertIsNone(state.attributes.get('white_value')) + self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) + + fire_mqtt_message(self.hass, 'test_light_rgb', 'on') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('rgb_color')) + self.assertIsNone(state.attributes.get('brightness')) + self.assertIsNone(state.attributes.get('color_temp')) + self.assertIsNone(state.attributes.get('white_value')) + + def test_state_brightness_color_effect_temp_white_change_via_topic(self): \ + # pylint: disable=invalid-name + """Test state, bri, color, effect, color temp, white val change.""" + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'effect_list': ['rainbow', 'colorloop'], + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,' + '{{ brightness|d }},' + '{{ color_temp|d }},' + '{{ white_value|d }},' + '{{ red|d }}-' + '{{ green|d }}-' + '{{ blue|d }},' + '{{ effect|d }}', + 'command_off_template': 'off', + 'state_template': '{{ value.split(",")[0] }}', + 'brightness_template': '{{ value.split(",")[1] }}', + 'color_temp_template': '{{ value.split(",")[2] }}', + 'white_value_template': '{{ value.split(",")[3] }}', + 'red_template': '{{ value.split(",")[4].' + 'split("-")[0] }}', + 'green_template': '{{ value.split(",")[4].' + 'split("-")[1] }}', + 'blue_template': '{{ value.split(",")[4].' + 'split("-")[2] }}', + 'effect_template': '{{ value.split(",")[5] }}' + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertIsNone(state.attributes.get('rgb_color')) + self.assertIsNone(state.attributes.get('brightness')) + self.assertIsNone(state.attributes.get('effect')) + self.assertIsNone(state.attributes.get('color_temp')) + self.assertIsNone(state.attributes.get('white_value')) + self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) + + # turn on the light, full white + fire_mqtt_message(self.hass, 'test_light_rgb', + 'on,255,145,123,255-128-64,') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual([255, 128, 64], state.attributes.get('rgb_color')) + self.assertEqual(255, state.attributes.get('brightness')) + self.assertEqual(145, state.attributes.get('color_temp')) + self.assertEqual(123, state.attributes.get('white_value')) + self.assertIsNone(state.attributes.get('effect')) + + # turn the light off + fire_mqtt_message(self.hass, 'test_light_rgb', 'off') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + # lower the brightness + fire_mqtt_message(self.hass, 'test_light_rgb', 'on,100') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.hass.block_till_done() + self.assertEqual(100, light_state.attributes['brightness']) + + # change the color temp + fire_mqtt_message(self.hass, 'test_light_rgb', 'on,,195') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.hass.block_till_done() + self.assertEqual(195, light_state.attributes['color_temp']) + + # change the color + fire_mqtt_message(self.hass, 'test_light_rgb', 'on,,,,41-42-43') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.assertEqual([41, 42, 43], light_state.attributes.get('rgb_color')) + + # change the white value + fire_mqtt_message(self.hass, 'test_light_rgb', 'on,,,134') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.hass.block_till_done() + self.assertEqual(134, light_state.attributes['white_value']) + + # change the effect + fire_mqtt_message(self.hass, 'test_light_rgb', + 'on,,,,41-42-43,rainbow') + self.hass.block_till_done() + + light_state = self.hass.states.get('light.test') + self.assertEqual('rainbow', light_state.attributes.get('effect')) + + def test_optimistic(self): \ + # pylint: disable=invalid-name + """Test optimistic mode.""" + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,' + '{{ brightness|d }},' + '{{ color_temp|d }},' + '{{ white_value|d }},' + '{{ red|d }}-' + '{{ green|d }}-' + '{{ blue|d }}', + 'command_off_template': 'off', + 'qos': 2 + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertTrue(state.attributes.get(ATTR_ASSUMED_STATE)) + + # turn on the light + light.turn_on(self.hass, 'light.test') + self.hass.block_till_done() + + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'on,,,,--', 2, False) + self.mock_publish.async_publish.reset_mock() + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + + # turn the light off + light.turn_off(self.hass, 'light.test') + self.hass.block_till_done() + + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'off', 2, False) + self.mock_publish.async_publish.reset_mock() + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + # turn on the light with brightness, color + light.turn_on(self.hass, 'light.test', brightness=50, + rgb_color=[75, 75, 75]) + self.hass.block_till_done() + + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'on,50,,,75-75-75', 2, False) + self.mock_publish.async_publish.reset_mock() + + # turn on the light with color temp and white val + light.turn_on(self.hass, 'light.test', color_temp=200, white_value=139) + self.hass.block_till_done() + + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'on,,200,139,--', 2, False) + + # check the state + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual((75, 75, 75), state.attributes['rgb_color']) + self.assertEqual(50, state.attributes['brightness']) + self.assertEqual(200, state.attributes['color_temp']) + self.assertEqual(139, state.attributes['white_value']) + + def test_flash(self): \ + # pylint: disable=invalid-name + """Test flash.""" + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,{{ flash }}', + 'command_off_template': 'off', + 'qos': 0 + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + # short flash + light.turn_on(self.hass, 'light.test', flash='short') + self.hass.block_till_done() + + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'on,short', 0, False) + self.mock_publish.async_publish.reset_mock() + + # long flash + light.turn_on(self.hass, 'light.test', flash='long') + self.hass.block_till_done() + + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'on,long', 0, False) + + def test_transition(self): + """Test for transition time being sent when included.""" + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,{{ transition }}', + 'command_off_template': 'off,{{ transition|d }}' + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + # transition on + light.turn_on(self.hass, 'light.test', transition=10) + self.hass.block_till_done() + + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'on,10', 0, False) + self.mock_publish.async_publish.reset_mock() + + # transition off + light.turn_off(self.hass, 'light.test', transition=4) + self.hass.block_till_done() + + self.mock_publish.async_publish.assert_called_once_with( + 'test_light_rgb/set', 'off,4', 0, False) + + def test_invalid_values(self): \ + # pylint: disable=invalid-name + """Test that invalid values are ignored.""" + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'effect_list': ['rainbow', 'colorloop'], + 'state_topic': 'test_light_rgb', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,' + '{{ brightness|d }},' + '{{ color_temp|d }},' + '{{ red|d }}-' + '{{ green|d }}-' + '{{ blue|d }},' + '{{ effect|d }}', + 'command_off_template': 'off', + 'state_template': '{{ value.split(",")[0] }}', + 'brightness_template': '{{ value.split(",")[1] }}', + 'color_temp_template': '{{ value.split(",")[2] }}', + 'white_value_template': '{{ value.split(",")[3] }}', + 'red_template': '{{ value.split(",")[4].' + 'split("-")[0] }}', + 'green_template': '{{ value.split(",")[4].' + 'split("-")[1] }}', + 'blue_template': '{{ value.split(",")[4].' + 'split("-")[2] }}', + 'effect_template': '{{ value.split(",")[5] }}', + } + }) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + self.assertIsNone(state.attributes.get('rgb_color')) + self.assertIsNone(state.attributes.get('brightness')) + self.assertIsNone(state.attributes.get('color_temp')) + self.assertIsNone(state.attributes.get('effect')) + self.assertIsNone(state.attributes.get('white_value')) + self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) + + # turn on the light, full white + fire_mqtt_message(self.hass, 'test_light_rgb', + 'on,255,215,222,255-255-255,rainbow') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual(255, state.attributes.get('brightness')) + self.assertEqual(215, state.attributes.get('color_temp')) + self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) + self.assertEqual(222, state.attributes.get('white_value')) + self.assertEqual('rainbow', state.attributes.get('effect')) + + # bad state value + fire_mqtt_message(self.hass, 'test_light_rgb', 'offf') + self.hass.block_till_done() + + # state should not have changed + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + + # bad brightness values + fire_mqtt_message(self.hass, 'test_light_rgb', 'on,off,255-255-255') + self.hass.block_till_done() + + # brightness should not have changed + state = self.hass.states.get('light.test') + self.assertEqual(255, state.attributes.get('brightness')) + + # bad color temp values + fire_mqtt_message(self.hass, 'test_light_rgb', 'on,,off,255-255-255') + self.hass.block_till_done() + + # color temp should not have changed + state = self.hass.states.get('light.test') + self.assertEqual(215, state.attributes.get('color_temp')) + + # bad color values + fire_mqtt_message(self.hass, 'test_light_rgb', 'on,255,a-b-c') + self.hass.block_till_done() + + # color should not have changed + state = self.hass.states.get('light.test') + self.assertEqual([255, 255, 255], state.attributes.get('rgb_color')) + + # bad white value values + fire_mqtt_message(self.hass, 'test_light_rgb', 'on,,,off,255-255-255') + self.hass.block_till_done() + + # white value should not have changed + state = self.hass.states.get('light.test') + self.assertEqual(222, state.attributes.get('white_value')) + + # bad effect value + fire_mqtt_message(self.hass, 'test_light_rgb', 'on,255,a-b-c,white') + self.hass.block_till_done() + + # effect should not have changed + state = self.hass.states.get('light.test') + self.assertEqual('rainbow', state.attributes.get('effect')) + + def test_default_availability_payload(self): + """Test availability by default payload with defined topic.""" + self.assertTrue(setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,{{ transition }}', + 'command_off_template': 'off,{{ transition|d }}', + 'availability_topic': 'availability-topic' + } + })) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'online') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'offline') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + def test_custom_availability_payload(self): + """Test availability by custom payload with defined topic.""" + self.assertTrue(setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,{{ transition }}', + 'command_off_template': 'off,{{ transition|d }}', + 'availability_topic': 'availability-topic', + 'payload_available': 'good', + 'payload_not_available': 'nogood' + } + })) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'good') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertNotEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability-topic', 'nogood') + self.hass.block_till_done() + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) diff --git a/tests/fixtures/pushbullet_devices.json b/tests/fixtures/pushbullet_devices.json index 04f336c8adf..576e748471a 100755 --- a/tests/fixtures/pushbullet_devices.json +++ b/tests/fixtures/pushbullet_devices.json @@ -1,43 +1,43 @@ -{ - "accounts": [], - "blocks": [], - "channels": [], - "chats": [], - "clients": [], - "contacts": [], - "devices": [{ - "active": true, - "iden": "identity1", - "created": 1.514520333770855e+09, - "modified": 1.5151951594363022e+09, - "type": "windows", - "kind": "windows", - "nickname": "DESKTOP", - "manufacturer": "Microsoft", - "model": "Windows 10 Home", - "app_version": 396, - "fingerprint": "{\"cpu\":\"AMD\",\"computer_name\":\"DESKTOP\"}", - "pushable": true, - "icon": "desktop", - "remote_files": "disabled" - }, { - "active": true, - "iden": "identity2", - "created": 1.5144974875448499e+09, - "modified": 1.514574792288634e+09, - "type": "ios", - "kind": "ios", - "nickname": "My iPhone", - "manufacturer": "Apple", - "model": "iPhone", - "app_version": 8646, - "push_token": "production:mytoken", - "pushable": true, - "icon": "phone" - }], - "grants": [], - "pushes": [], - "profiles": [], - "subscriptions": [], - "texts": [] -} +{ + "accounts": [], + "blocks": [], + "channels": [], + "chats": [], + "clients": [], + "contacts": [], + "devices": [{ + "active": true, + "iden": "identity1", + "created": 1.514520333770855e+09, + "modified": 1.5151951594363022e+09, + "type": "windows", + "kind": "windows", + "nickname": "DESKTOP", + "manufacturer": "Microsoft", + "model": "Windows 10 Home", + "app_version": 396, + "fingerprint": "{\"cpu\":\"AMD\",\"computer_name\":\"DESKTOP\"}", + "pushable": true, + "icon": "desktop", + "remote_files": "disabled" + }, { + "active": true, + "iden": "identity2", + "created": 1.5144974875448499e+09, + "modified": 1.514574792288634e+09, + "type": "ios", + "kind": "ios", + "nickname": "My iPhone", + "manufacturer": "Apple", + "model": "iPhone", + "app_version": 8646, + "push_token": "production:mytoken", + "pushable": true, + "icon": "phone" + }], + "grants": [], + "pushes": [], + "profiles": [], + "subscriptions": [], + "texts": [] +} diff --git a/tests/fixtures/yahooweather.json b/tests/fixtures/yahooweather.json index f6ab2980618..7d8188764df 100644 --- a/tests/fixtures/yahooweather.json +++ b/tests/fixtures/yahooweather.json @@ -1,138 +1,138 @@ -{ - "query": { - "count": 1, - "created": "2017-11-17T13:40:47Z", - "lang": "en-US", - "results": { - "channel": { - "units": { - "distance": "km", - "pressure": "mb", - "speed": "km/h", - "temperature": "C" - }, - "title": "Yahoo! Weather - San Diego, CA, US", - "link": "http://us.rd.yahoo.com/dailynews/rss/weather/Country__Country/*https://weather.yahoo.com/country/state/city-23511632/", - "description": "Yahoo! Weather for San Diego, CA, US", - "language": "en-us", - "lastBuildDate": "Fri, 17 Nov 2017 05:40 AM PST", - "ttl": "60", - "location": { - "city": "San Diego", - "country": "United States", - "region": " CA" - }, - "wind": { - "chill": "56", - "direction": "0", - "speed": "6.34" - }, - "atmosphere": { - "humidity": "71", - "pressure": "33863.75", - "rising": "0", - "visibility": "22.91" - }, - "astronomy": { - "sunrise": "6:21 am", - "sunset": "4:47 pm" - }, - "image": { - "title": "Yahoo! Weather", - "width": "142", - "height": "18", - "link": "http://weather.yahoo.com", - "url": "http://l.yimg.com/a/i/brand/purplelogo//uh/us/news-wea.gif" - }, - "item": { - "title": "Conditions for San Diego, CA, US at 05:00 AM PST", - "lat": "32.878101", - "long": "-117.23497", - "link": "http://us.rd.yahoo.com/dailynews/rss/weather/Country__Country/*https://weather.yahoo.com/country/state/city-23511632/", - "pubDate": "Fri, 17 Nov 2017 05:00 AM PST", - "condition": { - "code": "26", - "date": "Fri, 17 Nov 2017 05:00 AM PST", - "temp": "18", - "text": "Cloudy" - }, - "forecast": [{ - "code": "28", - "date": "17 Nov 2017", - "day": "Fri", - "high": "23", - "low": "16", - "text": "Mostly Cloudy" - }, { - "code": "30", - "date": "18 Nov 2017", - "day": "Sat", - "high": "22", - "low": "13", - "text": "Partly Cloudy" - }, { - "code": "30", - "date": "19 Nov 2017", - "day": "Sun", - "high": "22", - "low": "12", - "text": "Partly Cloudy" - }, { - "code": "28", - "date": "20 Nov 2017", - "day": "Mon", - "high": "21", - "low": "11", - "text": "Mostly Cloudy" - }, { - "code": "28", - "date": "21 Nov 2017", - "day": "Tue", - "high": "24", - "low": "14", - "text": "Mostly Cloudy" - }, { - "code": "30", - "date": "22 Nov 2017", - "day": "Wed", - "high": "27", - "low": "15", - "text": "Partly Cloudy" - }, { - "code": "34", - "date": "23 Nov 2017", - "day": "Thu", - "high": "27", - "low": "15", - "text": "Mostly Sunny" - }, { - "code": "30", - "date": "24 Nov 2017", - "day": "Fri", - "high": "23", - "low": "16", - "text": "Partly Cloudy" - }, { - "code": "30", - "date": "25 Nov 2017", - "day": "Sat", - "high": "22", - "low": "15", - "text": "Partly Cloudy" - }, { - "code": "28", - "date": "26 Nov 2017", - "day": "Sun", - "high": "24", - "low": "13", - "text": "Mostly Cloudy" - }], - "description": "\n
\nCurrent Conditions:\n
Cloudy\n
\n
\nForecast:\n
Fri - Mostly Cloudy. High: 23Low: 16\n
Sat - Partly Cloudy. High: 22Low: 13\n
Sun - Partly Cloudy. High: 22Low: 12\n
Mon - Mostly Cloudy. High: 21Low: 11\n
Tue - Mostly Cloudy. High: 24Low: 14\n
\n
\nFull Forecast at Yahoo! Weather\n
\n
\n
\n]]>", - "guid": { - "isPermaLink": "false" - } - } - } - } - } -} +{ + "query": { + "count": 1, + "created": "2017-11-17T13:40:47Z", + "lang": "en-US", + "results": { + "channel": { + "units": { + "distance": "km", + "pressure": "mb", + "speed": "km/h", + "temperature": "C" + }, + "title": "Yahoo! Weather - San Diego, CA, US", + "link": "http://us.rd.yahoo.com/dailynews/rss/weather/Country__Country/*https://weather.yahoo.com/country/state/city-23511632/", + "description": "Yahoo! Weather for San Diego, CA, US", + "language": "en-us", + "lastBuildDate": "Fri, 17 Nov 2017 05:40 AM PST", + "ttl": "60", + "location": { + "city": "San Diego", + "country": "United States", + "region": " CA" + }, + "wind": { + "chill": "56", + "direction": "0", + "speed": "6.34" + }, + "atmosphere": { + "humidity": "71", + "pressure": "33863.75", + "rising": "0", + "visibility": "22.91" + }, + "astronomy": { + "sunrise": "6:21 am", + "sunset": "4:47 pm" + }, + "image": { + "title": "Yahoo! Weather", + "width": "142", + "height": "18", + "link": "http://weather.yahoo.com", + "url": "http://l.yimg.com/a/i/brand/purplelogo//uh/us/news-wea.gif" + }, + "item": { + "title": "Conditions for San Diego, CA, US at 05:00 AM PST", + "lat": "32.878101", + "long": "-117.23497", + "link": "http://us.rd.yahoo.com/dailynews/rss/weather/Country__Country/*https://weather.yahoo.com/country/state/city-23511632/", + "pubDate": "Fri, 17 Nov 2017 05:00 AM PST", + "condition": { + "code": "26", + "date": "Fri, 17 Nov 2017 05:00 AM PST", + "temp": "18", + "text": "Cloudy" + }, + "forecast": [{ + "code": "28", + "date": "17 Nov 2017", + "day": "Fri", + "high": "23", + "low": "16", + "text": "Mostly Cloudy" + }, { + "code": "30", + "date": "18 Nov 2017", + "day": "Sat", + "high": "22", + "low": "13", + "text": "Partly Cloudy" + }, { + "code": "30", + "date": "19 Nov 2017", + "day": "Sun", + "high": "22", + "low": "12", + "text": "Partly Cloudy" + }, { + "code": "28", + "date": "20 Nov 2017", + "day": "Mon", + "high": "21", + "low": "11", + "text": "Mostly Cloudy" + }, { + "code": "28", + "date": "21 Nov 2017", + "day": "Tue", + "high": "24", + "low": "14", + "text": "Mostly Cloudy" + }, { + "code": "30", + "date": "22 Nov 2017", + "day": "Wed", + "high": "27", + "low": "15", + "text": "Partly Cloudy" + }, { + "code": "34", + "date": "23 Nov 2017", + "day": "Thu", + "high": "27", + "low": "15", + "text": "Mostly Sunny" + }, { + "code": "30", + "date": "24 Nov 2017", + "day": "Fri", + "high": "23", + "low": "16", + "text": "Partly Cloudy" + }, { + "code": "30", + "date": "25 Nov 2017", + "day": "Sat", + "high": "22", + "low": "15", + "text": "Partly Cloudy" + }, { + "code": "28", + "date": "26 Nov 2017", + "day": "Sun", + "high": "24", + "low": "13", + "text": "Mostly Cloudy" + }], + "description": "\n
\nCurrent Conditions:\n
Cloudy\n
\n
\nForecast:\n
Fri - Mostly Cloudy. High: 23Low: 16\n
Sat - Partly Cloudy. High: 22Low: 13\n
Sun - Partly Cloudy. High: 22Low: 12\n
Mon - Mostly Cloudy. High: 21Low: 11\n
Tue - Mostly Cloudy. High: 24Low: 14\n
\n
\nFull Forecast at Yahoo! Weather\n
\n
\n
\n]]>", + "guid": { + "isPermaLink": "false" + } + } + } + } + } +} From 7059b6c6c1d1eecb299cea40122023ba445fe33e Mon Sep 17 00:00:00 2001 From: Richard Lucas Date: Sun, 11 Feb 2018 23:20:54 -0800 Subject: [PATCH 034/173] Always return lockState == LOCKED when handling Alexa.LockController (#12328) --- homeassistant/components/alexa/smart_home.py | 11 ++++++++++- tests/components/alexa/test_smart_home.py | 8 +++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index c0a42ef8aa6..a4f0225d22d 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -1065,7 +1065,16 @@ def async_api_lock(hass, config, request, entity): ATTR_ENTITY_ID: entity.entity_id }, blocking=False) - return api_message(request) + # Alexa expects a lockState in the response, we don't know the actual + # lockState at this point but assume it is locked. It is reported + # correctly later when ReportState is called. The alt. to this approach + # is to implement DeferredResponse + properties = [{ + 'name': 'lockState', + 'namespace': 'Alexa.LockController', + 'value': 'LOCKED' + }] + return api_message(request, context={'properties': properties}) # Not supported by Alexa yet diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 96e16544438..9654c667c5f 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -401,11 +401,17 @@ def test_lock(hass): assert appliance['friendlyName'] == "Test lock" assert_endpoint_capabilities(appliance, 'Alexa.LockController') - yield from assert_request_calls_service( + _, msg = yield from assert_request_calls_service( 'Alexa.LockController', 'Lock', 'lock#test', 'lock.lock', hass) + # always return LOCKED for now + properties = msg['context']['properties'][0] + assert properties['name'] == 'lockState' + assert properties['namespace'] == 'Alexa.LockController' + assert properties['value'] == 'LOCKED' + @asyncio.coroutine def test_media_player(hass): From eaa27915392e9a4103cefd353e6e0af984d7386a Mon Sep 17 00:00:00 2001 From: Mike O'Driscoll Date: Mon, 12 Feb 2018 02:23:53 -0500 Subject: [PATCH 035/173] Unifi tracking filter by SSID (#12281) Enable unifi device tracker component to track devices only on specific SSIDs. --- .../components/device_tracker/unifi.py | 18 +++++-- tests/components/device_tracker/test_unifi.py | 52 +++++++++++++++---- 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/device_tracker/unifi.py b/homeassistant/components/device_tracker/unifi.py index 59b538cd824..54aa9a5972c 100644 --- a/homeassistant/components/device_tracker/unifi.py +++ b/homeassistant/components/device_tracker/unifi.py @@ -21,11 +21,13 @@ _LOGGER = logging.getLogger(__name__) CONF_PORT = 'port' CONF_SITE_ID = 'site_id' CONF_DETECTION_TIME = 'detection_time' +CONF_SSID_FILTER = 'ssid_filter' DEFAULT_HOST = 'localhost' DEFAULT_PORT = 8443 DEFAULT_VERIFY_SSL = True DEFAULT_DETECTION_TIME = timedelta(seconds=300) +DEFAULT_SSID_FILTER = None NOTIFICATION_ID = 'unifi_notification' NOTIFICATION_TITLE = 'Unifi Device Tracker Setup' @@ -39,7 +41,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): vol.Any( cv.boolean, cv.isfile), vol.Optional(CONF_DETECTION_TIME, default=DEFAULT_DETECTION_TIME): vol.All( - cv.time_period, cv.positive_timedelta) + cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_SSID_FILTER, default=DEFAULT_SSID_FILTER): vol.All( + cv.ensure_list, [cv.string]) }) @@ -54,6 +58,7 @@ def get_scanner(hass, config): port = config[DOMAIN].get(CONF_PORT) verify_ssl = config[DOMAIN].get(CONF_VERIFY_SSL) detection_time = config[DOMAIN].get(CONF_DETECTION_TIME) + ssid_filter = config[DOMAIN].get(CONF_SSID_FILTER) try: ctrl = Controller(host, username, password, port, version='v4', @@ -69,16 +74,18 @@ def get_scanner(hass, config): notification_id=NOTIFICATION_ID) return False - return UnifiScanner(ctrl, detection_time) + return UnifiScanner(ctrl, detection_time, ssid_filter) class UnifiScanner(DeviceScanner): """Provide device_tracker support from Unifi WAP client data.""" - def __init__(self, controller, detection_time: timedelta) -> None: + def __init__(self, controller, detection_time: timedelta, + ssid_filter) -> None: """Initialize the scanner.""" self._detection_time = detection_time self._controller = controller + self._ssid_filter = ssid_filter self._update() def _update(self): @@ -90,6 +97,11 @@ class UnifiScanner(DeviceScanner): _LOGGER.error("Failed to scan clients: %s", ex) clients = [] + # Filter clients to provided SSID list + if self._ssid_filter: + clients = filter(lambda x: x['essid'] in self._ssid_filter, + clients) + self._clients = { client['mac']: client for client in clients diff --git a/tests/components/device_tracker/test_unifi.py b/tests/components/device_tracker/test_unifi.py index 083315b4c71..ccc58d728ed 100644 --- a/tests/components/device_tracker/test_unifi.py +++ b/tests/components/device_tracker/test_unifi.py @@ -53,7 +53,8 @@ def test_config_valid_verify_ssl(hass, mock_scanner, mock_ctrl): assert mock_scanner.call_count == 1 assert mock_scanner.call_args == mock.call(mock_ctrl.return_value, - DEFAULT_DETECTION_TIME) + DEFAULT_DETECTION_TIME, + None) def test_config_minimal(hass, mock_scanner, mock_ctrl): @@ -74,7 +75,8 @@ def test_config_minimal(hass, mock_scanner, mock_ctrl): assert mock_scanner.call_count == 1 assert mock_scanner.call_args == mock.call(mock_ctrl.return_value, - DEFAULT_DETECTION_TIME) + DEFAULT_DETECTION_TIME, + None) def test_config_full(hass, mock_scanner, mock_ctrl): @@ -100,7 +102,8 @@ def test_config_full(hass, mock_scanner, mock_ctrl): assert mock_scanner.call_count == 1 assert mock_scanner.call_args == mock.call(mock_ctrl.return_value, - DEFAULT_DETECTION_TIME) + DEFAULT_DETECTION_TIME, + None) def test_config_error(): @@ -148,11 +151,13 @@ def test_scanner_update(): """Test the scanner update.""" ctrl = mock.MagicMock() fake_clients = [ - {'mac': '123', 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, - {'mac': '234', 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, + {'mac': '123', 'essid': 'barnet', + 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, + {'mac': '234', 'essid': 'barnet', + 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, ] ctrl.get_clients.return_value = fake_clients - unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME) + unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None) assert ctrl.get_clients.call_count == 1 assert ctrl.get_clients.call_args == mock.call() @@ -162,36 +167,61 @@ def test_scanner_update_error(): ctrl = mock.MagicMock() ctrl.get_clients.side_effect = APIError( '/', 500, 'foo', {}, None) - unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME) + unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None) def test_scan_devices(): """Test the scanning for devices.""" ctrl = mock.MagicMock() fake_clients = [ - {'mac': '123', 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, - {'mac': '234', 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, + {'mac': '123', 'essid': 'barnet', + 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, + {'mac': '234', 'essid': 'barnet', + 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, ] ctrl.get_clients.return_value = fake_clients - scanner = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME) + scanner = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None) assert set(scanner.scan_devices()) == set(['123', '234']) +def test_scan_devices_filtered(): + """Test the scanning for devices based on SSID.""" + ctrl = mock.MagicMock() + fake_clients = [ + {'mac': '123', 'essid': 'foonet', + 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, + {'mac': '234', 'essid': 'foonet', + 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, + {'mac': '567', 'essid': 'notnet', + 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, + {'mac': '890', 'essid': 'barnet', + 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, + ] + + ssid_filter = ['foonet', 'barnet'] + ctrl.get_clients.return_value = fake_clients + scanner = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, ssid_filter) + assert set(scanner.scan_devices()) == set(['123', '234', '890']) + + def test_get_device_name(): """Test the getting of device names.""" ctrl = mock.MagicMock() fake_clients = [ {'mac': '123', 'hostname': 'foobar', + 'essid': 'barnet', 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, {'mac': '234', 'name': 'Nice Name', + 'essid': 'barnet', 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, {'mac': '456', + 'essid': 'barnet', 'last_seen': '1504786810'}, ] ctrl.get_clients.return_value = fake_clients - scanner = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME) + scanner = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None) assert scanner.get_device_name('123') == 'foobar' assert scanner.get_device_name('234') == 'Nice Name' assert scanner.get_device_name('456') is None From ebe4418afe686d3c4345119c271fa528359c5e19 Mon Sep 17 00:00:00 2001 From: Albert Lee Date: Mon, 12 Feb 2018 01:24:29 -0600 Subject: [PATCH 036/173] device_tracker.asuswrt: Ignore unreachable ip neigh entries (#12201) --- homeassistant/components/device_tracker/asuswrt.py | 6 +++++- tests/components/device_tracker/test_asuswrt.py | 10 ++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index ee243f12988..1956e42cb78 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -63,6 +63,7 @@ _IP_NEIGH_REGEX = re.compile( r'\w+\s' r'(\w+\s(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))))?\s' r'\s?(router)?' + r'\s?(nud)?' r'(?P(\w+))') _ARP_CMD = 'arp -n' @@ -211,6 +212,9 @@ class AsusWrtDeviceScanner(DeviceScanner): result = _parse_lines(lines, _IP_NEIGH_REGEX) devices = {} for device in result: + status = device['status'] + if status is None or status.upper() != 'REACHABLE': + continue if device['mac'] is not None: mac = device['mac'].upper() old_device = cur_devices.get(mac) @@ -225,7 +229,7 @@ class AsusWrtDeviceScanner(DeviceScanner): result = _parse_lines(lines, _ARP_REGEX) devices = {} for device in result: - if device['mac']: + if device['mac'] is not None: mac = device['mac'].upper() devices[mac] = Device(mac, device['ip'], None) return devices diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py index bf7d5145e33..48ddf1d3692 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -52,8 +52,8 @@ WL_DEVICES = { ARP_DATA = [ '? (123.123.123.125) at 01:02:03:04:06:08 [ether] on eth0\r', - '? (123.123.123.126) at 08:09:10:11:12:14 [ether] on br0\r' - '? (123.123.123.127) at on br0\r' + '? (123.123.123.126) at 08:09:10:11:12:14 [ether] on br0\r', + '? (123.123.123.127) at on br0\r', ] ARP_DEVICES = { @@ -65,8 +65,10 @@ ARP_DEVICES = { NEIGH_DATA = [ '123.123.123.125 dev eth0 lladdr 01:02:03:04:06:08 REACHABLE\r', - '123.123.123.126 dev br0 lladdr 08:09:10:11:12:14 STALE\r' - '123.123.123.127 dev br0 FAILED\r' + '123.123.123.126 dev br0 lladdr 08:09:10:11:12:14 REACHABLE\r', + '123.123.123.127 dev br0 FAILED\r', + '123.123.123.128 dev br0 lladdr 08:09:15:15:15:15 DELAY\r', + 'fe80::feff:a6ff:feff:12ff dev br0 lladdr fc:ff:a6:ff:12:ff STALE\r', ] NEIGH_DEVICES = { From 04b68902e3b63b942bf7b9399c69eada92c00f73 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 11 Feb 2018 23:26:52 -0800 Subject: [PATCH 037/173] Fix platform dependencies (#12330) --- homeassistant/setup.py | 2 +- tests/helpers/test_entity_component.py | 30 +++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 364bbc94230..2c69fdefeee 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -206,7 +206,7 @@ def async_prepare_setup_platform(hass: core.HomeAssistant, config, domain: str, return platform try: - yield from _process_deps_reqs(hass, config, platform_name, platform) + yield from _process_deps_reqs(hass, config, platform_path, platform) except HomeAssistantError as err: log_error(str(err)) return None diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index ef92da3172b..d8dac11f6a0 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -12,7 +12,7 @@ import homeassistant.loader as loader from homeassistant.exceptions import PlatformNotReady from homeassistant.components import group from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.setup import setup_component +from homeassistant.setup import setup_component, async_setup_component from homeassistant.helpers import discovery import homeassistant.util.dt as dt_util @@ -305,3 +305,31 @@ def test_extract_from_service_no_group_expand(hass): extracted = component.async_extract_from_service(call, expand_group=False) assert extracted == [test_group] + + +@asyncio.coroutine +def test_setup_dependencies_platform(hass): + """Test we setup the dependencies of a platform. + + We're explictely testing that we process dependencies even if a component + with the same name has already been loaded. + """ + loader.set_component('test_component', MockModule('test_component')) + loader.set_component('test_component2', MockModule('test_component2')) + loader.set_component( + 'test_domain.test_component', + MockPlatform(dependencies=['test_component', 'test_component2'])) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + + yield from async_setup_component(hass, 'test_component', {}) + + yield from component.async_setup({ + DOMAIN: { + 'platform': 'test_component', + } + }) + + assert 'test_component' in hass.config.components + assert 'test_component2' in hass.config.components + assert 'test_domain.test_component' in hass.config.components From d34a4fb6e33b50d9e0702a9d872d138e644edaed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 12 Feb 2018 07:52:00 +0000 Subject: [PATCH 038/173] nmap_tracker: don't scan on setup (#12322) * nmap_tracker: don't scan on setup A scan takes about 6 seconds so it delays HA from booting. Since another scan is done by the device_tracker base component during setup, there is no need to do two scans on boot. * simplify setup --- homeassistant/components/device_tracker/nmap_tracker.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/device_tracker/nmap_tracker.py b/homeassistant/components/device_tracker/nmap_tracker.py index 3c3fd954a73..d21e416e153 100644 --- a/homeassistant/components/device_tracker/nmap_tracker.py +++ b/homeassistant/components/device_tracker/nmap_tracker.py @@ -41,9 +41,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def get_scanner(hass, config): """Validate the configuration and return a Nmap scanner.""" - scanner = NmapDeviceScanner(config[DOMAIN]) - - return scanner if scanner.success_init else None + return NmapDeviceScanner(config[DOMAIN]) Device = namedtuple('Device', ['mac', 'name', 'ip', 'last_update']) @@ -76,7 +74,6 @@ class NmapDeviceScanner(DeviceScanner): self._options = config[CONF_OPTIONS] self.home_interval = timedelta(minutes=minutes) - self.success_init = self._update_info() _LOGGER.info("Scanner initialized") def scan_devices(self): From 034eb9ae1aa33cec829393f409343919c036044c Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 12 Feb 2018 11:24:26 +0100 Subject: [PATCH 039/173] Upgrade Sphinx to 1.7.0 (#12335) --- requirements_docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_docs.txt b/requirements_docs.txt index c5c48e0bc73..4c159fd4d94 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,3 +1,3 @@ -Sphinx==1.6.7 +Sphinx==1.7.0 sphinx-autodoc-typehints==1.2.4 sphinx-autodoc-annotation==1.0.post1 From 870728f68fcc54a1cb4c3c15386b0b2af5c32ff5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 12 Feb 2018 10:59:20 -0800 Subject: [PATCH 040/173] Mock Module + Platform default to async (#12347) * Mock Module + Platform default to async * Change checks --- tests/common.py | 27 ++++++++++++--------------- tests/helpers/test_entity_platform.py | 2 +- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/tests/common.py b/tests/common.py index d194ad4d040..9e4575780bc 100644 --- a/tests/common.py +++ b/tests/common.py @@ -344,7 +344,6 @@ class MockModule(object): self.DOMAIN = domain self.DEPENDENCIES = dependencies or [] self.REQUIREMENTS = requirements or [] - self._setup = setup if config_schema is not None: self.CONFIG_SCHEMA = config_schema @@ -352,18 +351,15 @@ class MockModule(object): if platform_schema is not None: self.PLATFORM_SCHEMA = platform_schema + if setup is not None: + # We run this in executor, wrap it in function + self.setup = lambda *args: setup(*args) + if async_setup is not None: self.async_setup = async_setup - def setup(self, hass, config): - """Set up the component. - - We always define this mock because MagicMock setups will be seen by the - executor as a coroutine, raising an exception. - """ - if self._setup is not None: - return self._setup(hass, config) - return True + if setup is None and async_setup is None: + self.async_setup = mock_coro_func(True) class MockPlatform(object): @@ -374,18 +370,19 @@ class MockPlatform(object): platform_schema=None, async_setup_platform=None): """Initialize the platform.""" self.DEPENDENCIES = dependencies or [] - self._setup_platform = setup_platform if platform_schema is not None: self.PLATFORM_SCHEMA = platform_schema + if setup_platform is not None: + # We run this in executor, wrap it in function + self.setup_platform = lambda *args: setup_platform(*args) + if async_setup_platform is not None: self.async_setup_platform = async_setup_platform - def setup_platform(self, hass, config, add_devices, discovery_info=None): - """Set up the platform.""" - if self._setup_platform is not None: - self._setup_platform(hass, config, add_devices, discovery_info) + if setup_platform is None and async_setup_platform is None: + self.async_setup_platform = mock_coro_func() class MockToggleDevice(entity.ToggleEntity): diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index a54a6de511a..c9705a73f7b 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -331,7 +331,7 @@ def test_parallel_updates_async_platform_with_constant(hass): @asyncio.coroutine def test_parallel_updates_sync_platform(hass): """Warn we log when platform setup takes a long time.""" - platform = MockPlatform() + platform = MockPlatform(setup_platform=lambda *args: None) loader.set_component('test_domain.platform', platform) From 52f57b755e938831cb63f5f4409b1b78d5f88a79 Mon Sep 17 00:00:00 2001 From: Mike O'Driscoll Date: Mon, 12 Feb 2018 14:02:07 -0500 Subject: [PATCH 041/173] Change Unifi SSID filtering to list comprehension (#12344) Changed from filter to list comprehension. --- homeassistant/components/device_tracker/unifi.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/unifi.py b/homeassistant/components/device_tracker/unifi.py index 54aa9a5972c..72ea4f0902e 100644 --- a/homeassistant/components/device_tracker/unifi.py +++ b/homeassistant/components/device_tracker/unifi.py @@ -99,8 +99,8 @@ class UnifiScanner(DeviceScanner): # Filter clients to provided SSID list if self._ssid_filter: - clients = filter(lambda x: x['essid'] in self._ssid_filter, - clients) + clients = [client for client in clients + if client['essid'] in self._ssid_filter] self._clients = { client['mac']: client From 2d77a2bb393e66fbedd013b4d067c11b34feab37 Mon Sep 17 00:00:00 2001 From: Dougal Matthews Date: Mon, 12 Feb 2018 19:15:10 +0000 Subject: [PATCH 042/173] Use the speedometer icon in the fastdotcom sensor (#12348) This change makes the icon match the one used in the speedtest sensor. --- homeassistant/components/sensor/fastdotcom.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/sensor/fastdotcom.py b/homeassistant/components/sensor/fastdotcom.py index 02dd32c20af..9143ccaf23f 100644 --- a/homeassistant/components/sensor/fastdotcom.py +++ b/homeassistant/components/sensor/fastdotcom.py @@ -26,6 +26,8 @@ CONF_HOUR = 'hour' CONF_DAY = 'day' CONF_MANUAL = 'manual' +ICON = 'mdi:speedometer' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SECOND, default=[0]): vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(0, 59))]), @@ -94,6 +96,11 @@ class SpeedtestSensor(Entity): return self._state = state.state + @property + def icon(self): + """Return icon.""" + return ICON + class SpeedtestData(object): """Get the latest data from fast.com.""" From 04bde68db3930867c75979a2f5ff46e56d2c2cb7 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Mon, 12 Feb 2018 20:24:48 +0100 Subject: [PATCH 043/173] Communication reduced. Setting brightness and/or color temperature will turn on the device. (#12343) --- homeassistant/components/light/xiaomi_miio.py | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/light/xiaomi_miio.py b/homeassistant/components/light/xiaomi_miio.py index 06d585b8593..ff4d851142b 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -289,6 +289,28 @@ class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light): color_temp, self.max_mireds, self.min_mireds, CCT_MIN, CCT_MAX) + if ATTR_BRIGHTNESS in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] + percent_brightness = ceil(100 * brightness / 255.0) + + if ATTR_BRIGHTNESS in kwargs and ATTR_COLOR_TEMP in kwargs: + _LOGGER.debug( + "Setting brightness and color temperature: " + "%s %s%%, %s mireds, %s%% cct", + brightness, percent_brightness, + color_temp, percent_color_temp) + + result = yield from self._try_command( + "Setting brightness and color temperature failed: " + "%s bri, %s cct", + self._light.set_brightness_and_color_temperature, + percent_brightness, percent_color_temp) + + if result: + self._color_temp = color_temp + self._brightness = brightness + + elif ATTR_COLOR_TEMP in kwargs: _LOGGER.debug( "Setting color temperature: " "%s mireds, %s%% cct", @@ -301,7 +323,7 @@ class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light): if result: self._color_temp = color_temp - if ATTR_BRIGHTNESS in kwargs: + elif ATTR_BRIGHTNESS in kwargs: brightness = kwargs[ATTR_BRIGHTNESS] percent_brightness = ceil(100 * brightness / 255.0) @@ -316,8 +338,9 @@ class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light): if result: self._brightness = brightness - self._state = yield from self._try_command( - "Turning the light on failed.", self._light.on) + else: + self._state = yield from self._try_command( + "Turning the light on failed.", self._light.on) @asyncio.coroutine def async_update(self): From 2c202690d8db4e6ccdfaf09b5935e8d47057a778 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Mon, 12 Feb 2018 22:15:28 +0100 Subject: [PATCH 044/173] Fix WUnderground names (#12346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 📝 Fix WUnderground names * 👻 Fix using event loop callback --- .../components/sensor/wunderground.py | 19 +++++++------- tests/components/sensor/test_wunderground.py | 26 +++++++++++++------ 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/sensor/wunderground.py b/homeassistant/components/sensor/wunderground.py index d0d9758c13a..aa5d431a7b0 100644 --- a/homeassistant/components/sensor/wunderground.py +++ b/homeassistant/components/sensor/wunderground.py @@ -11,14 +11,14 @@ import re import requests import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.components.sensor import PLATFORM_SCHEMA, ENTITY_ID_FORMAT from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, TEMP_FAHRENHEIT, TEMP_CELSIUS, LENGTH_INCHES, LENGTH_KILOMETERS, - LENGTH_MILES, LENGTH_FEET, STATE_UNKNOWN, ATTR_ATTRIBUTION, - ATTR_FRIENDLY_NAME) + LENGTH_MILES, LENGTH_FEET, STATE_UNKNOWN, ATTR_ATTRIBUTION) from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, generate_entity_id from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv @@ -637,7 +637,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): config.get(CONF_LANG), latitude, longitude) sensors = [] for variable in config[CONF_MONITORED_CONDITIONS]: - sensors.append(WUndergroundSensor(rest, variable)) + sensors.append(WUndergroundSensor(hass, rest, variable)) rest.update() if not rest.data: @@ -651,7 +651,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class WUndergroundSensor(Entity): """Implementing the WUnderground sensor.""" - def __init__(self, rest, condition): + def __init__(self, hass: HomeAssistantType, rest, condition): """Initialize the sensor.""" self.rest = rest self._condition = condition @@ -663,6 +663,8 @@ class WUndergroundSensor(Entity): self._entity_picture = None self._unit_of_measurement = self._cfg_expand("unit_of_measurement") self.rest.request_feature(SENSOR_TYPES[condition].feature) + self.entity_id = generate_entity_id( + ENTITY_ID_FORMAT, "pws_" + condition, hass=hass) def _cfg_expand(self, what, default=None): """Parse and return sensor data.""" @@ -684,9 +686,6 @@ class WUndergroundSensor(Entity): """Parse and update device state attributes.""" attrs = self._cfg_expand("device_state_attributes", {}) - self._attributes[ATTR_FRIENDLY_NAME] = self._cfg_expand( - "friendly_name") - for (attr, callback) in attrs.items(): if callable(callback): try: @@ -701,7 +700,7 @@ class WUndergroundSensor(Entity): @property def name(self): """Return the name of the sensor.""" - return "PWS_" + self._condition + return self._cfg_expand("friendly_name") @property def state(self): diff --git a/tests/components/sensor/test_wunderground.py b/tests/components/sensor/test_wunderground.py index 5f6028b1a14..c1508f49851 100644 --- a/tests/components/sensor/test_wunderground.py +++ b/tests/components/sensor/test_wunderground.py @@ -249,31 +249,41 @@ class TestWundergroundSetup(unittest.TestCase): None) for device in self.DEVICES: device.update() - self.assertTrue(str(device.name).startswith('PWS_')) - if device.name == 'PWS_weather': + entity_id = device.entity_id + friendly_name = device.name + self.assertTrue(entity_id.startswith('sensor.pws_')) + if entity_id == 'sensor.pws_weather': self.assertEqual(HTTPS_ICON_URL, device.entity_picture) self.assertEqual(WEATHER, device.state) self.assertIsNone(device.unit_of_measurement) - elif device.name == 'PWS_alerts': + self.assertEqual("Weather Summary", friendly_name) + elif entity_id == 'sensor.pws_alerts': self.assertEqual(1, device.state) self.assertEqual(ALERT_MESSAGE, device.device_state_attributes['Message']) self.assertEqual(ALERT_ICON, device.icon) self.assertIsNone(device.entity_picture) - elif device.name == 'PWS_location': + self.assertEqual('Alerts', friendly_name) + elif entity_id == 'sensor.pws_location': self.assertEqual('Holly Springs, NC', device.state) - elif device.name == 'PWS_elevation': + self.assertEqual('Location', friendly_name) + elif entity_id == 'sensor.pws_elevation': self.assertEqual('413', device.state) - elif device.name == 'PWS_feelslike_c': + self.assertEqual('Elevation', friendly_name) + elif entity_id == 'sensor.pws_feelslike_c': self.assertIsNone(device.entity_picture) self.assertEqual(FEELS_LIKE, device.state) self.assertEqual(TEMP_CELSIUS, device.unit_of_measurement) - elif device.name == 'PWS_weather_1d_metric': + self.assertEqual("Feels Like", friendly_name) + elif entity_id == 'sensor.pws_weather_1d_metric': self.assertEqual(FORECAST_TEXT, device.state) + self.assertEqual('Tuesday', friendly_name) else: - self.assertEqual(device.name, 'PWS_precip_1d_in') + self.assertEqual(entity_id, 'sensor.pws_precip_1d_in') self.assertEqual(PRECIP_IN, device.state) self.assertEqual(LENGTH_INCHES, device.unit_of_measurement) + self.assertEqual('Precipitation Intensity Today', + friendly_name) @unittest.mock.patch('requests.get', side_effect=ConnectionError('test exception')) From 0a558a0e824bbf741dfa19f07729a1de028b6d31 Mon Sep 17 00:00:00 2001 From: Mike O'Driscoll Date: Mon, 12 Feb 2018 22:43:56 -0500 Subject: [PATCH 045/173] Add New Sensor for ISP Start.ca (#12356) Adding a new sensor for ISP Start.ca to track download/upload usage. --- homeassistant/components/sensor/startca.py | 186 ++++++++++++++++++ requirements_all.txt | 1 + tests/components/sensor/test_startca.py | 215 +++++++++++++++++++++ 3 files changed, 402 insertions(+) create mode 100644 homeassistant/components/sensor/startca.py create mode 100644 tests/components/sensor/test_startca.py diff --git a/homeassistant/components/sensor/startca.py b/homeassistant/components/sensor/startca.py new file mode 100644 index 00000000000..a5908812b6c --- /dev/null +++ b/homeassistant/components/sensor/startca.py @@ -0,0 +1,186 @@ +""" +Support for Start.ca Bandwidth Monitor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.startca/ +""" +from datetime import timedelta +from xml.parsers.expat import ExpatError +import logging +import asyncio +import async_timeout + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_API_KEY, CONF_MONITORED_VARIABLES, CONF_NAME) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['xmltodict==0.11.0'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Start.ca' +CONF_TOTAL_BANDWIDTH = 'total_bandwidth' + +GIGABYTES = 'GB' # type: str +PERCENT = '%' # type: str + +MIN_TIME_BETWEEN_UPDATES = timedelta(hours=1) +REQUEST_TIMEOUT = 5 # seconds + +SENSOR_TYPES = { + 'usage': ['Usage Ratio', PERCENT, 'mdi:percent'], + 'usage_gb': ['Usage', GIGABYTES, 'mdi:download'], + 'limit': ['Data limit', GIGABYTES, 'mdi:download'], + 'used_download': ['Used Download', GIGABYTES, 'mdi:download'], + 'used_upload': ['Used Upload', GIGABYTES, 'mdi:upload'], + 'used_total': ['Used Total', GIGABYTES, 'mdi:download'], + 'grace_download': ['Grace Download', GIGABYTES, 'mdi:download'], + 'grace_upload': ['Grace Upload', GIGABYTES, 'mdi:upload'], + 'grace_total': ['Grace Total', GIGABYTES, 'mdi:download'], + 'total_download': ['Total Download', GIGABYTES, 'mdi:download'], + 'total_upload': ['Total Upload', GIGABYTES, 'mdi:download'], + 'used_remaining': ['Remaining', GIGABYTES, 'mdi:download'] +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_MONITORED_VARIABLES): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_TOTAL_BANDWIDTH): cv.positive_int, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the sensor platform.""" + websession = async_get_clientsession(hass) + apikey = config.get(CONF_API_KEY) + bandwidthcap = config.get(CONF_TOTAL_BANDWIDTH) + + ts_data = StartcaData(hass.loop, websession, apikey, bandwidthcap) + ret = yield from ts_data.async_update() + if ret is False: + _LOGGER.error("Invalid Start.ca API key: %s", apikey) + return + + name = config.get(CONF_NAME) + sensors = [] + for variable in config[CONF_MONITORED_VARIABLES]: + sensors.append(StartcaSensor(ts_data, variable, name)) + async_add_devices(sensors, True) + + +class StartcaSensor(Entity): + """Representation of Start.ca Bandwidth sensor.""" + + def __init__(self, startcadata, sensor_type, name): + """Initialize the sensor.""" + self.client_name = name + self.type = sensor_type + self._name = SENSOR_TYPES[sensor_type][0] + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._icon = SENSOR_TYPES[sensor_type][2] + self.startcadata = startcadata + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return '{} {}'.format(self.client_name, self._name) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @asyncio.coroutine + def async_update(self): + """Get the latest data from Start.ca and update the state.""" + yield from self.startcadata.async_update() + if self.type in self.startcadata.data: + self._state = round(self.startcadata.data[self.type], 2) + + +class StartcaData(object): + """Get data from Start.ca API.""" + + def __init__(self, loop, websession, api_key, bandwidth_cap): + """Initialize the data object.""" + self.loop = loop + self.websession = websession + self.api_key = api_key + self.bandwidth_cap = bandwidth_cap + # Set unlimited users to infinite, otherwise the cap. + self.data = {"limit": self.bandwidth_cap} if self.bandwidth_cap > 0 \ + else {"limit": float('inf')} + + @staticmethod + def bytes_to_gb(value): + """Convert from bytes to GB. + + :param value: The value in bytes to convert to GB. + :return: Converted GB value + """ + return float(value) * 10 ** -9 + + @asyncio.coroutine + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def async_update(self): + """Get the Start.ca bandwidth data from the web service.""" + import xmltodict + _LOGGER.debug("Updating Start.ca usage data") + url = 'https://www.start.ca/support/usage/api?key=' + \ + self.api_key + with async_timeout.timeout(REQUEST_TIMEOUT, loop=self.loop): + req = yield from self.websession.get(url) + if req.status != 200: + _LOGGER.error("Request failed with status: %u", req.status) + return False + + data = yield from req.text() + try: + xml_data = xmltodict.parse(data) + except ExpatError: + return False + + used_dl = self.bytes_to_gb(xml_data['usage']['used']['download']) + used_ul = self.bytes_to_gb(xml_data['usage']['used']['upload']) + grace_dl = self.bytes_to_gb(xml_data['usage']['grace']['download']) + grace_ul = self.bytes_to_gb(xml_data['usage']['grace']['upload']) + total_dl = self.bytes_to_gb(xml_data['usage']['total']['download']) + total_ul = self.bytes_to_gb(xml_data['usage']['total']['upload']) + + limit = self.data['limit'] + if self.bandwidth_cap > 0: + self.data['usage'] = 100*used_dl/self.bandwidth_cap + else: + self.data['usage'] = 0 + self.data['usage_gb'] = used_dl + self.data['used_download'] = used_dl + self.data['used_upload'] = used_ul + self.data['used_total'] = used_dl + used_ul + self.data['grace_download'] = grace_dl + self.data['grace_upload'] = grace_ul + self.data['grace_total'] = grace_dl + grace_ul + self.data['total_download'] = total_dl + self.data['total_upload'] = total_ul + self.data['used_remaining'] = limit - used_dl + + return True diff --git a/requirements_all.txt b/requirements_all.txt index 82c98817aff..d1b3daeaf00 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1242,6 +1242,7 @@ xboxapi==0.1.1 xknx==0.7.18 # homeassistant.components.media_player.bluesound +# homeassistant.components.sensor.startca # homeassistant.components.sensor.swiss_hydrological_data # homeassistant.components.sensor.ted5000 # homeassistant.components.sensor.yr diff --git a/tests/components/sensor/test_startca.py b/tests/components/sensor/test_startca.py new file mode 100644 index 00000000000..95da1c93a0c --- /dev/null +++ b/tests/components/sensor/test_startca.py @@ -0,0 +1,215 @@ +"""Tests for the Start.ca sensor platform.""" +import asyncio +from homeassistant.bootstrap import async_setup_component +from homeassistant.components.sensor.startca import StartcaData +from homeassistant.helpers.aiohttp_client import async_get_clientsession + + +@asyncio.coroutine +def test_capped_setup(hass, aioclient_mock): + """Test the default setup.""" + config = {'platform': 'startca', + 'api_key': 'NOTAKEY', + 'total_bandwidth': 400, + 'monitored_variables': [ + 'usage', + 'usage_gb', + 'limit', + 'used_download', + 'used_upload', + 'used_total', + 'grace_download', + 'grace_upload', + 'grace_total', + 'total_download', + 'total_upload', + 'used_remaining']} + + result = ''\ + ''\ + '1.1'\ + ' '\ + '304946829777'\ + '6480700153'\ + ''\ + ' '\ + '304946829777'\ + '6480700153'\ + ''\ + ' '\ + '304946829777'\ + '6480700153'\ + ''\ + '' + aioclient_mock.get('https://www.start.ca/support/usage/api?key=' + 'NOTAKEY', + text=result) + + yield from async_setup_component(hass, 'sensor', {'sensor': config}) + + state = hass.states.get('sensor.startca_usage_ratio') + assert state.attributes.get('unit_of_measurement') == '%' + assert state.state == '76.24' + + state = hass.states.get('sensor.startca_usage') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '304.95' + + state = hass.states.get('sensor.startca_data_limit') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '400' + + state = hass.states.get('sensor.startca_used_download') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '304.95' + + state = hass.states.get('sensor.startca_used_upload') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '6.48' + + state = hass.states.get('sensor.startca_used_total') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '311.43' + + state = hass.states.get('sensor.startca_grace_download') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '304.95' + + state = hass.states.get('sensor.startca_grace_upload') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '6.48' + + state = hass.states.get('sensor.startca_grace_total') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '311.43' + + state = hass.states.get('sensor.startca_total_download') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '304.95' + + state = hass.states.get('sensor.startca_total_upload') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '6.48' + + state = hass.states.get('sensor.startca_remaining') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '95.05' + + +@asyncio.coroutine +def test_unlimited_setup(hass, aioclient_mock): + """Test the default setup.""" + config = {'platform': 'startca', + 'api_key': 'NOTAKEY', + 'total_bandwidth': 0, + 'monitored_variables': [ + 'usage', + 'usage_gb', + 'limit', + 'used_download', + 'used_upload', + 'used_total', + 'grace_download', + 'grace_upload', + 'grace_total', + 'total_download', + 'total_upload', + 'used_remaining']} + + result = ''\ + ''\ + '1.1'\ + ' '\ + '304946829777'\ + '6480700153'\ + ''\ + ' '\ + '0'\ + '0'\ + ''\ + ' '\ + '304946829777'\ + '6480700153'\ + ''\ + '' + aioclient_mock.get('https://www.start.ca/support/usage/api?key=' + 'NOTAKEY', + text=result) + + yield from async_setup_component(hass, 'sensor', {'sensor': config}) + + state = hass.states.get('sensor.startca_usage_ratio') + assert state.attributes.get('unit_of_measurement') == '%' + assert state.state == '0' + + state = hass.states.get('sensor.startca_usage') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '0.0' + + state = hass.states.get('sensor.startca_data_limit') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == 'inf' + + state = hass.states.get('sensor.startca_used_download') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '0.0' + + state = hass.states.get('sensor.startca_used_upload') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '0.0' + + state = hass.states.get('sensor.startca_used_total') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '0.0' + + state = hass.states.get('sensor.startca_grace_download') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '304.95' + + state = hass.states.get('sensor.startca_grace_upload') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '6.48' + + state = hass.states.get('sensor.startca_grace_total') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '311.43' + + state = hass.states.get('sensor.startca_total_download') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '304.95' + + state = hass.states.get('sensor.startca_total_upload') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '6.48' + + state = hass.states.get('sensor.startca_remaining') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == 'inf' + + +@asyncio.coroutine +def test_bad_return_code(hass, aioclient_mock): + """Test handling a return code that isn't HTTP OK.""" + aioclient_mock.get('https://www.start.ca/support/usage/api?key=' + 'NOTAKEY', + status=404) + + scd = StartcaData(hass.loop, async_get_clientsession(hass), + 'NOTAKEY', 400) + + result = yield from scd.async_update() + assert result is False + + +@asyncio.coroutine +def test_bad_json_decode(hass, aioclient_mock): + """Test decoding invalid json result.""" + aioclient_mock.get('https://www.start.ca/support/usage/api?key=' + 'NOTAKEY', + text='this is not xml') + + scd = StartcaData(hass.loop, async_get_clientsession(hass), + 'NOTAKEY', 400) + + result = yield from scd.async_update() + assert result is False From ba9fef4de68b6778206e9a87c3afeeb6121f59fa Mon Sep 17 00:00:00 2001 From: happyleavesaoc Date: Tue, 13 Feb 2018 00:02:03 -0500 Subject: [PATCH 046/173] bump fedex version (#12362) --- homeassistant/components/sensor/fedex.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/fedex.py b/homeassistant/components/sensor/fedex.py index 7991a94eb05..0c42ef28496 100644 --- a/homeassistant/components/sensor/fedex.py +++ b/homeassistant/components/sensor/fedex.py @@ -19,7 +19,7 @@ from homeassistant.util import Throttle from homeassistant.util.dt import now, parse_date import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['fedexdeliverymanager==1.0.4'] +REQUIREMENTS = ['fedexdeliverymanager==1.0.5'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index d1b3daeaf00..0466922625f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -266,7 +266,7 @@ evohomeclient==0.2.5 fastdotcom==0.0.3 # homeassistant.components.sensor.fedex -fedexdeliverymanager==1.0.4 +fedexdeliverymanager==1.0.5 # homeassistant.components.feedreader # homeassistant.components.sensor.geo_rss_events From 66d14da5e95812cb0ff153f98f861526e2e6ebe2 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 13 Feb 2018 06:06:03 +0100 Subject: [PATCH 047/173] Upgrade alpha_vantage to 1.9.0 (#12352) --- homeassistant/components/sensor/alpha_vantage.py | 11 ++++------- requirements_all.txt | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sensor/alpha_vantage.py b/homeassistant/components/sensor/alpha_vantage.py index fce82f7eda5..81c84a7f918 100644 --- a/homeassistant/components/sensor/alpha_vantage.py +++ b/homeassistant/components/sensor/alpha_vantage.py @@ -15,7 +15,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['alpha_vantage==1.8.0'] +REQUIREMENTS = ['alpha_vantage==1.9.0'] _LOGGER = logging.getLogger(__name__) @@ -98,8 +98,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): from_cur = conversion.get(CONF_FROM) to_cur = conversion.get(CONF_TO) try: - _LOGGER.debug("Configuring forex %s - %s", - from_cur, to_cur) + _LOGGER.debug("Configuring forex %s - %s", from_cur, to_cur) forex.get_currency_exchange_rate( from_currency=from_cur, to_currency=to_cur) except ValueError as error: @@ -214,10 +213,8 @@ class AlphaVantageForeignExchange(Entity): def update(self): """Get the latest data and updates the states.""" _LOGGER.debug("Requesting new data for forex %s - %s", - self._from_currency, - self._to_currency) + self._from_currency, self._to_currency) self.values, _ = self._foreign_exchange.get_currency_exchange_rate( from_currency=self._from_currency, to_currency=self._to_currency) _LOGGER.debug("Received new data for forex %s - %s", - self._from_currency, - self._to_currency) + self._from_currency, self._to_currency) diff --git a/requirements_all.txt b/requirements_all.txt index 0466922625f..f1c9d927a0b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -85,7 +85,7 @@ aiopvapi==1.5.4 alarmdecoder==1.13.2 # homeassistant.components.sensor.alpha_vantage -alpha_vantage==1.8.0 +alpha_vantage==1.9.0 # homeassistant.components.amcrest amcrest==1.2.1 From 00ff305bd7dddaea875d635adc7addbfd449b763 Mon Sep 17 00:00:00 2001 From: Rene Nulsch <33263735+ReneNulschDE@users.noreply.github.com> Date: Tue, 13 Feb 2018 06:07:20 +0100 Subject: [PATCH 048/173] Fix MercedesMe - add check for unsupported features (#12342) * Add check for unsupported features * Lint fix * change to guard clause --- homeassistant/components/binary_sensor/mercedesme.py | 9 ++++++--- homeassistant/components/device_tracker/mercedesme.py | 7 +++++-- homeassistant/components/mercedesme.py | 2 ++ homeassistant/components/sensor/mercedesme.py | 10 +++++++--- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/binary_sensor/mercedesme.py b/homeassistant/components/binary_sensor/mercedesme.py index a6c8da56ce8..fcf2d7122e2 100644 --- a/homeassistant/components/binary_sensor/mercedesme.py +++ b/homeassistant/components/binary_sensor/mercedesme.py @@ -9,7 +9,7 @@ import datetime from homeassistant.components.binary_sensor import (BinarySensorDevice) from homeassistant.components.mercedesme import ( - DATA_MME, MercedesMeEntity, BINARY_SENSORS) + DATA_MME, FEATURE_NOT_AVAILABLE, MercedesMeEntity, BINARY_SENSORS) DEPENDENCIES = ['mercedesme'] @@ -27,8 +27,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for car in data.cars: for key, value in sorted(BINARY_SENSORS.items()): - devices.append(MercedesMEBinarySensor( - data, key, value[0], car["vin"], None)) + if car['availabilities'].get(key, 'INVALID') == 'VALID': + devices.append(MercedesMEBinarySensor( + data, key, value[0], car["vin"], None)) + else: + _LOGGER.warning(FEATURE_NOT_AVAILABLE, key, car["license"]) add_devices(devices, True) diff --git a/homeassistant/components/device_tracker/mercedesme.py b/homeassistant/components/device_tracker/mercedesme.py index 0aa2be96290..dcc9e3ab2ec 100644 --- a/homeassistant/components/device_tracker/mercedesme.py +++ b/homeassistant/components/device_tracker/mercedesme.py @@ -49,10 +49,13 @@ class MercedesMEDeviceTracker(object): def update_info(self, now=None): """Update the device info.""" for device in self.data.cars: - _LOGGER.debug("Updating %s", device["vin"]) + if not device['services'].get('VEHICLE_FINDER', False): + continue + location = self.data.get_location(device["vin"]) if location is None: - return False + continue + dev_id = device["vin"] name = device["license"] diff --git a/homeassistant/components/mercedesme.py b/homeassistant/components/mercedesme.py index a228486e2c8..b809e46ec64 100644 --- a/homeassistant/components/mercedesme.py +++ b/homeassistant/components/mercedesme.py @@ -41,6 +41,8 @@ SENSORS = { DATA_MME = 'mercedesme' DOMAIN = 'mercedesme' +FEATURE_NOT_AVAILABLE = "The feature %s is not available for your car %s" + NOTIFICATION_ID = 'mercedesme_integration_notification' NOTIFICATION_TITLE = 'Mercedes me integration setup' diff --git a/homeassistant/components/sensor/mercedesme.py b/homeassistant/components/sensor/mercedesme.py index bc368745e40..bb7212678a7 100644 --- a/homeassistant/components/sensor/mercedesme.py +++ b/homeassistant/components/sensor/mercedesme.py @@ -8,7 +8,7 @@ import logging import datetime from homeassistant.components.mercedesme import ( - DATA_MME, MercedesMeEntity, SENSORS) + DATA_MME, FEATURE_NOT_AVAILABLE, MercedesMeEntity, SENSORS) DEPENDENCIES = ['mercedesme'] @@ -29,8 +29,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for car in data.cars: for key, value in sorted(SENSORS.items()): - devices.append( - MercedesMESensor(data, key, value[0], car["vin"], value[1])) + if car['availabilities'].get(key, 'INVALID') == 'VALID': + devices.append( + MercedesMESensor( + data, key, value[0], car["vin"], value[1])) + else: + _LOGGER.warning(FEATURE_NOT_AVAILABLE, key, car["license"]) add_devices(devices, True) From f5c2e7ff68e5053f5e4f1a6657341401503a10a1 Mon Sep 17 00:00:00 2001 From: karlkar Date: Tue, 13 Feb 2018 07:29:58 +0100 Subject: [PATCH 049/173] Eq3btsmart more reliable (#11555) * Eq3btsmart more reliable * Fixed 'Line too long' violations * Fixed trailing whitespaces * Fixed indents * Fix for disallowing external temperature setting * Logic fix after increasing eq3bt version --- homeassistant/components/climate/eq3btsmart.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/homeassistant/components/climate/eq3btsmart.py b/homeassistant/components/climate/eq3btsmart.py index 4a402887864..cbfb35d06e5 100644 --- a/homeassistant/components/climate/eq3btsmart.py +++ b/homeassistant/components/climate/eq3btsmart.py @@ -75,6 +75,8 @@ class EQ3BTSmartThermostat(ClimateDevice): self._name = _name self._thermostat = eq3.Thermostat(_mac) + self._target_temperature = None + self._target_mode = None @property def supported_features(self): @@ -116,6 +118,7 @@ class EQ3BTSmartThermostat(ClimateDevice): temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return + self._target_temperature = temperature self._thermostat.target_temperature = temperature @property @@ -132,6 +135,7 @@ class EQ3BTSmartThermostat(ClimateDevice): def set_operation_mode(self, operation_mode): """Set operation mode.""" + self._target_mode = operation_mode self._thermostat.mode = self.reverse_modes[operation_mode] def turn_away_mode_off(self): @@ -177,3 +181,15 @@ class EQ3BTSmartThermostat(ClimateDevice): self._thermostat.update() except BTLEException as ex: _LOGGER.warning("Updating the state failed: %s", ex) + + if (self._target_temperature and + self._thermostat.target_temperature + != self._target_temperature): + self.set_temperature(temperature=self._target_temperature) + else: + self._target_temperature = None + if (self._target_mode and + self.modes[self._thermostat.mode] != self._target_mode): + self.set_operation_mode(operation_mode=self._target_mode) + else: + self._target_mode = None From a4b88fc31ba57ba36fbb73b88220cff0a7996c69 Mon Sep 17 00:00:00 2001 From: citruz Date: Tue, 13 Feb 2018 11:32:44 +0100 Subject: [PATCH 050/173] Updated beacontools (#12368) --- homeassistant/components/sensor/eddystone_temperature.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/eddystone_temperature.py b/homeassistant/components/sensor/eddystone_temperature.py index ef06458cd84..fb5fa2c1fba 100644 --- a/homeassistant/components/sensor/eddystone_temperature.py +++ b/homeassistant/components/sensor/eddystone_temperature.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_NAME, TEMP_CELSIUS, STATE_UNKNOWN, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) -REQUIREMENTS = ['beacontools[scan]==1.0.1'] +REQUIREMENTS = ['beacontools[scan]==1.2.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index f1c9d927a0b..3822ce26957 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -118,7 +118,7 @@ basicmodem==0.7 batinfo==0.4.2 # homeassistant.components.sensor.eddystone_temperature -# beacontools[scan]==1.0.1 +# beacontools[scan]==1.2.1 # homeassistant.components.device_tracker.linksys_ap # homeassistant.components.sensor.geizhals From d2cea84254e4d4cd3df6c3b1e35c0c66a13548df Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 13 Feb 2018 04:33:15 -0800 Subject: [PATCH 051/173] Allow disabling entities in the registry (#12360) --- homeassistant/helpers/entity_platform.py | 8 +++++++ homeassistant/helpers/entity_registry.py | 14 ++++++++++- tests/helpers/test_entity_platform.py | 30 ++++++++++++++++++++---- tests/helpers/test_entity_registry.py | 18 ++++++++++++++ 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 6cf58212c8e..e17e178bcfb 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -216,6 +216,14 @@ class EntityPlatform(object): entry = registry.async_get_or_create( self.domain, self.platform_name, entity.unique_id, suggested_object_id=suggested_object_id) + + if entry.disabled: + self.logger.info( + "Not adding entity %s because it's disabled", + entry.name or entity.name or + '"{} {}"'.format(self.platform_name, entity.unique_id)) + return + entity.entity_id = entry.entity_id entity.registry_name = entry.name diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index d33ca93f290..89719b0b823 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -26,6 +26,9 @@ PATH_REGISTRY = 'entity_registry.yaml' SAVE_DELAY = 10 _LOGGER = logging.getLogger(__name__) +DISABLED_HASS = 'hass' +DISABLED_USER = 'user' + @attr.s(slots=True, frozen=True) class RegistryEntry: @@ -35,12 +38,20 @@ class RegistryEntry: unique_id = attr.ib(type=str) platform = attr.ib(type=str) name = attr.ib(type=str, default=None) + disabled_by = attr.ib( + type=str, default=None, + validator=attr.validators.in_((DISABLED_HASS, DISABLED_USER, None))) domain = attr.ib(type=str, default=None, init=False, repr=False) def __attrs_post_init__(self): """Computed properties.""" object.__setattr__(self, "domain", split_entity_id(self.entity_id)[0]) + @property + def disabled(self): + """Return if entry is disabled.""" + return self.disabled_by is not None + class EntityRegistry: """Class to hold a registry of entities.""" @@ -116,7 +127,8 @@ class EntityRegistry: entity_id=entity_id, unique_id=info['unique_id'], platform=info['platform'], - name=info.get('name') + name=info.get('name'), + disabled_by=info.get('disabled_by') ) self.entities = entities diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index c9705a73f7b..0681691ed67 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -19,16 +19,17 @@ from tests.common import ( _LOGGER = logging.getLogger(__name__) DOMAIN = "test_domain" +PLATFORM = 'test_platform' class MockEntityPlatform(entity_platform.EntityPlatform): """Mock class with some mock defaults.""" def __init__( - self, *, hass, + self, hass, logger=None, - domain='test', - platform_name='test_platform', + domain=DOMAIN, + platform_name=PLATFORM, scan_interval=timedelta(seconds=15), parallel_updates=0, entity_namespace=None, @@ -486,7 +487,26 @@ def test_overriding_name_from_registry(hass): def test_registry_respect_entity_namespace(hass): """Test that the registry respects entity namespace.""" mock_registry(hass) - platform = MockEntityPlatform(hass=hass, entity_namespace='ns') + platform = MockEntityPlatform(hass, entity_namespace='ns') entity = MockEntity(unique_id='1234', name='Device Name') yield from platform.async_add_entities([entity]) - assert entity.entity_id == 'test.ns_device_name' + assert entity.entity_id == 'test_domain.ns_device_name' + + +@asyncio.coroutine +def test_registry_respect_entity_disabled(hass): + """Test that the registry respects entity disabled.""" + mock_registry(hass, { + 'test_domain.world': entity_registry.RegistryEntry( + entity_id='test_domain.world', + unique_id='1234', + # Using component.async_add_entities is equal to platform "domain" + platform='test_platform', + disabled_by=entity_registry.DISABLED_USER + ) + }) + platform = MockEntityPlatform(hass) + entity = MockEntity(unique_id='1234') + yield from platform.async_add_entities([entity]) + assert entity.entity_id is None + assert hass.states.async_entity_ids() == [] diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 7e1150638c1..cb8703d1fe6 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -148,6 +148,14 @@ test.named: test.no_name: platform: super_platform unique_id: without-name +test.disabled_user: + platform: super_platform + unique_id: disabled-user + disabled_by: user +test.disabled_hass: + platform: super_platform + unique_id: disabled-hass + disabled_by: hass """ registry = entity_registry.EntityRegistry(hass) @@ -162,3 +170,13 @@ test.no_name: 'test', 'super_platform', 'without-name') assert entry_with_name.name == 'registry override' assert entry_without_name.name is None + assert not entry_with_name.disabled + + entry_disabled_hass = registry.async_get_or_create( + 'test', 'super_platform', 'disabled-hass') + entry_disabled_user = registry.async_get_or_create( + 'test', 'super_platform', 'disabled-user') + assert entry_disabled_hass.disabled + assert entry_disabled_hass.disabled_by == entity_registry.DISABLED_HASS + assert entry_disabled_user.disabled + assert entry_disabled_user.disabled_by == entity_registry.DISABLED_USER From 80d2c76e85afbf2da3786492bfa2ab3977bd601a Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 13 Feb 2018 16:06:30 +0000 Subject: [PATCH 052/173] Upgrade panasonic_viera to 0.3.1 (#12370) * Bump panasonic_viera library to 0.3.1 Fixes media_play in hassio environment * update requirements --- homeassistant/components/media_player/panasonic_viera.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/panasonic_viera.py b/homeassistant/components/media_player/panasonic_viera.py index 21a897f4d35..39e5f81b71d 100644 --- a/homeassistant/components/media_player/panasonic_viera.py +++ b/homeassistant/components/media_player/panasonic_viera.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN, CONF_PORT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['panasonic_viera==0.3', +REQUIREMENTS = ['panasonic_viera==0.3.1', 'wakeonlan==1.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 3822ce26957..5793d539c02 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -549,7 +549,7 @@ orvibo==1.1.1 paho-mqtt==1.3.1 # homeassistant.components.media_player.panasonic_viera -panasonic_viera==0.3 +panasonic_viera==0.3.1 # homeassistant.components.media_player.dunehd pdunehd==1.3 From 5995c2f3133bb65e14b430ac17561c9425d41dd4 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Tue, 13 Feb 2018 22:03:56 +0200 Subject: [PATCH 053/173] SMA sensor add SSL and upgrade to pysma 0.2 (#12354) --- homeassistant/components/sensor/sma.py | 13 +++++++++---- requirements_all.txt | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sensor/sma.py b/homeassistant/components/sensor/sma.py index 2f3a29efbc0..3451789424b 100644 --- a/homeassistant/components/sensor/sma.py +++ b/homeassistant/components/sensor/sma.py @@ -12,13 +12,14 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PASSWORD, CONF_SCAN_INTERVAL) + EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PASSWORD, CONF_SCAN_INTERVAL, + CONF_SSL) from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pysma==0.1.3'] +REQUIREMENTS = ['pysma==0.2'] _LOGGER = logging.getLogger(__name__) @@ -49,6 +50,7 @@ def _check_sensor_schema(conf): PLATFORM_SCHEMA = vol.All(PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): str, + vol.Optional(CONF_SSL, default=False): cv.boolean, vol.Required(CONF_PASSWORD): str, vol.Optional(CONF_GROUP, default=GROUPS[0]): vol.In(GROUPS), vol.Required(CONF_SENSORS): vol.Schema({cv.slug: cv.ensure_list}), @@ -97,8 +99,11 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): session = async_get_clientsession(hass) grp = {GROUP_INSTALLER: pysma.GROUP_INSTALLER, GROUP_USER: pysma.GROUP_USER}[config[CONF_GROUP]] - sma = pysma.SMA(session, config[CONF_HOST], config[CONF_PASSWORD], - group=grp) + + url = "http{}://{}".format( + "s" if config[CONF_SSL] else "", config[CONF_HOST]) + + sma = pysma.SMA(session, url, config[CONF_PASSWORD], group=grp) # Ensure we logout on shutdown @asyncio.coroutine diff --git a/requirements_all.txt b/requirements_all.txt index 5793d539c02..cc1b1fd9d51 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -862,7 +862,7 @@ pysesame==0.1.0 pysher==0.2.0 # homeassistant.components.sensor.sma -pysma==0.1.3 +pysma==0.2 # homeassistant.components.device_tracker.snmp # homeassistant.components.sensor.snmp From f0231c1f2971e71832c9e6c34c131b2a1c319299 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 13 Feb 2018 14:23:34 -0800 Subject: [PATCH 054/173] Specify algorithms for webpush jwt verification (#12378) --- homeassistant/components/notify/html5.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 5d41004ba1d..45439dbfbfe 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -255,12 +255,12 @@ class HTML5PushCallbackView(HomeAssistantView): # 2a. If decode is successful, return the payload. # 2b. If decode is unsuccessful, return a 401. - target_check = jwt.decode(token, options={'verify_signature': False}) + target_check = jwt.decode(token, verify=False) if target_check[ATTR_TARGET] in self.registrations: possible_target = self.registrations[target_check[ATTR_TARGET]] key = possible_target[ATTR_SUBSCRIPTION][ATTR_KEYS][ATTR_AUTH] try: - return jwt.decode(token, key) + return jwt.decode(token, key, algorithms=["ES256", "HS256"]) except jwt.exceptions.DecodeError: pass From 16dafaa5af3519213176021aa24c249091bba71e Mon Sep 17 00:00:00 2001 From: Sean Dague Date: Tue, 13 Feb 2018 17:24:03 -0500 Subject: [PATCH 055/173] Introduce zone_id to identify player+zone (#12382) The yamaha component previously used a property named unique_id to ensure that exactly 1 media_player was discovered per zone per control_url. This was introduced so that hard coded devices wouldn't be duplicated by automatically discovered devices. In HA 0.63 unique_id became a reserved concept as part of the new device registry, and the property was removed from the component. But the default returns None, which had the side effect of only ever registering a single unit + zone, the first one discovered. This was typically the Main_Zone of the unit, but there is actually no guaruntee of that. This fix brings back the logic under a different property called zone_id. This is not guarunteed to be globally stable like unique_id is supposed to be, but it is suitable for the deduplication for yamaha media players. --- homeassistant/components/media_player/yamaha.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/yamaha.py b/homeassistant/components/media_player/yamaha.py index f102d8a490d..5b8ac2ad236 100644 --- a/homeassistant/components/media_player/yamaha.py +++ b/homeassistant/components/media_player/yamaha.py @@ -60,7 +60,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): import rxv # Keep track of configured receivers so that we don't end up # discovering a receiver dynamically that we have static config - # for. Map each device from its unique_id to an instance since + # for. Map each device from its zone_id to an instance since # YamahaDevice is not hashable (thus not possible to add to a set). if hass.data.get(DATA_YAMAHA) is None: hass.data[DATA_YAMAHA] = {} @@ -100,8 +100,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): source_names, zone_names) # Only add device if it's not already added - if device.unique_id not in hass.data[DATA_YAMAHA]: - hass.data[DATA_YAMAHA][device.unique_id] = device + if device.zone_id not in hass.data[DATA_YAMAHA]: + hass.data[DATA_YAMAHA][device.zone_id] = device devices.append(device) else: _LOGGER.debug('Ignoring duplicate receiver %s', name) @@ -220,6 +220,11 @@ class YamahaDevice(MediaPlayerDevice): """List of available input sources.""" return self._source_list + @property + def zone_id(self): + """Return an zone_id to ensure 1 media player per zone.""" + return '{0}:{1}'.format(self.receiver.ctrl_url, self._zone) + @property def supported_features(self): """Flag media player features that are supported.""" From 429628ec1dfde2e020d3016b06c066289e208d17 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 13 Feb 2018 23:24:18 +0100 Subject: [PATCH 056/173] Upgrade youtube_dl to 2018.02.11 (#12383) --- homeassistant/components/media_extractor.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index a2ec11cc948..265784be74d 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2018.01.21'] +REQUIREMENTS = ['youtube_dl==2018.02.11'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index cc1b1fd9d51..b72d01d726f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1262,7 +1262,7 @@ yeelight==0.3.3 yeelightsunflower==0.0.8 # homeassistant.components.media_extractor -youtube_dl==2018.01.21 +youtube_dl==2018.02.11 # homeassistant.components.light.zengge zengge==0.2 From c7416c8986099ea6fc36a01f2c9c2ce67cfed052 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 13 Feb 2018 14:25:06 -0800 Subject: [PATCH 057/173] Remove usage of deprecated assert method (#12379) --- tests/components/climate/test_melissa.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/climate/test_melissa.py b/tests/components/climate/test_melissa.py index f8a044c2f4b..446eec9aba1 100644 --- a/tests/components/climate/test_melissa.py +++ b/tests/components/climate/test_melissa.py @@ -202,7 +202,7 @@ class TestMelissa(unittest.TestCase): self.thermostat._cur_settings = None self.assertFalse(self.thermostat.send({ 'fan': self.api.FAN_LOW})) - self.assertNotEquals(SPEED_LOW, self.thermostat.current_fan_mode) + self.assertNotEqual(SPEED_LOW, self.thermostat.current_fan_mode) self.assertIsNone(self.thermostat._cur_settings) @mock.patch('homeassistant.components.climate.melissa._LOGGER.warning') From bc6405321449ed1ec8ce8006e2a95612b43720f4 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 13 Feb 2018 23:47:59 +0100 Subject: [PATCH 058/173] Add attributes (fixes #12332) (#12377) * Add attributes (fixes #12332) * Fix pylint issue --- homeassistant/components/sensor/speedtest.py | 92 +++++++++++--------- requirements_all.txt | 2 +- 2 files changed, 52 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/sensor/speedtest.py b/homeassistant/components/sensor/speedtest.py index c7ba61ef504..5b03be036d5 100644 --- a/homeassistant/components/sensor/speedtest.py +++ b/homeassistant/components/sensor/speedtest.py @@ -6,27 +6,30 @@ https://home-assistant.io/components/sensor.speedtest/ """ import asyncio import logging -import re -import sys -from subprocess import check_output, CalledProcessError import voluptuous as vol -import homeassistant.util.dt as dt_util +from homeassistant.components.sensor import DOMAIN, PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS import homeassistant.helpers.config_validation as cv -from homeassistant.components.sensor import (DOMAIN, PLATFORM_SCHEMA) -from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_time_change from homeassistant.helpers.restore_state import async_get_last_state +import homeassistant.util.dt as dt_util -REQUIREMENTS = ['speedtest-cli==1.0.7'] +REQUIREMENTS = ['speedtest-cli==2.0.0'] _LOGGER = logging.getLogger(__name__) -_SPEEDTEST_REGEX = re.compile(r'Ping:\s(\d+\.\d+)\sms[\r\n]+' - r'Download:\s(\d+\.\d+)\sMbit/s[\r\n]+' - r'Upload:\s(\d+\.\d+)\sMbit/s[\r\n]+') +ATTR_BYTES_RECEIVED = 'bytes_received' +ATTR_BYTES_SENT = 'bytes_sent' +ATTR_SERVER_COUNTRY = 'server_country' +ATTR_SERVER_HOST = 'server_host' +ATTR_SERVER_ID = 'server_id' +ATTR_SERVER_LATENCY = 'latency' +ATTR_SERVER_NAME = 'server_name' + +CONF_ATTRIBUTION = "Data retrieved from Speedtest by Ookla" CONF_SECOND = 'second' CONF_MINUTE = 'minute' CONF_HOUR = 'hour' @@ -45,28 +48,26 @@ SENSOR_TYPES = { PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list, [vol.In(list(SENSOR_TYPES))]), - vol.Optional(CONF_SERVER_ID): cv.positive_int, - vol.Optional(CONF_SECOND, default=[0]): - vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(0, 59))]), - vol.Optional(CONF_MINUTE, default=[0]): - vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(0, 59))]), - vol.Optional(CONF_HOUR): - vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(0, 23))]), vol.Optional(CONF_DAY): vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(1, 31))]), + vol.Optional(CONF_HOUR): + vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(0, 23))]), vol.Optional(CONF_MANUAL, default=False): cv.boolean, + vol.Optional(CONF_MINUTE, default=[0]): + vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(0, 59))]), + vol.Optional(CONF_SECOND, default=[0]): + vol.All(cv.ensure_list, [vol.All(vol.Coerce(int), vol.Range(0, 59))]), + vol.Optional(CONF_SERVER_ID): cv.positive_int, }) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Speedtest sensor.""" data = SpeedtestData(hass, config) + dev = [] for sensor in config[CONF_MONITORED_CONDITIONS]: - if sensor not in SENSOR_TYPES: - _LOGGER.error("Sensor type: %s does not exist", sensor) - else: - dev.append(SpeedtestSensor(data, sensor)) + dev.append(SpeedtestSensor(data, sensor)) add_devices(dev) @@ -88,6 +89,7 @@ class SpeedtestSensor(Entity): self.speedtest_client = speedtest_data self.type = sensor_type self._state = None + self._data = None self._unit_of_measurement = SENSOR_TYPES[self.type][1] @property @@ -110,18 +112,32 @@ class SpeedtestSensor(Entity): """Return icon.""" return ICON + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._data is not None: + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_BYTES_RECEIVED: self._data['bytes_received'], + ATTR_BYTES_SENT: self._data['bytes_sent'], + ATTR_SERVER_COUNTRY: self._data['server']['country'], + ATTR_SERVER_ID: self._data['server']['id'], + ATTR_SERVER_LATENCY: self._data['server']['latency'], + ATTR_SERVER_NAME: self._data['server']['name'], + } + def update(self): """Get the latest data and update the states.""" - data = self.speedtest_client.data - if data is None: + self._data = self.speedtest_client.data + if self._data is None: return if self.type == 'ping': - self._state = data['ping'] + self._state = self._data['ping'] elif self.type == 'download': - self._state = data['download'] + self._state = round(self._data['download'] / 10**6, 2) elif self.type == 'upload': - self._state = data['upload'] + self._state = round(self._data['upload'] / 10**6, 2) @asyncio.coroutine def async_added_to_hass(self): @@ -148,20 +164,14 @@ class SpeedtestData(object): def update(self, now): """Get the latest data from speedtest.net.""" import speedtest + _LOGGER.debug("Executing speedtest...") - _LOGGER.info("Executing speedtest...") - try: - args = [sys.executable, speedtest.__file__, '--simple'] - if self._server_id: - args = args + ['--server', str(self._server_id)] + servers = [] if self._server_id is None else [self._server_id] - re_output = _SPEEDTEST_REGEX.split( - check_output(args).decode('utf-8')) - except CalledProcessError as process_error: - _LOGGER.error("Error executing speedtest: %s", process_error) - return - self.data = { - 'ping': round(float(re_output[1]), 2), - 'download': round(float(re_output[2]), 2), - 'upload': round(float(re_output[3]), 2), - } + speed = speedtest.Speedtest() + speed.get_servers(servers) + speed.get_best_server() + speed.download() + speed.upload() + + self.data = speed.results.dict() diff --git a/requirements_all.txt b/requirements_all.txt index b72d01d726f..e9200eb51a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1121,7 +1121,7 @@ snapcast==2.0.8 somecomfort==0.5.0 # homeassistant.components.sensor.speedtest -speedtest-cli==1.0.7 +speedtest-cli==2.0.0 # homeassistant.components.recorder # homeassistant.scripts.db_migrator From a4944da68f359fb0f7cea29f004fdf5b23a59caf Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Wed, 14 Feb 2018 00:17:47 +0100 Subject: [PATCH 059/173] python-miio version bumped. (Closes: #12389, Closes: #12298) (#12392) --- homeassistant/components/fan/xiaomi_miio.py | 2 +- homeassistant/components/light/xiaomi_miio.py | 2 +- homeassistant/components/remote/xiaomi_miio.py | 2 +- homeassistant/components/switch/xiaomi_miio.py | 2 +- homeassistant/components/vacuum/xiaomi_miio.py | 2 +- requirements_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index 2749bf298c0..535fa507fde 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -29,7 +29,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-miio==0.3.5'] +REQUIREMENTS = ['python-miio==0.3.6'] ATTR_TEMPERATURE = 'temperature' ATTR_HUMIDITY = 'humidity' diff --git a/homeassistant/components/light/xiaomi_miio.py b/homeassistant/components/light/xiaomi_miio.py index ff4d851142b..1ede40baf56 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -30,7 +30,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-miio==0.3.5'] +REQUIREMENTS = ['python-miio==0.3.6'] # The light does not accept cct values < 1 CCT_MIN = 1 diff --git a/homeassistant/components/remote/xiaomi_miio.py b/homeassistant/components/remote/xiaomi_miio.py index 7561f584dc3..423fd99eb73 100644 --- a/homeassistant/components/remote/xiaomi_miio.py +++ b/homeassistant/components/remote/xiaomi_miio.py @@ -21,7 +21,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow -REQUIREMENTS = ['python-miio==0.3.5'] +REQUIREMENTS = ['python-miio==0.3.6'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/xiaomi_miio.py b/homeassistant/components/switch/xiaomi_miio.py index 87871079a9c..ad71b3944cf 100644 --- a/homeassistant/components/switch/xiaomi_miio.py +++ b/homeassistant/components/switch/xiaomi_miio.py @@ -25,7 +25,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-miio==0.3.5'] +REQUIREMENTS = ['python-miio==0.3.6'] ATTR_POWER = 'power' ATTR_TEMPERATURE = 'temperature' diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index 3f194b7eeac..82753fcf7bc 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-miio==0.3.5'] +REQUIREMENTS = ['python-miio==0.3.6'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index e9200eb51a1..223179374db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -919,7 +919,7 @@ python-juicenet==0.0.5 # homeassistant.components.remote.xiaomi_miio # homeassistant.components.switch.xiaomi_miio # homeassistant.components.vacuum.xiaomi_miio -python-miio==0.3.5 +python-miio==0.3.6 # homeassistant.components.media_player.mpd python-mpd2==0.5.5 From 8bff8130149b9d758091325e717ac4a4f03480e1 Mon Sep 17 00:00:00 2001 From: Kane610 Date: Wed, 14 Feb 2018 01:23:04 +0100 Subject: [PATCH 060/173] Improve service by allowing to reference entity id instead of deconz id (#11862) * Improve service by allowing to reference entity id instead of deconz id * Change from having access to full entities to only store entity id together with deconz id * Don't use eval, there is a dict type for voluptuous * Use entity registry instead of keeping a local registry over entity ids * Removed old code * Add test for get_entry * Bump dependency to v28 Fixed call to protected member * Use chain to iterate over dict values * Cleanup * Fix hound comment * Cleanup * Follow refactoring of entity * Revert to using a local registry * Remove unused import * self.hass is automatically available when entity is registered in hass --- .../components/binary_sensor/deconz.py | 6 ++++-- homeassistant/components/deconz/__init__.py | 21 ++++++++++++++++--- homeassistant/components/deconz/services.yaml | 7 +++++-- homeassistant/components/light/deconz.py | 10 ++++----- homeassistant/components/scene/deconz.py | 10 +++++++-- homeassistant/components/sensor/deconz.py | 7 +++++-- requirements_all.txt | 2 +- 7 files changed, 46 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/binary_sensor/deconz.py b/homeassistant/components/binary_sensor/deconz.py index 0d7c3e086bb..8fea7891c3d 100644 --- a/homeassistant/components/binary_sensor/deconz.py +++ b/homeassistant/components/binary_sensor/deconz.py @@ -7,7 +7,8 @@ https://home-assistant.io/components/binary_sensor.deconz/ import asyncio from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.deconz import DOMAIN as DECONZ_DATA +from homeassistant.components.deconz import ( + DOMAIN as DATA_DECONZ, DATA_DECONZ_ID) from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import callback @@ -21,7 +22,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): return from pydeconz.sensor import DECONZ_BINARY_SENSOR - sensors = hass.data[DECONZ_DATA].sensors + sensors = hass.data[DATA_DECONZ].sensors entities = [] for key in sorted(sensors.keys(), key=int): @@ -42,6 +43,7 @@ class DeconzBinarySensor(BinarySensorDevice): def async_added_to_hass(self): """Subscribe sensors events.""" self._sensor.register_async_callback(self.async_update_callback) + self.hass.data[DATA_DECONZ_ID][self.entity_id] = self._sensor.deconz_id @callback def async_update_callback(self, reason): diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 9d7d253c328..8435f6ef8a6 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -4,6 +4,7 @@ Support for deCONZ devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/deconz/ """ + import asyncio import logging @@ -17,11 +18,12 @@ from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.json import load_json, save_json -REQUIREMENTS = ['pydeconz==27'] +REQUIREMENTS = ['pydeconz==28'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'deconz' +DATA_DECONZ_ID = 'deconz_entities' CONFIG_FILE = 'deconz.conf' @@ -34,13 +36,16 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) SERVICE_FIELD = 'field' +SERVICE_ENTITY = 'entity' SERVICE_DATA = 'data' SERVICE_SCHEMA = vol.Schema({ - vol.Required(SERVICE_FIELD): cv.string, + vol.Exclusive(SERVICE_FIELD, 'deconz_id'): cv.string, + vol.Exclusive(SERVICE_ENTITY, 'deconz_id'): cv.entity_id, vol.Required(SERVICE_DATA): dict, }) + CONFIG_INSTRUCTIONS = """ Unlock your deCONZ gateway to register with Home Assistant. @@ -100,6 +105,7 @@ def async_setup_deconz(hass, config, deconz_config): return False hass.data[DOMAIN] = deconz + hass.data[DATA_DECONZ_ID] = {} for component in ['binary_sensor', 'light', 'scene', 'sensor']: hass.async_add_job(discovery.async_load_platform( @@ -112,6 +118,7 @@ def async_setup_deconz(hass, config, deconz_config): Field is a string representing a specific device in deCONZ e.g. field='/lights/1/state'. + Entity_id can be used to retrieve the proper field. Data is a json object with what data you want to alter e.g. data={'on': true}. { @@ -121,9 +128,17 @@ def async_setup_deconz(hass, config, deconz_config): See Dresden Elektroniks REST API documentation for details: http://dresden-elektronik.github.io/deconz-rest-doc/rest/ """ - deconz = hass.data[DOMAIN] field = call.data.get(SERVICE_FIELD) + entity_id = call.data.get(SERVICE_ENTITY) data = call.data.get(SERVICE_DATA) + deconz = hass.data[DOMAIN] + if entity_id: + entities = hass.data.get(DATA_DECONZ_ID) + if entities: + field = entities.get(entity_id) + if field is None: + _LOGGER.error('Could not find the entity %s', entity_id) + return yield from deconz.async_put_state(field, data) hass.services.async_register( DOMAIN, 'configure', async_configure, schema=SERVICE_SCHEMA) diff --git a/homeassistant/components/deconz/services.yaml b/homeassistant/components/deconz/services.yaml index 2e6593c6ea0..78bf7041a93 100644 --- a/homeassistant/components/deconz/services.yaml +++ b/homeassistant/components/deconz/services.yaml @@ -1,10 +1,13 @@ configure: - description: Set attribute of device in Deconz. See Dresden Elektroniks REST API documentation for details http://dresden-elektronik.github.io/deconz-rest-doc/rest/ + description: Set attribute of device in deCONZ. See https://home-assistant.io/components/deconz/#device-services for details. fields: field: - description: Field is a string representing a specific device in Deconz. + description: Field is a string representing a specific device in deCONZ. example: '/lights/1/state' + entity: + description: Entity id representing a specific device in deCONZ. + example: 'light.rgb_light' data: description: Data is a json object with what data you want to alter. example: '{"on": true}' diff --git a/homeassistant/components/light/deconz.py b/homeassistant/components/light/deconz.py index 529917c36e2..0eef5a868b4 100644 --- a/homeassistant/components/light/deconz.py +++ b/homeassistant/components/light/deconz.py @@ -6,7 +6,8 @@ https://home-assistant.io/components/light.deconz/ """ import asyncio -from homeassistant.components.deconz import DOMAIN as DECONZ_DATA +from homeassistant.components.deconz import ( + DOMAIN as DATA_DECONZ, DATA_DECONZ_ID) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_RGB_COLOR, ATTR_TRANSITION, ATTR_XY_COLOR, EFFECT_COLORLOOP, FLASH_LONG, FLASH_SHORT, @@ -17,8 +18,6 @@ from homeassistant.util.color import color_RGB_to_xy DEPENDENCIES = ['deconz'] -ATTR_LIGHT_GROUP = 'LightGroup' - @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): @@ -26,8 +25,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if discovery_info is None: return - lights = hass.data[DECONZ_DATA].lights - groups = hass.data[DECONZ_DATA].groups + lights = hass.data[DATA_DECONZ].lights + groups = hass.data[DATA_DECONZ].groups entities = [] for light in lights.values(): @@ -64,6 +63,7 @@ class DeconzLight(Light): def async_added_to_hass(self): """Subscribe to lights events.""" self._light.register_async_callback(self.async_update_callback) + self.hass.data[DATA_DECONZ_ID][self.entity_id] = self._light.deconz_id @callback def async_update_callback(self, reason): diff --git a/homeassistant/components/scene/deconz.py b/homeassistant/components/scene/deconz.py index b3400c306af..db81d84c2b7 100644 --- a/homeassistant/components/scene/deconz.py +++ b/homeassistant/components/scene/deconz.py @@ -6,7 +6,8 @@ https://home-assistant.io/components/scene.deconz/ """ import asyncio -from homeassistant.components.deconz import DOMAIN as DECONZ_DATA +from homeassistant.components.deconz import ( + DOMAIN as DATA_DECONZ, DATA_DECONZ_ID) from homeassistant.components.scene import Scene DEPENDENCIES = ['deconz'] @@ -18,7 +19,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if discovery_info is None: return - scenes = hass.data[DECONZ_DATA].scenes + scenes = hass.data[DATA_DECONZ].scenes entities = [] for scene in scenes.values(): @@ -33,6 +34,11 @@ class DeconzScene(Scene): """Set up a scene.""" self._scene = scene + @asyncio.coroutine + def async_added_to_hass(self): + """Subscribe to sensors events.""" + self.hass.data[DATA_DECONZ_ID][self.entity_id] = self._scene.deconz_id + @asyncio.coroutine def async_activate(self): """Activate the scene.""" diff --git a/homeassistant/components/sensor/deconz.py b/homeassistant/components/sensor/deconz.py index b3adaa412ff..b60df1c6ac9 100644 --- a/homeassistant/components/sensor/deconz.py +++ b/homeassistant/components/sensor/deconz.py @@ -6,7 +6,8 @@ https://home-assistant.io/components/sensor.deconz/ """ import asyncio -from homeassistant.components.deconz import DOMAIN as DECONZ_DATA +from homeassistant.components.deconz import ( + DOMAIN as DATA_DECONZ, DATA_DECONZ_ID) from homeassistant.const import ATTR_BATTERY_LEVEL, CONF_EVENT, CONF_ID from homeassistant.core import EventOrigin, callback from homeassistant.helpers.entity import Entity @@ -25,7 +26,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): return from pydeconz.sensor import DECONZ_SENSOR, SWITCH as DECONZ_REMOTE - sensors = hass.data[DECONZ_DATA].sensors + sensors = hass.data[DATA_DECONZ].sensors entities = [] for key in sorted(sensors.keys(), key=int): @@ -51,6 +52,7 @@ class DeconzSensor(Entity): def async_added_to_hass(self): """Subscribe to sensors events.""" self._sensor.register_async_callback(self.async_update_callback) + self.hass.data[DATA_DECONZ_ID][self.entity_id] = self._sensor.deconz_id @callback def async_update_callback(self, reason): @@ -127,6 +129,7 @@ class DeconzBattery(Entity): def async_added_to_hass(self): """Subscribe to sensors events.""" self._device.register_async_callback(self.async_update_callback) + self.hass.data[DATA_DECONZ_ID][self.entity_id] = self._device.deconz_id @callback def async_update_callback(self, reason): diff --git a/requirements_all.txt b/requirements_all.txt index 223179374db..ab0c6be4693 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -688,7 +688,7 @@ pycsspeechtts==1.0.2 pydaikin==0.4 # homeassistant.components.deconz -pydeconz==27 +pydeconz==28 # homeassistant.components.zwave pydispatcher==2.0.5 From c5c409bed3f19d3d0ed4f95b79eb253edec93402 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 13 Feb 2018 17:25:10 -0700 Subject: [PATCH 061/173] Pollen.com: Entity Registry updates and cleanup (#12361) * Updated Pollen sensors to be entity registry-friendly * Pollen.com: Entity Registry updates and cleanup * Small cleanup * Owner-requested changes --- homeassistant/components/sensor/pollen.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/pollen.py b/homeassistant/components/sensor/pollen.py index 3998af7e32f..0771e7cbd2e 100644 --- a/homeassistant/components/sensor/pollen.py +++ b/homeassistant/components/sensor/pollen.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_STATE, CONF_MONITORED_CONDITIONS ) from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle +from homeassistant.util import Throttle, slugify REQUIREMENTS = ['pypollencom==1.1.1'] _LOGGER = logging.getLogger(__name__) @@ -125,6 +125,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): 'allergy_index_data': AllergyIndexData(client), 'disease_average_data': DiseaseData(client) } + classes = { + 'AllergyAverageSensor': AllergyAverageSensor, + 'AllergyIndexSensor': AllergyIndexSensor + } for data in datas.values(): data.update() @@ -132,11 +136,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): sensors = [] for condition in config[CONF_MONITORED_CONDITIONS]: name, sensor_class, data_key, params, icon = CONDITIONS[condition] - sensors.append(globals()[sensor_class]( + sensors.append(classes[sensor_class]( datas[data_key], params, name, - icon + icon, + config[CONF_ZIP_CODE] )) add_devices(sensors, True) @@ -154,7 +159,7 @@ def calculate_trend(list_of_nums): class BaseSensor(Entity): """Define a base class for all of our sensors.""" - def __init__(self, data, data_params, name, icon): + def __init__(self, data, data_params, name, icon, unique_id): """Initialize the sensor.""" self._attrs = {} self._icon = icon @@ -162,6 +167,7 @@ class BaseSensor(Entity): self._data_params = data_params self._state = None self._unit = None + self._unique_id = unique_id self.data = data @property @@ -185,6 +191,11 @@ class BaseSensor(Entity): """Return the state.""" return self._state + @property + def unique_id(self): + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_{1}'.format(self._unique_id, slugify(self._name)) + @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" From 9bfeb3b5af0d6a19dac80fadf51b8f8006ded862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrik=20Ekstr=C3=B6m?= <33833959+patrik3k@users.noreply.github.com> Date: Wed, 14 Feb 2018 06:55:50 +0100 Subject: [PATCH 062/173] Changed pyvera version to 0.2.41 (#12391) * Changed pyvera version to 0.2.41 Changed required pyvera version to 0.2.41 from 0.2.39. The 0.2.41 supports the VeraSecure built in siren. Siren is treated as switch and can now be turned on and off. Before it was armable but generated error in Vera controller. This allows for both detecting status of Siren if triggered from within Vera and also outside controll from HA. * Added pyvera 0.2.41 library --- homeassistant/components/vera.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vera.py b/homeassistant/components/vera.py index b15c4ddabfd..a7c10462e0d 100644 --- a/homeassistant/components/vera.py +++ b/homeassistant/components/vera.py @@ -19,7 +19,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, CONF_LIGHTS, CONF_EXCLUDE) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyvera==0.2.39'] +REQUIREMENTS = ['pyvera==0.2.41'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index ab0c6be4693..053baac28d7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -998,7 +998,7 @@ pyunifi==2.13 # pyuserinput==0.1.11 # homeassistant.components.vera -pyvera==0.2.39 +pyvera==0.2.41 # homeassistant.components.media_player.vizio pyvizio==0.0.2 From 28964806c598795cfa872e7e298cf9f25cca4ef9 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Wed, 14 Feb 2018 07:58:31 +0100 Subject: [PATCH 063/173] Downgrade limitlessled to 1.0.8 (#12403) --- homeassistant/components/light/limitlessled.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index 94c02577a6b..ad3aa4e92e8 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, Light, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['limitlessled==1.0.9'] +REQUIREMENTS = ['limitlessled==1.0.8'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 053baac28d7..ec7276dddc9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -448,7 +448,7 @@ liffylights==0.9.4 lightify==1.0.6.1 # homeassistant.components.light.limitlessled -limitlessled==1.0.9 +limitlessled==1.0.8 # homeassistant.components.linode linode-api==4.1.4b2 From 6500cb791588e115a62712199c0cbed626af8f31 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 14 Feb 2018 08:07:50 +0100 Subject: [PATCH 064/173] File Path fixes for RPi Camera (#12338) * Checked file path with is_allowed_path() for RPi Camera * Used cv.isfile to verify file path instead of manual checks * Changed default file path for RPiCamera to config_dir/image.jpg * Used tempfiles for storing RPi Camera images, if no other path is defined * Stopped checking for whitelisted paths on temporary files --- homeassistant/components/camera/rpi_camera.py | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/camera/rpi_camera.py b/homeassistant/components/camera/rpi_camera.py index f37e7778414..f1f110d7c6a 100644 --- a/homeassistant/components/camera/rpi_camera.py +++ b/homeassistant/components/camera/rpi_camera.py @@ -8,6 +8,7 @@ import os import subprocess import logging import shutil +from tempfile import NamedTemporaryFile import voluptuous as vol @@ -36,7 +37,7 @@ DEFAULT_TIMELAPSE = 1000 DEFAULT_VERTICAL_FLIP = 0 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_FILE_PATH): cv.string, + vol.Optional(CONF_FILE_PATH): cv.isfile, vol.Optional(CONF_HORIZONTAL_FLIP, default=DEFAULT_HORIZONTAL_FLIP): vol.All(vol.Coerce(int), vol.Range(min=0, max=1)), vol.Optional(CONF_IMAGE_HEIGHT, default=DEFAULT_IMAGE_HEIGHT): @@ -77,25 +78,32 @@ def setup_platform(hass, config, add_devices, discovery_info=None): CONF_TIMELAPSE: config.get(CONF_TIMELAPSE), CONF_HORIZONTAL_FLIP: config.get(CONF_HORIZONTAL_FLIP), CONF_VERTICAL_FLIP: config.get(CONF_VERTICAL_FLIP), - CONF_FILE_PATH: config.get(CONF_FILE_PATH, - os.path.join(os.path.dirname(__file__), - 'image.jpg')) + CONF_FILE_PATH: config.get(CONF_FILE_PATH) } ) hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, kill_raspistill) - try: - # Try to create an empty file (or open existing) to ensure we have - # proper permissions. - open(setup_config[CONF_FILE_PATH], 'a').close() + file_path = setup_config[CONF_FILE_PATH] - add_devices([RaspberryCamera(setup_config)]) - except PermissionError: - _LOGGER.error("File path is not writable") - return False - except FileNotFoundError: - _LOGGER.error("Could not create output file (missing directory?)") + def delete_temp_file(*args): + """Delete the temporary file to prevent saving multiple temp images. + + Only used when no path is defined + """ + os.remove(file_path) + + # If no file path is defined, use a temporary file + if file_path is None: + temp_file = NamedTemporaryFile(suffix='.jpg', delete=False) + temp_file.close() + file_path = temp_file.name + setup_config[CONF_FILE_PATH] = file_path + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, delete_temp_file) + + # Check whether the file path has been whitelisted + elif not hass.config.is_allowed_path(file_path): + _LOGGER.error("'%s' is not a whitelisted directory", file_path) return False From f25d56d6669875d7da2f87543003a039ead3d237 Mon Sep 17 00:00:00 2001 From: Julius Mittenzwei Date: Wed, 14 Feb 2018 15:00:45 +0100 Subject: [PATCH 065/173] Code cleanup of velux scene (#12390) * Code cleanup of velux scene * fixed review comments * fixed review comments --- homeassistant/components/scene/velux.py | 33 +++++++++---------------- homeassistant/components/velux.py | 3 --- 2 files changed, 11 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/scene/velux.py b/homeassistant/components/scene/velux.py index 8c87b434471..86d71153a2b 100644 --- a/homeassistant/components/scene/velux.py +++ b/homeassistant/components/scene/velux.py @@ -4,6 +4,8 @@ Support for VELUX scenes. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/scene.velux/ """ +import asyncio + from homeassistant.components.scene import Scene from homeassistant.components.velux import _LOGGER, DATA_VELUX @@ -11,26 +13,22 @@ from homeassistant.components.velux import _LOGGER, DATA_VELUX DEPENDENCIES = ['velux'] -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the scenes for velux platform.""" - if DATA_VELUX not in hass.data \ - or not hass.data[DATA_VELUX].initialized: - return False - entities = [] for scene in hass.data[DATA_VELUX].pyvlx.scenes: - entities.append(VeluxScene(hass, scene)) - add_devices(entities) - return True + entities.append(VeluxScene(scene)) + async_add_devices(entities) class VeluxScene(Scene): """Representation of a velux scene.""" - def __init__(self, hass, scene): + def __init__(self, scene): """Init velux scene.""" _LOGGER.info("Adding VELUX scene: %s", scene) - self.hass = hass self.scene = scene @property @@ -38,16 +36,7 @@ class VeluxScene(Scene): """Return the name of the scene.""" return self.scene.name - @property - def should_poll(self): - """Return that polling is not necessary.""" - return False - - @property - def is_on(self): - """There is no way of detecting if a scene is active (yet).""" - return False - - def activate(self): + @asyncio.coroutine + def async_activate(self): """Activate the scene.""" - self.hass.async_add_job(self.scene.run()) + yield from self.scene.run() diff --git a/homeassistant/components/velux.py b/homeassistant/components/velux.py index b0c902aa83e..ad541ee9cfe 100644 --- a/homeassistant/components/velux.py +++ b/homeassistant/components/velux.py @@ -52,16 +52,13 @@ class VeluxModule: def __init__(self, hass, config): """Initialize for velux component.""" from pyvlx import PyVLX - self.initialized = False host = config[DOMAIN].get(CONF_HOST) password = config[DOMAIN].get(CONF_PASSWORD) self.pyvlx = PyVLX( host=host, password=password) - self.hass = hass @asyncio.coroutine def async_start(self): """Start velux component.""" yield from self.pyvlx.load_scenes() - self.initialized = True From 416f64fc70fd6c8f86908f58b8245fc819265ab8 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 14 Feb 2018 15:01:39 +0100 Subject: [PATCH 066/173] Upgrade sphinx-autodoc-typehints to 1.2.5 (#12404) --- requirements_docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_docs.txt b/requirements_docs.txt index 4c159fd4d94..60946fd00a8 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,3 +1,3 @@ Sphinx==1.7.0 -sphinx-autodoc-typehints==1.2.4 +sphinx-autodoc-typehints==1.2.5 sphinx-autodoc-annotation==1.0.post1 From 78c44180f436ee39f6b91609b5ad1c0393edcc1d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 14 Feb 2018 12:06:03 -0800 Subject: [PATCH 067/173] Extract data validator to own file and add tests (#12401) --- homeassistant/components/cloud/http_api.py | 5 +- homeassistant/components/conversation.py | 4 +- homeassistant/components/http/__init__.py | 42 +--------- .../components/http/data_validator.py | 51 ++++++++++++ homeassistant/components/http/util.py | 2 +- homeassistant/components/shopping_list.py | 4 +- tests/components/http/test_data_validator.py | 77 +++++++++++++++++++ 7 files changed, 139 insertions(+), 46 deletions(-) create mode 100644 homeassistant/components/http/data_validator.py create mode 100644 tests/components/http/test_data_validator.py diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index af966e180eb..f7f327f2f2c 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -6,8 +6,9 @@ import logging import async_timeout import voluptuous as vol -from homeassistant.components.http import ( - HomeAssistantView, RequestDataValidator) +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http.data_validator import ( + RequestDataValidator) from . import auth_api from .const import DOMAIN, REQUEST_TIMEOUT diff --git a/homeassistant/components/conversation.py b/homeassistant/components/conversation.py index c1dd89d31cd..9f325f3eb89 100644 --- a/homeassistant/components/conversation.py +++ b/homeassistant/components/conversation.py @@ -12,6 +12,8 @@ import voluptuous as vol from homeassistant import core from homeassistant.components import http +from homeassistant.components.http.data_validator import ( + RequestDataValidator) from homeassistant.helpers import config_validation as cv from homeassistant.helpers import intent @@ -148,7 +150,7 @@ class ConversationProcessView(http.HomeAssistantView): url = '/api/conversation/process' name = "api:conversation:process" - @http.RequestDataValidator(vol.Schema({ + @RequestDataValidator(vol.Schema({ vol.Required('text'): str, })) @asyncio.coroutine diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 33f97395945..22f8c90dfb1 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -5,7 +5,6 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/http/ """ import asyncio -from functools import wraps from ipaddress import ip_network import json import logging @@ -415,14 +414,13 @@ def request_handler_factory(view, handler): if not request.app['hass'].is_running: return web.Response(status=503) - remote_addr = get_real_ip(request) authenticated = request.get(KEY_AUTHENTICATED, False) if view.requires_auth and not authenticated: raise HTTPUnauthorized() _LOGGER.info('Serving %s to %s (auth: %s)', - request.path, remote_addr, authenticated) + request.path, get_real_ip(request), authenticated) result = handler(request, **request.match_info) @@ -449,41 +447,3 @@ def request_handler_factory(view, handler): return web.Response(body=result, status=status_code) return handle - - -class RequestDataValidator: - """Decorator that will validate the incoming data. - - Takes in a voluptuous schema and adds 'post_data' as - keyword argument to the function call. - - Will return a 400 if no JSON provided or doesn't match schema. - """ - - def __init__(self, schema): - """Initialize the decorator.""" - self._schema = schema - - def __call__(self, method): - """Decorate a function.""" - @asyncio.coroutine - @wraps(method) - def wrapper(view, request, *args, **kwargs): - """Wrap a request handler with data validation.""" - try: - data = yield from request.json() - except ValueError: - _LOGGER.error('Invalid JSON received.') - return view.json_message('Invalid JSON.', 400) - - try: - kwargs['data'] = self._schema(data) - except vol.Invalid as err: - _LOGGER.error('Data does not match schema: %s', err) - return view.json_message( - 'Message format incorrect: {}'.format(err), 400) - - result = yield from method(view, request, *args, **kwargs) - return result - - return wrapper diff --git a/homeassistant/components/http/data_validator.py b/homeassistant/components/http/data_validator.py new file mode 100644 index 00000000000..528c0a598e3 --- /dev/null +++ b/homeassistant/components/http/data_validator.py @@ -0,0 +1,51 @@ +"""Decorator for view methods to help with data validation.""" +import asyncio +from functools import wraps +import logging + +import voluptuous as vol + +_LOGGER = logging.getLogger(__name__) + + +class RequestDataValidator: + """Decorator that will validate the incoming data. + + Takes in a voluptuous schema and adds 'post_data' as + keyword argument to the function call. + + Will return a 400 if no JSON provided or doesn't match schema. + """ + + def __init__(self, schema, allow_empty=False): + """Initialize the decorator.""" + self._schema = schema + self._allow_empty = allow_empty + + def __call__(self, method): + """Decorate a function.""" + @asyncio.coroutine + @wraps(method) + def wrapper(view, request, *args, **kwargs): + """Wrap a request handler with data validation.""" + data = None + try: + data = yield from request.json() + except ValueError: + if not self._allow_empty or \ + (yield from request.content.read()) != b'': + _LOGGER.error('Invalid JSON received.') + return view.json_message('Invalid JSON.', 400) + data = {} + + try: + kwargs['data'] = self._schema(data) + except vol.Invalid as err: + _LOGGER.error('Data does not match schema: %s', err) + return view.json_message( + 'Message format incorrect: {}'.format(err), 400) + + result = yield from method(view, request, *args, **kwargs) + return result + + return wrapper diff --git a/homeassistant/components/http/util.py b/homeassistant/components/http/util.py index 1a5a3d98a22..359c20f4fa1 100644 --- a/homeassistant/components/http/util.py +++ b/homeassistant/components/http/util.py @@ -10,7 +10,7 @@ def get_real_ip(request): if KEY_REAL_IP in request: return request[KEY_REAL_IP] - if (request.app[KEY_USE_X_FORWARDED_FOR] and + if (request.app.get(KEY_USE_X_FORWARDED_FOR) and HTTP_HEADER_X_FORWARDED_FOR in request.headers): request[KEY_REAL_IP] = ip_address( request.headers.get(HTTP_HEADER_X_FORWARDED_FOR).split(',')[0]) diff --git a/homeassistant/components/shopping_list.py b/homeassistant/components/shopping_list.py index 31259325c04..416fdd3f6d0 100644 --- a/homeassistant/components/shopping_list.py +++ b/homeassistant/components/shopping_list.py @@ -10,6 +10,8 @@ import voluptuous as vol from homeassistant.const import HTTP_NOT_FOUND, HTTP_BAD_REQUEST from homeassistant.core import callback from homeassistant.components import http +from homeassistant.components.http.data_validator import ( + RequestDataValidator) from homeassistant.helpers import intent import homeassistant.helpers.config_validation as cv @@ -199,7 +201,7 @@ class CreateShoppingListItemView(http.HomeAssistantView): url = '/api/shopping_list/item' name = "api:shopping_list:item" - @http.RequestDataValidator(vol.Schema({ + @RequestDataValidator(vol.Schema({ vol.Required('name'): str, })) @asyncio.coroutine diff --git a/tests/components/http/test_data_validator.py b/tests/components/http/test_data_validator.py new file mode 100644 index 00000000000..f00be4fc6f9 --- /dev/null +++ b/tests/components/http/test_data_validator.py @@ -0,0 +1,77 @@ +"""Test data validator decorator.""" +import asyncio +from unittest.mock import Mock + +from aiohttp import web +import voluptuous as vol + +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http.data_validator import RequestDataValidator + + +@asyncio.coroutine +def get_client(test_client, validator): + """Generate a client that hits a view decorated with validator.""" + app = web.Application() + app['hass'] = Mock(is_running=True) + + class TestView(HomeAssistantView): + url = '/' + name = 'test' + requires_auth = False + + @asyncio.coroutine + @validator + def post(self, request, data): + """Test method.""" + return b'' + + TestView().register(app.router) + client = yield from test_client(app) + return client + + +@asyncio.coroutine +def test_validator(test_client): + """Test the validator.""" + client = yield from get_client( + test_client, RequestDataValidator(vol.Schema({ + vol.Required('test'): str + }))) + + resp = yield from client.post('/', json={ + 'test': 'bla' + }) + assert resp.status == 200 + + resp = yield from client.post('/', json={ + 'test': 100 + }) + assert resp.status == 400 + + resp = yield from client.post('/') + assert resp.status == 400 + + +@asyncio.coroutine +def test_validator_allow_empty(test_client): + """Test the validator with empty data.""" + client = yield from get_client( + test_client, RequestDataValidator(vol.Schema({ + # Although we allow empty, our schema should still be able + # to validate an empty dict. + vol.Optional('test'): str + }), allow_empty=True)) + + resp = yield from client.post('/', json={ + 'test': 'bla' + }) + assert resp.status == 200 + + resp = yield from client.post('/', json={ + 'test': 100 + }) + assert resp.status == 400 + + resp = yield from client.post('/') + assert resp.status == 200 From c25c4c85d65d992337879dd946abc90b735f111b Mon Sep 17 00:00:00 2001 From: Greg Laabs Date: Wed, 14 Feb 2018 21:58:49 -0800 Subject: [PATCH 068/173] Fixed 3 small issues in isy994 component (#12421) 1. FanLincs have two nodes: one light and one fan motor. In order for each node to get detected as different Hass entity types, I removed the device-type check for FanLinc. The logic will now fall back on the uom checks which should work just fine. (An alternative approach here would be to special case FanLincs and handle them directly - but seeing as the newer 5.x ISY firmware already handles this much better using NodeDefs, I think this quick and dirty approach is fine for the older firmware.) Fixes #12030 2. Some non-dimming switches were appearing as `light`s in Hass due to an duplicate NodeDef being in the light domain filter. Removed! Fixes #12340 3. The `unqiue_id` property was throwing an error for certain entity types that don't have an `_id` property from the ISY. This issue has always been present, but was exposed by the entity registry which seems to be the first thing to actually try reading the `unique_id` property from the isy994 component. --- homeassistant/components/isy994.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py index 04437d7055c..fbdf6e48143 100644 --- a/homeassistant/components/isy994.py +++ b/homeassistant/components/isy994.py @@ -83,9 +83,9 @@ NODE_FILTERS = { }, 'fan': { 'uom': [], - 'states': ['on', 'off', 'low', 'medium', 'high'], + 'states': ['off', 'low', 'medium', 'high'], 'node_def_id': ['FanLincMotor'], - 'insteon_type': ['1.46.'] + 'insteon_type': [] }, 'cover': { 'uom': ['97'], @@ -99,7 +99,7 @@ NODE_FILTERS = { 'node_def_id': ['DimmerLampSwitch', 'DimmerLampSwitch_ADV', 'DimmerSwitchOnly', 'DimmerSwitchOnly_ADV', 'DimmerLampOnly', 'BallastRelayLampSwitch', - 'BallastRelayLampSwitch_ADV', 'RelayLampSwitch', + 'BallastRelayLampSwitch_ADV', 'RemoteLinc2', 'RemoteLinc2_ADV'], 'insteon_type': ['1.'] }, @@ -433,7 +433,10 @@ class ISYDevice(Entity): def unique_id(self) -> str: """Get the unique identifier of the device.""" # pylint: disable=protected-access - return self._node._id + if hasattr(self._node, '_id'): + return self._node._id + + return None @property def name(self) -> str: From 5d4b1ecd3b331a23de0c883aea49a0204736dcae Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Thu, 15 Feb 2018 07:00:49 +0100 Subject: [PATCH 069/173] Fix MQTT payload decode returning prematurely (#12420) * Fix MQTT returning prematurely * Add test --- homeassistant/components/mqtt/__init__.py | 2 +- tests/components/mqtt/test_init.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 30c18953964..0485d82a274 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -607,7 +607,7 @@ class MQTT(object): "with encoding %s", msg.payload, msg.topic, subscription.encoding) - return + continue self.hass.async_run_job(subscription.callback, msg.topic, payload, msg.qos) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index a1edff8333d..24308bc9a7e 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -172,6 +172,17 @@ class TestMQTTCallbacks(unittest.TestCase): "b'\\x9a' on test-topic with encoding utf-8", test_handle.output[0]) + def test_all_subscriptions_run_when_decode_fails(self): + """Test all other subscriptions still run when decode fails for one.""" + mqtt.subscribe(self.hass, 'test-topic', self.record_calls, + encoding='ascii') + mqtt.subscribe(self.hass, 'test-topic', self.record_calls) + + fire_mqtt_message(self.hass, 'test-topic', '°C') + + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + def test_subscribe_topic(self): """Test the subscription of a topic.""" unsub = mqtt.subscribe(self.hass, 'test-topic', self.record_calls) From 7e2e82d9567675f10967ac34504ccdad76d45351 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Thu, 15 Feb 2018 07:01:30 +0100 Subject: [PATCH 070/173] Print every changed file on new line (#12412) --- script/lint | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/lint b/script/lint index ab7561b9a5b..102dd84a407 100755 --- a/script/lint +++ b/script/lint @@ -8,7 +8,7 @@ if [ "$1" = "--changed" ]; then echo "=================================================" echo "FILES CHANGED (git diff upstream/dev --name-only)" echo "=================================================" - echo $files + printf "%s\n" $files echo "================" echo "LINT with flake8" echo "================" From 96bd153c80b608b40ff9abc58ad38958bb9956e6 Mon Sep 17 00:00:00 2001 From: Julius Mittenzwei Date: Thu, 15 Feb 2018 07:06:36 +0100 Subject: [PATCH 071/173] Added support for colored KNX lights (#12411) --- homeassistant/components/knx.py | 6 +++--- homeassistant/components/light/knx.py | 17 +++++++++++++++-- requirements_all.txt | 2 +- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index eb5ae9a4590..727d9a3fdbd 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -14,7 +14,7 @@ from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.script import Script -REQUIREMENTS = ['xknx==0.7.18'] +REQUIREMENTS = ['xknx==0.8.3'] DOMAIN = "knx" DATA_KNX = "data_knx" @@ -216,7 +216,7 @@ class KNXModule(object): @asyncio.coroutine def service_send_to_knx_bus(self, call): """Service for sending an arbitrary KNX message to the KNX bus.""" - from xknx.knx import Telegram, Address, DPTBinary, DPTArray + from xknx.knx import Telegram, GroupAddress, DPTBinary, DPTArray attr_payload = call.data.get(SERVICE_KNX_ATTR_PAYLOAD) attr_address = call.data.get(SERVICE_KNX_ATTR_ADDRESS) @@ -226,7 +226,7 @@ class KNXModule(object): return DPTBinary(attr_payload) return DPTArray(attr_payload) payload = calculate_payload(attr_payload) - address = Address(attr_address) + address = GroupAddress(attr_address) telegram = Telegram() telegram.payload = payload diff --git a/homeassistant/components/light/knx.py b/homeassistant/components/light/knx.py index 8c9e78ab2b0..7ee3d5c114f 100644 --- a/homeassistant/components/light/knx.py +++ b/homeassistant/components/light/knx.py @@ -10,7 +10,8 @@ import voluptuous as vol from homeassistant.components.knx import ATTR_DISCOVER_DEVICES, DATA_KNX from homeassistant.components.light import ( - ATTR_BRIGHTNESS, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, Light) + ATTR_BRIGHTNESS, ATTR_RGB_COLOR, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, + SUPPORT_RGB_COLOR, Light) from homeassistant.const import CONF_NAME from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -19,6 +20,8 @@ CONF_ADDRESS = 'address' CONF_STATE_ADDRESS = 'state_address' CONF_BRIGHTNESS_ADDRESS = 'brightness_address' CONF_BRIGHTNESS_STATE_ADDRESS = 'brightness_state_address' +CONF_COLOR_ADDRESS = 'color_address' +CONF_COLOR_STATE_ADDRESS = 'color_state_address' DEFAULT_NAME = 'KNX Light' DEPENDENCIES = ['knx'] @@ -29,6 +32,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_STATE_ADDRESS): cv.string, vol.Optional(CONF_BRIGHTNESS_ADDRESS): cv.string, vol.Optional(CONF_BRIGHTNESS_STATE_ADDRESS): cv.string, + vol.Optional(CONF_COLOR_ADDRESS): cv.string, + vol.Optional(CONF_COLOR_STATE_ADDRESS): cv.string, }) @@ -66,7 +71,9 @@ def async_add_devices_config(hass, config, async_add_devices): group_address_switch_state=config.get(CONF_STATE_ADDRESS), group_address_brightness=config.get(CONF_BRIGHTNESS_ADDRESS), group_address_brightness_state=config.get( - CONF_BRIGHTNESS_STATE_ADDRESS)) + CONF_BRIGHTNESS_STATE_ADDRESS), + group_address_color=config.get(CONF_COLOR_ADDRESS), + group_address_color_state=config.get(CONF_COLOR_STATE_ADDRESS)) hass.data[DATA_KNX].xknx.devices.add(light) async_add_devices([KNXLight(hass, light)]) @@ -120,6 +127,8 @@ class KNXLight(Light): @property def rgb_color(self): """Return the RBG color value.""" + if self.device.supports_color: + return self.device.current_color() return None @property @@ -153,6 +162,8 @@ class KNXLight(Light): flags = 0 if self.device.supports_dimming: flags |= SUPPORT_BRIGHTNESS + if self.device.supports_color: + flags |= SUPPORT_RGB_COLOR return flags @asyncio.coroutine @@ -160,6 +171,8 @@ class KNXLight(Light): """Turn the light on.""" if ATTR_BRIGHTNESS in kwargs and self.device.supports_dimming: yield from self.device.set_brightness(int(kwargs[ATTR_BRIGHTNESS])) + elif ATTR_RGB_COLOR in kwargs: + yield from self.device.set_color(kwargs[ATTR_RGB_COLOR]) else: yield from self.device.set_on() diff --git a/requirements_all.txt b/requirements_all.txt index ec7276dddc9..fad3434f712 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1239,7 +1239,7 @@ xbee-helper==0.0.7 xboxapi==0.1.1 # homeassistant.components.knx -xknx==0.7.18 +xknx==0.8.3 # homeassistant.components.media_player.bluesound # homeassistant.components.sensor.startca From f5d1f53fabd1557ece6c19b35182fdb6306ad42d Mon Sep 17 00:00:00 2001 From: Julius Mittenzwei Date: Thu, 15 Feb 2018 07:07:04 +0100 Subject: [PATCH 072/173] Small code cleanup: (#12409) - should_poll of base class already returns False - there is no is_on within Scene --- homeassistant/components/scene/litejet.py | 5 ----- homeassistant/components/scene/lutron_caseta.py | 10 ---------- homeassistant/components/scene/vera.py | 5 ----- homeassistant/components/scene/wink.py | 5 ----- 4 files changed, 25 deletions(-) diff --git a/homeassistant/components/scene/litejet.py b/homeassistant/components/scene/litejet.py index b8f3a82c0e3..37fb58d8dc7 100644 --- a/homeassistant/components/scene/litejet.py +++ b/homeassistant/components/scene/litejet.py @@ -42,11 +42,6 @@ class LiteJetScene(Scene): """Return the name of the scene.""" return self._name - @property - def should_poll(self): - """Return that polling is not necessary.""" - return False - @property def device_state_attributes(self): """Return the device-specific state attributes.""" diff --git a/homeassistant/components/scene/lutron_caseta.py b/homeassistant/components/scene/lutron_caseta.py index 5f96e126321..0d9024d194e 100644 --- a/homeassistant/components/scene/lutron_caseta.py +++ b/homeassistant/components/scene/lutron_caseta.py @@ -42,16 +42,6 @@ class LutronCasetaScene(Scene): """Return the name of the scene.""" return self._scene_name - @property - def should_poll(self): - """Return that polling is not necessary.""" - return False - - @property - def is_on(self): - """There is no way of detecting if a scene is active (yet).""" - return False - @asyncio.coroutine def async_activate(self): """Activate the scene.""" diff --git a/homeassistant/components/scene/vera.py b/homeassistant/components/scene/vera.py index 24dfaef1fb1..4f580356fbb 100644 --- a/homeassistant/components/scene/vera.py +++ b/homeassistant/components/scene/vera.py @@ -53,8 +53,3 @@ class VeraScene(Scene): def device_state_attributes(self): """Return the state attributes of the scene.""" return {'vera_scene_id': self.vera_scene.vera_scene_id} - - @property - def should_poll(self): - """Return that polling is not necessary.""" - return False diff --git a/homeassistant/components/scene/wink.py b/homeassistant/components/scene/wink.py index 0f617511818..5bd053bdd39 100644 --- a/homeassistant/components/scene/wink.py +++ b/homeassistant/components/scene/wink.py @@ -38,11 +38,6 @@ class WinkScene(WinkDevice, Scene): """Call when entity is added to hass.""" self.hass.data[DOMAIN]['entities']['scene'].append(self) - @property - def is_on(self): - """Python-wink will always return False.""" - return self.wink.state() - def activate(self): """Activate the scene.""" self.wink.activate() From ae32d208d994be43e4eafdddf79b2357d2cd1754 Mon Sep 17 00:00:00 2001 From: Julius Mittenzwei Date: Thu, 15 Feb 2018 07:10:12 +0100 Subject: [PATCH 073/173] Cleanup of knx component (#12408) --- homeassistant/components/binary_sensor/knx.py | 3 --- homeassistant/components/climate/knx.py | 3 --- homeassistant/components/cover/knx.py | 3 --- homeassistant/components/knx.py | 1 - homeassistant/components/light/knx.py | 4 ---- homeassistant/components/notify/knx.py | 11 +++-------- homeassistant/components/sensor/knx.py | 3 --- homeassistant/components/switch/knx.py | 3 --- 8 files changed, 3 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py index c01654a3663..82463264f88 100644 --- a/homeassistant/components/binary_sensor/knx.py +++ b/homeassistant/components/binary_sensor/knx.py @@ -56,9 +56,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up binary sensor(s) for KNX platform.""" - if DATA_KNX not in hass.data or not hass.data[DATA_KNX].initialized: - return - if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) else: diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index a78c277fa33..e9601f25564 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -64,9 +64,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up climate(s) for KNX platform.""" - if DATA_KNX not in hass.data or not hass.data[DATA_KNX].initialized: - return - if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) else: diff --git a/homeassistant/components/cover/knx.py b/homeassistant/components/cover/knx.py index a6cd1263a73..730a2b29a2e 100644 --- a/homeassistant/components/cover/knx.py +++ b/homeassistant/components/cover/knx.py @@ -53,9 +53,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up cover(s) for KNX platform.""" - if DATA_KNX not in hass.data or not hass.data[DATA_KNX].initialized: - return - if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) else: diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index 727d9a3fdbd..a90a5246759 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -120,7 +120,6 @@ class KNXModule(object): self.hass = hass self.config = config self.connected = False - self.initialized = True self.init_xknx() self.register_callbacks() diff --git a/homeassistant/components/light/knx.py b/homeassistant/components/light/knx.py index 7ee3d5c114f..020184b8501 100644 --- a/homeassistant/components/light/knx.py +++ b/homeassistant/components/light/knx.py @@ -40,10 +40,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up lights for KNX platform.""" - if DATA_KNX not in hass.data \ - or not hass.data[DATA_KNX].initialized: - return - if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) else: diff --git a/homeassistant/components/notify/knx.py b/homeassistant/components/notify/knx.py index d14d8dcf8ad..e6bb400d421 100644 --- a/homeassistant/components/notify/knx.py +++ b/homeassistant/components/notify/knx.py @@ -27,10 +27,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_get_service(hass, config, discovery_info=None): """Get the KNX notification service.""" - if DATA_KNX not in hass.data \ - or not hass.data[DATA_KNX].initialized: - return False - return async_get_service_discovery(hass, discovery_info) \ if discovery_info is not None else \ async_get_service_config(hass, config) @@ -44,7 +40,7 @@ def async_get_service_discovery(hass, discovery_info): device = hass.data[DATA_KNX].xknx.devices[device_name] notification_devices.append(device) return \ - KNXNotificationService(hass, notification_devices) \ + KNXNotificationService(notification_devices) \ if notification_devices else \ None @@ -58,15 +54,14 @@ def async_get_service_config(hass, config): name=config.get(CONF_NAME), group_address=config.get(CONF_ADDRESS)) hass.data[DATA_KNX].xknx.devices.add(notification) - return KNXNotificationService(hass, [notification, ]) + return KNXNotificationService([notification, ]) class KNXNotificationService(BaseNotificationService): """Implement demo notification service.""" - def __init__(self, hass, devices): + def __init__(self, devices): """Initialize the service.""" - self.hass = hass self.devices = devices @property diff --git a/homeassistant/components/sensor/knx.py b/homeassistant/components/sensor/knx.py index 70afa6fe1e1..bdceb729e89 100644 --- a/homeassistant/components/sensor/knx.py +++ b/homeassistant/components/sensor/knx.py @@ -31,9 +31,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up sensor(s) for KNX platform.""" - if DATA_KNX not in hass.data or not hass.data[DATA_KNX].initialized: - return - if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) else: diff --git a/homeassistant/components/switch/knx.py b/homeassistant/components/switch/knx.py index 01c08767ca0..86a9adf0495 100644 --- a/homeassistant/components/switch/knx.py +++ b/homeassistant/components/switch/knx.py @@ -30,9 +30,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up switch(es) for KNX platform.""" - if DATA_KNX not in hass.data or not hass.data[DATA_KNX].initialized: - return - if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) else: From b4dbfe9bbdc459c2808a19d47b0c12065600ad61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Thu, 15 Feb 2018 12:33:49 +0100 Subject: [PATCH 074/173] Update the Tibber sensor at startup (#12428) --- homeassistant/components/sensor/tibber.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index dd09b9f7891..519ff05cbd8 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -42,7 +42,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): yield from home.update_info() dev.append(TibberSensor(home)) - async_add_devices(dev) + async_add_devices(dev, True) class TibberSensor(Entity): From ad8fe8a93a45584515b36611f89feba336ea7816 Mon Sep 17 00:00:00 2001 From: Russell Cloran Date: Thu, 15 Feb 2018 04:38:56 -0800 Subject: [PATCH 075/173] zha: Add unique_id to entities (#12331) * zha: Add unique_id to entities * Lint * fix comments * Update __init__.py * Update __init__.py --- homeassistant/components/zha/__init__.py | 31 +++++++++++++++--------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 71a04338023..e4f38549e32 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -184,7 +184,7 @@ class ApplicationListener: component = None profile_clusters = ([], []) - device_key = '%s-%s' % (str(device.ieee), endpoint_id) + device_key = "{}-{}".format(str(device.ieee), endpoint_id) node_config = self._config[DOMAIN][CONF_DEVICE_CONFIG].get( device_key, {}) @@ -214,6 +214,7 @@ class ApplicationListener: 'in_clusters': {c.cluster_id: c for c in in_clusters}, 'out_clusters': {c.cluster_id: c for c in out_clusters}, 'new_join': join, + 'unique_id': "{}-{}".format(device.ieee, endpoint_id), } discovery_info.update(discovered_info) self._hass.data[DISCOVERY_KEY][device_key] = discovery_info @@ -240,9 +241,11 @@ class ApplicationListener: 'in_clusters': {cluster.cluster_id: cluster}, 'out_clusters': {}, 'new_join': join, + 'unique_id': "{}-{}-{}".format( + device.ieee, endpoint_id, cluster_id), } discovery_info.update(discovered_info) - cluster_key = '%s-%s' % (device_key, cluster_id) + cluster_key = "{}-{}".format(device_key, cluster_id) self._hass.data[DISCOVERY_KEY][cluster_key] = discovery_info yield from discovery.async_load_platform( @@ -264,25 +267,25 @@ class Entity(entity.Entity): _domain = None # Must be overridden by subclasses def __init__(self, endpoint, in_clusters, out_clusters, manufacturer, - model, application_listener, **kwargs): + model, application_listener, unique_id, **kwargs): """Init ZHA entity.""" self._device_state_attributes = {} ieee = endpoint.device.ieee ieeetail = ''.join(['%02x' % (o, ) for o in ieee[-4:]]) if manufacturer and model is not None: - self.entity_id = '%s.%s_%s_%s_%s' % ( + self.entity_id = "{}.{}_{}_{}_{}".format( self._domain, slugify(manufacturer), slugify(model), ieeetail, endpoint.endpoint_id, ) - self._device_state_attributes['friendly_name'] = '%s %s' % ( + self._device_state_attributes['friendly_name'] = "{} {}".format( manufacturer, model, ) else: - self.entity_id = "%s.zha_%s_%s" % ( + self.entity_id = "{}.zha_{}_{}".format( self._domain, ieeetail, endpoint.endpoint_id, @@ -295,9 +298,20 @@ class Entity(entity.Entity): self._in_clusters = in_clusters self._out_clusters = out_clusters self._state = ha_const.STATE_UNKNOWN + self._unique_id = unique_id application_listener.register_entity(ieee, self) + @property + def unique_id(self) -> str: + """Return an unique ID.""" + return self._unique_id + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + return self._device_state_attributes + def attribute_updated(self, attribute, value): """Handle an attribute updated on this cluster.""" pass @@ -306,11 +320,6 @@ class Entity(entity.Entity): """Handle a ZDO command received on this cluster.""" pass - @property - def device_state_attributes(self): - """Return device specific state attributes.""" - return self._device_state_attributes - @asyncio.coroutine def _discover_endpoint_info(endpoint): From f32911d0365ca9d3412d6c1f449f34b6aeafae60 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 15 Feb 2018 13:06:14 -0800 Subject: [PATCH 076/173] Cleanup http (#12424) * Clean up HTTP component * Clean up HTTP mock * Remove unused import * Fix test * Lint --- .../components/emulated_hue/__init__.py | 4 +- homeassistant/components/frontend/__init__.py | 4 +- homeassistant/components/http/__init__.py | 74 +-- homeassistant/components/http/auth.py | 83 +-- homeassistant/components/http/ban.py | 44 +- homeassistant/components/http/const.py | 8 - homeassistant/components/http/cors.py | 43 ++ homeassistant/components/http/real_ip.py | 35 ++ homeassistant/components/http/util.py | 25 - .../components/telegram_bot/webhooks.py | 4 +- tests/common.py | 34 -- tests/components/camera/test_uvc.py | 3 +- tests/components/config/test_hassbian.py | 4 +- tests/components/config/test_init.py | 10 +- tests/components/config/test_zwave.py | 139 +---- .../device_tracker/test_automatic.py | 9 +- tests/components/http/__init__.py | 37 ++ tests/components/http/test_auth.py | 265 ++++----- tests/components/http/test_ban.py | 117 ++-- tests/components/http/test_cors.py | 104 ++++ tests/components/http/test_init.py | 155 ++--- tests/components/http/test_real_ip.py | 48 ++ tests/components/mqtt/test_server.py | 7 +- tests/components/notify/test_html5.py | 538 ++++++------------ tests/components/test_history.py | 4 +- tests/components/test_logbook.py | 7 +- tests/components/test_shopping_list.py | 1 - tests/components/test_websocket_api.py | 19 +- 28 files changed, 811 insertions(+), 1014 deletions(-) create mode 100644 homeassistant/components/http/cors.py create mode 100644 homeassistant/components/http/real_ip.py delete mode 100644 homeassistant/components/http/util.py create mode 100644 tests/components/http/test_cors.py create mode 100644 tests/components/http/test_real_ip.py diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 9fba21b81dc..c89e4fda358 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -14,7 +14,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) from homeassistant.components.http import REQUIREMENTS # NOQA -from homeassistant.components.http import HomeAssistantWSGI +from homeassistant.components.http import HomeAssistantHTTP from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.deprecation import get_deprecated import homeassistant.helpers.config_validation as cv @@ -86,7 +86,7 @@ def setup(hass, yaml_config): """Activate the emulated_hue component.""" config = Config(hass, yaml_config.get(DOMAIN, {})) - server = HomeAssistantWSGI( + server = HomeAssistantHTTP( hass, server_host=config.host_ip_addr, server_port=config.listen_port, diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index c426a775fc5..7fa1634778d 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -17,7 +17,7 @@ import jinja2 import homeassistant.helpers.config_validation as cv from homeassistant.components.http import HomeAssistantView -from homeassistant.components.http.auth import is_trusted_ip +from homeassistant.components.http.const import KEY_AUTHENTICATED from homeassistant.config import find_config_file, load_yaml_config_file from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback @@ -490,7 +490,7 @@ class IndexView(HomeAssistantView): panel_url = hass.data[DATA_PANELS][panel].webcomponent_url_es5 no_auth = '1' - if hass.config.api.api_password and not is_trusted_ip(request): + if hass.config.api.api_password and not request[KEY_AUTHENTICATED]: # do not try to auto connect on load no_auth = '0' diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 22f8c90dfb1..ac253b2821a 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -12,35 +12,28 @@ import os import ssl from aiohttp import web -from aiohttp.hdrs import ACCEPT, ORIGIN, CONTENT_TYPE from aiohttp.web_exceptions import HTTPUnauthorized, HTTPMovedPermanently import voluptuous as vol from homeassistant.const import ( - SERVER_PORT, CONTENT_TYPE_JSON, HTTP_HEADER_HA_AUTH, - EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START, - HTTP_HEADER_X_REQUESTED_WITH) + SERVER_PORT, CONTENT_TYPE_JSON, + EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START,) from homeassistant.core import is_callback import homeassistant.helpers.config_validation as cv import homeassistant.remote as rem import homeassistant.util as hass_util from homeassistant.util.logging import HideSensitiveDataFilter -from .auth import auth_middleware -from .ban import ban_middleware -from .const import ( - KEY_BANS_ENABLED, KEY_AUTHENTICATED, KEY_LOGIN_THRESHOLD, - KEY_TRUSTED_NETWORKS, KEY_USE_X_FORWARDED_FOR) +from .auth import setup_auth +from .ban import setup_bans +from .cors import setup_cors +from .real_ip import setup_real_ip +from .const import KEY_AUTHENTICATED, KEY_REAL_IP from .static import ( CachingFileResponse, CachingStaticResource, staticresource_middleware) -from .util import get_real_ip REQUIREMENTS = ['aiohttp_cors==0.6.0'] -ALLOWED_CORS_HEADERS = [ - ORIGIN, ACCEPT, HTTP_HEADER_X_REQUESTED_WITH, CONTENT_TYPE, - HTTP_HEADER_HA_AUTH] - DOMAIN = 'http' CONF_API_PASSWORD = 'api_password' @@ -127,7 +120,7 @@ def async_setup(hass, config): logging.getLogger('aiohttp.access').addFilter( HideSensitiveDataFilter(api_password)) - server = HomeAssistantWSGI( + server = HomeAssistantHTTP( hass, server_host=server_host, server_port=server_port, @@ -173,25 +166,29 @@ def async_setup(hass, config): return True -class HomeAssistantWSGI(object): - """WSGI server for Home Assistant.""" +class HomeAssistantHTTP(object): + """HTTP server for Home Assistant.""" def __init__(self, hass, api_password, ssl_certificate, ssl_key, server_host, server_port, cors_origins, use_x_forwarded_for, trusted_networks, login_threshold, is_ban_enabled): - """Initialize the WSGI Home Assistant server.""" - middlewares = [auth_middleware, staticresource_middleware] + """Initialize the HTTP Home Assistant server.""" + app = self.app = web.Application( + middlewares=[staticresource_middleware]) + + # This order matters + setup_real_ip(app, use_x_forwarded_for) if is_ban_enabled: - middlewares.insert(0, ban_middleware) + setup_bans(hass, app, login_threshold) - self.app = web.Application(middlewares=middlewares) - self.app['hass'] = hass - self.app[KEY_USE_X_FORWARDED_FOR] = use_x_forwarded_for - self.app[KEY_TRUSTED_NETWORKS] = trusted_networks - self.app[KEY_BANS_ENABLED] = is_ban_enabled - self.app[KEY_LOGIN_THRESHOLD] = login_threshold + setup_auth(app, trusted_networks, api_password) + + if cors_origins: + setup_cors(app, cors_origins) + + app['hass'] = hass self.hass = hass self.api_password = api_password @@ -199,21 +196,10 @@ class HomeAssistantWSGI(object): self.ssl_key = ssl_key self.server_host = server_host self.server_port = server_port + self.is_ban_enabled = is_ban_enabled self._handler = None self.server = None - if cors_origins: - import aiohttp_cors - - self.cors = aiohttp_cors.setup(self.app, defaults={ - host: aiohttp_cors.ResourceOptions( - allow_headers=ALLOWED_CORS_HEADERS, - allow_methods='*', - ) for host in cors_origins - }) - else: - self.cors = None - def register_view(self, view): """Register a view with the WSGI server. @@ -292,15 +278,7 @@ class HomeAssistantWSGI(object): @asyncio.coroutine def start(self): """Start the WSGI server.""" - cors_added = set() - if self.cors is not None: - for route in list(self.app.router.routes()): - if hasattr(route, 'resource'): - route = route.resource - if route in cors_added: - continue - self.cors.add(route) - cors_added.add(route) + yield from self.app.startup() if self.ssl_certificate: try: @@ -420,7 +398,7 @@ def request_handler_factory(view, handler): raise HTTPUnauthorized() _LOGGER.info('Serving %s to %s (auth: %s)', - request.path, get_real_ip(request), authenticated) + request.path, request.get(KEY_REAL_IP), authenticated) result = handler(request, **request.match_info) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index a6a412b6ba2..3128489437a 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -7,55 +7,66 @@ import logging from aiohttp import hdrs from aiohttp.web import middleware +from homeassistant.core import callback from homeassistant.const import HTTP_HEADER_HA_AUTH -from .util import get_real_ip -from .const import KEY_TRUSTED_NETWORKS, KEY_AUTHENTICATED +from .const import KEY_AUTHENTICATED, KEY_REAL_IP DATA_API_PASSWORD = 'api_password' _LOGGER = logging.getLogger(__name__) -@middleware -@asyncio.coroutine -def auth_middleware(request, handler): - """Authenticate as middleware.""" - # If no password set, just always set authenticated=True - if request.app['hass'].http.api_password is None: - request[KEY_AUTHENTICATED] = True +@callback +def setup_auth(app, trusted_networks, api_password): + """Create auth middleware for the app.""" + @middleware + @asyncio.coroutine + def auth_middleware(request, handler): + """Authenticate as middleware.""" + # If no password set, just always set authenticated=True + if api_password is None: + request[KEY_AUTHENTICATED] = True + return (yield from handler(request)) + + # Check authentication + authenticated = False + + if (HTTP_HEADER_HA_AUTH in request.headers and + hmac.compare_digest( + api_password, request.headers[HTTP_HEADER_HA_AUTH])): + # A valid auth header has been set + authenticated = True + + elif (DATA_API_PASSWORD in request.query and + hmac.compare_digest(api_password, + request.query[DATA_API_PASSWORD])): + authenticated = True + + elif (hdrs.AUTHORIZATION in request.headers and + validate_authorization_header(api_password, request)): + authenticated = True + + elif _is_trusted_ip(request, trusted_networks): + authenticated = True + + request[KEY_AUTHENTICATED] = authenticated return (yield from handler(request)) - # Check authentication - authenticated = False + @asyncio.coroutine + def auth_startup(app): + """Initialize auth middleware when app starts up.""" + app.middlewares.append(auth_middleware) - if (HTTP_HEADER_HA_AUTH in request.headers and - validate_password( - request, request.headers[HTTP_HEADER_HA_AUTH])): - # A valid auth header has been set - authenticated = True - - elif (DATA_API_PASSWORD in request.query and - validate_password(request, request.query[DATA_API_PASSWORD])): - authenticated = True - - elif (hdrs.AUTHORIZATION in request.headers and - validate_authorization_header(request)): - authenticated = True - - elif is_trusted_ip(request): - authenticated = True - - request[KEY_AUTHENTICATED] = authenticated - return (yield from handler(request)) + app.on_startup.append(auth_startup) -def is_trusted_ip(request): +def _is_trusted_ip(request, trusted_networks): """Test if request is from a trusted ip.""" - ip_addr = get_real_ip(request) + ip_addr = request[KEY_REAL_IP] - return ip_addr and any( + return any( ip_addr in trusted_network for trusted_network - in request.app[KEY_TRUSTED_NETWORKS]) + in trusted_networks) def validate_password(request, api_password): @@ -64,7 +75,7 @@ def validate_password(request, api_password): api_password, request.app['hass'].http.api_password) -def validate_authorization_header(request): +def validate_authorization_header(api_password, request): """Test an authorization header if valid password.""" if hdrs.AUTHORIZATION not in request.headers: return False @@ -80,4 +91,4 @@ def validate_authorization_header(request): if username != 'homeassistant': return False - return validate_password(request, password) + return hmac.compare_digest(api_password, password) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 8423c53716b..4c797b05b19 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -10,18 +10,20 @@ from aiohttp.web import middleware from aiohttp.web_exceptions import HTTPForbidden, HTTPUnauthorized import voluptuous as vol +from homeassistant.core import callback from homeassistant.components import persistent_notification from homeassistant.config import load_yaml_config_file from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.util.yaml import dump -from .const import ( - KEY_BANS_ENABLED, KEY_BANNED_IPS, KEY_LOGIN_THRESHOLD, - KEY_FAILED_LOGIN_ATTEMPTS) -from .util import get_real_ip +from .const import KEY_REAL_IP _LOGGER = logging.getLogger(__name__) +KEY_BANNED_IPS = 'ha_banned_ips' +KEY_FAILED_LOGIN_ATTEMPTS = 'ha_failed_login_attempts' +KEY_LOGIN_THRESHOLD = 'ha_login_threshold' + NOTIFICATION_ID_BAN = 'ip-ban' NOTIFICATION_ID_LOGIN = 'http-login' @@ -33,21 +35,31 @@ SCHEMA_IP_BAN_ENTRY = vol.Schema({ }) +@callback +def setup_bans(hass, app, login_threshold): + """Create IP Ban middleware for the app.""" + @asyncio.coroutine + def ban_startup(app): + """Initialize bans when app starts up.""" + app.middlewares.append(ban_middleware) + app[KEY_BANNED_IPS] = yield from hass.async_add_job( + load_ip_bans_config, hass.config.path(IP_BANS_FILE)) + app[KEY_FAILED_LOGIN_ATTEMPTS] = defaultdict(int) + app[KEY_LOGIN_THRESHOLD] = login_threshold + + app.on_startup.append(ban_startup) + + @middleware @asyncio.coroutine def ban_middleware(request, handler): """IP Ban middleware.""" - if not request.app[KEY_BANS_ENABLED]: + if KEY_BANNED_IPS not in request.app: + _LOGGER.error('IP Ban middleware loaded but banned IPs not loaded') return (yield from handler(request)) - if KEY_BANNED_IPS not in request.app: - hass = request.app['hass'] - request.app[KEY_BANNED_IPS] = yield from hass.async_add_job( - load_ip_bans_config, hass.config.path(IP_BANS_FILE)) - # Verify if IP is not banned - ip_address_ = get_real_ip(request) - + ip_address_ = request[KEY_REAL_IP] is_banned = any(ip_ban.ip_address == ip_address_ for ip_ban in request.app[KEY_BANNED_IPS]) @@ -64,7 +76,7 @@ def ban_middleware(request, handler): @asyncio.coroutine def process_wrong_login(request): """Process a wrong login attempt.""" - remote_addr = get_real_ip(request) + remote_addr = request[KEY_REAL_IP] msg = ('Login attempt or request with invalid authentication ' 'from {}'.format(remote_addr)) @@ -73,13 +85,11 @@ def process_wrong_login(request): request.app['hass'], msg, 'Login attempt failed', NOTIFICATION_ID_LOGIN) - if (not request.app[KEY_BANS_ENABLED] or + # Check if ban middleware is loaded + if (KEY_BANNED_IPS not in request.app or request.app[KEY_LOGIN_THRESHOLD] < 1): return - if KEY_FAILED_LOGIN_ATTEMPTS not in request.app: - request.app[KEY_FAILED_LOGIN_ATTEMPTS] = defaultdict(int) - request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] += 1 if (request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] > diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py index 4250dd32514..e5494e945c4 100644 --- a/homeassistant/components/http/const.py +++ b/homeassistant/components/http/const.py @@ -1,11 +1,3 @@ """HTTP specific constants.""" KEY_AUTHENTICATED = 'ha_authenticated' -KEY_USE_X_FORWARDED_FOR = 'ha_use_x_forwarded_for' -KEY_TRUSTED_NETWORKS = 'ha_trusted_networks' KEY_REAL_IP = 'ha_real_ip' -KEY_BANS_ENABLED = 'ha_bans_enabled' -KEY_BANNED_IPS = 'ha_banned_ips' -KEY_FAILED_LOGIN_ATTEMPTS = 'ha_failed_login_attempts' -KEY_LOGIN_THRESHOLD = 'ha_login_threshold' - -HTTP_HEADER_X_FORWARDED_FOR = 'X-Forwarded-For' diff --git a/homeassistant/components/http/cors.py b/homeassistant/components/http/cors.py new file mode 100644 index 00000000000..2eb92732d1e --- /dev/null +++ b/homeassistant/components/http/cors.py @@ -0,0 +1,43 @@ +"""Provide cors support for the HTTP component.""" +import asyncio + +from aiohttp.hdrs import ACCEPT, ORIGIN, CONTENT_TYPE + +from homeassistant.const import ( + HTTP_HEADER_X_REQUESTED_WITH, HTTP_HEADER_HA_AUTH) + + +from homeassistant.core import callback + + +ALLOWED_CORS_HEADERS = [ + ORIGIN, ACCEPT, HTTP_HEADER_X_REQUESTED_WITH, CONTENT_TYPE, + HTTP_HEADER_HA_AUTH] + + +@callback +def setup_cors(app, origins): + """Setup cors.""" + import aiohttp_cors + + cors = aiohttp_cors.setup(app, defaults={ + host: aiohttp_cors.ResourceOptions( + allow_headers=ALLOWED_CORS_HEADERS, + allow_methods='*', + ) for host in origins + }) + + @asyncio.coroutine + def cors_startup(app): + """Initialize cors when app starts up.""" + cors_added = set() + + for route in list(app.router.routes()): + if hasattr(route, 'resource'): + route = route.resource + if route in cors_added: + continue + cors.add(route) + cors_added.add(route) + + app.on_startup.append(cors_startup) diff --git a/homeassistant/components/http/real_ip.py b/homeassistant/components/http/real_ip.py new file mode 100644 index 00000000000..1e50f33f69e --- /dev/null +++ b/homeassistant/components/http/real_ip.py @@ -0,0 +1,35 @@ +"""Middleware to fetch real IP.""" +import asyncio +from ipaddress import ip_address + +from aiohttp.web import middleware +from aiohttp.hdrs import X_FORWARDED_FOR + +from homeassistant.core import callback + +from .const import KEY_REAL_IP + + +@callback +def setup_real_ip(app, use_x_forwarded_for): + """Create IP Ban middleware for the app.""" + @middleware + @asyncio.coroutine + def real_ip_middleware(request, handler): + """Real IP middleware.""" + if (use_x_forwarded_for and + X_FORWARDED_FOR in request.headers): + request[KEY_REAL_IP] = ip_address( + request.headers.get(X_FORWARDED_FOR).split(',')[0]) + else: + request[KEY_REAL_IP] = \ + ip_address(request.transport.get_extra_info('peername')[0]) + + return (yield from handler(request)) + + @asyncio.coroutine + def app_startup(app): + """Initialize bans when app starts up.""" + app.middlewares.append(real_ip_middleware) + + app.on_startup.append(app_startup) diff --git a/homeassistant/components/http/util.py b/homeassistant/components/http/util.py deleted file mode 100644 index 359c20f4fa1..00000000000 --- a/homeassistant/components/http/util.py +++ /dev/null @@ -1,25 +0,0 @@ -"""HTTP utilities.""" -from ipaddress import ip_address - -from .const import ( - KEY_REAL_IP, KEY_USE_X_FORWARDED_FOR, HTTP_HEADER_X_FORWARDED_FOR) - - -def get_real_ip(request): - """Get IP address of client.""" - if KEY_REAL_IP in request: - return request[KEY_REAL_IP] - - if (request.app.get(KEY_USE_X_FORWARDED_FOR) and - HTTP_HEADER_X_FORWARDED_FOR in request.headers): - request[KEY_REAL_IP] = ip_address( - request.headers.get(HTTP_HEADER_X_FORWARDED_FOR).split(',')[0]) - else: - peername = request.transport.get_extra_info('peername') - - if peername: - request[KEY_REAL_IP] = ip_address(peername[0]) - else: - request[KEY_REAL_IP] = None - - return request[KEY_REAL_IP] diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index 055f68884a6..5c293459447 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -12,7 +12,7 @@ import logging import voluptuous as vol from homeassistant.components.http import HomeAssistantView -from homeassistant.components.http.util import get_real_ip +from homeassistant.components.http.const import KEY_REAL_IP from homeassistant.components.telegram_bot import ( CONF_ALLOWED_CHAT_IDS, BaseTelegramBotEntity, PLATFORM_SCHEMA) from homeassistant.const import ( @@ -110,7 +110,7 @@ class BotPushReceiver(HomeAssistantView, BaseTelegramBotEntity): @asyncio.coroutine def post(self, request): """Accept the POST from telegram.""" - real_ip = get_real_ip(request) + real_ip = request[KEY_REAL_IP] if not any(real_ip in net for net in self.trusted_networks): _LOGGER.warning("Access denied from %s", real_ip) return self.json_message('Access denied', HTTP_UNAUTHORIZED) diff --git a/tests/common.py b/tests/common.py index 9e4575780bc..1b79d15b319 100644 --- a/tests/common.py +++ b/tests/common.py @@ -9,8 +9,6 @@ import logging import threading from contextlib import contextmanager -from aiohttp import web - from homeassistant import core as ha, loader from homeassistant.setup import setup_component, async_setup_component from homeassistant.config import async_process_component_config @@ -25,9 +23,6 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, EVENT_PLATFORM_DISCOVERED, ATTR_SERVICE, ATTR_DISCOVERED, SERVER_PORT, EVENT_HOMEASSISTANT_CLOSE) from homeassistant.components import mqtt, recorder -from homeassistant.components.http.auth import auth_middleware -from homeassistant.components.http.const import ( - KEY_USE_X_FORWARDED_FOR, KEY_BANS_ENABLED, KEY_TRUSTED_NETWORKS) from homeassistant.util.async import ( run_callback_threadsafe, run_coroutine_threadsafe) @@ -262,35 +257,6 @@ def mock_state_change_event(hass, new_state, old_state=None): hass.bus.fire(EVENT_STATE_CHANGED, event_data) -def mock_http_component(hass, api_password=None): - """Mock the HTTP component.""" - hass.http = MagicMock(api_password=api_password) - mock_component(hass, 'http') - hass.http.views = {} - - def mock_register_view(view): - """Store registered view.""" - if isinstance(view, type): - # Instantiate the view, if needed - view = view() - - hass.http.views[view.name] = view - - hass.http.register_view = mock_register_view - - -def mock_http_component_app(hass, api_password=None): - """Create an aiohttp.web.Application instance for testing.""" - if 'http' not in hass.config.components: - mock_http_component(hass, api_password) - app = web.Application(middlewares=[auth_middleware]) - app['hass'] = hass - app[KEY_USE_X_FORWARDED_FOR] = False - app[KEY_BANS_ENABLED] = False - app[KEY_TRUSTED_NETWORKS] = [] - return app - - @asyncio.coroutine def async_mock_mqtt_component(hass, config=None): """Mock the MQTT component.""" diff --git a/tests/components/camera/test_uvc.py b/tests/components/camera/test_uvc.py index ad7ee5f5bcb..40b4fb2d8e2 100644 --- a/tests/components/camera/test_uvc.py +++ b/tests/components/camera/test_uvc.py @@ -9,7 +9,7 @@ from uvcclient import nvr from homeassistant.setup import setup_component from homeassistant.components.camera import uvc -from tests.common import get_test_home_assistant, mock_http_component +from tests.common import get_test_home_assistant class TestUVCSetup(unittest.TestCase): @@ -18,7 +18,6 @@ class TestUVCSetup(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - mock_http_component(self.hass) def tearDown(self): """Stop everything that was started.""" diff --git a/tests/components/config/test_hassbian.py b/tests/components/config/test_hassbian.py index 659e5ad2448..9038ccc6aa4 100644 --- a/tests/components/config/test_hassbian.py +++ b/tests/components/config/test_hassbian.py @@ -14,7 +14,7 @@ def test_setup_check_env_prevents_load(hass, loop): with patch.dict(os.environ, clear=True), \ patch.object(config, 'SECTIONS', ['hassbian']), \ patch('homeassistant.components.http.' - 'HomeAssistantWSGI.register_view') as reg_view: + 'HomeAssistantHTTP.register_view') as reg_view: loop.run_until_complete(async_setup_component(hass, 'config', {})) assert 'config' in hass.config.components assert reg_view.called is False @@ -25,7 +25,7 @@ def test_setup_check_env_works(hass, loop): with patch.dict(os.environ, {'FORCE_HASSBIAN': '1'}), \ patch.object(config, 'SECTIONS', ['hassbian']), \ patch('homeassistant.components.http.' - 'HomeAssistantWSGI.register_view') as reg_view: + 'HomeAssistantHTTP.register_view') as reg_view: loop.run_until_complete(async_setup_component(hass, 'config', {})) assert 'config' in hass.config.components assert len(reg_view.mock_calls) == 2 diff --git a/tests/components/config/test_init.py b/tests/components/config/test_init.py index 6f69f886419..2d5d814ac8a 100644 --- a/tests/components/config/test_init.py +++ b/tests/components/config/test_init.py @@ -2,19 +2,11 @@ import asyncio from unittest.mock import patch -import pytest - from homeassistant.const import EVENT_COMPONENT_LOADED from homeassistant.setup import async_setup_component, ATTR_COMPONENT from homeassistant.components import config -from tests.common import mock_http_component, mock_coro, mock_component - - -@pytest.fixture(autouse=True) -def stub_http(hass): - """Stub the HTTP component.""" - mock_http_component(hass) +from tests.common import mock_coro, mock_component @asyncio.coroutine diff --git a/tests/components/config/test_zwave.py b/tests/components/config/test_zwave.py index 81800d709e3..c98385a3c32 100644 --- a/tests/components/config/test_zwave.py +++ b/tests/components/config/test_zwave.py @@ -3,28 +3,30 @@ import asyncio import json from unittest.mock import MagicMock, patch +import pytest + from homeassistant.bootstrap import async_setup_component from homeassistant.components import config from homeassistant.components.zwave import DATA_NETWORK, const -from homeassistant.components.config.zwave import ( - ZWaveNodeValueView, ZWaveNodeGroupView, ZWaveNodeConfigView, - ZWaveUserCodeView, ZWaveConfigWriteView) -from tests.common import mock_http_component_app from tests.mock.zwave import MockNode, MockValue, MockEntityValues VIEW_NAME = 'api:config:zwave:device_config' -@asyncio.coroutine -def test_get_device_config(hass, test_client): - """Test getting device config.""" +@pytest.fixture +def client(loop, hass, test_client): + """Client to communicate with Z-Wave config views.""" with patch.object(config, 'SECTIONS', ['zwave']): - yield from async_setup_component(hass, 'config', {}) + loop.run_until_complete(async_setup_component(hass, 'config', {})) - client = yield from test_client(hass.http.app) + return loop.run_until_complete(test_client(hass.http.app)) + +@asyncio.coroutine +def test_get_device_config(client): + """Test getting device config.""" def mock_read(path): """Mock reading data.""" return { @@ -47,13 +49,8 @@ def test_get_device_config(hass, test_client): @asyncio.coroutine -def test_update_device_config(hass, test_client): +def test_update_device_config(client): """Test updating device config.""" - with patch.object(config, 'SECTIONS', ['zwave']): - yield from async_setup_component(hass, 'config', {}) - - client = yield from test_client(hass.http.app) - orig_data = { 'hello.beer': { 'ignored': True, @@ -90,13 +87,8 @@ def test_update_device_config(hass, test_client): @asyncio.coroutine -def test_update_device_config_invalid_key(hass, test_client): +def test_update_device_config_invalid_key(client): """Test updating device config.""" - with patch.object(config, 'SECTIONS', ['zwave']): - yield from async_setup_component(hass, 'config', {}) - - client = yield from test_client(hass.http.app) - resp = yield from client.post( '/api/config/zwave/device_config/invalid_entity', data=json.dumps({ 'polling_intensity': 2 @@ -106,13 +98,8 @@ def test_update_device_config_invalid_key(hass, test_client): @asyncio.coroutine -def test_update_device_config_invalid_data(hass, test_client): +def test_update_device_config_invalid_data(client): """Test updating device config.""" - with patch.object(config, 'SECTIONS', ['zwave']): - yield from async_setup_component(hass, 'config', {}) - - client = yield from test_client(hass.http.app) - resp = yield from client.post( '/api/config/zwave/device_config/hello.beer', data=json.dumps({ 'invalid_option': 2 @@ -122,13 +109,8 @@ def test_update_device_config_invalid_data(hass, test_client): @asyncio.coroutine -def test_update_device_config_invalid_json(hass, test_client): +def test_update_device_config_invalid_json(client): """Test updating device config.""" - with patch.object(config, 'SECTIONS', ['zwave']): - yield from async_setup_component(hass, 'config', {}) - - client = yield from test_client(hass.http.app) - resp = yield from client.post( '/api/config/zwave/device_config/hello.beer', data='not json') @@ -136,11 +118,8 @@ def test_update_device_config_invalid_json(hass, test_client): @asyncio.coroutine -def test_get_values(hass, test_client): +def test_get_values(hass, client): """Test getting values on node.""" - app = mock_http_component_app(hass) - ZWaveNodeValueView().register(app.router) - node = MockNode(node_id=1) value = MockValue(value_id=123456, node=node, label='Test Label', instance=1, index=2, poll_intensity=4) @@ -150,8 +129,6 @@ def test_get_values(hass, test_client): values2 = MockEntityValues(primary=value2) hass.data[const.DATA_ENTITY_VALUES] = [values, values2] - client = yield from test_client(app) - resp = yield from client.get('/api/zwave/values/1') assert resp.status == 200 @@ -168,11 +145,8 @@ def test_get_values(hass, test_client): @asyncio.coroutine -def test_get_groups(hass, test_client): +def test_get_groups(hass, client): """Test getting groupdata on node.""" - app = mock_http_component_app(hass) - ZWaveNodeGroupView().register(app.router) - network = hass.data[DATA_NETWORK] = MagicMock() node = MockNode(node_id=2) node.groups.associations = 'assoc' @@ -182,8 +156,6 @@ def test_get_groups(hass, test_client): node.groups = {1: node.groups} network.nodes = {2: node} - client = yield from test_client(app) - resp = yield from client.get('/api/zwave/groups/2') assert resp.status == 200 @@ -200,18 +172,13 @@ def test_get_groups(hass, test_client): @asyncio.coroutine -def test_get_groups_nogroups(hass, test_client): +def test_get_groups_nogroups(hass, client): """Test getting groupdata on node with no groups.""" - app = mock_http_component_app(hass) - ZWaveNodeGroupView().register(app.router) - network = hass.data[DATA_NETWORK] = MagicMock() node = MockNode(node_id=2) network.nodes = {2: node} - client = yield from test_client(app) - resp = yield from client.get('/api/zwave/groups/2') assert resp.status == 200 @@ -221,16 +188,11 @@ def test_get_groups_nogroups(hass, test_client): @asyncio.coroutine -def test_get_groups_nonode(hass, test_client): +def test_get_groups_nonode(hass, client): """Test getting groupdata on nonexisting node.""" - app = mock_http_component_app(hass) - ZWaveNodeGroupView().register(app.router) - network = hass.data[DATA_NETWORK] = MagicMock() network.nodes = {1: 1, 5: 5} - client = yield from test_client(app) - resp = yield from client.get('/api/zwave/groups/2') assert resp.status == 404 @@ -240,11 +202,8 @@ def test_get_groups_nonode(hass, test_client): @asyncio.coroutine -def test_get_config(hass, test_client): +def test_get_config(hass, client): """Test getting config on node.""" - app = mock_http_component_app(hass) - ZWaveNodeConfigView().register(app.router) - network = hass.data[DATA_NETWORK] = MagicMock() node = MockNode(node_id=2) value = MockValue( @@ -261,8 +220,6 @@ def test_get_config(hass, test_client): network.nodes = {2: node} node.get_values.return_value = node.values - client = yield from test_client(app) - resp = yield from client.get('/api/zwave/config/2') assert resp.status == 200 @@ -278,19 +235,14 @@ def test_get_config(hass, test_client): @asyncio.coroutine -def test_get_config_noconfig_node(hass, test_client): +def test_get_config_noconfig_node(hass, client): """Test getting config on node without config.""" - app = mock_http_component_app(hass) - ZWaveNodeConfigView().register(app.router) - network = hass.data[DATA_NETWORK] = MagicMock() node = MockNode(node_id=2) network.nodes = {2: node} node.get_values.return_value = node.values - client = yield from test_client(app) - resp = yield from client.get('/api/zwave/config/2') assert resp.status == 200 @@ -300,16 +252,11 @@ def test_get_config_noconfig_node(hass, test_client): @asyncio.coroutine -def test_get_config_nonode(hass, test_client): +def test_get_config_nonode(hass, client): """Test getting config on nonexisting node.""" - app = mock_http_component_app(hass) - ZWaveNodeConfigView().register(app.router) - network = hass.data[DATA_NETWORK] = MagicMock() network.nodes = {1: 1, 5: 5} - client = yield from test_client(app) - resp = yield from client.get('/api/zwave/config/2') assert resp.status == 404 @@ -319,16 +266,11 @@ def test_get_config_nonode(hass, test_client): @asyncio.coroutine -def test_get_usercodes_nonode(hass, test_client): +def test_get_usercodes_nonode(hass, client): """Test getting usercodes on nonexisting node.""" - app = mock_http_component_app(hass) - ZWaveUserCodeView().register(app.router) - network = hass.data[DATA_NETWORK] = MagicMock() network.nodes = {1: 1, 5: 5} - client = yield from test_client(app) - resp = yield from client.get('/api/zwave/usercodes/2') assert resp.status == 404 @@ -338,11 +280,8 @@ def test_get_usercodes_nonode(hass, test_client): @asyncio.coroutine -def test_get_usercodes(hass, test_client): +def test_get_usercodes(hass, client): """Test getting usercodes on node.""" - app = mock_http_component_app(hass) - ZWaveUserCodeView().register(app.router) - network = hass.data[DATA_NETWORK] = MagicMock() node = MockNode(node_id=18, command_classes=[const.COMMAND_CLASS_USER_CODE]) @@ -356,8 +295,6 @@ def test_get_usercodes(hass, test_client): network.nodes = {18: node} node.get_values.return_value = node.values - client = yield from test_client(app) - resp = yield from client.get('/api/zwave/usercodes/18') assert resp.status == 200 @@ -369,19 +306,14 @@ def test_get_usercodes(hass, test_client): @asyncio.coroutine -def test_get_usercode_nousercode_node(hass, test_client): +def test_get_usercode_nousercode_node(hass, client): """Test getting usercodes on node without usercodes.""" - app = mock_http_component_app(hass) - ZWaveUserCodeView().register(app.router) - network = hass.data[DATA_NETWORK] = MagicMock() node = MockNode(node_id=18) network.nodes = {18: node} node.get_values.return_value = node.values - client = yield from test_client(app) - resp = yield from client.get('/api/zwave/usercodes/18') assert resp.status == 200 @@ -391,11 +323,8 @@ def test_get_usercode_nousercode_node(hass, test_client): @asyncio.coroutine -def test_get_usercodes_no_genreuser(hass, test_client): +def test_get_usercodes_no_genreuser(hass, client): """Test getting usercodes on node missing genre user.""" - app = mock_http_component_app(hass) - ZWaveUserCodeView().register(app.router) - network = hass.data[DATA_NETWORK] = MagicMock() node = MockNode(node_id=18, command_classes=[const.COMMAND_CLASS_USER_CODE]) @@ -409,8 +338,6 @@ def test_get_usercodes_no_genreuser(hass, test_client): network.nodes = {18: node} node.get_values.return_value = node.values - client = yield from test_client(app) - resp = yield from client.get('/api/zwave/usercodes/18') assert resp.status == 200 @@ -420,13 +347,8 @@ def test_get_usercodes_no_genreuser(hass, test_client): @asyncio.coroutine -def test_save_config_no_network(hass, test_client): +def test_save_config_no_network(hass, client): """Test saving configuration without network data.""" - app = mock_http_component_app(hass) - ZWaveConfigWriteView().register(app.router) - - client = yield from test_client(app) - resp = yield from client.post('/api/zwave/saveconfig') assert resp.status == 404 @@ -435,15 +357,10 @@ def test_save_config_no_network(hass, test_client): @asyncio.coroutine -def test_save_config(hass, test_client): +def test_save_config(hass, client): """Test saving configuration.""" - app = mock_http_component_app(hass) - ZWaveConfigWriteView().register(app.router) - network = hass.data[DATA_NETWORK] = MagicMock() - client = yield from test_client(app) - resp = yield from client.post('/api/zwave/saveconfig') assert resp.status == 200 diff --git a/tests/components/device_tracker/test_automatic.py b/tests/components/device_tracker/test_automatic.py index d40c1518ffa..d90b5c0dd62 100644 --- a/tests/components/device_tracker/test_automatic.py +++ b/tests/components/device_tracker/test_automatic.py @@ -5,11 +5,10 @@ import logging from unittest.mock import patch, MagicMock import aioautomatic +from homeassistant.setup import async_setup_component from homeassistant.components.device_tracker.automatic import ( async_setup_scanner) -from tests.common import mock_http_component - _LOGGER = logging.getLogger(__name__) @@ -23,8 +22,7 @@ def test_invalid_credentials( mock_open, mock_isfile, mock_makedirs, mock_json_dump, mock_json_load, mock_create_session, hass): """Test with invalid credentials.""" - mock_http_component(hass) - + hass.loop.run_until_complete(async_setup_component(hass, 'http', {})) mock_json_load.return_value = {'refresh_token': 'bad_token'} @asyncio.coroutine @@ -59,8 +57,7 @@ def test_valid_credentials( mock_open, mock_isfile, mock_makedirs, mock_json_dump, mock_json_load, mock_ws_connect, mock_create_session, hass): """Test with valid credentials.""" - mock_http_component(hass) - + hass.loop.run_until_complete(async_setup_component(hass, 'http', {})) mock_json_load.return_value = {'refresh_token': 'good_token'} session = MagicMock() diff --git a/tests/components/http/__init__.py b/tests/components/http/__init__.py index 869e80fff75..ef9817a2f1b 100644 --- a/tests/components/http/__init__.py +++ b/tests/components/http/__init__.py @@ -1 +1,38 @@ """Tests for the HTTP component.""" +import asyncio +from ipaddress import ip_address + +from aiohttp import web + +from homeassistant.components.http.const import KEY_REAL_IP + + +def mock_real_ip(app): + """Inject middleware to mock real IP. + + Returns a function to set the real IP. + """ + ip_to_mock = None + + def set_ip_to_mock(value): + nonlocal ip_to_mock + ip_to_mock = value + + @asyncio.coroutine + @web.middleware + def mock_real_ip(request, handler): + """Mock Real IP middleware.""" + nonlocal ip_to_mock + + request[KEY_REAL_IP] = ip_address(ip_to_mock) + + return (yield from handler(request)) + + @asyncio.coroutine + def real_ip_startup(app): + """Startup of real ip.""" + app.middlewares.insert(0, mock_real_ip) + + app.on_startup.append(real_ip_startup) + + return set_ip_to_mock diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index ef9c63ad09e..c2687c05a8f 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -1,195 +1,156 @@ """The tests for the Home Assistant HTTP component.""" # pylint: disable=protected-access import asyncio -from ipaddress import ip_address, ip_network +from ipaddress import ip_network from unittest.mock import patch -import aiohttp +from aiohttp import BasicAuth, web +from aiohttp.web_exceptions import HTTPUnauthorized import pytest -from homeassistant import const +from homeassistant.const import HTTP_HEADER_HA_AUTH from homeassistant.setup import async_setup_component -import homeassistant.components.http as http -from homeassistant.components.http.const import ( - KEY_TRUSTED_NETWORKS, KEY_USE_X_FORWARDED_FOR, HTTP_HEADER_X_FORWARDED_FOR) +from homeassistant.components.http.auth import setup_auth +from homeassistant.components.http.real_ip import setup_real_ip +from homeassistant.components.http.const import KEY_AUTHENTICATED + +from . import mock_real_ip API_PASSWORD = 'test1234' # Don't add 127.0.0.1/::1 as trusted, as it may interfere with other test cases -TRUSTED_NETWORKS = ['192.0.2.0/24', '2001:DB8:ABCD::/48', '100.64.0.1', - 'FD01:DB8::1'] +TRUSTED_NETWORKS = [ + ip_network('192.0.2.0/24'), + ip_network('2001:DB8:ABCD::/48'), + ip_network('100.64.0.1'), + ip_network('FD01:DB8::1'), +] TRUSTED_ADDRESSES = ['100.64.0.1', '192.0.2.100', 'FD01:DB8::1', '2001:DB8:ABCD::1'] UNTRUSTED_ADDRESSES = ['198.51.100.1', '2001:DB8:FA1::1', '127.0.0.1', '::1'] -@pytest.fixture -def mock_api_client(hass, test_client): - """Start the Hass HTTP component.""" - hass.loop.run_until_complete(async_setup_component(hass, 'api', { - 'http': { - http.CONF_API_PASSWORD: API_PASSWORD, - } - })) - return hass.loop.run_until_complete(test_client(hass.http.app)) +@asyncio.coroutine +def mock_handler(request): + """Return if request was authenticated.""" + if not request[KEY_AUTHENTICATED]: + raise HTTPUnauthorized + return web.Response(status=200) @pytest.fixture -def mock_trusted_networks(hass, mock_api_client): - """Mock trusted networks.""" - hass.http.app[KEY_TRUSTED_NETWORKS] = [ - ip_network(trusted_network) - for trusted_network in TRUSTED_NETWORKS] +def app(): + """Fixture to setup a web.Application.""" + app = web.Application() + app.router.add_get('/', mock_handler) + setup_real_ip(app, False) + return app @asyncio.coroutine -def test_access_denied_without_password(mock_api_client): +def test_auth_middleware_loaded_by_default(hass): + """Test accessing to server from banned IP when feature is off.""" + with patch('homeassistant.components.http.setup_auth') as mock_setup: + yield from async_setup_component(hass, 'http', { + 'http': {} + }) + + assert len(mock_setup.mock_calls) == 1 + + +@asyncio.coroutine +def test_access_without_password(app, test_client): """Test access without password.""" - resp = yield from mock_api_client.get(const.URL_API) + setup_auth(app, [], None) + client = yield from test_client(app) + + resp = yield from client.get('/') + assert resp.status == 200 + + +@asyncio.coroutine +def test_access_with_password_in_header(app, test_client): + """Test access with password in URL.""" + setup_auth(app, [], API_PASSWORD) + client = yield from test_client(app) + + req = yield from client.get( + '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) + assert req.status == 200 + + req = yield from client.get( + '/', headers={HTTP_HEADER_HA_AUTH: 'wrong-pass'}) + assert req.status == 401 + + +@asyncio.coroutine +def test_access_with_password_in_query(app, test_client): + """Test access without password.""" + setup_auth(app, [], API_PASSWORD) + client = yield from test_client(app) + + resp = yield from client.get('/', params={ + 'api_password': API_PASSWORD + }) + assert resp.status == 200 + + resp = yield from client.get('/') assert resp.status == 401 - -@asyncio.coroutine -def test_access_denied_with_wrong_password_in_header(mock_api_client): - """Test access with wrong password.""" - resp = yield from mock_api_client.get(const.URL_API, headers={ - const.HTTP_HEADER_HA_AUTH: 'wrongpassword' + resp = yield from client.get('/', params={ + 'api_password': 'wrong-password' }) assert resp.status == 401 @asyncio.coroutine -def test_access_denied_with_x_forwarded_for(hass, mock_api_client, - mock_trusted_networks): - """Test access denied through the X-Forwarded-For http header.""" - hass.http.use_x_forwarded_for = True - for remote_addr in UNTRUSTED_ADDRESSES: - resp = yield from mock_api_client.get(const.URL_API, headers={ - HTTP_HEADER_X_FORWARDED_FOR: remote_addr}) - - assert resp.status == 401, \ - "{} shouldn't be trusted".format(remote_addr) - - -@asyncio.coroutine -def test_access_denied_with_untrusted_ip(mock_api_client, - mock_trusted_networks): - """Test access with an untrusted ip address.""" - for remote_addr in UNTRUSTED_ADDRESSES: - with patch('homeassistant.components.http.' - 'util.get_real_ip', - return_value=ip_address(remote_addr)): - resp = yield from mock_api_client.get( - const.URL_API, params={'api_password': ''}) - - assert resp.status == 401, \ - "{} shouldn't be trusted".format(remote_addr) - - -@asyncio.coroutine -def test_access_with_password_in_header(mock_api_client, caplog): - """Test access with password in URL.""" - # Hide logging from requests package that we use to test logging - req = yield from mock_api_client.get( - const.URL_API, headers={const.HTTP_HEADER_HA_AUTH: API_PASSWORD}) - - assert req.status == 200 - - logs = caplog.text - - assert const.URL_API in logs - assert API_PASSWORD not in logs - - -@asyncio.coroutine -def test_access_denied_with_wrong_password_in_url(mock_api_client): - """Test access with wrong password.""" - resp = yield from mock_api_client.get( - const.URL_API, params={'api_password': 'wrongpassword'}) - - assert resp.status == 401 - - -@asyncio.coroutine -def test_access_with_password_in_url(mock_api_client, caplog): - """Test access with password in URL.""" - req = yield from mock_api_client.get( - const.URL_API, params={'api_password': API_PASSWORD}) - - assert req.status == 200 - - logs = caplog.text - - assert const.URL_API in logs - assert API_PASSWORD not in logs - - -@asyncio.coroutine -def test_access_granted_with_x_forwarded_for(hass, mock_api_client, caplog, - mock_trusted_networks): - """Test access denied through the X-Forwarded-For http header.""" - hass.http.app[KEY_USE_X_FORWARDED_FOR] = True - for remote_addr in TRUSTED_ADDRESSES: - resp = yield from mock_api_client.get(const.URL_API, headers={ - HTTP_HEADER_X_FORWARDED_FOR: remote_addr}) - - assert resp.status == 200, \ - "{} should be trusted".format(remote_addr) - - -@asyncio.coroutine -def test_access_granted_with_trusted_ip(mock_api_client, caplog, - mock_trusted_networks): - """Test access with trusted addresses.""" - for remote_addr in TRUSTED_ADDRESSES: - with patch('homeassistant.components.http.' - 'auth.get_real_ip', - return_value=ip_address(remote_addr)): - resp = yield from mock_api_client.get( - const.URL_API, params={'api_password': ''}) - - assert resp.status == 200, \ - '{} should be trusted'.format(remote_addr) - - -@asyncio.coroutine -def test_basic_auth_works(mock_api_client, caplog): +def test_basic_auth_works(app, test_client): """Test access with basic authentication.""" - req = yield from mock_api_client.get( - const.URL_API, - auth=aiohttp.BasicAuth('homeassistant', API_PASSWORD)) + setup_auth(app, [], API_PASSWORD) + client = yield from test_client(app) + req = yield from client.get( + '/', + auth=BasicAuth('homeassistant', API_PASSWORD)) assert req.status == 200 - assert const.URL_API in caplog.text - - -@asyncio.coroutine -def test_basic_auth_username_homeassistant(mock_api_client, caplog): - """Test access with basic auth requires username homeassistant.""" - req = yield from mock_api_client.get( - const.URL_API, - auth=aiohttp.BasicAuth('wrong_username', API_PASSWORD)) + req = yield from client.get( + '/', + auth=BasicAuth('wrong_username', API_PASSWORD)) assert req.status == 401 - -@asyncio.coroutine -def test_basic_auth_wrong_password(mock_api_client, caplog): - """Test access with basic auth not allowed with wrong password.""" - req = yield from mock_api_client.get( - const.URL_API, - auth=aiohttp.BasicAuth('homeassistant', 'wrong password')) - + req = yield from client.get( + '/', + auth=BasicAuth('homeassistant', 'wrong password')) assert req.status == 401 - -@asyncio.coroutine -def test_authorization_header_must_be_basic_type(mock_api_client, caplog): - """Test only basic authorization is allowed for auth header.""" - req = yield from mock_api_client.get( - const.URL_API, + req = yield from client.get( + '/', headers={ 'authorization': 'NotBasic abcdefg' }) - assert req.status == 401 + + +@asyncio.coroutine +def test_access_with_trusted_ip(test_client): + """Test access with an untrusted ip address.""" + app = web.Application() + app.router.add_get('/', mock_handler) + + setup_auth(app, TRUSTED_NETWORKS, 'some-pass') + + set_mock_ip = mock_real_ip(app) + client = yield from test_client(app) + + for remote_addr in UNTRUSTED_ADDRESSES: + set_mock_ip(remote_addr) + resp = yield from client.get('/') + assert resp.status == 401, \ + "{} shouldn't be trusted".format(remote_addr) + + for remote_addr in TRUSTED_ADDRESSES: + set_mock_ip(remote_addr) + resp = yield from client.get('/') + assert resp.status == 200, \ + "{} should be trusted".format(remote_addr) diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index c9147367c10..bd6df4f4e73 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -1,91 +1,96 @@ """The tests for the Home Assistant HTTP component.""" # pylint: disable=protected-access import asyncio -from ipaddress import ip_address from unittest.mock import patch, mock_open -import pytest +from aiohttp import web +from aiohttp.web_exceptions import HTTPUnauthorized -from homeassistant import const from homeassistant.setup import async_setup_component import homeassistant.components.http as http -from homeassistant.components.http.const import ( - KEY_BANS_ENABLED, KEY_LOGIN_THRESHOLD, KEY_BANNED_IPS) -from homeassistant.components.http.ban import IpBan, IP_BANS_FILE +from homeassistant.components.http.ban import ( + IpBan, IP_BANS_FILE, setup_bans, KEY_BANNED_IPS) + +from . import mock_real_ip -API_PASSWORD = 'test1234' BANNED_IPS = ['200.201.202.203', '100.64.0.2'] -@pytest.fixture -def mock_api_client(hass, test_client): - """Start the Hass HTTP component.""" - hass.loop.run_until_complete(async_setup_component(hass, 'api', { - 'http': { - http.CONF_API_PASSWORD: API_PASSWORD, - } - })) - hass.http.app[KEY_BANNED_IPS] = [IpBan(banned_ip) for banned_ip - in BANNED_IPS] - return hass.loop.run_until_complete(test_client(hass.http.app)) - - @asyncio.coroutine -def test_access_from_banned_ip(hass, mock_api_client): +def test_access_from_banned_ip(hass, test_client): """Test accessing to server from banned IP. Both trusted and not.""" - hass.http.app[KEY_BANS_ENABLED] = True + app = web.Application() + setup_bans(hass, app, 5) + set_real_ip = mock_real_ip(app) + + with patch('homeassistant.components.http.ban.load_ip_bans_config', + return_value=[IpBan(banned_ip) for banned_ip + in BANNED_IPS]): + client = yield from test_client(app) + for remote_addr in BANNED_IPS: - with patch('homeassistant.components.http.' - 'ban.get_real_ip', - return_value=ip_address(remote_addr)): - resp = yield from mock_api_client.get( - const.URL_API) - assert resp.status == 403 + set_real_ip(remote_addr) + resp = yield from client.get('/') + assert resp.status == 403 @asyncio.coroutine -def test_access_from_banned_ip_when_ban_is_off(hass, mock_api_client): +def test_ban_middleware_not_loaded_by_config(hass): """Test accessing to server from banned IP when feature is off.""" - hass.http.app[KEY_BANS_ENABLED] = False - for remote_addr in BANNED_IPS: - with patch('homeassistant.components.http.' - 'ban.get_real_ip', - return_value=ip_address(remote_addr)): - resp = yield from mock_api_client.get( - const.URL_API, - headers={const.HTTP_HEADER_HA_AUTH: API_PASSWORD}) - assert resp.status == 200 + with patch('homeassistant.components.http.setup_bans') as mock_setup: + yield from async_setup_component(hass, 'http', { + 'http': { + http.CONF_IP_BAN_ENABLED: False, + } + }) + + assert len(mock_setup.mock_calls) == 0 @asyncio.coroutine -def test_ip_bans_file_creation(hass, mock_api_client): +def test_ban_middleware_loaded_by_default(hass): + """Test accessing to server from banned IP when feature is off.""" + with patch('homeassistant.components.http.setup_bans') as mock_setup: + yield from async_setup_component(hass, 'http', { + 'http': {} + }) + + assert len(mock_setup.mock_calls) == 1 + + +@asyncio.coroutine +def test_ip_bans_file_creation(hass, test_client): """Testing if banned IP file created.""" - hass.http.app[KEY_BANS_ENABLED] = True - hass.http.app[KEY_LOGIN_THRESHOLD] = 1 + app = web.Application() + app['hass'] = hass + + @asyncio.coroutine + def unauth_handler(request): + """Return a mock web response.""" + raise HTTPUnauthorized + + app.router.add_get('/', unauth_handler) + setup_bans(hass, app, 1) + mock_real_ip(app)("200.201.202.204") + + with patch('homeassistant.components.http.ban.load_ip_bans_config', + return_value=[IpBan(banned_ip) for banned_ip + in BANNED_IPS]): + client = yield from test_client(app) m = mock_open() - @asyncio.coroutine - def call_server(): - with patch('homeassistant.components.http.' - 'ban.get_real_ip', - return_value=ip_address("200.201.202.204")): - resp = yield from mock_api_client.get( - const.URL_API, - headers={const.HTTP_HEADER_HA_AUTH: 'Wrong password'}) - return resp - with patch('homeassistant.components.http.ban.open', m, create=True): - resp = yield from call_server() + resp = yield from client.get('/') assert resp.status == 401 - assert len(hass.http.app[KEY_BANNED_IPS]) == len(BANNED_IPS) + assert len(app[KEY_BANNED_IPS]) == len(BANNED_IPS) assert m.call_count == 0 - resp = yield from call_server() + resp = yield from client.get('/') assert resp.status == 401 - assert len(hass.http.app[KEY_BANNED_IPS]) == len(BANNED_IPS) + 1 + assert len(app[KEY_BANNED_IPS]) == len(BANNED_IPS) + 1 m.assert_called_once_with(hass.config.path(IP_BANS_FILE), 'a') - resp = yield from call_server() + resp = yield from client.get('/') assert resp.status == 403 assert m.call_count == 1 diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py new file mode 100644 index 00000000000..22b70e1c0c5 --- /dev/null +++ b/tests/components/http/test_cors.py @@ -0,0 +1,104 @@ +"""Test cors for the HTTP component.""" +import asyncio +from unittest.mock import patch + +from aiohttp import web +from aiohttp.hdrs import ( + ACCESS_CONTROL_ALLOW_ORIGIN, + ACCESS_CONTROL_ALLOW_HEADERS, + ACCESS_CONTROL_REQUEST_HEADERS, + ACCESS_CONTROL_REQUEST_METHOD, + ORIGIN +) +import pytest + +from homeassistant.const import HTTP_HEADER_HA_AUTH +from homeassistant.setup import async_setup_component +from homeassistant.components.http.cors import setup_cors + + +TRUSTED_ORIGIN = 'https://home-assistant.io' + + +@asyncio.coroutine +def test_cors_middleware_not_loaded_by_default(hass): + """Test accessing to server from banned IP when feature is off.""" + with patch('homeassistant.components.http.setup_cors') as mock_setup: + yield from async_setup_component(hass, 'http', { + 'http': {} + }) + + assert len(mock_setup.mock_calls) == 0 + + +@asyncio.coroutine +def test_cors_middleware_loaded_from_config(hass): + """Test accessing to server from banned IP when feature is off.""" + with patch('homeassistant.components.http.setup_cors') as mock_setup: + yield from async_setup_component(hass, 'http', { + 'http': { + 'cors_allowed_origins': ['http://home-assistant.io'] + } + }) + + assert len(mock_setup.mock_calls) == 1 + + +@asyncio.coroutine +def mock_handler(request): + """Return if request was authenticated.""" + return web.Response(status=200) + + +@pytest.fixture +def client(loop, test_client): + """Fixture to setup a web.Application.""" + app = web.Application() + app.router.add_get('/', mock_handler) + setup_cors(app, [TRUSTED_ORIGIN]) + return loop.run_until_complete(test_client(app)) + + +@asyncio.coroutine +def test_cors_requests(client): + """Test cross origin requests.""" + req = yield from client.get('/', headers={ + ORIGIN: TRUSTED_ORIGIN + }) + assert req.status == 200 + assert req.headers[ACCESS_CONTROL_ALLOW_ORIGIN] == \ + TRUSTED_ORIGIN + + # With password in URL + req = yield from client.get('/', params={ + 'api_password': 'some-pass' + }, headers={ + ORIGIN: TRUSTED_ORIGIN + }) + assert req.status == 200 + assert req.headers[ACCESS_CONTROL_ALLOW_ORIGIN] == \ + TRUSTED_ORIGIN + + # With password in headers + req = yield from client.get('/', headers={ + HTTP_HEADER_HA_AUTH: 'some-pass', + ORIGIN: TRUSTED_ORIGIN + }) + assert req.status == 200 + assert req.headers[ACCESS_CONTROL_ALLOW_ORIGIN] == \ + TRUSTED_ORIGIN + + +@asyncio.coroutine +def test_cors_preflight_allowed(client): + """Test cross origin resource sharing preflight (OPTIONS) request.""" + req = yield from client.options('/', headers={ + ORIGIN: TRUSTED_ORIGIN, + ACCESS_CONTROL_REQUEST_METHOD: 'GET', + ACCESS_CONTROL_REQUEST_HEADERS: 'x-ha-access' + }) + + assert req.status == 200 + assert req.headers[ACCESS_CONTROL_ALLOW_ORIGIN] == TRUSTED_ORIGIN + assert req.headers[ACCESS_CONTROL_ALLOW_HEADERS] == \ + HTTP_HEADER_HA_AUTH.upper() diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 4ff87efd137..ab06b48043e 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -1,124 +1,10 @@ """The tests for the Home Assistant HTTP component.""" import asyncio -from aiohttp.hdrs import ( - ORIGIN, ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_ALLOW_HEADERS, - ACCESS_CONTROL_REQUEST_METHOD, ACCESS_CONTROL_REQUEST_HEADERS, - CONTENT_TYPE) -import requests -from tests.common import get_test_instance_port, get_test_home_assistant +from homeassistant.setup import async_setup_component -from homeassistant import const, setup import homeassistant.components.http as http -API_PASSWORD = 'test1234' -SERVER_PORT = get_test_instance_port() -HTTP_BASE = '127.0.0.1:{}'.format(SERVER_PORT) -HTTP_BASE_URL = 'http://{}'.format(HTTP_BASE) -HA_HEADERS = { - const.HTTP_HEADER_HA_AUTH: API_PASSWORD, - CONTENT_TYPE: const.CONTENT_TYPE_JSON, -} -CORS_ORIGINS = [HTTP_BASE_URL, HTTP_BASE] - -hass = None - - -def _url(path=''): - """Helper method to generate URLs.""" - return HTTP_BASE_URL + path - - -# pylint: disable=invalid-name -def setUpModule(): - """Initialize a Home Assistant server.""" - global hass - - hass = get_test_home_assistant() - - setup.setup_component( - hass, http.DOMAIN, { - http.DOMAIN: { - http.CONF_API_PASSWORD: API_PASSWORD, - http.CONF_SERVER_PORT: SERVER_PORT, - http.CONF_CORS_ORIGINS: CORS_ORIGINS, - } - } - ) - - setup.setup_component(hass, 'api') - - # Registering static path as it caused CORS to blow up - hass.http.register_static_path( - '/custom_components', hass.config.path('custom_components')) - - hass.start() - - -# pylint: disable=invalid-name -def tearDownModule(): - """Stop the Home Assistant server.""" - hass.stop() - - -class TestCors: - """Test HTTP component.""" - - def test_cors_allowed_with_password_in_url(self): - """Test cross origin resource sharing with password in url.""" - req = requests.get(_url(const.URL_API), - params={'api_password': API_PASSWORD}, - headers={ORIGIN: HTTP_BASE_URL}) - - allow_origin = ACCESS_CONTROL_ALLOW_ORIGIN - - assert req.status_code == 200 - assert req.headers.get(allow_origin) == HTTP_BASE_URL - - def test_cors_allowed_with_password_in_header(self): - """Test cross origin resource sharing with password in header.""" - headers = { - const.HTTP_HEADER_HA_AUTH: API_PASSWORD, - ORIGIN: HTTP_BASE_URL - } - req = requests.get(_url(const.URL_API), headers=headers) - - allow_origin = ACCESS_CONTROL_ALLOW_ORIGIN - - assert req.status_code == 200 - assert req.headers.get(allow_origin) == HTTP_BASE_URL - - def test_cors_denied_without_origin_header(self): - """Test cross origin resource sharing with password in header.""" - headers = { - const.HTTP_HEADER_HA_AUTH: API_PASSWORD - } - req = requests.get(_url(const.URL_API), headers=headers) - - allow_origin = ACCESS_CONTROL_ALLOW_ORIGIN - allow_headers = ACCESS_CONTROL_ALLOW_HEADERS - - assert req.status_code == 200 - assert allow_origin not in req.headers - assert allow_headers not in req.headers - - def test_cors_preflight_allowed(self): - """Test cross origin resource sharing preflight (OPTIONS) request.""" - headers = { - ORIGIN: HTTP_BASE_URL, - ACCESS_CONTROL_REQUEST_METHOD: 'GET', - ACCESS_CONTROL_REQUEST_HEADERS: 'x-ha-access' - } - req = requests.options(_url(const.URL_API), headers=headers) - - allow_origin = ACCESS_CONTROL_ALLOW_ORIGIN - allow_headers = ACCESS_CONTROL_ALLOW_HEADERS - - assert req.status_code == 200 - assert req.headers.get(allow_origin) == HTTP_BASE_URL - assert req.headers.get(allow_headers) == \ - const.HTTP_HEADER_HA_AUTH.upper() - class TestView(http.HomeAssistantView): """Test the HTTP views.""" @@ -133,12 +19,12 @@ class TestView(http.HomeAssistantView): @asyncio.coroutine -def test_registering_view_while_running(hass, test_client): +def test_registering_view_while_running(hass, test_client, unused_port): """Test that we can register a view while the server is running.""" - yield from setup.async_setup_component( + yield from async_setup_component( hass, http.DOMAIN, { http.DOMAIN: { - http.CONF_SERVER_PORT: get_test_instance_port(), + http.CONF_SERVER_PORT: unused_port(), } } ) @@ -151,7 +37,7 @@ def test_registering_view_while_running(hass, test_client): @asyncio.coroutine def test_api_base_url_with_domain(hass): """Test setting API URL.""" - result = yield from setup.async_setup_component(hass, 'http', { + result = yield from async_setup_component(hass, 'http', { 'http': { 'base_url': 'example.com' } @@ -163,7 +49,7 @@ def test_api_base_url_with_domain(hass): @asyncio.coroutine def test_api_base_url_with_ip(hass): """Test setting api url.""" - result = yield from setup.async_setup_component(hass, 'http', { + result = yield from async_setup_component(hass, 'http', { 'http': { 'server_host': '1.1.1.1' } @@ -175,7 +61,7 @@ def test_api_base_url_with_ip(hass): @asyncio.coroutine def test_api_base_url_with_ip_port(hass): """Test setting api url.""" - result = yield from setup.async_setup_component(hass, 'http', { + result = yield from async_setup_component(hass, 'http', { 'http': { 'base_url': '1.1.1.1:8124' } @@ -187,9 +73,34 @@ def test_api_base_url_with_ip_port(hass): @asyncio.coroutine def test_api_no_base_url(hass): """Test setting api url.""" - result = yield from setup.async_setup_component(hass, 'http', { + result = yield from async_setup_component(hass, 'http', { 'http': { } }) assert result assert hass.config.api.base_url == 'http://127.0.0.1:8123' + + +@asyncio.coroutine +def test_not_log_password(hass, unused_port, test_client, caplog): + """Test access with password doesn't get logged.""" + result = yield from async_setup_component(hass, 'api', { + 'http': { + http.CONF_SERVER_PORT: unused_port(), + http.CONF_API_PASSWORD: 'some-pass' + } + }) + assert result + + client = yield from test_client(hass.http.app) + + resp = yield from client.get('/api/', params={ + 'api_password': 'some-pass' + }) + + assert resp.status == 200 + logs = caplog.text + + # Ensure we don't log API passwords + assert '/api/' in logs + assert 'some-pass' not in logs diff --git a/tests/components/http/test_real_ip.py b/tests/components/http/test_real_ip.py new file mode 100644 index 00000000000..90201ab4c10 --- /dev/null +++ b/tests/components/http/test_real_ip.py @@ -0,0 +1,48 @@ +"""Test real IP middleware.""" +import asyncio + +from aiohttp import web +from aiohttp.hdrs import X_FORWARDED_FOR + +from homeassistant.components.http.real_ip import setup_real_ip +from homeassistant.components.http.const import KEY_REAL_IP + + +@asyncio.coroutine +def mock_handler(request): + """Handler that returns the real IP as text.""" + return web.Response(text=str(request[KEY_REAL_IP])) + + +@asyncio.coroutine +def test_ignore_x_forwarded_for(test_client): + """Test that we get the IP from the transport.""" + app = web.Application() + app.router.add_get('/', mock_handler) + setup_real_ip(app, False) + + mock_api_client = yield from test_client(app) + + resp = yield from mock_api_client.get('/', headers={ + X_FORWARDED_FOR: '255.255.255.255' + }) + assert resp.status == 200 + text = yield from resp.text() + assert text != '255.255.255.255' + + +@asyncio.coroutine +def test_use_x_forwarded_for(test_client): + """Test that we get the IP from the transport.""" + app = web.Application() + app.router.add_get('/', mock_handler) + setup_real_ip(app, True) + + mock_api_client = yield from test_client(app) + + resp = yield from mock_api_client.get('/', headers={ + X_FORWARDED_FOR: '255.255.255.255' + }) + assert resp.status == 200 + text = yield from resp.text() + assert text == '255.255.255.255' diff --git a/tests/components/mqtt/test_server.py b/tests/components/mqtt/test_server.py index 7ce9ec00797..9b4c0c69ac6 100644 --- a/tests/components/mqtt/test_server.py +++ b/tests/components/mqtt/test_server.py @@ -4,8 +4,7 @@ from unittest.mock import Mock, MagicMock, patch from homeassistant.setup import setup_component import homeassistant.components.mqtt as mqtt -from tests.common import ( - get_test_home_assistant, mock_coro, mock_http_component) +from tests.common import get_test_home_assistant, mock_coro class TestMQTT: @@ -14,7 +13,9 @@ class TestMQTT: def setup_method(self, method): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - mock_http_component(self.hass, 'super_secret') + setup_component(self.hass, 'http', { + 'api_password': 'super_secret' + }) def teardown_method(self, method): """Stop everything that was started.""" diff --git a/tests/components/notify/test_html5.py b/tests/components/notify/test_html5.py index 6fb2e6454de..d6c06f77d93 100644 --- a/tests/components/notify/test_html5.py +++ b/tests/components/notify/test_html5.py @@ -4,12 +4,10 @@ import json from unittest.mock import patch, MagicMock, mock_open from aiohttp.hdrs import AUTHORIZATION +from homeassistant.setup import async_setup_component from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.json import save_json from homeassistant.components.notify import html5 -from tests.common import mock_http_component_app - CONFIG_FILE = 'file.conf' SUBSCRIPTION_1 = { @@ -52,6 +50,23 @@ REGISTER_URL = '/api/notify.html5' PUBLISH_URL = '/api/notify.html5/callback' +@asyncio.coroutine +def mock_client(hass, test_client, registrations=None): + """Create a test client for HTML5 views.""" + if registrations is None: + registrations = {} + + with patch('homeassistant.components.notify.html5._load_config', + return_value=registrations): + yield from async_setup_component(hass, 'notify', { + 'notify': { + 'platform': 'html5' + } + }) + + return (yield from test_client(hass.http.app)) + + class TestHtml5Notify(object): """Tests for HTML5 notify platform.""" @@ -89,8 +104,6 @@ class TestHtml5Notify(object): service.send_message('Hello', target=['device', 'non_existing'], data={'icon': 'beer.png'}) - print(mock_wp.mock_calls) - assert len(mock_wp.mock_calls) == 3 # WebPusher constructor @@ -104,421 +117,224 @@ class TestHtml5Notify(object): assert payload['body'] == 'Hello' assert payload['icon'] == 'beer.png' - @asyncio.coroutine - def test_registering_new_device_view(self, loop, test_client): - """Test that the HTML view works.""" - hass = MagicMock() - expected = { - 'unnamed device': SUBSCRIPTION_1, - } - hass.config.path.return_value = CONFIG_FILE - service = html5.get_service(hass, {}) +@asyncio.coroutine +def test_registering_new_device_view(hass, test_client): + """Test that the HTML view works.""" + client = yield from mock_client(hass, test_client) - assert service is not None - - assert len(hass.mock_calls) == 3 - - view = hass.mock_calls[1][1][0] - assert view.json_path == hass.config.path.return_value - assert view.registrations == {} - - hass.loop = loop - app = mock_http_component_app(hass) - view.register(app.router) - client = yield from test_client(app) - hass.http.is_banned_ip.return_value = False + with patch('homeassistant.components.notify.html5.save_json') as mock_save: resp = yield from client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) - content = yield from resp.text() - assert resp.status == 200, content - assert view.registrations == expected + assert resp.status == 200 + assert len(mock_save.mock_calls) == 1 + assert mock_save.mock_calls[0][1][1] == { + 'unnamed device': SUBSCRIPTION_1, + } - hass.async_add_job.assert_called_with(save_json, CONFIG_FILE, expected) - @asyncio.coroutine - def test_registering_new_device_expiration_view(self, loop, test_client): - """Test that the HTML view works.""" - hass = MagicMock() - expected = { - 'unnamed device': SUBSCRIPTION_4, - } +@asyncio.coroutine +def test_registering_new_device_expiration_view(hass, test_client): + """Test that the HTML view works.""" + client = yield from mock_client(hass, test_client) - hass.config.path.return_value = CONFIG_FILE - service = html5.get_service(hass, {}) - - assert service is not None - - # assert hass.called - assert len(hass.mock_calls) == 3 - - view = hass.mock_calls[1][1][0] - assert view.json_path == hass.config.path.return_value - assert view.registrations == {} - - hass.loop = loop - app = mock_http_component_app(hass) - view.register(app.router) - client = yield from test_client(app) - hass.http.is_banned_ip.return_value = False + with patch('homeassistant.components.notify.html5.save_json') as mock_save: resp = yield from client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) - content = yield from resp.text() - assert resp.status == 200, content - assert view.registrations == expected + assert resp.status == 200 + assert mock_save.mock_calls[0][1][1] == { + 'unnamed device': SUBSCRIPTION_4, + } - hass.async_add_job.assert_called_with(save_json, CONFIG_FILE, expected) - @asyncio.coroutine - def test_registering_new_device_fails_view(self, loop, test_client): - """Test subs. are not altered when registering a new device fails.""" - hass = MagicMock() - expected = {} - - hass.config.path.return_value = CONFIG_FILE - html5.get_service(hass, {}) - view = hass.mock_calls[1][1][0] - - hass.loop = loop - app = mock_http_component_app(hass) - view.register(app.router) - client = yield from test_client(app) - hass.http.is_banned_ip.return_value = False - - hass.async_add_job.side_effect = HomeAssistantError() +@asyncio.coroutine +def test_registering_new_device_fails_view(hass, test_client): + """Test subs. are not altered when registering a new device fails.""" + registrations = {} + client = yield from mock_client(hass, test_client, registrations) + with patch('homeassistant.components.notify.html5.save_json', + side_effect=HomeAssistantError()): resp = yield from client.post(REGISTER_URL, - data=json.dumps(SUBSCRIPTION_1)) + data=json.dumps(SUBSCRIPTION_4)) - content = yield from resp.text() - assert resp.status == 500, content - assert view.registrations == expected + assert resp.status == 500 + assert registrations == {} - @asyncio.coroutine - def test_registering_existing_device_view(self, loop, test_client): - """Test subscription is updated when registering existing device.""" - hass = MagicMock() - expected = { - 'unnamed device': SUBSCRIPTION_4, - } - hass.config.path.return_value = CONFIG_FILE - html5.get_service(hass, {}) - view = hass.mock_calls[1][1][0] - - hass.loop = loop - app = mock_http_component_app(hass) - view.register(app.router) - client = yield from test_client(app) - hass.http.is_banned_ip.return_value = False +@asyncio.coroutine +def test_registering_existing_device_view(hass, test_client): + """Test subscription is updated when registering existing device.""" + registrations = {} + client = yield from mock_client(hass, test_client, registrations) + with patch('homeassistant.components.notify.html5.save_json') as mock_save: yield from client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) resp = yield from client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) - content = yield from resp.text() - assert resp.status == 200, content - assert view.registrations == expected + assert resp.status == 200 + assert mock_save.mock_calls[0][1][1] == { + 'unnamed device': SUBSCRIPTION_4, + } + assert registrations == { + 'unnamed device': SUBSCRIPTION_4, + } - hass.async_add_job.assert_called_with(save_json, CONFIG_FILE, expected) - @asyncio.coroutine - def test_registering_existing_device_fails_view(self, loop, test_client): - """Test sub. is not updated when registering existing device fails.""" - hass = MagicMock() - expected = { - 'unnamed device': SUBSCRIPTION_1, - } - - hass.config.path.return_value = CONFIG_FILE - html5.get_service(hass, {}) - view = hass.mock_calls[1][1][0] - - hass.loop = loop - app = mock_http_component_app(hass) - view.register(app.router) - client = yield from test_client(app) - hass.http.is_banned_ip.return_value = False +@asyncio.coroutine +def test_registering_existing_device_fails_view(hass, test_client): + """Test sub. is not updated when registering existing device fails.""" + registrations = {} + client = yield from mock_client(hass, test_client, registrations) + with patch('homeassistant.components.notify.html5.save_json') as mock_save: yield from client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) - - hass.async_add_job.side_effect = HomeAssistantError() + mock_save.side_effect = HomeAssistantError resp = yield from client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) - content = yield from resp.text() - assert resp.status == 500, content - assert view.registrations == expected + assert resp.status == 500 + assert registrations == { + 'unnamed device': SUBSCRIPTION_1, + } - @asyncio.coroutine - def test_registering_new_device_validation(self, loop, test_client): - """Test various errors when registering a new device.""" - hass = MagicMock() - m = mock_open() - with patch( - 'homeassistant.util.json.open', - m, create=True - ): - hass.config.path.return_value = CONFIG_FILE - service = html5.get_service(hass, {}) +@asyncio.coroutine +def test_registering_new_device_validation(hass, test_client): + """Test various errors when registering a new device.""" + client = yield from mock_client(hass, test_client) - assert service is not None + resp = yield from client.post(REGISTER_URL, data=json.dumps({ + 'browser': 'invalid browser', + 'subscription': 'sub info', + })) + assert resp.status == 400 - # assert hass.called - assert len(hass.mock_calls) == 3 + resp = yield from client.post(REGISTER_URL, data=json.dumps({ + 'browser': 'chrome', + })) + assert resp.status == 400 - view = hass.mock_calls[1][1][0] + with patch('homeassistant.components.notify.html5.save_json', + return_value=False): + resp = yield from client.post(REGISTER_URL, data=json.dumps({ + 'browser': 'chrome', + 'subscription': 'sub info', + })) + assert resp.status == 400 - hass.loop = loop - app = mock_http_component_app(hass) - view.register(app.router) - client = yield from test_client(app) - hass.http.is_banned_ip.return_value = False - resp = yield from client.post(REGISTER_URL, data=json.dumps({ - 'browser': 'invalid browser', - 'subscription': 'sub info', - })) - assert resp.status == 400 +@asyncio.coroutine +def test_unregistering_device_view(hass, test_client): + """Test that the HTML unregister view works.""" + registrations = { + 'some device': SUBSCRIPTION_1, + 'other device': SUBSCRIPTION_2, + } + client = yield from mock_client(hass, test_client, registrations) - resp = yield from client.post(REGISTER_URL, data=json.dumps({ - 'browser': 'chrome', - })) - assert resp.status == 400 + with patch('homeassistant.components.notify.html5.save_json') as mock_save: + resp = yield from client.delete(REGISTER_URL, data=json.dumps({ + 'subscription': SUBSCRIPTION_1['subscription'], + })) - with patch('homeassistant.components.notify.html5.save_json', - return_value=False): - # resp = view.post(Request(builder.get_environ())) - resp = yield from client.post(REGISTER_URL, data=json.dumps({ - 'browser': 'chrome', - 'subscription': 'sub info', - })) + assert resp.status == 200 + assert len(mock_save.mock_calls) == 1 + assert registrations == { + 'other device': SUBSCRIPTION_2 + } - assert resp.status == 400 - @asyncio.coroutine - def test_unregistering_device_view(self, loop, test_client): - """Test that the HTML unregister view works.""" - hass = MagicMock() +@asyncio.coroutine +def test_unregister_device_view_handle_unknown_subscription(hass, test_client): + """Test that the HTML unregister view handles unknown subscriptions.""" + registrations = {} + client = yield from mock_client(hass, test_client, registrations) - config = { - 'some device': SUBSCRIPTION_1, - 'other device': SUBSCRIPTION_2, - } + with patch('homeassistant.components.notify.html5.save_json') as mock_save: + resp = yield from client.delete(REGISTER_URL, data=json.dumps({ + 'subscription': SUBSCRIPTION_3['subscription'] + })) - m = mock_open(read_data=json.dumps(config)) - with patch( - 'homeassistant.util.json.open', - m, create=True - ): - hass.config.path.return_value = CONFIG_FILE - service = html5.get_service(hass, {}) + assert resp.status == 200, resp.response + assert registrations == {} + assert len(mock_save.mock_calls) == 0 - assert service is not None - # assert hass.called - assert len(hass.mock_calls) == 3 +@asyncio.coroutine +def test_unregistering_device_view_handles_save_error(hass, test_client): + """Test that the HTML unregister view handles save errors.""" + registrations = { + 'some device': SUBSCRIPTION_1, + 'other device': SUBSCRIPTION_2, + } + client = yield from mock_client(hass, test_client, registrations) - view = hass.mock_calls[1][1][0] - assert view.json_path == hass.config.path.return_value - assert view.registrations == config + with patch('homeassistant.components.notify.html5.save_json', + side_effect=HomeAssistantError()): + resp = yield from client.delete(REGISTER_URL, data=json.dumps({ + 'subscription': SUBSCRIPTION_1['subscription'], + })) - hass.loop = loop - app = mock_http_component_app(hass) - view.register(app.router) - client = yield from test_client(app) - hass.http.is_banned_ip.return_value = False + assert resp.status == 500, resp.response + assert registrations == { + 'some device': SUBSCRIPTION_1, + 'other device': SUBSCRIPTION_2, + } - resp = yield from client.delete(REGISTER_URL, data=json.dumps({ - 'subscription': SUBSCRIPTION_1['subscription'], - })) - config.pop('some device') +@asyncio.coroutine +def test_callback_view_no_jwt(hass, test_client): + """Test that the notification callback view works without JWT.""" + client = yield from mock_client(hass, test_client) + resp = yield from client.post(PUBLISH_URL, data=json.dumps({ + 'type': 'push', + 'tag': '3bc28d69-0921-41f1-ac6a-7a627ba0aa72' + })) - assert resp.status == 200, resp.response - assert view.registrations == config + assert resp.status == 401, resp.response - hass.async_add_job.assert_called_with(save_json, CONFIG_FILE, - config) - @asyncio.coroutine - def test_unregister_device_view_handle_unknown_subscription( - self, loop, test_client): - """Test that the HTML unregister view handles unknown subscriptions.""" - hass = MagicMock() +@asyncio.coroutine +def test_callback_view_with_jwt(hass, test_client): + """Test that the notification callback view works with JWT.""" + registrations = { + 'device': SUBSCRIPTION_1 + } + client = yield from mock_client(hass, test_client, registrations) - config = { - 'some device': SUBSCRIPTION_1, - 'other device': SUBSCRIPTION_2, - } + with patch('pywebpush.WebPusher') as mock_wp: + yield from hass.services.async_call('notify', 'notify', { + 'message': 'Hello', + 'target': ['device'], + 'data': {'icon': 'beer.png'} + }, blocking=True) - m = mock_open(read_data=json.dumps(config)) - with patch( - 'homeassistant.util.json.open', - m, create=True - ): - hass.config.path.return_value = CONFIG_FILE - service = html5.get_service(hass, {}) + assert len(mock_wp.mock_calls) == 3 - assert service is not None + # WebPusher constructor + assert mock_wp.mock_calls[0][1][0] == \ + SUBSCRIPTION_1['subscription'] + # Third mock_call checks the status_code of the response. + assert mock_wp.mock_calls[2][0] == '().send().status_code.__eq__' - # assert hass.called - assert len(hass.mock_calls) == 3 + # Call to send + push_payload = json.loads(mock_wp.mock_calls[1][1][0]) - view = hass.mock_calls[1][1][0] - assert view.json_path == hass.config.path.return_value - assert view.registrations == config + assert push_payload['body'] == 'Hello' + assert push_payload['icon'] == 'beer.png' - hass.loop = loop - app = mock_http_component_app(hass) - view.register(app.router) - client = yield from test_client(app) - hass.http.is_banned_ip.return_value = False + bearer_token = "Bearer {}".format(push_payload['data']['jwt']) - resp = yield from client.delete(REGISTER_URL, data=json.dumps({ - 'subscription': SUBSCRIPTION_3['subscription'] - })) + resp = yield from client.post(PUBLISH_URL, json={ + 'type': 'push', + }, headers={AUTHORIZATION: bearer_token}) - assert resp.status == 200, resp.response - assert view.registrations == config - - hass.async_add_job.assert_not_called() - - @asyncio.coroutine - def test_unregistering_device_view_handles_save_error( - self, loop, test_client): - """Test that the HTML unregister view handles save errors.""" - hass = MagicMock() - - config = { - 'some device': SUBSCRIPTION_1, - 'other device': SUBSCRIPTION_2, - } - - m = mock_open(read_data=json.dumps(config)) - with patch( - 'homeassistant.util.json.open', - m, create=True - ): - hass.config.path.return_value = CONFIG_FILE - service = html5.get_service(hass, {}) - - assert service is not None - - # assert hass.called - assert len(hass.mock_calls) == 3 - - view = hass.mock_calls[1][1][0] - assert view.json_path == hass.config.path.return_value - assert view.registrations == config - - hass.loop = loop - app = mock_http_component_app(hass) - view.register(app.router) - client = yield from test_client(app) - hass.http.is_banned_ip.return_value = False - - hass.async_add_job.side_effect = HomeAssistantError() - resp = yield from client.delete(REGISTER_URL, data=json.dumps({ - 'subscription': SUBSCRIPTION_1['subscription'], - })) - - assert resp.status == 500, resp.response - assert view.registrations == config - - @asyncio.coroutine - def test_callback_view_no_jwt(self, loop, test_client): - """Test that the notification callback view works without JWT.""" - hass = MagicMock() - - m = mock_open() - with patch( - 'homeassistant.util.json.open', - m, create=True - ): - hass.config.path.return_value = CONFIG_FILE - service = html5.get_service(hass, {}) - - assert service is not None - - # assert hass.called - assert len(hass.mock_calls) == 3 - - view = hass.mock_calls[2][1][0] - - hass.loop = loop - app = mock_http_component_app(hass) - view.register(app.router) - client = yield from test_client(app) - hass.http.is_banned_ip.return_value = False - - resp = yield from client.post(PUBLISH_URL, data=json.dumps({ - 'type': 'push', - 'tag': '3bc28d69-0921-41f1-ac6a-7a627ba0aa72' - })) - - assert resp.status == 401, resp.response - - @asyncio.coroutine - def test_callback_view_with_jwt(self, loop, test_client): - """Test that the notification callback view works with JWT.""" - hass = MagicMock() - - data = { - 'device': SUBSCRIPTION_1 - } - - m = mock_open(read_data=json.dumps(data)) - with patch( - 'homeassistant.util.json.open', - m, create=True - ): - hass.config.path.return_value = CONFIG_FILE - service = html5.get_service(hass, {'gcm_sender_id': '100'}) - - assert service is not None - - # assert hass.called - assert len(hass.mock_calls) == 3 - - with patch('pywebpush.WebPusher') as mock_wp: - service.send_message( - 'Hello', target=['device'], data={'icon': 'beer.png'}) - - assert len(mock_wp.mock_calls) == 3 - - # WebPusher constructor - assert mock_wp.mock_calls[0][1][0] == \ - SUBSCRIPTION_1['subscription'] - # Third mock_call checks the status_code of the response. - assert mock_wp.mock_calls[2][0] == '().send().status_code.__eq__' - - # Call to send - push_payload = json.loads(mock_wp.mock_calls[1][1][0]) - - assert push_payload['body'] == 'Hello' - assert push_payload['icon'] == 'beer.png' - - view = hass.mock_calls[2][1][0] - view.registrations = data - - bearer_token = "Bearer {}".format(push_payload['data']['jwt']) - - hass.loop = loop - app = mock_http_component_app(hass) - view.register(app.router) - client = yield from test_client(app) - hass.http.is_banned_ip.return_value = False - - resp = yield from client.post(PUBLISH_URL, data=json.dumps({ - 'type': 'push', - }), headers={AUTHORIZATION: bearer_token}) - - assert resp.status == 200 - body = yield from resp.json() - assert body == {"event": "push", "status": "ok"} + assert resp.status == 200 + body = yield from resp.json() + assert body == {"event": "push", "status": "ok"} diff --git a/tests/components/test_history.py b/tests/components/test_history.py index 8484e2c536f..0c6995cc1ad 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -10,8 +10,7 @@ import homeassistant.util.dt as dt_util from homeassistant.components import history, recorder from tests.common import ( - init_recorder_component, mock_http_component, mock_state_change_event, - get_test_home_assistant) + init_recorder_component, mock_state_change_event, get_test_home_assistant) class TestComponentHistory(unittest.TestCase): @@ -38,7 +37,6 @@ class TestComponentHistory(unittest.TestCase): def test_setup(self): """Test setup method of history.""" - mock_http_component(self.hass) config = history.CONFIG_SCHEMA({ # ha.DOMAIN: {}, history.DOMAIN: { diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index 6a79994586c..472590ae13d 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -14,7 +14,7 @@ from homeassistant.components import logbook from homeassistant.setup import setup_component from tests.common import ( - mock_http_component, init_recorder_component, get_test_home_assistant) + init_recorder_component, get_test_home_assistant) _LOGGER = logging.getLogger(__name__) @@ -29,10 +29,7 @@ class TestComponentLogbook(unittest.TestCase): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() init_recorder_component(self.hass) # Force an in memory DB - mock_http_component(self.hass) - self.hass.config.components |= set(['frontend', 'recorder', 'api']) - assert setup_component(self.hass, logbook.DOMAIN, - self.EMPTY_CONFIG) + assert setup_component(self.hass, logbook.DOMAIN, self.EMPTY_CONFIG) self.hass.start() def tearDown(self): diff --git a/tests/components/test_shopping_list.py b/tests/components/test_shopping_list.py index 2e1a03c37d0..4203f7587ae 100644 --- a/tests/components/test_shopping_list.py +++ b/tests/components/test_shopping_list.py @@ -150,7 +150,6 @@ def test_api_update_fails(hass, test_client): assert resp.status == 404 beer_id = hass.data['shopping_list'].items[0]['id'] - client = yield from test_client(hass.http.app) resp = yield from client.post( '/api/shopping_list/item/{}'.format(beer_id), json={ 'name': 123, diff --git a/tests/components/test_websocket_api.py b/tests/components/test_websocket_api.py index 8b6c7494214..f85030a6892 100644 --- a/tests/components/test_websocket_api.py +++ b/tests/components/test_websocket_api.py @@ -8,8 +8,9 @@ import pytest from homeassistant.core import callback from homeassistant.components import websocket_api as wapi, frontend +from homeassistant.setup import async_setup_component -from tests.common import mock_http_component_app, mock_coro +from tests.common import mock_coro API_PASSWORD = 'test1234' @@ -17,10 +18,10 @@ API_PASSWORD = 'test1234' @pytest.fixture def websocket_client(loop, hass, test_client): """Websocket client fixture connected to websocket server.""" - websocket_app = mock_http_component_app(hass) - wapi.WebsocketAPIView().register(websocket_app.router) + assert loop.run_until_complete( + async_setup_component(hass, 'websocket_api')) - client = loop.run_until_complete(test_client(websocket_app)) + client = loop.run_until_complete(test_client(hass.http.app)) ws = loop.run_until_complete(client.ws_connect(wapi.URL)) auth_ok = loop.run_until_complete(ws.receive_json()) @@ -35,10 +36,14 @@ def websocket_client(loop, hass, test_client): @pytest.fixture def no_auth_websocket_client(hass, loop, test_client): """Websocket connection that requires authentication.""" - websocket_app = mock_http_component_app(hass, API_PASSWORD) - wapi.WebsocketAPIView().register(websocket_app.router) + assert loop.run_until_complete( + async_setup_component(hass, 'websocket_api', { + 'http': { + 'api_password': API_PASSWORD + } + })) - client = loop.run_until_complete(test_client(websocket_app)) + client = loop.run_until_complete(test_client(hass.http.app)) ws = loop.run_until_complete(client.ws_connect(wapi.URL)) auth_ok = loop.run_until_complete(ws.receive_json()) From d18709df5bf47f6dc5a7215676a7688ac9d7e1a8 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Thu, 15 Feb 2018 21:40:30 +0000 Subject: [PATCH 077/173] Update CODEOWNERS (#12440) As contributor of the components --- CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CODEOWNERS b/CODEOWNERS index 6e088a84e5d..af887118923 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -54,6 +54,7 @@ homeassistant/components/history_graph.py @andrey-git homeassistant/components/light/tplink.py @rytilahti homeassistant/components/light/yeelight.py @rytilahti homeassistant/components/media_player/kodi.py @armills +homeassistant/components/media_player/mediaroom.py @dgomes homeassistant/components/media_player/monoprice.py @etsinko homeassistant/components/media_player/yamaha_musiccast.py @jalmeroth homeassistant/components/plant.py @ChristianKuehnel @@ -63,6 +64,7 @@ homeassistant/components/sensor/irish_rail_transport.py @ttroy50 homeassistant/components/sensor/miflora.py @danielhiversen @ChristianKuehnel homeassistant/components/sensor/pollen.py @bachya homeassistant/components/sensor/sytadin.py @gautric +homeassistant/components/sensor/sql.py @dgomes homeassistant/components/sensor/tibber.py @danielhiversen homeassistant/components/sensor/waqi.py @andrey-git homeassistant/components/switch/rainmachine.py @bachya From f0d9e5d7ffd3dd7df6ee4c6f1fd809f5c6776b53 Mon Sep 17 00:00:00 2001 From: Sergio Viudes Date: Thu, 15 Feb 2018 23:49:46 +0100 Subject: [PATCH 078/173] Fix: timeout data attribute now is parsed to float (#12432) --- homeassistant/components/telegram_bot/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 170e1517a6d..d4ac115d9c6 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -96,6 +96,7 @@ BASE_SERVICE_SCHEMA = vol.Schema({ vol.Optional(ATTR_DISABLE_WEB_PREV): cv.boolean, vol.Optional(ATTR_KEYBOARD): vol.All(cv.ensure_list, [cv.string]), vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, + vol.Optional(CONF_TIMEOUT): vol.Coerce(float), }, extra=vol.ALLOW_EXTRA) SERVICE_SCHEMA_SEND_MESSAGE = BASE_SERVICE_SCHEMA.extend({ From 612dd302019079900aeb252b8e2611d5b6a09ac7 Mon Sep 17 00:00:00 2001 From: Igor Bernstein Date: Thu, 15 Feb 2018 17:51:54 -0500 Subject: [PATCH 079/173] Stop mapping zigbee switches to lights & switches. (#12280) Zigbee switches only contain client clusters that are meant to control server clusters on a different device/endpoint. This device type -> cluster mapping can be found in https://www.nxp.com/docs/en/user-guide/JN-UG-3076.pdf. The intended usage is of connecting client clusters to server clusters is described in https://products.currentbyge.com/sites/products.currentbyge.com/files/document_file/DT200-GE-Zigbee-Primer-Whitepaper.pdf The lack of server clusters on switches has been verified using a GE ZigBee Lighting Switch 45856GE and a 45857GE dimmer. Output from a 45856GE: Device: NWK: 0x0cd8 IEEE: 00:22:a3:00:00:1f:37:68 Endpoints: 1: profile=0x104, device_type=DeviceType.ON_OFF_LIGHT Input Clusters: Basic (0) Identify (3) Groups (4) Scenes (5) On/Off (6) Metering (1794) Diagnostic (2821) Output Clusters: Time (10) Ota (25) 2: profile=0x104, device_type=DeviceType.ON_OFF_LIGHT_SWITCH Input Clusters: Basic (0) Identify (3) Diagnostic (2821) Output Clusters: Identify (3) On/Off (6) --- homeassistant/components/zha/const.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py index a8d4671ebf7..05716da58ce 100644 --- a/homeassistant/components/zha/const.py +++ b/homeassistant/components/zha/const.py @@ -15,15 +15,11 @@ def populate_data(): from zigpy.profiles import PROFILES, zha, zll DEVICE_CLASS[zha.PROFILE_ID] = { - zha.DeviceType.ON_OFF_SWITCH: 'switch', zha.DeviceType.SMART_PLUG: 'switch', zha.DeviceType.ON_OFF_LIGHT: 'light', zha.DeviceType.DIMMABLE_LIGHT: 'light', zha.DeviceType.COLOR_DIMMABLE_LIGHT: 'light', - zha.DeviceType.ON_OFF_LIGHT_SWITCH: 'light', - zha.DeviceType.DIMMER_SWITCH: 'light', - zha.DeviceType.COLOR_DIMMER_SWITCH: 'light', } DEVICE_CLASS[zll.PROFILE_ID] = { zll.DeviceType.ON_OFF_LIGHT: 'light', From c7c0df53aab8e5cd22e7d5973e406e8d0a34b152 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 15 Feb 2018 15:52:47 -0700 Subject: [PATCH 080/173] AirVisual: Entity Registry updates and cleanup (#12319) * AirVisual: Entity Registry updates and cleanup * Small cleanup * Owner-requested changes * Changed hashing function * Put a better class instatiation mechanism in place * Small cleanup * Reverting unintended breaking change * Removing hashing as part of creating the unique ID * Attempting to jumpstart Travis --- homeassistant/components/sensor/airvisual.py | 94 ++++++++++++-------- 1 file changed, 57 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/sensor/airvisual.py b/homeassistant/components/sensor/airvisual.py index 5ea24dab823..d67415fc65e 100644 --- a/homeassistant/components/sensor/airvisual.py +++ b/homeassistant/components/sensor/airvisual.py @@ -19,7 +19,6 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle REQUIREMENTS = ['pyairvisual==1.0.0'] - _LOGGER = getLogger(__name__) ATTR_CITY = 'city' @@ -27,7 +26,6 @@ ATTR_COUNTRY = 'country' ATTR_POLLUTANT_SYMBOL = 'pollutant_symbol' ATTR_POLLUTANT_UNIT = 'pollutant_unit' ATTR_REGION = 'region' -ATTR_TIMESTAMP = 'timestamp' CONF_CITY = 'city' CONF_COUNTRY = 'country' @@ -39,6 +37,12 @@ VOLUME_MICROGRAMS_PER_CUBIC_METER = 'µg/m3' MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) +SENSOR_TYPES = [ + ('AirPollutionLevelSensor', 'Air Pollution Level', 'mdi:scale'), + ('AirQualityIndexSensor', 'Air Quality Index', 'mdi:format-list-numbers'), + ('MainPollutantSensor', 'Main Pollutant', 'mdi:chemical-weapon'), +] + POLLUTANT_LEVEL_MAPPING = [ {'label': 'Good', 'minimum': 0, 'maximum': 50}, {'label': 'Moderate', 'minimum': 51, 'maximum': 100}, @@ -58,11 +62,6 @@ POLLUTANT_MAPPING = { } SENSOR_LOCALES = {'cn': 'Chinese', 'us': 'U.S.'} -SENSOR_TYPES = [ - ('AirPollutionLevelSensor', 'Air Pollution Level', 'mdi:scale'), - ('AirQualityIndexSensor', 'Air Quality Index', 'mdi:format-list-numbers'), - ('MainPollutantSensor', 'Main Pollutant', 'mdi:chemical-weapon'), -] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, @@ -80,7 +79,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Configure the platform and add the sensors.""" - import pyairvisual as pav + from pyairvisual import Client + + classes = { + 'AirPollutionLevelSensor': AirPollutionLevelSensor, + 'AirQualityIndexSensor': AirQualityIndexSensor, + 'MainPollutantSensor': MainPollutantSensor + } api_key = config.get(CONF_API_KEY) monitored_locales = config.get(CONF_MONITORED_CONDITIONS) @@ -95,60 +100,63 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if city and state and country: _LOGGER.debug( "Using city, state, and country: %s, %s, %s", city, state, country) + location_id = ','.join((city, state, country)) data = AirVisualData( - pav.Client(api_key), city=city, state=state, country=country, + Client(api_key), city=city, state=state, country=country, show_on_map=show_on_map) else: _LOGGER.debug( "Using latitude and longitude: %s, %s", latitude, longitude) + location_id = ','.join((str(latitude), str(longitude))) data = AirVisualData( - pav.Client(api_key), latitude=latitude, longitude=longitude, + Client(api_key), latitude=latitude, longitude=longitude, radius=radius, show_on_map=show_on_map) data.update() + sensors = [] for locale in monitored_locales: for sensor_class, name, icon in SENSOR_TYPES: - sensors.append(globals()[sensor_class](data, name, icon, locale)) + sensors.append(classes[sensor_class]( + data, + name, + icon, + locale, + location_id + )) add_devices(sensors, True) -def merge_two_dicts(dict1, dict2): - """Merge two dicts into a new dict as a shallow copy.""" - final = dict1.copy() - final.update(dict2) - return final - - class AirVisualBaseSensor(Entity): """Define a base class for all of our sensors.""" - def __init__(self, data, name, icon, locale): + def __init__(self, data, name, icon, locale, entity_id): """Initialize the sensor.""" self.data = data + self._attrs = {} self._icon = icon self._locale = locale self._name = name self._state = None + self._entity_id = entity_id self._unit = None @property def device_state_attributes(self): """Return the device state attributes.""" - attrs = merge_two_dicts({ + self._attrs.update({ ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - ATTR_TIMESTAMP: self.data.pollution_info.get('ts') - }, self.data.attrs) + }) if self.data.show_on_map: - attrs[ATTR_LATITUDE] = self.data.latitude - attrs[ATTR_LONGITUDE] = self.data.longitude + self._attrs[ATTR_LATITUDE] = self.data.latitude + self._attrs[ATTR_LONGITUDE] = self.data.longitude else: - attrs['lati'] = self.data.latitude - attrs['long'] = self.data.longitude + self._attrs['lati'] = self.data.latitude + self._attrs['long'] = self.data.longitude - return attrs + return self._attrs @property def icon(self): @@ -169,6 +177,11 @@ class AirVisualBaseSensor(Entity): class AirPollutionLevelSensor(AirVisualBaseSensor): """Define a sensor to measure air pollution level.""" + @property + def unique_id(self): + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_pollution_level'.format(self._entity_id) + def update(self): """Update the status of the sensor.""" self.data.update() @@ -189,6 +202,11 @@ class AirPollutionLevelSensor(AirVisualBaseSensor): class AirQualityIndexSensor(AirVisualBaseSensor): """Define a sensor to measure AQI.""" + @property + def unique_id(self): + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_aqi'.format(self._entity_id) + @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" @@ -205,19 +223,16 @@ class AirQualityIndexSensor(AirVisualBaseSensor): class MainPollutantSensor(AirVisualBaseSensor): """Define a sensor to the main pollutant of an area.""" - def __init__(self, data, name, icon, locale): + def __init__(self, data, name, icon, locale, entity_id): """Initialize the sensor.""" - super().__init__(data, name, icon, locale) + super().__init__(data, name, icon, locale, entity_id) self._symbol = None self._unit = None @property - def device_state_attributes(self): - """Return the device state attributes.""" - return merge_two_dicts(super().device_state_attributes, { - ATTR_POLLUTANT_SYMBOL: self._symbol, - ATTR_POLLUTANT_UNIT: self._unit - }) + def unique_id(self): + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_main_pollutant'.format(self._entity_id) def update(self): """Update the status of the sensor.""" @@ -229,6 +244,11 @@ class MainPollutantSensor(AirVisualBaseSensor): self._unit = pollution_info.get('unit') self._symbol = symbol + self._attrs.update({ + ATTR_POLLUTANT_SYMBOL: self._symbol, + ATTR_POLLUTANT_UNIT: self._unit + }) + class AirVisualData(object): """Define an object to hold sensor data.""" @@ -252,7 +272,7 @@ class AirVisualData(object): @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Update with new AirVisual data.""" - import pyairvisual.exceptions as exceptions + from pyairvisual.exceptions import HTTPError try: if self.city and self.state and self.country: @@ -272,7 +292,7 @@ class AirVisualData(object): ATTR_REGION: resp.get('state'), ATTR_COUNTRY: resp.get('country') } - except exceptions.HTTPError as exc_info: + except HTTPError as exc_info: _LOGGER.error("Unable to retrieve data on this location: %s", self.__dict__) _LOGGER.debug(exc_info) From d43a8e593a04bf9b2ebabc1595616e0a464789ce Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Fri, 16 Feb 2018 04:20:45 +0000 Subject: [PATCH 081/173] [SQL Sensor] always close session (#12452) * close aborted session * blank line --- homeassistant/components/sensor/sql.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/sql.py b/homeassistant/components/sensor/sql.py index 99da8c3c680..395c082f9d2 100644 --- a/homeassistant/components/sensor/sql.py +++ b/homeassistant/components/sensor/sql.py @@ -126,6 +126,8 @@ class SQLSensor(Entity): except sqlalchemy.exc.SQLAlchemyError as err: _LOGGER.error("Error executing query %s: %s", self._query, err) return + finally: + sess.close() for res in result: _LOGGER.debug(res.items()) @@ -141,5 +143,3 @@ class SQLSensor(Entity): data, None) else: self._state = data - - sess.close() From 0e2d98dbf56c7476b44953e383d80cd066d5c9e1 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Fri, 16 Feb 2018 05:22:57 +0100 Subject: [PATCH 082/173] Optimize recorder purge (#12448) --- homeassistant/components/recorder/purge.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index d4b07232436..81c28bb94d9 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -12,8 +12,7 @@ _LOGGER = logging.getLogger(__name__) def purge_old_data(instance, purge_days, repack): """Purge events and states older than purge_days ago.""" from .models import States, Events - from sqlalchemy import orm - from sqlalchemy.sql import exists + from sqlalchemy import func purge_before = dt_util.utcnow() - timedelta(days=purge_days) _LOGGER.debug("Purging events before %s", purge_before) @@ -22,18 +21,10 @@ def purge_old_data(instance, purge_days, repack): # For each entity, the most recent state is protected from deletion # s.t. we can properly restore state even if the entity has not been # updated in a long time - states_alias = orm.aliased(States, name='StatesAlias') - protected_states = session.query(States.state_id, States.event_id)\ - .filter(~exists() - .where(States.entity_id == - states_alias.entity_id) - .where(states_alias.last_updated > - States.last_updated))\ - .all() + protected_states = session.query(func.max(States.state_id)) \ + .group_by(States.entity_id).all() protected_state_ids = tuple((state[0] for state in protected_states)) - protected_event_ids = tuple((state[1] for state in protected_states - if state[1] is not None)) deleted_rows = session.query(States) \ .filter((States.last_updated < purge_before)) \ @@ -46,6 +37,13 @@ def purge_old_data(instance, purge_days, repack): # Otherwise, if the SQL server has "ON DELETE CASCADE" as default, it # will delete the protected state when deleting its associated # event. Also, we would be producing NULLed foreign keys otherwise. + protected_events = session.query(States.event_id) \ + .filter(States.state_id.in_(protected_state_ids)) \ + .filter(States.event_id.isnot(None)) \ + .all() + + protected_event_ids = tuple((state[0] for state in protected_events)) + deleted_rows = session.query(Events) \ .filter((Events.time_fired < purge_before)) \ .filter(~Events.event_id.in_( From facd833e6dd9e3859701e176ef31bb166c0fd486 Mon Sep 17 00:00:00 2001 From: Tabakhase Date: Fri, 16 Feb 2018 06:53:10 +0100 Subject: [PATCH 083/173] Vagrant - sendfile python3.5 debian-stretch (#12454) * vagrant dev - force hass-AIOHTTP to not use sendfile Ref: https://www.virtualbox.org/ticket/9069 strongly needed for `home-assistant-polymer` * vagrant dev - python 3.4 to 3.5 by upgrade to debian-stretch --- virtualization/vagrant/Vagrantfile | 2 +- virtualization/vagrant/home-assistant@.service | 3 +++ virtualization/vagrant/provision.sh | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/virtualization/vagrant/Vagrantfile b/virtualization/vagrant/Vagrantfile index 21d5bd04adc..e50c4e6de00 100644 --- a/virtualization/vagrant/Vagrantfile +++ b/virtualization/vagrant/Vagrantfile @@ -2,7 +2,7 @@ # vi: set ft=ruby : Vagrant.configure(2) do |config| - config.vm.box = "debian/contrib-jessie64" + config.vm.box = "debian/contrib-stretch64" config.vm.synced_folder "../../", "/home-assistant" config.vm.synced_folder "./config", "/root/.homeassistant" config.vm.network "forwarded_port", guest: 8123, host: 8123 diff --git a/virtualization/vagrant/home-assistant@.service b/virtualization/vagrant/home-assistant@.service index 8e520952db9..91b7307f30f 100644 --- a/virtualization/vagrant/home-assistant@.service +++ b/virtualization/vagrant/home-assistant@.service @@ -16,5 +16,8 @@ ExecStart=/usr/bin/hass --runner SendSIGKILL=no RestartForceExitStatus=100 +# on vagrant (vboxfs), disable sendfile https://www.virtualbox.org/ticket/9069 +Environment=AIOHTTP_NOSENDFILE=1 + [Install] WantedBy=multi-user.target diff --git a/virtualization/vagrant/provision.sh b/virtualization/vagrant/provision.sh index da5d48c6f18..d4ef4e0b446 100755 --- a/virtualization/vagrant/provision.sh +++ b/virtualization/vagrant/provision.sh @@ -105,7 +105,7 @@ main() { vagrant up --provision; exit ;; esac # ...otherwise we assume it's the Vagrant provisioner - if [ $(hostname) != "contrib-jessie" ]; then usage; exit; fi + if [ $(hostname) != "contrib-jessie" ] && [ $(hostname) != "contrib-stretch" ]; then usage; exit; fi if ! [ -f $SETUP_DONE ]; then setup; fi if [ -f $RESTART ]; then restart; fi if [ -f $RUN_TESTS ]; then run_tests; fi From 8d48272cbdbe5e7a30d496823d6a2b092bdede05 Mon Sep 17 00:00:00 2001 From: Jesse Hills Date: Fri, 16 Feb 2018 18:57:58 +1300 Subject: [PATCH 084/173] Add effects to iGlo Lights (#12365) * Add effects to iGlo Lights Simplify state variables to library * Fix lint issues --- homeassistant/components/light/iglo.py | 48 +++++++++++++++----------- requirements_all.txt | 2 +- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/light/iglo.py b/homeassistant/components/light/iglo.py index ba78546cf77..e39b5dbf540 100644 --- a/homeassistant/components/light/iglo.py +++ b/homeassistant/components/light/iglo.py @@ -10,13 +10,14 @@ import math import voluptuous as vol from homeassistant.components.light import ( - ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, PLATFORM_SCHEMA, - SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, Light) + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_RGB_COLOR, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, SUPPORT_EFFECT, + PLATFORM_SCHEMA, Light) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util -REQUIREMENTS = ['iglo==1.1.3'] +REQUIREMENTS = ['iglo==1.2.5'] _LOGGER = logging.getLogger(__name__) @@ -46,10 +47,6 @@ class IGloLamp(Light): from iglo import Lamp self._name = name self._lamp = Lamp(0, host, port) - self._on = True - self._brightness = 255 - self._rgb = (0, 0, 0) - self._color_temp = 0 @property def name(self): @@ -59,12 +56,13 @@ class IGloLamp(Light): @property def brightness(self): """Return the brightness of this light between 0..255.""" - return int((self._brightness / 200.0) * 255) + return int((self._lamp.state['brightness'] / 200.0) * 255) @property def color_temp(self): """Return the color temperature.""" - return color_util.color_temperature_kelvin_to_mired(self._color_temp) + return color_util.color_temperature_kelvin_to_mired( + self._lamp.state['white']) @property def min_mireds(self): @@ -81,21 +79,32 @@ class IGloLamp(Light): @property def rgb_color(self): """Return the RGB value.""" - return self._rgb + return self._lamp.state['rgb'] + + @property + def effect(self): + """Return the current effect.""" + return self._lamp.state['effect'] + + @property + def effect_list(self): + """Return the list of supported effects.""" + return self._lamp.effect_list @property def supported_features(self): """Flag supported features.""" - return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_RGB_COLOR + return (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | + SUPPORT_RGB_COLOR | SUPPORT_EFFECT) @property def is_on(self): """Return true if light is on.""" - return self._on + return self._lamp.state['on'] def turn_on(self, **kwargs): """Turn the light on.""" - if not self._on: + if not self.is_on: self._lamp.switch(True) if ATTR_BRIGHTNESS in kwargs: brightness = int((kwargs[ATTR_BRIGHTNESS] / 255.0) * 200.0) @@ -113,14 +122,11 @@ class IGloLamp(Light): self._lamp.white(kelvin) return + if ATTR_EFFECT in kwargs: + effect = kwargs[ATTR_EFFECT] + self._lamp.effect(effect) + return + def turn_off(self, **kwargs): """Turn the light off.""" self._lamp.switch(False) - - def update(self): - """Update light status.""" - state = self._lamp.state() - self._on = state['on'] - self._brightness = state['brightness'] - self._rgb = state['rgb'] - self._color_temp = state['white'] diff --git a/requirements_all.txt b/requirements_all.txt index fad3434f712..a77021d2297 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -394,7 +394,7 @@ https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0 # i2csense==0.0.4 # homeassistant.components.light.iglo -iglo==1.1.3 +iglo==1.2.5 # homeassistant.components.ihc ihcsdk==2.1.1 From 1f041d54d9db4100831df2053a244db27cdb3d25 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Fri, 16 Feb 2018 07:01:34 +0100 Subject: [PATCH 085/173] Fake the state for a short period and skip the next update. (#12446) On state change the device doesn't provide the new state immediately. Flag the device as unavailable if no communication is possible. --- homeassistant/components/fan/xiaomi_miio.py | 26 ++++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index 535fa507fde..ce3582599c1 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.helpers.entity import ToggleEntity from homeassistant.components.fan import (FanEntity, PLATFORM_SCHEMA, - SUPPORT_SET_SPEED, DOMAIN) + SUPPORT_SET_SPEED, DOMAIN, ) from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, ATTR_ENTITY_ID, ) from homeassistant.exceptions import PlatformNotReady @@ -167,6 +167,7 @@ class XiaomiAirPurifier(FanEntity): ATTR_AVERAGE_AIR_QUALITY_INDEX: None, ATTR_PURIFY_VOLUME: None, } + self._skip_update = False @property def supported_features(self): @@ -218,23 +219,35 @@ class XiaomiAirPurifier(FanEntity): """Turn the fan on.""" if speed: # If operation mode was set the device must not be turned on. - yield from self.async_set_speed(speed) - return + result = yield from self.async_set_speed(speed) + else: + result = yield from self._try_command( + "Turning the air purifier on failed.", self._air_purifier.on) - yield from self._try_command( - "Turning the air purifier on failed.", self._air_purifier.on) + if result: + self._state = True + self._skip_update = True @asyncio.coroutine def async_turn_off(self: ToggleEntity, **kwargs) -> None: """Turn the fan off.""" - yield from self._try_command( + result = yield from self._try_command( "Turning the air purifier off failed.", self._air_purifier.off) + if result: + self._state = False + self._skip_update = True + @asyncio.coroutine def async_update(self): """Fetch state from the device.""" from miio import DeviceException + # On state change the device doesn't provide the new state immediately. + if self._skip_update: + self._skip_update = False + return + try: state = yield from self.hass.async_add_job( self._air_purifier.status) @@ -262,6 +275,7 @@ class XiaomiAirPurifier(FanEntity): ATTR_LED_BRIGHTNESS] = state.led_brightness.value except DeviceException as ex: + self._state = None _LOGGER.error("Got exception while fetching the state: %s", ex) @property From 13d6e561062afad13c373272fca8b02e16206407 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 16 Feb 2018 19:32:11 +0200 Subject: [PATCH 086/173] Fix light template to return brightness as int (#12447) --- homeassistant/components/light/template.py | 3 +-- tests/components/light/test_template.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/light/template.py b/homeassistant/components/light/template.py index d4f2b93e6b5..cfd050f54f2 100644 --- a/homeassistant/components/light/template.py +++ b/homeassistant/components/light/template.py @@ -237,7 +237,6 @@ class LightTemplate(Light): @asyncio.coroutine def async_update(self): """Update the state from the template.""" - print("ASYNC UPDATE") if self._template is not None: try: state = self._template.async_render().lower() @@ -262,7 +261,7 @@ class LightTemplate(Light): self._state = None if 0 <= int(brightness) <= 255: - self._brightness = brightness + self._brightness = int(brightness) else: _LOGGER.error( 'Received invalid brightness : %s' + diff --git a/tests/components/light/test_template.py b/tests/components/light/test_template.py index aaac0617590..2d45ad1bf94 100644 --- a/tests/components/light/test_template.py +++ b/tests/components/light/test_template.py @@ -586,7 +586,7 @@ class TestTemplateLight: state = self.hass.states.get('light.test_template_light') assert state is not None - assert state.attributes.get('brightness') == '42' + assert state.attributes.get('brightness') == 42 def test_friendly_name(self): """Test the accessibility of the friendly_name attribute.""" From 2053c8a908ec61193380e93cecc123a088ca9dd9 Mon Sep 17 00:00:00 2001 From: Ryan McLean Date: Fri, 16 Feb 2018 19:51:19 +0000 Subject: [PATCH 087/173] Fix for contentRating error (#12445) * Fix for contentRating * Use getattr instead of hasattr * Lint --- homeassistant/components/media_player/plex.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index b2a89341cf0..dc38bb17dd3 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -370,7 +370,8 @@ class PlexClient(MediaPlayerDevice): self._is_player_available = False self._media_position = self._session.viewOffset self._media_content_id = self._session.ratingKey - self._media_content_rating = self._session.contentRating + self._media_content_rating = getattr( + self._session, 'contentRating', None) self._set_player_state() From b3a47722f0d5c6693e29af415a9fad4d3d956566 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 16 Feb 2018 14:07:38 -0800 Subject: [PATCH 088/173] Initial support for Config Entries (#12079) * Introduce Config Entries * Rebase fail * Address comments * Address more comments * RequestDataValidator moved --- homeassistant/bootstrap.py | 7 +- homeassistant/components/config/__init__.py | 10 +- .../components/config/config_entries.py | 182 ++++++ .../components/config_entry_example.py | 102 ++++ homeassistant/config_entries.py | 516 ++++++++++++++++++ homeassistant/setup.py | 10 +- requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/common.py | 43 +- .../binary_sensor/test_command_line.py | 11 - .../components/config/test_config_entries.py | 317 +++++++++++ tests/components/sensor/test_command_line.py | 11 - tests/components/test_config_entry_example.py | 38 ++ tests/test_config_entries.py | 397 ++++++++++++++ 15 files changed, 1622 insertions(+), 29 deletions(-) create mode 100644 homeassistant/components/config/config_entries.py create mode 100644 homeassistant/components/config_entry_example.py create mode 100644 homeassistant/config_entries.py create mode 100644 tests/components/config/test_config_entries.py create mode 100644 tests/components/test_config_entry_example.py create mode 100644 tests/test_config_entries.py diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index c5b01916d8c..4971cbccc9c 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -12,7 +12,8 @@ from typing import Any, Optional, Dict import voluptuous as vol from homeassistant import ( - core, config as conf_util, loader, components as core_components) + core, config as conf_util, config_entries, loader, + components as core_components) from homeassistant.components import persistent_notification from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE from homeassistant.setup import async_setup_component @@ -123,9 +124,13 @@ def async_from_config_dict(config: Dict[str, Any], new_config[key] = value or {} config = new_config + hass.config_entries = config_entries.ConfigEntries(hass, config) + yield from hass.config_entries.async_load() + # Filter out the repeating and common config section [homeassistant] components = set(key.split(' ')[0] for key in config.keys() if key != core.DOMAIN) + components.update(hass.config_entries.async_domains()) # setup components # pylint: disable=not-an-iterable diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index c45e3561c47..7f2041249e0 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -14,15 +14,23 @@ from homeassistant.util.yaml import load_yaml, dump DOMAIN = 'config' DEPENDENCIES = ['http'] SECTIONS = ('core', 'customize', 'group', 'hassbian', 'automation', 'script') -ON_DEMAND = ('zwave') +ON_DEMAND = ('zwave',) +FEATURE_FLAGS = ('hidden_entries',) @asyncio.coroutine def async_setup(hass, config): """Set up the config component.""" + global SECTIONS + yield from hass.components.frontend.async_register_built_in_panel( 'config', 'config', 'mdi:settings') + # Temporary way of allowing people to opt-in for unreleased config sections + for key, value in config.get(DOMAIN, {}).items(): + if key in FEATURE_FLAGS and value: + SECTIONS += (key,) + @asyncio.coroutine def setup_panel(panel_name): """Set up a panel.""" diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py new file mode 100644 index 00000000000..d33e97b9e88 --- /dev/null +++ b/homeassistant/components/config/config_entries.py @@ -0,0 +1,182 @@ +"""Http views to control the config manager.""" +import asyncio + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http.data_validator import RequestDataValidator + + +REQUIREMENTS = ['voluptuous-serialize==0.1'] + + +@asyncio.coroutine +def async_setup(hass): + """Enable the Home Assistant views.""" + hass.http.register_view(ConfigManagerEntryIndexView) + hass.http.register_view(ConfigManagerEntryResourceView) + hass.http.register_view(ConfigManagerFlowIndexView) + hass.http.register_view(ConfigManagerFlowResourceView) + hass.http.register_view(ConfigManagerAvailableFlowView) + return True + + +def _prepare_json(result): + """Convert result for JSON.""" + if result['type'] != config_entries.RESULT_TYPE_FORM: + return result + + import voluptuous_serialize + + data = result.copy() + + schema = data['data_schema'] + if schema is None: + data['data_schema'] = [] + else: + data['data_schema'] = voluptuous_serialize.convert(schema) + + return data + + +class ConfigManagerEntryIndexView(HomeAssistantView): + """View to get available config entries.""" + + url = '/api/config/config_entries/entry' + name = 'api:config:config_entries:entry' + + @asyncio.coroutine + def get(self, request): + """List flows in progress.""" + hass = request.app['hass'] + return self.json([{ + 'entry_id': entry.entry_id, + 'domain': entry.domain, + 'title': entry.title, + 'source': entry.source, + 'state': entry.state, + } for entry in hass.config_entries.async_entries()]) + + +class ConfigManagerEntryResourceView(HomeAssistantView): + """View to interact with a config entry.""" + + url = '/api/config/config_entries/entry/{entry_id}' + name = 'api:config:config_entries:entry:resource' + + @asyncio.coroutine + def delete(self, request, entry_id): + """Delete a config entry.""" + hass = request.app['hass'] + + try: + result = yield from hass.config_entries.async_remove(entry_id) + except config_entries.UnknownEntry: + return self.json_message('Invalid entry specified', 404) + + return self.json(result) + + +class ConfigManagerFlowIndexView(HomeAssistantView): + """View to create config flows.""" + + url = '/api/config/config_entries/flow' + name = 'api:config:config_entries:flow' + + @asyncio.coroutine + def get(self, request): + """List flows that are in progress but not started by a user. + + Example of a non-user initiated flow is a discovered Hue hub that + requires user interaction to finish setup. + """ + hass = request.app['hass'] + + return self.json([ + flow for flow in hass.config_entries.flow.async_progress() + if flow['source'] != config_entries.SOURCE_USER]) + + @asyncio.coroutine + @RequestDataValidator(vol.Schema({ + vol.Required('domain'): str, + })) + def post(self, request, data): + """Handle a POST request.""" + hass = request.app['hass'] + + try: + result = yield from hass.config_entries.flow.async_init( + data['domain']) + except config_entries.UnknownHandler: + return self.json_message('Invalid handler specified', 404) + except config_entries.UnknownStep: + return self.json_message('Handler does not support init', 400) + + result = _prepare_json(result) + + return self.json(result) + + +class ConfigManagerFlowResourceView(HomeAssistantView): + """View to interact with the flow manager.""" + + url = '/api/config/config_entries/flow/{flow_id}' + name = 'api:config:config_entries:flow:resource' + + @asyncio.coroutine + def get(self, request, flow_id): + """Get the current state of a flow.""" + hass = request.app['hass'] + + try: + result = yield from hass.config_entries.flow.async_configure( + flow_id) + except config_entries.UnknownFlow: + return self.json_message('Invalid flow specified', 404) + + result = _prepare_json(result) + + return self.json(result) + + @asyncio.coroutine + @RequestDataValidator(vol.Schema(dict), allow_empty=True) + def post(self, request, flow_id, data): + """Handle a POST request.""" + hass = request.app['hass'] + + try: + result = yield from hass.config_entries.flow.async_configure( + flow_id, data) + except config_entries.UnknownFlow: + return self.json_message('Invalid flow specified', 404) + except vol.Invalid: + return self.json_message('User input malformed', 400) + + result = _prepare_json(result) + + return self.json(result) + + @asyncio.coroutine + def delete(self, request, flow_id): + """Cancel a flow in progress.""" + hass = request.app['hass'] + + try: + hass.config_entries.async_abort(flow_id) + except config_entries.UnknownFlow: + return self.json_message('Invalid flow specified', 404) + + return self.json_message('Flow aborted') + + +class ConfigManagerAvailableFlowView(HomeAssistantView): + """View to query available flows.""" + + url = '/api/config/config_entries/flow_handlers' + name = 'api:config:config_entries:flow_handlers' + + @asyncio.coroutine + def get(self, request): + """List available flow handlers.""" + return self.json(config_entries.FLOWS) diff --git a/homeassistant/components/config_entry_example.py b/homeassistant/components/config_entry_example.py new file mode 100644 index 00000000000..2d5ea728ff3 --- /dev/null +++ b/homeassistant/components/config_entry_example.py @@ -0,0 +1,102 @@ +"""Example component to show how config entries work.""" + +import asyncio + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.util import slugify + + +DOMAIN = 'config_entry_example' + + +@asyncio.coroutine +def async_setup(hass, config): + """Setup for our example component.""" + return True + + +@asyncio.coroutine +def async_setup_entry(hass, entry): + """Initialize an entry.""" + entity_id = '{}.{}'.format(DOMAIN, entry.data['object_id']) + hass.states.async_set(entity_id, 'loaded', { + ATTR_FRIENDLY_NAME: entry.data['name'] + }) + + # Indicate setup was successful. + return True + + +@asyncio.coroutine +def async_unload_entry(hass, entry): + """Unload an entry.""" + entity_id = '{}.{}'.format(DOMAIN, entry.data['object_id']) + hass.states.async_remove(entity_id) + + # Indicate unload was successful. + return True + + +@config_entries.HANDLERS.register(DOMAIN) +class ExampleConfigFlow(config_entries.ConfigFlowHandler): + """Handle an example configuration flow.""" + + VERSION = 1 + + def __init__(self): + """Initialize a Hue config handler.""" + self.object_id = None + + @asyncio.coroutine + def async_step_init(self, user_input=None): + """Start config flow.""" + errors = None + if user_input is not None: + object_id = user_input['object_id'] + + if object_id != '' and object_id == slugify(object_id): + self.object_id = user_input['object_id'] + return (yield from self.async_step_name()) + + errors = { + 'object_id': 'Invalid object id.' + } + + return self.async_show_form( + title='Pick object id', + step_id='init', + description="Please enter an object_id for the test entity.", + data_schema=vol.Schema({ + 'object_id': str + }), + errors=errors + ) + + @asyncio.coroutine + def async_step_name(self, user_input=None): + """Ask user to enter the name.""" + errors = None + if user_input is not None: + name = user_input['name'] + + if name != '': + return self.async_create_entry( + title=name, + data={ + 'name': name, + 'object_id': self.object_id, + } + ) + + return self.async_show_form( + title='Name of the entity', + step_id='name', + description="Please enter a name for the test entity.", + data_schema=vol.Schema({ + 'name': str + }), + errors=errors + ) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py new file mode 100644 index 00000000000..7b5d23d284f --- /dev/null +++ b/homeassistant/config_entries.py @@ -0,0 +1,516 @@ +"""The Config Manager is responsible for managing configuration for components. + +The Config Manager allows for creating config entries to be consumed by +components. Each entry is created via a Config Flow Handler, as defined by each +component. + +During startup, Home Assistant will setup the entries during the normal setup +of a component. It will first call the normal setup and then call the method +`async_setup_entry(hass, entry)` for each entry. The same method is called when +Home Assistant is running while a config entry is created. + +## Config Flows + +A component needs to define a Config Handler to allow the user to create config +entries for that component. A config flow will manage the creation of entries +from user input, discovery or other sources (like hassio). + +When a config flow is started for a domain, the handler will be instantiated +and receives a unique id. The instance of this handler will be reused for every +interaction of the user with this flow. This makes it possible to store +instance variables on the handler. + +Before instantiating the handler, Home Assistant will make sure to load all +dependencies and install the requirements of the component. + +At a minimum, each config flow will have to define a version number and the +'init' step. + + @config_entries.HANDLERS.register(DOMAIN) + class ExampleConfigFlow(config_entries.ConfigFlowHandler): + + VERSION = 1 + + async def async_step_init(self, user_input=None): + … + +The 'init' step is the first step of a flow and is called when a user +starts a new flow. Each step has three different possible results: "Show Form", +"Abort" and "Create Entry". + +### Show Form + +This will show a form to the user to fill in. You define the current step, +a title, a description and the schema of the data that needs to be returned. + + async def async_step_init(self, user_input=None): + # Use OrderedDict to guarantee order of the form shown to the user + data_schema = OrderedDict() + data_schema[vol.Required('username')] = str + data_schema[vol.Required('password')] = str + + return self.async_show_form( + step_id='init', + title='Account Info', + data_schema=vol.Schema(data_schema) + ) + +After the user has filled in the form, the step method will be called again and +the user input is passed in. If the validation of the user input fails , you +can return a dictionary with errors. Each key in the dictionary refers to a +field name that contains the error. Use the key 'base' if you want to show a +generic error. + + async def async_step_init(self, user_input=None): + errors = None + if user_input is not None: + # Validate user input + if valid: + return self.create_entry(…) + + errors['base'] = 'Unable to reach authentication server.' + + return self.async_show_form(…) + +If the user input passes validation, you can again return one of the three +return values. If you want to navigate the user to the next step, return the +return value of that step: + + return (await self.async_step_account()) + +### Abort + +When the result is "Abort", a message will be shown to the user and the +configuration flow is finished. + + return self.async_abort( + reason='This device is not supported by Home Assistant.' + ) + +### Create Entry + +When the result is "Create Entry", an entry will be created and stored in Home +Assistant, a success message is shown to the user and the flow is finished. + +## Initializing a config flow from an external source + +You might want to initialize a config flow programmatically. For example, if +we discover a device on the network that requires user interaction to finish +setup. To do so, pass a source parameter and optional user input to the init +step: + + await hass.config_entries.flow.async_init( + 'hue', source='discovery', data=discovery_info) + +The config flow handler will need to add a step to support the source. The step +should follow the same return values as a normal step. + + async def async_step_discovery(info): + +If the result of the step is to show a form, the user will be able to continue +the flow from the config panel. +""" +import asyncio +import logging +import os +import uuid + +from .core import callback +from .exceptions import HomeAssistantError +from .setup import async_setup_component, async_process_deps_reqs +from .util.json import load_json, save_json +from .util.decorator import Registry + + +_LOGGER = logging.getLogger(__name__) +HANDLERS = Registry() +# Components that have config flows. In future we will auto-generate this list. +FLOWS = [ + 'config_entry_example' +] + +SOURCE_USER = 'user' +SOURCE_DISCOVERY = 'discovery' + +PATH_CONFIG = '.config_entries.json' + +SAVE_DELAY = 1 + +RESULT_TYPE_FORM = 'form' +RESULT_TYPE_CREATE_ENTRY = 'create_entry' +RESULT_TYPE_ABORT = 'abort' + +ENTRY_STATE_LOADED = 'loaded' +ENTRY_STATE_SETUP_ERROR = 'setup_error' +ENTRY_STATE_NOT_LOADED = 'not_loaded' +ENTRY_STATE_FAILED_UNLOAD = 'failed_unload' + + +class ConfigEntry: + """Hold a configuration entry.""" + + __slots__ = ('entry_id', 'version', 'domain', 'title', 'data', 'source', + 'state') + + def __init__(self, version, domain, title, data, source, entry_id=None, + state=ENTRY_STATE_NOT_LOADED): + """Initialize a config entry.""" + # Unique id of the config entry + self.entry_id = entry_id or uuid.uuid4().hex + + # Version of the configuration. + self.version = version + + # Domain the configuration belongs to + self.domain = domain + + # Title of the configuration + self.title = title + + # Config data + self.data = data + + # Source of the configuration (user, discovery, cloud) + self.source = source + + # State of the entry (LOADED, NOT_LOADED) + self.state = state + + @asyncio.coroutine + def async_setup(self, hass, *, component=None): + """Set up an entry.""" + if component is None: + component = getattr(hass.components, self.domain) + + try: + result = yield from component.async_setup_entry(hass, self) + + if not isinstance(result, bool): + _LOGGER.error('%s.async_config_entry did not return boolean', + self.domain) + result = False + except Exception: # pylint: disable=broad-except + _LOGGER.exception('Error setting up entry %s for %s', + self.title, self.domain) + result = False + + if result: + self.state = ENTRY_STATE_LOADED + else: + self.state = ENTRY_STATE_SETUP_ERROR + + @asyncio.coroutine + def async_unload(self, hass): + """Unload an entry. + + Returns if unload is possible and was successful. + """ + component = getattr(hass.components, self.domain) + + supports_unload = hasattr(component, 'async_unload_entry') + + if not supports_unload: + return False + + try: + result = yield from component.async_unload_entry(hass, self) + + if not isinstance(result, bool): + _LOGGER.error('%s.async_unload_entry did not return boolean', + self.domain) + result = False + + return result + except Exception: # pylint: disable=broad-except + _LOGGER.exception('Error unloading entry %s for %s', + self.title, self.domain) + self.state = ENTRY_STATE_FAILED_UNLOAD + return False + + def as_dict(self): + """Return dictionary version of this entry.""" + return { + 'entry_id': self.entry_id, + 'version': self.version, + 'domain': self.domain, + 'title': self.title, + 'data': self.data, + 'source': self.source, + } + + +class ConfigError(HomeAssistantError): + """Error while configuring an account.""" + + +class UnknownEntry(ConfigError): + """Unknown entry specified.""" + + +class UnknownHandler(ConfigError): + """Unknown handler specified.""" + + +class UnknownFlow(ConfigError): + """Uknown flow specified.""" + + +class UnknownStep(ConfigError): + """Unknown step specified.""" + + +class ConfigEntries: + """Manage the configuration entries. + + An instance of this object is available via `hass.config_entries`. + """ + + def __init__(self, hass, hass_config): + """Initialize the entry manager.""" + self.hass = hass + self.flow = FlowManager(hass, hass_config, self._async_add_entry) + self._hass_config = hass_config + self._entries = None + self._sched_save = None + + @callback + def async_domains(self): + """Return domains for which we have entries.""" + seen = set() + result = [] + + for entry in self._entries: + if entry.domain not in seen: + seen.add(entry.domain) + result.append(entry.domain) + + return result + + @callback + def async_entries(self, domain=None): + """Return all entries or entries for a specific domain.""" + if domain is None: + return list(self._entries) + return [entry for entry in self._entries if entry.domain == domain] + + @asyncio.coroutine + def async_remove(self, entry_id): + """Remove an entry.""" + found = None + for index, entry in enumerate(self._entries): + if entry.entry_id == entry_id: + found = index + break + + if found is None: + raise UnknownEntry + + entry = self._entries.pop(found) + self._async_schedule_save() + + unloaded = yield from entry.async_unload(self.hass) + + return { + 'require_restart': not unloaded + } + + @asyncio.coroutine + def async_load(self): + """Load the config.""" + path = self.hass.config.path(PATH_CONFIG) + if not os.path.isfile(path): + self._entries = [] + return + + entries = yield from self.hass.async_add_job(load_json, path) + self._entries = [ConfigEntry(**entry) for entry in entries] + + @asyncio.coroutine + def _async_add_entry(self, entry): + """Add an entry.""" + self._entries.append(entry) + self._async_schedule_save() + + # Setup entry + if entry.domain in self.hass.config.components: + # Component already set up, just need to call setup_entry + yield from entry.async_setup(self.hass) + else: + # Setting up component will also load the entries + yield from async_setup_component( + self.hass, entry.domain, self._hass_config) + + @callback + def _async_schedule_save(self): + """Schedule saving the entity registry.""" + if self._sched_save is not None: + self._sched_save.cancel() + + self._sched_save = self.hass.loop.call_later( + SAVE_DELAY, self.hass.async_add_job, self._async_save + ) + + @asyncio.coroutine + def _async_save(self): + """Save the entity registry to a file.""" + self._sched_save = None + data = [entry.as_dict() for entry in self._entries] + + yield from self.hass.async_add_job( + save_json, self.hass.config.path(PATH_CONFIG), data) + + +class FlowManager: + """Manage all the config flows that are in progress.""" + + def __init__(self, hass, hass_config, async_add_entry): + """Initialize the flow manager.""" + self.hass = hass + self._hass_config = hass_config + self._progress = {} + self._async_add_entry = async_add_entry + + @callback + def async_progress(self): + """Return the flows in progress.""" + return [{ + 'flow_id': flow.flow_id, + 'domain': flow.domain, + 'source': flow.source, + } for flow in self._progress.values()] + + @asyncio.coroutine + def async_init(self, domain, *, source=SOURCE_USER, data=None): + """Start a configuration flow.""" + handler = HANDLERS.get(domain) + + if handler is None: + # This will load the component and thus register the handler + component = getattr(self.hass.components, domain) + handler = HANDLERS.get(domain) + + if handler is None: + raise self.hass.helpers.UnknownHandler + + # Make sure requirements and dependencies of component are resolved + yield from async_process_deps_reqs( + self.hass, self._hass_config, domain, component) + + flow_id = uuid.uuid4().hex + flow = self._progress[flow_id] = handler() + flow.hass = self.hass + flow.domain = domain + flow.flow_id = flow_id + flow.source = source + + if source == SOURCE_USER: + step = 'init' + else: + step = source + + return (yield from self._async_handle_step(flow, step, data)) + + @asyncio.coroutine + def async_configure(self, flow_id, user_input=None): + """Start or continue a configuration flow.""" + flow = self._progress.get(flow_id) + + if flow is None: + raise UnknownFlow + + step_id, data_schema = flow.cur_step + + if data_schema is not None and user_input is not None: + user_input = data_schema(user_input) + + return (yield from self._async_handle_step( + flow, step_id, user_input)) + + @callback + def async_abort(self, flow_id): + """Abort a flow.""" + if self._progress.pop(flow_id, None) is None: + raise UnknownFlow + + @asyncio.coroutine + def _async_handle_step(self, flow, step_id, user_input): + """Handle a step of a flow.""" + method = "async_step_{}".format(step_id) + + if not hasattr(flow, method): + self._progress.pop(flow.flow_id) + raise UnknownStep("Handler {} doesn't support step {}".format( + flow.__class__.__name__, step_id)) + + result = yield from getattr(flow, method)(user_input) + + if result['type'] not in (RESULT_TYPE_FORM, RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_ABORT): + raise ValueError( + 'Handler returned incorrect type: {}'.format(result['type'])) + + if result['type'] == RESULT_TYPE_FORM: + flow.cur_step = (result.pop('step_id'), result['data_schema']) + return result + + # Abort and Success results both finish the flow + self._progress.pop(flow.flow_id) + + if result['type'] == RESULT_TYPE_ABORT: + return result + + entry = ConfigEntry( + version=flow.VERSION, + domain=flow.domain, + title=result['title'], + data=result.pop('data'), + source=flow.source + ) + yield from self._async_add_entry(entry) + return result + + +class ConfigFlowHandler: + """Handle the configuration flow of a component.""" + + # Set by flow manager + flow_id = None + hass = None + source = SOURCE_USER + cur_step = None + + # Set by dev + # VERSION + + @callback + def async_show_form(self, *, title, step_id, description=None, + data_schema=None, errors=None): + """Return the definition of a form to gather user input.""" + return { + 'type': RESULT_TYPE_FORM, + 'flow_id': self.flow_id, + 'title': title, + 'step_id': step_id, + 'description': description, + 'data_schema': data_schema, + 'errors': errors, + } + + @callback + def async_create_entry(self, *, title, data): + """Finish config flow and create a config entry.""" + return { + 'type': RESULT_TYPE_CREATE_ENTRY, + 'flow_id': self.flow_id, + 'title': title, + 'data': data, + } + + @callback + def async_abort(self, *, reason): + """Abort the config flow.""" + return { + 'type': RESULT_TYPE_ABORT, + 'flow_id': self.flow_id, + 'reason': reason + } diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 2c69fdefeee..5a8681e82fd 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -123,7 +123,7 @@ def _async_setup_component(hass: core.HomeAssistant, return False try: - yield from _process_deps_reqs(hass, config, domain, component) + yield from async_process_deps_reqs(hass, config, domain, component) except HomeAssistantError as err: log_error(str(err)) return False @@ -165,6 +165,9 @@ def _async_setup_component(hass: core.HomeAssistant, loader.set_component(domain, None) return False + for entry in hass.config_entries.async_entries(domain): + yield from entry.async_setup(hass, component=component) + hass.config.components.add(component.DOMAIN) # Cleanup @@ -206,7 +209,8 @@ def async_prepare_setup_platform(hass: core.HomeAssistant, config, domain: str, return platform try: - yield from _process_deps_reqs(hass, config, platform_path, platform) + yield from async_process_deps_reqs( + hass, config, platform_path, platform) except HomeAssistantError as err: log_error(str(err)) return None @@ -215,7 +219,7 @@ def async_prepare_setup_platform(hass: core.HomeAssistant, config, domain: str, @asyncio.coroutine -def _process_deps_reqs(hass, config, name, module): +def async_process_deps_reqs(hass, config, name, module): """Process all dependencies and requirements for a module. Module is a Python module of either a component or platform. diff --git a/requirements_all.txt b/requirements_all.txt index a77021d2297..24156b517a8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1198,6 +1198,9 @@ uvcclient==0.10.1 # homeassistant.components.climate.venstar venstarcolortouch==0.6 +# homeassistant.components.config.config_entries +voluptuous-serialize==0.1 + # homeassistant.components.volvooncall volvooncall==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ecde5a5fc9e..4155fea78be 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -175,6 +175,9 @@ statsd==3.2.1 # homeassistant.components.camera.uvc uvcclient==0.10.1 +# homeassistant.components.config.config_entries +voluptuous-serialize==0.1 + # homeassistant.components.vultr vultr==0.1.2 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 9c510d8339e..42acee96206 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -82,6 +82,7 @@ TEST_REQUIREMENTS = ( 'sqlalchemy', 'statsd', 'uvcclient', + 'voluptuous-serialize', 'warrant', 'yahoo-finance', 'pythonwhois', diff --git a/tests/common.py b/tests/common.py index 1b79d15b319..6fee7b1bec0 100644 --- a/tests/common.py +++ b/tests/common.py @@ -9,7 +9,7 @@ import logging import threading from contextlib import contextmanager -from homeassistant import core as ha, loader +from homeassistant import core as ha, loader, config_entries from homeassistant.setup import setup_component, async_setup_component from homeassistant.config import async_process_component_config from homeassistant.helpers import ( @@ -109,6 +109,9 @@ def get_test_home_assistant(): def async_test_home_assistant(loop): """Return a Home Assistant object pointing at test config dir.""" hass = ha.HomeAssistant(loop) + hass.config_entries = config_entries.ConfigEntries(hass, {}) + hass.config_entries._entries = [] + hass.config.async_load = Mock() INSTANCES.append(hass) orig_async_add_job = hass.async_add_job @@ -305,7 +308,8 @@ class MockModule(object): # pylint: disable=invalid-name def __init__(self, domain=None, dependencies=None, setup=None, requirements=None, config_schema=None, platform_schema=None, - async_setup=None): + async_setup=None, async_setup_entry=None, + async_unload_entry=None): """Initialize the mock module.""" self.DOMAIN = domain self.DEPENDENCIES = dependencies or [] @@ -327,6 +331,12 @@ class MockModule(object): if setup is None and async_setup is None: self.async_setup = mock_coro_func(True) + if async_setup_entry is not None: + self.async_setup_entry = async_setup_entry + + if async_unload_entry is not None: + self.async_unload_entry = async_unload_entry + class MockPlatform(object): """Provide a fake platform.""" @@ -402,6 +412,35 @@ class MockToggleDevice(entity.ToggleEntity): return None +class MockConfigEntry(config_entries.ConfigEntry): + """Helper for creating config entries that adds some defaults.""" + + def __init__(self, *, domain='test', data=None, version=0, entry_id=None, + source=config_entries.SOURCE_USER, title='Mock Title', + state=None): + """Initialize a mock config entry.""" + kwargs = { + 'entry_id': entry_id or 'mock-id', + 'domain': domain, + 'data': data or {}, + 'version': version, + 'title': title + } + if source is not None: + kwargs['source'] = source + if state is not None: + kwargs['state'] = state + super().__init__(**kwargs) + + def add_to_hass(self, hass): + """Test helper to add entry to hass.""" + hass.config_entries._entries.append(self) + + def add_to_manager(self, manager): + """Test helper to add entry to entry manager.""" + manager._entries.append(self) + + def patch_yaml_files(files_dict, endswith=True): """Patch load_yaml with a dictionary of yaml files.""" # match using endswith, start search with longest string diff --git a/tests/components/binary_sensor/test_command_line.py b/tests/components/binary_sensor/test_command_line.py index f35e6f08452..d01b62e4c12 100644 --- a/tests/components/binary_sensor/test_command_line.py +++ b/tests/components/binary_sensor/test_command_line.py @@ -3,7 +3,6 @@ import unittest from homeassistant.const import (STATE_ON, STATE_OFF) from homeassistant.components.binary_sensor import command_line -from homeassistant import setup from homeassistant.helpers import template from tests.common import get_test_home_assistant @@ -42,16 +41,6 @@ class TestCommandSensorBinarySensor(unittest.TestCase): self.assertEqual('Test', entity.name) self.assertEqual(STATE_ON, entity.state) - def test_setup_bad_config(self): - """Test the setup with a bad configuration.""" - config = {'name': 'test', - 'platform': 'not_command_line', - } - - self.assertFalse(setup.setup_component(self.hass, 'test', { - 'command_line': config, - })) - def test_template(self): """Test setting the state with a template.""" data = command_line.CommandSensorData(self.hass, 'echo 10') diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py new file mode 100644 index 00000000000..1551ba74319 --- /dev/null +++ b/tests/components/config/test_config_entries.py @@ -0,0 +1,317 @@ +"""Test config entries API.""" + +import asyncio +from collections import OrderedDict +from unittest.mock import patch + +import pytest +import voluptuous as vol + +from homeassistant import config_entries as core_ce +from homeassistant.config_entries import ConfigFlowHandler, HANDLERS +from homeassistant.setup import async_setup_component +from homeassistant.components.config import config_entries +from homeassistant.loader import set_component + +from tests.common import MockConfigEntry, MockModule, mock_coro_func + + +@pytest.fixture +def client(hass, test_client): + """Fixture that can interact with the config manager API.""" + hass.loop.run_until_complete(async_setup_component(hass, 'http', {})) + hass.loop.run_until_complete(config_entries.async_setup(hass)) + yield hass.loop.run_until_complete(test_client(hass.http.app)) + + +@asyncio.coroutine +def test_get_entries(hass, client): + """Test get entries.""" + MockConfigEntry( + domain='comp', + title='Test 1', + source='bla' + ).add_to_hass(hass) + MockConfigEntry( + domain='comp2', + title='Test 2', + source='bla2', + state=core_ce.ENTRY_STATE_LOADED, + ).add_to_hass(hass) + resp = yield from client.get('/api/config/config_entries/entry') + assert resp.status == 200 + data = yield from resp.json() + for entry in data: + entry.pop('entry_id') + assert data == [ + { + 'domain': 'comp', + 'title': 'Test 1', + 'source': 'bla', + 'state': 'not_loaded' + }, + { + 'domain': 'comp2', + 'title': 'Test 2', + 'source': 'bla2', + 'state': 'loaded', + }, + ] + + +@asyncio.coroutine +def test_remove_entry(hass, client): + """Test removing an entry via the API.""" + entry = MockConfigEntry(domain='demo') + entry.add_to_hass(hass) + resp = yield from client.delete( + '/api/config/config_entries/entry/{}'.format(entry.entry_id)) + assert resp.status == 200 + data = yield from resp.json() + assert data == { + 'require_restart': True + } + assert len(hass.config_entries.async_entries()) == 0 + + +@asyncio.coroutine +def test_available_flows(hass, client): + """Test querying the available flows.""" + with patch.object(core_ce, 'FLOWS', ['hello', 'world']): + resp = yield from client.get( + '/api/config/config_entries/flow_handlers') + assert resp.status == 200 + data = yield from resp.json() + assert data == ['hello', 'world'] + + +############################ +# FLOW MANAGER API TESTS # +############################ + + +@asyncio.coroutine +def test_initialize_flow(hass, client): + """Test we can initialize a flow.""" + class TestFlow(ConfigFlowHandler): + @asyncio.coroutine + def async_step_init(self, user_input=None): + schema = OrderedDict() + schema[vol.Required('username')] = str + schema[vol.Required('password')] = str + + return self.async_show_form( + title='test-title', + step_id='init', + description='test-description', + data_schema=schema, + errors={ + 'username': 'Should be unique.' + } + ) + + with patch.dict(HANDLERS, {'test': TestFlow}): + resp = yield from client.post('/api/config/config_entries/flow', + json={'domain': 'test'}) + + assert resp.status == 200 + data = yield from resp.json() + + data.pop('flow_id') + + assert data == { + 'type': 'form', + 'title': 'test-title', + 'description': 'test-description', + 'data_schema': [ + { + 'name': 'username', + 'required': True, + 'type': 'string' + }, + { + 'name': 'password', + 'required': True, + 'type': 'string' + } + ], + 'errors': { + 'username': 'Should be unique.' + } + } + + +@asyncio.coroutine +def test_abort(hass, client): + """Test a flow that aborts.""" + class TestFlow(ConfigFlowHandler): + @asyncio.coroutine + def async_step_init(self, user_input=None): + return self.async_abort(reason='bla') + + with patch.dict(HANDLERS, {'test': TestFlow}): + resp = yield from client.post('/api/config/config_entries/flow', + json={'domain': 'test'}) + + assert resp.status == 200 + data = yield from resp.json() + data.pop('flow_id') + assert data == { + 'reason': 'bla', + 'type': 'abort' + } + + +@asyncio.coroutine +def test_create_account(hass, client): + """Test a flow that creates an account.""" + set_component( + 'test', MockModule('test', async_setup_entry=mock_coro_func(True))) + + class TestFlow(ConfigFlowHandler): + VERSION = 1 + + @asyncio.coroutine + def async_step_init(self, user_input=None): + return self.async_create_entry( + title='Test Entry', + data={'secret': 'account_token'} + ) + + with patch.dict(HANDLERS, {'test': TestFlow}): + resp = yield from client.post('/api/config/config_entries/flow', + json={'domain': 'test'}) + + assert resp.status == 200 + data = yield from resp.json() + data.pop('flow_id') + assert data == { + 'title': 'Test Entry', + 'type': 'create_entry' + } + + +@asyncio.coroutine +def test_two_step_flow(hass, client): + """Test we can finish a two step flow.""" + set_component( + 'test', MockModule('test', async_setup_entry=mock_coro_func(True))) + + class TestFlow(ConfigFlowHandler): + VERSION = 1 + + @asyncio.coroutine + def async_step_init(self, user_input=None): + return self.async_show_form( + title='test-title', + step_id='account', + data_schema=vol.Schema({ + 'user_title': str + })) + + @asyncio.coroutine + def async_step_account(self, user_input=None): + return self.async_create_entry( + title=user_input['user_title'], + data={'secret': 'account_token'} + ) + + with patch.dict(HANDLERS, {'test': TestFlow}): + resp = yield from client.post('/api/config/config_entries/flow', + json={'domain': 'test'}) + assert resp.status == 200 + data = yield from resp.json() + flow_id = data.pop('flow_id') + assert data == { + 'type': 'form', + 'title': 'test-title', + 'description': None, + 'data_schema': [ + { + 'name': 'user_title', + 'type': 'string' + } + ], + 'errors': None + } + + with patch.dict(HANDLERS, {'test': TestFlow}): + resp = yield from client.post( + '/api/config/config_entries/flow/{}'.format(flow_id), + json={'user_title': 'user-title'}) + assert resp.status == 200 + data = yield from resp.json() + data.pop('flow_id') + assert data == { + 'type': 'create_entry', + 'title': 'user-title', + } + + +@asyncio.coroutine +def test_get_progress_index(hass, client): + """Test querying for the flows that are in progress.""" + class TestFlow(ConfigFlowHandler): + VERSION = 5 + + @asyncio.coroutine + def async_step_hassio(self, info): + return (yield from self.async_step_account()) + + @asyncio.coroutine + def async_step_account(self, user_input=None): + return self.async_show_form( + step_id='account', + title='Finish setup' + ) + + with patch.dict(HANDLERS, {'test': TestFlow}): + form = yield from hass.config_entries.flow.async_init( + 'test', source='hassio') + + resp = yield from client.get('/api/config/config_entries/flow') + assert resp.status == 200 + data = yield from resp.json() + assert data == [ + { + 'flow_id': form['flow_id'], + 'domain': 'test', + 'source': 'hassio' + } + ] + + +@asyncio.coroutine +def test_get_progress_flow(hass, client): + """Test we can query the API for same result as we get from init a flow.""" + class TestFlow(ConfigFlowHandler): + @asyncio.coroutine + def async_step_init(self, user_input=None): + schema = OrderedDict() + schema[vol.Required('username')] = str + schema[vol.Required('password')] = str + + return self.async_show_form( + title='test-title', + step_id='init', + description='test-description', + data_schema=schema, + errors={ + 'username': 'Should be unique.' + } + ) + + with patch.dict(HANDLERS, {'test': TestFlow}): + resp = yield from client.post('/api/config/config_entries/flow', + json={'domain': 'test'}) + + assert resp.status == 200 + data = yield from resp.json() + + resp2 = yield from client.get( + '/api/config/config_entries/flow/{}'.format(data['flow_id'])) + + assert resp2.status == 200 + data2 = yield from resp2.json() + + assert data == data2 diff --git a/tests/components/sensor/test_command_line.py b/tests/components/sensor/test_command_line.py index 6eb97b41e11..bc073a04c47 100644 --- a/tests/components/sensor/test_command_line.py +++ b/tests/components/sensor/test_command_line.py @@ -3,7 +3,6 @@ import unittest from homeassistant.helpers.template import Template from homeassistant.components.sensor import command_line -from homeassistant import setup from tests.common import get_test_home_assistant @@ -40,16 +39,6 @@ class TestCommandSensorSensor(unittest.TestCase): self.assertEqual('in', entity.unit_of_measurement) self.assertEqual('5', entity.state) - def test_setup_bad_config(self): - """Test setup with a bad configuration.""" - config = {'name': 'test', - 'platform': 'not_command_line', - } - - self.assertFalse(setup.setup_component(self.hass, 'test', { - 'command_line': config, - })) - def test_template(self): """Test command sensor with template.""" data = command_line.CommandSensorData(self.hass, 'echo 50') diff --git a/tests/components/test_config_entry_example.py b/tests/components/test_config_entry_example.py new file mode 100644 index 00000000000..31084384c31 --- /dev/null +++ b/tests/components/test_config_entry_example.py @@ -0,0 +1,38 @@ +"""Test the config entry example component.""" +import asyncio + +from homeassistant import config_entries + + +@asyncio.coroutine +def test_flow_works(hass): + """Test that the config flow works.""" + result = yield from hass.config_entries.flow.async_init( + 'config_entry_example') + + assert result['type'] == config_entries.RESULT_TYPE_FORM + + result = yield from hass.config_entries.flow.async_configure( + result['flow_id'], { + 'object_id': 'bla' + }) + + assert result['type'] == config_entries.RESULT_TYPE_FORM + + result = yield from hass.config_entries.flow.async_configure( + result['flow_id'], { + 'name': 'Hello' + }) + + assert result['type'] == config_entries.RESULT_TYPE_CREATE_ENTRY + state = hass.states.get('config_entry_example.bla') + assert state is not None + assert state.name == 'Hello' + assert 'config_entry_example' in hass.config.components + assert len(hass.config_entries.async_entries()) == 1 + + # Test removing entry. + entry = hass.config_entries.async_entries()[0] + yield from hass.config_entries.async_remove(entry.entry_id) + state = hass.states.get('config_entry_example.bla') + assert state is None diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py new file mode 100644 index 00000000000..3a1fe1d9d3e --- /dev/null +++ b/tests/test_config_entries.py @@ -0,0 +1,397 @@ +"""Test the config manager.""" +import asyncio +from unittest.mock import MagicMock, patch, mock_open + +import pytest +import voluptuous as vol + +from homeassistant import config_entries, loader +from homeassistant.setup import async_setup_component + +from tests.common import MockModule, mock_coro, MockConfigEntry + + +@pytest.fixture +def manager(hass): + """Fixture of a loaded config manager.""" + manager = config_entries.ConfigEntries(hass, {}) + manager._entries = [] + hass.config_entries = manager + return manager + + +@asyncio.coroutine +def test_call_setup_entry(hass): + """Test we call .setup_entry.""" + MockConfigEntry(domain='comp').add_to_hass(hass) + + mock_setup_entry = MagicMock(return_value=mock_coro(True)) + + loader.set_component( + 'comp', + MockModule('comp', async_setup_entry=mock_setup_entry)) + + result = yield from async_setup_component(hass, 'comp', {}) + assert result + assert len(mock_setup_entry.mock_calls) == 1 + + +@asyncio.coroutine +def test_remove_entry(manager): + """Test that we can remove an entry.""" + mock_unload_entry = MagicMock(return_value=mock_coro(True)) + + loader.set_component( + 'test', + MockModule('comp', async_unload_entry=mock_unload_entry)) + + MockConfigEntry(domain='test', entry_id='test1').add_to_manager(manager) + MockConfigEntry(domain='test', entry_id='test2').add_to_manager(manager) + MockConfigEntry(domain='test', entry_id='test3').add_to_manager(manager) + + assert [item.entry_id for item in manager.async_entries()] == \ + ['test1', 'test2', 'test3'] + + result = yield from manager.async_remove('test2') + + assert result == { + 'require_restart': False + } + assert [item.entry_id for item in manager.async_entries()] == \ + ['test1', 'test3'] + + assert len(mock_unload_entry.mock_calls) == 1 + + +@asyncio.coroutine +def test_remove_entry_raises(manager): + """Test if a component raises while removing entry.""" + @asyncio.coroutine + def mock_unload_entry(hass, entry): + """Mock unload entry function.""" + raise Exception("BROKEN") + + loader.set_component( + 'test', + MockModule('comp', async_unload_entry=mock_unload_entry)) + + MockConfigEntry(domain='test', entry_id='test1').add_to_manager(manager) + MockConfigEntry(domain='test', entry_id='test2').add_to_manager(manager) + MockConfigEntry(domain='test', entry_id='test3').add_to_manager(manager) + + assert [item.entry_id for item in manager.async_entries()] == \ + ['test1', 'test2', 'test3'] + + result = yield from manager.async_remove('test2') + + assert result == { + 'require_restart': True + } + assert [item.entry_id for item in manager.async_entries()] == \ + ['test1', 'test3'] + + +@asyncio.coroutine +def test_add_entry_calls_setup_entry(hass, manager): + """Test we call setup_config_entry.""" + mock_setup_entry = MagicMock(return_value=mock_coro(True)) + + loader.set_component( + 'comp', + MockModule('comp', async_setup_entry=mock_setup_entry)) + + class TestFlow(config_entries.ConfigFlowHandler): + + VERSION = 1 + + @asyncio.coroutine + def async_step_init(self, user_input=None): + return self.async_create_entry( + title='title', + data={ + 'token': 'supersecret' + }) + + with patch.dict(config_entries.HANDLERS, {'comp': TestFlow}): + yield from manager.flow.async_init('comp') + yield from hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + p_hass, p_entry = mock_setup_entry.mock_calls[0][1] + + assert p_hass is hass + assert p_entry.data == { + 'token': 'supersecret' + } + + +@asyncio.coroutine +def test_entries_gets_entries(manager): + """Test entries are filtered by domain.""" + MockConfigEntry(domain='test').add_to_manager(manager) + entry1 = MockConfigEntry(domain='test2') + entry1.add_to_manager(manager) + entry2 = MockConfigEntry(domain='test2') + entry2.add_to_manager(manager) + + assert manager.async_entries('test2') == [entry1, entry2] + + +@asyncio.coroutine +def test_domains_gets_uniques(manager): + """Test we only return each domain once.""" + MockConfigEntry(domain='test').add_to_manager(manager) + MockConfigEntry(domain='test2').add_to_manager(manager) + MockConfigEntry(domain='test2').add_to_manager(manager) + MockConfigEntry(domain='test').add_to_manager(manager) + MockConfigEntry(domain='test3').add_to_manager(manager) + + assert manager.async_domains() == ['test', 'test2', 'test3'] + + +@asyncio.coroutine +def test_saving_and_loading(hass): + """Test that we're saving and loading correctly.""" + class TestFlow(config_entries.ConfigFlowHandler): + VERSION = 5 + + @asyncio.coroutine + def async_step_init(self, user_input=None): + return self.async_create_entry( + title='Test Title', + data={ + 'token': 'abcd' + } + ) + + with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): + yield from hass.config_entries.flow.async_init('test') + + class Test2Flow(config_entries.ConfigFlowHandler): + VERSION = 3 + + @asyncio.coroutine + def async_step_init(self, user_input=None): + return self.async_create_entry( + title='Test 2 Title', + data={ + 'username': 'bla' + } + ) + + json_path = 'homeassistant.util.json.open' + + with patch('homeassistant.config_entries.HANDLERS.get', + return_value=Test2Flow), \ + patch.object(config_entries, 'SAVE_DELAY', 0): + yield from hass.config_entries.flow.async_init('test') + + with patch(json_path, mock_open(), create=True) as mock_write: + # To trigger the call_later + yield from asyncio.sleep(0, loop=hass.loop) + # To execute the save + yield from hass.async_block_till_done() + + # Mock open calls are: open file, context enter, write, context leave + written = mock_write.mock_calls[2][1][0] + + # Now load written data in new config manager + manager = config_entries.ConfigEntries(hass, {}) + + with patch('os.path.isfile', return_value=True), \ + patch(json_path, mock_open(read_data=written), create=True): + yield from manager.async_load() + + # Ensure same order + for orig, loaded in zip(hass.config_entries.async_entries(), + manager.async_entries()): + assert orig.version == loaded.version + assert orig.domain == loaded.domain + assert orig.title == loaded.title + assert orig.data == loaded.data + assert orig.source == loaded.source + + +####################### +# FLOW MANAGER TESTS # +####################### + +@asyncio.coroutine +def test_configure_reuses_handler_instance(manager): + """Test that we reuse instances.""" + class TestFlow(config_entries.ConfigFlowHandler): + handle_count = 0 + + @asyncio.coroutine + def async_step_init(self, user_input=None): + self.handle_count += 1 + return self.async_show_form( + title=str(self.handle_count), + step_id='init') + + with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): + form = yield from manager.flow.async_init('test') + assert form['title'] == '1' + form = yield from manager.flow.async_configure(form['flow_id']) + assert form['title'] == '2' + assert len(manager.flow.async_progress()) == 1 + assert len(manager.async_entries()) == 0 + + +@asyncio.coroutine +def test_configure_two_steps(manager): + """Test that we reuse instances.""" + class TestFlow(config_entries.ConfigFlowHandler): + VERSION = 1 + + @asyncio.coroutine + def async_step_init(self, user_input=None): + if user_input is not None: + self.init_data = user_input + return self.async_step_second() + return self.async_show_form( + title='title', + step_id='init', + data_schema=vol.Schema([str]) + ) + + @asyncio.coroutine + def async_step_second(self, user_input=None): + if user_input is not None: + return self.async_create_entry( + title='Test Entry', + data=self.init_data + user_input + ) + return self.async_show_form( + title='title', + step_id='second', + data_schema=vol.Schema([str]) + ) + + with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): + form = yield from manager.flow.async_init('test') + + with pytest.raises(vol.Invalid): + form = yield from manager.flow.async_configure( + form['flow_id'], 'INCORRECT-DATA') + + form = yield from manager.flow.async_configure( + form['flow_id'], ['INIT-DATA']) + form = yield from manager.flow.async_configure( + form['flow_id'], ['SECOND-DATA']) + assert form['type'] == config_entries.RESULT_TYPE_CREATE_ENTRY + assert len(manager.flow.async_progress()) == 0 + assert len(manager.async_entries()) == 1 + entry = manager.async_entries()[0] + assert entry.domain == 'test' + assert entry.data == ['INIT-DATA', 'SECOND-DATA'] + + +@asyncio.coroutine +def test_show_form(manager): + """Test that abort removes the flow from progress.""" + schema = vol.Schema({ + vol.Required('username'): str, + vol.Required('password'): str + }) + + class TestFlow(config_entries.ConfigFlowHandler): + @asyncio.coroutine + def async_step_init(self, user_input=None): + return self.async_show_form( + title='Hello form', + step_id='init', + description='test-description', + data_schema=schema, + errors={ + 'username': 'Should be unique.' + } + ) + + with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): + form = yield from manager.flow.async_init('test') + assert form['type'] == 'form' + assert form['title'] == 'Hello form' + assert form['description'] == 'test-description' + assert form['data_schema'] is schema + assert form['errors'] == { + 'username': 'Should be unique.' + } + + +@asyncio.coroutine +def test_abort_removes_instance(manager): + """Test that abort removes the flow from progress.""" + class TestFlow(config_entries.ConfigFlowHandler): + is_new = True + + @asyncio.coroutine + def async_step_init(self, user_input=None): + old = self.is_new + self.is_new = False + return self.async_abort(reason=str(old)) + + with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): + form = yield from manager.flow.async_init('test') + assert form['reason'] == 'True' + assert len(manager.flow.async_progress()) == 0 + assert len(manager.async_entries()) == 0 + form = yield from manager.flow.async_init('test') + assert form['reason'] == 'True' + assert len(manager.flow.async_progress()) == 0 + assert len(manager.async_entries()) == 0 + + +@asyncio.coroutine +def test_create_saves_data(manager): + """Test creating a config entry.""" + class TestFlow(config_entries.ConfigFlowHandler): + VERSION = 5 + + @asyncio.coroutine + def async_step_init(self, user_input=None): + return self.async_create_entry( + title='Test Title', + data='Test Data' + ) + + with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): + yield from manager.flow.async_init('test') + assert len(manager.flow.async_progress()) == 0 + assert len(manager.async_entries()) == 1 + + entry = manager.async_entries()[0] + assert entry.version == 5 + assert entry.domain == 'test' + assert entry.title == 'Test Title' + assert entry.data == 'Test Data' + assert entry.source == config_entries.SOURCE_USER + + +@asyncio.coroutine +def test_discovery_init_flow(manager): + """Test a flow initialized by discovery.""" + class TestFlow(config_entries.ConfigFlowHandler): + VERSION = 5 + + @asyncio.coroutine + def async_step_discovery(self, info): + return self.async_create_entry(title=info['id'], data=info) + + data = { + 'id': 'hello', + 'token': 'secret' + } + + with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): + yield from manager.flow.async_init( + 'test', source=config_entries.SOURCE_DISCOVERY, data=data) + assert len(manager.flow.async_progress()) == 0 + assert len(manager.async_entries()) == 1 + + entry = manager.async_entries()[0] + assert entry.version == 5 + assert entry.domain == 'test' + assert entry.title == 'hello' + assert entry.data == data + assert entry.source == config_entries.SOURCE_DISCOVERY From fe5626b92790897d30bd28e2c6e5312776671f97 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Fri, 16 Feb 2018 23:54:11 +0100 Subject: [PATCH 089/173] Make WUnderground async (#12385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🐎 Async WUnderground * ∞ Them lines be too long * Fix pylint warnings * Changes according to comments * Remove STATE_UNKNOWN * 🔬 Fix tests * Improve tests --- .../components/sensor/wunderground.py | 51 +-- tests/components/sensor/test_wunderground.py | 347 +++++------------- tests/fixtures/wunderground-error.json | 11 + tests/fixtures/wunderground-invalid.json | 18 + tests/fixtures/wunderground-valid.json | 90 +++++ 5 files changed, 241 insertions(+), 276 deletions(-) create mode 100644 tests/fixtures/wunderground-error.json create mode 100644 tests/fixtures/wunderground-invalid.json create mode 100644 tests/fixtures/wunderground-valid.json diff --git a/homeassistant/components/sensor/wunderground.py b/homeassistant/components/sensor/wunderground.py index aa5d431a7b0..e9e0c00d47d 100644 --- a/homeassistant/components/sensor/wunderground.py +++ b/homeassistant/components/sensor/wunderground.py @@ -4,21 +4,24 @@ Support for WUnderground weather service. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.wunderground/ """ +import asyncio from datetime import timedelta import logging - import re -import requests + +import aiohttp +import async_timeout import voluptuous as vol -from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.typing import HomeAssistantType, ConfigType from homeassistant.components.sensor import PLATFORM_SCHEMA, ENTITY_ID_FORMAT from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, TEMP_FAHRENHEIT, TEMP_CELSIUS, LENGTH_INCHES, LENGTH_KILOMETERS, - LENGTH_MILES, LENGTH_FEET, STATE_UNKNOWN, ATTR_ATTRIBUTION) + LENGTH_MILES, LENGTH_FEET, ATTR_ATTRIBUTION) from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.entity import Entity, generate_entity_id +from homeassistant.helpers.entity import Entity, async_generate_entity_id +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv @@ -627,7 +630,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_devices, discovery_info=None): """Set up the WUnderground sensor.""" latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) @@ -639,13 +644,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for variable in config[CONF_MONITORED_CONDITIONS]: sensors.append(WUndergroundSensor(hass, rest, variable)) - rest.update() + yield from rest.async_update() if not rest.data: raise PlatformNotReady - add_devices(sensors) - - return True + async_add_devices(sensors, True) class WUndergroundSensor(Entity): @@ -663,7 +666,7 @@ class WUndergroundSensor(Entity): self._entity_picture = None self._unit_of_measurement = self._cfg_expand("unit_of_measurement") self.rest.request_feature(SENSOR_TYPES[condition].feature) - self.entity_id = generate_entity_id( + self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, "pws_" + condition, hass=hass) def _cfg_expand(self, what, default=None): @@ -727,15 +730,16 @@ class WUndergroundSensor(Entity): """Return the units of measurement.""" return self._unit_of_measurement - def update(self): + @asyncio.coroutine + def async_update(self): """Update current conditions.""" - self.rest.update() + yield from self.rest.async_update() if not self.rest.data: # no data, return return - self._state = self._cfg_expand("value", STATE_UNKNOWN) + self._state = self._cfg_expand("value") self._update_attrs() self._icon = self._cfg_expand("icon", super().icon) url = self._cfg_expand("entity_picture") @@ -757,6 +761,7 @@ class WUndergroundData(object): self._longitude = longitude self._features = set() self.data = None + self._session = async_get_clientsession(self._hass) def request_feature(self, feature): """Register feature to be fetched from WU API.""" @@ -764,7 +769,7 @@ class WUndergroundData(object): def _build_url(self, baseurl=_RESOURCE): url = baseurl.format( - self._api_key, "/".join(self._features), self._lang) + self._api_key, '/'.join(sorted(self._features)), self._lang) if self._pws_id: url = url + 'pws:{}'.format(self._pws_id) else: @@ -772,20 +777,20 @@ class WUndergroundData(object): return url + '.json' + @asyncio.coroutine @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): + def async_update(self): """Get the latest data from WUnderground.""" try: - result = requests.get(self._build_url(), timeout=10).json() + with async_timeout.timeout(10, loop=self._hass.loop): + response = yield from self._session.get(self._build_url()) + result = yield from response.json() if "error" in result['response']: - raise ValueError(result['response']["error"] - ["description"]) - else: - self.data = result - return True + raise ValueError(result['response']["error"]["description"]) + self.data = result except ValueError as err: _LOGGER.error("Check WUnderground API %s", err.args) self.data = None - except requests.RequestException as err: + except (asyncio.TimeoutError, aiohttp.ClientError) as err: _LOGGER.error("Error fetching WUnderground data: %s", repr(err)) self.data = None diff --git a/tests/components/sensor/test_wunderground.py b/tests/components/sensor/test_wunderground.py index c1508f49851..27047ba0ad0 100644 --- a/tests/components/sensor/test_wunderground.py +++ b/tests/components/sensor/test_wunderground.py @@ -1,13 +1,14 @@ """The tests for the WUnderground platform.""" -import unittest +import asyncio +import aiohttp + +from pytest import raises from homeassistant.components.sensor import wunderground from homeassistant.const import TEMP_CELSIUS, LENGTH_INCHES, STATE_UNKNOWN from homeassistant.exceptions import PlatformNotReady - -from requests.exceptions import ConnectionError - -from tests.common import get_test_home_assistant +from homeassistant.setup import async_setup_component +from tests.common import load_fixture, assert_setup_component VALID_CONFIG_PWS = { 'platform': 'wunderground', @@ -21,6 +22,7 @@ VALID_CONFIG_PWS = { VALID_CONFIG = { 'platform': 'wunderground', 'api_key': 'foo', + 'lang': 'EN', 'monitored_conditions': [ 'weather', 'feelslike_c', 'alerts', 'elevation', 'location', 'weather_1d_metric', 'precip_1d_in' @@ -37,268 +39,107 @@ INVALID_CONFIG = { ] } -FEELS_LIKE = '40' -WEATHER = 'Clear' -HTTPS_ICON_URL = 'https://icons.wxug.com/i/c/k/clear.gif' -ALERT_MESSAGE = 'This is a test alert message' -ALERT_ICON = 'mdi:alert-circle-outline' -FORECAST_TEXT = 'Mostly Cloudy. Fog overnight.' -PRECIP_IN = 0.03 +URL = 'http://api.wunderground.com/api/foo/alerts/conditions/forecast/lang' \ + ':EN/q/32.87336,-117.22743.json' +PWS_URL = 'http://api.wunderground.com/api/foo/alerts/conditions/' \ + 'lang:EN/q/pws:bar.json' +INVALID_URL = 'http://api.wunderground.com/api/BOB/alerts/conditions/' \ + 'lang:foo/q/pws:bar.json' -def mocked_requests_get(*args, **kwargs): - """Mock requests.get invocations.""" - class MockResponse: - """Class to represent a mocked response.""" +@asyncio.coroutine +def test_setup(hass, aioclient_mock): + """Test that the component is loaded.""" + aioclient_mock.get(URL, text=load_fixture('wunderground-valid.json')) - def __init__(self, json_data, status_code): - """Initialize the mock response class.""" - self.json_data = json_data - self.status_code = status_code - - def json(self): - """Return the json of the response.""" - return self.json_data - - if str(args[0]).startswith('http://api.wunderground.com/api/foo/'): - return MockResponse({ - "response": { - "version": "0.1", - "termsofService": - "http://www.wunderground.com/weather/api/d/terms.html", - "features": { - "conditions": 1, - "alerts": 1, - "forecast": 1, - } - }, "current_observation": { - "image": { - "url": - 'http://icons.wxug.com/graphics/wu2/logo_130x80.png', - "title": "Weather Underground", - "link": "http://www.wunderground.com" - }, - "feelslike_c": FEELS_LIKE, - "weather": WEATHER, - "icon_url": 'http://icons.wxug.com/i/c/k/clear.gif', - "display_location": { - "city": "Holly Springs", - "country": "US", - "full": "Holly Springs, NC" - }, - "observation_location": { - "elevation": "413 ft", - "full": "Twin Lake, Holly Springs, North Carolina" - }, - }, "alerts": [ - { - "type": 'FLO', - "description": "Areal Flood Warning", - "date": "9:36 PM CDT on September 22, 2016", - "expires": "10:00 AM CDT on September 23, 2016", - "message": ALERT_MESSAGE, - }, - - ], "forecast": { - "txt_forecast": { - "date": "22:35 CEST", - "forecastday": [ - { - "period": 0, - "icon_url": - "http://icons.wxug.com/i/c/k/clear.gif", - "title": "Tuesday", - "fcttext": FORECAST_TEXT, - "fcttext_metric": FORECAST_TEXT, - "pop": "0" - }, - ], - }, "simpleforecast": { - "forecastday": [ - { - "date": { - "pretty": "19:00 CEST 4. Duben 2017", - }, - "period": 1, - "high": { - "fahrenheit": "56", - "celsius": "13", - }, - "low": { - "fahrenheit": "43", - "celsius": "6", - }, - "conditions": "Možnost deště", - "icon_url": - "http://icons.wxug.com/i/c/k/chancerain.gif", - "qpf_allday": { - "in": PRECIP_IN, - "mm": 1, - }, - "maxwind": { - "mph": 0, - "kph": 0, - "dir": "", - "degrees": 0, - }, - "avewind": { - "mph": 0, - "kph": 0, - "dir": "severní", - "degrees": 0 - } - }, - ], - }, - }, - }, 200) - else: - return MockResponse({ - "response": { - "version": "0.1", - "termsofService": - "http://www.wunderground.com/weather/api/d/terms.html", - "features": {}, - "error": { - "type": "keynotfound", - "description": "this key does not exist" - } - } - }, 200) + with assert_setup_component(1, 'sensor'): + yield from async_setup_component(hass, 'sensor', + {'sensor': VALID_CONFIG}) -def mocked_requests_get_invalid(*args, **kwargs): - """Mock requests.get invocations invalid data.""" - class MockResponse: - """Class to represent a mocked response.""" +@asyncio.coroutine +def test_setup_pws(hass, aioclient_mock): + """Test that the component is loaded with PWS id.""" + aioclient_mock.get(PWS_URL, text=load_fixture('wunderground-valid.json')) - def __init__(self, json_data, status_code): - """Initialize the mock response class.""" - self.json_data = json_data - self.status_code = status_code - - def json(self): - """Return the json of the response.""" - return self.json_data - - return MockResponse({ - "response": { - "version": "0.1", - "termsofService": - "http://www.wunderground.com/weather/api/d/terms.html", - "features": { - "conditions": 1, - "alerts": 1, - "forecast": 1, - } - }, "current_observation": { - "image": { - "url": - 'http://icons.wxug.com/graphics/wu2/logo_130x80.png', - "title": "Weather Underground", - "link": "http://www.wunderground.com" - }, - }, - }, 200) + with assert_setup_component(1, 'sensor'): + yield from async_setup_component(hass, 'sensor', + {'sensor': VALID_CONFIG_PWS}) -class TestWundergroundSetup(unittest.TestCase): - """Test the WUnderground platform.""" +@asyncio.coroutine +def test_setup_invalid(hass, aioclient_mock): + """Test that the component is not loaded with invalid config.""" + aioclient_mock.get(INVALID_URL, + text=load_fixture('wunderground-error.json')) - # pylint: disable=invalid-name - DEVICES = [] + with assert_setup_component(0, 'sensor'): + yield from async_setup_component(hass, 'sensor', + {'sensor': INVALID_CONFIG}) - def add_devices(self, devices): - """Mock add devices.""" - for device in devices: - self.DEVICES.append(device) - def setUp(self): - """Initialize values for this testcase class.""" - self.DEVICES = [] - self.hass = get_test_home_assistant() - self.key = 'foo' - self.config = VALID_CONFIG_PWS - self.lat = 37.8267 - self.lon = -122.423 - self.hass.config.latitude = self.lat - self.hass.config.longitude = self.lon +@asyncio.coroutine +def test_sensor(hass, aioclient_mock): + """Test the WUnderground sensor class and methods.""" + aioclient_mock.get(URL, text=load_fixture('wunderground-valid.json')) - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that was started.""" - self.hass.stop() + yield from async_setup_component(hass, 'sensor', {'sensor': VALID_CONFIG}) - @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) - def test_setup(self, req_mock): - """Test that the component is loaded if passed in PWS Id.""" - self.assertTrue( - wunderground.setup_platform(self.hass, VALID_CONFIG_PWS, - self.add_devices, None)) - self.assertTrue( - wunderground.setup_platform(self.hass, VALID_CONFIG, - self.add_devices, None)) + state = hass.states.get('sensor.pws_weather') + assert state.state == 'Clear' + assert state.name == "Weather Summary" + assert 'unit_of_measurement' not in state.attributes + assert state.attributes['entity_picture'] == \ + 'https://icons.wxug.com/i/c/k/clear.gif' - with self.assertRaises(PlatformNotReady): - wunderground.setup_platform(self.hass, INVALID_CONFIG, - self.add_devices, None) + state = hass.states.get('sensor.pws_alerts') + assert state.state == '1' + assert state.name == 'Alerts' + assert state.attributes['Message'] == \ + "This is a test alert message" + assert state.attributes['icon'] == 'mdi:alert-circle-outline' + assert 'entity_picture' not in state.attributes - @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) - def test_sensor(self, req_mock): - """Test the WUnderground sensor class and methods.""" - wunderground.setup_platform(self.hass, VALID_CONFIG, self.add_devices, - None) - for device in self.DEVICES: - device.update() - entity_id = device.entity_id - friendly_name = device.name - self.assertTrue(entity_id.startswith('sensor.pws_')) - if entity_id == 'sensor.pws_weather': - self.assertEqual(HTTPS_ICON_URL, device.entity_picture) - self.assertEqual(WEATHER, device.state) - self.assertIsNone(device.unit_of_measurement) - self.assertEqual("Weather Summary", friendly_name) - elif entity_id == 'sensor.pws_alerts': - self.assertEqual(1, device.state) - self.assertEqual(ALERT_MESSAGE, - device.device_state_attributes['Message']) - self.assertEqual(ALERT_ICON, device.icon) - self.assertIsNone(device.entity_picture) - self.assertEqual('Alerts', friendly_name) - elif entity_id == 'sensor.pws_location': - self.assertEqual('Holly Springs, NC', device.state) - self.assertEqual('Location', friendly_name) - elif entity_id == 'sensor.pws_elevation': - self.assertEqual('413', device.state) - self.assertEqual('Elevation', friendly_name) - elif entity_id == 'sensor.pws_feelslike_c': - self.assertIsNone(device.entity_picture) - self.assertEqual(FEELS_LIKE, device.state) - self.assertEqual(TEMP_CELSIUS, device.unit_of_measurement) - self.assertEqual("Feels Like", friendly_name) - elif entity_id == 'sensor.pws_weather_1d_metric': - self.assertEqual(FORECAST_TEXT, device.state) - self.assertEqual('Tuesday', friendly_name) - else: - self.assertEqual(entity_id, 'sensor.pws_precip_1d_in') - self.assertEqual(PRECIP_IN, device.state) - self.assertEqual(LENGTH_INCHES, device.unit_of_measurement) - self.assertEqual('Precipitation Intensity Today', - friendly_name) + state = hass.states.get('sensor.pws_location') + assert state.state == "Holly Springs, NC" + assert state.name == 'Location' - @unittest.mock.patch('requests.get', - side_effect=ConnectionError('test exception')) - def test_connect_failed(self, req_mock): - """Test the WUnderground connection error.""" - with self.assertRaises(PlatformNotReady): - wunderground.setup_platform(self.hass, VALID_CONFIG, - self.add_devices, None) + state = hass.states.get('sensor.pws_elevation') + assert state.state == '413' + assert state.name == 'Elevation' - @unittest.mock.patch('requests.get', - side_effect=mocked_requests_get_invalid) - def test_invalid_data(self, req_mock): - """Test the WUnderground invalid data.""" - wunderground.setup_platform(self.hass, VALID_CONFIG_PWS, - self.add_devices, None) - for device in self.DEVICES: - device.update() - self.assertEqual(STATE_UNKNOWN, device.state) + state = hass.states.get('sensor.pws_feelslike_c') + assert state.state == '40' + assert state.name == "Feels Like" + assert 'entity_picture' not in state.attributes + assert state.attributes['unit_of_measurement'] == TEMP_CELSIUS + + state = hass.states.get('sensor.pws_weather_1d_metric') + assert state.state == "Mostly Cloudy. Fog overnight." + assert state.name == 'Tuesday' + + state = hass.states.get('sensor.pws_precip_1d_in') + assert state.state == '0.03' + assert state.name == "Precipitation Intensity Today" + assert state.attributes['unit_of_measurement'] == LENGTH_INCHES + + +@asyncio.coroutine +def test_connect_failed(hass, aioclient_mock): + """Test the WUnderground connection error.""" + aioclient_mock.get(URL, exc=aiohttp.ClientError()) + with raises(PlatformNotReady): + yield from wunderground.async_setup_platform(hass, VALID_CONFIG, + lambda _: None) + + +@asyncio.coroutine +def test_invalid_data(hass, aioclient_mock): + """Test the WUnderground invalid data.""" + aioclient_mock.get(URL, text=load_fixture('wunderground-invalid.json')) + + yield from async_setup_component(hass, 'sensor', {'sensor': VALID_CONFIG}) + + for condition in VALID_CONFIG['monitored_conditions']: + state = hass.states.get('sensor.pws_' + condition) + assert state.state == STATE_UNKNOWN diff --git a/tests/fixtures/wunderground-error.json b/tests/fixtures/wunderground-error.json new file mode 100644 index 00000000000..264ecbf8cd6 --- /dev/null +++ b/tests/fixtures/wunderground-error.json @@ -0,0 +1,11 @@ +{ + "response": { + "version": "0.1", + "termsofService": "http://www.wunderground.com/weather/api/d/terms.html", + "features": {}, + "error": { + "type": "keynotfound", + "description": "this key does not exist" + } + } +} diff --git a/tests/fixtures/wunderground-invalid.json b/tests/fixtures/wunderground-invalid.json new file mode 100644 index 00000000000..59661c6694d --- /dev/null +++ b/tests/fixtures/wunderground-invalid.json @@ -0,0 +1,18 @@ +{ + "response": { + "version": "0.1", + "termsofService": "http://www.wunderground.com/weather/api/d/terms.html", + "features": { + "conditions": 1, + "alerts": 1, + "forecast": 1 + } + }, + "current_observation": { + "image": { + "url": "http://icons.wxug.com/graphics/wu2/logo_130x80.png", + "title": "Weather Underground", + "link": "http://www.wunderground.com" + } + } +} diff --git a/tests/fixtures/wunderground-valid.json b/tests/fixtures/wunderground-valid.json new file mode 100644 index 00000000000..7ac1081cb4e --- /dev/null +++ b/tests/fixtures/wunderground-valid.json @@ -0,0 +1,90 @@ +{ + "response": { + "version": "0.1", + "termsofService": "http://www.wunderground.com/weather/api/d/terms.html", + "features": { + "conditions": 1, + "alerts": 1, + "forecast": 1 + } + }, + "current_observation": { + "image": { + "url": "http://icons.wxug.com/graphics/wu2/logo_130x80.png", + "title": "Weather Underground", + "link": "http://www.wunderground.com" + }, + "feelslike_c": "40", + "weather": "Clear", + "icon_url": "http://icons.wxug.com/i/c/k/clear.gif", + "display_location": { + "city": "Holly Springs", + "country": "US", + "full": "Holly Springs, NC" + }, + "observation_location": { + "elevation": "413 ft", + "full": "Twin Lake, Holly Springs, North Carolina" + } + }, + "alerts": [ + { + "type": "FLO", + "description": "Areal Flood Warning", + "date": "9:36 PM CDT on September 22, 2016", + "expires": "10:00 AM CDT on September 23, 2016", + "message": "This is a test alert message" + } + ], + "forecast": { + "txt_forecast": { + "date": "22:35 CEST", + "forecastday": [ + { + "period": 0, + "icon_url": "http://icons.wxug.com/i/c/k/clear.gif", + "title": "Tuesday", + "fcttext": "Mostly Cloudy. Fog overnight.", + "fcttext_metric": "Mostly Cloudy. Fog overnight.", + "pop": "0" + } + ] + }, + "simpleforecast": { + "forecastday": [ + { + "date": { + "pretty": "19:00 CEST 4. Duben 2017" + }, + "period": 1, + "high": { + "fahrenheit": "56", + "celsius": "13" + }, + "low": { + "fahrenheit": "43", + "celsius": "6" + }, + "conditions": "Mo\u017enost de\u0161t\u011b", + "icon_url": "http://icons.wxug.com/i/c/k/chancerain.gif", + "qpf_allday": { + "in": 0.03, + "mm": 1 + }, + "maxwind": { + "mph": 0, + "kph": 0, + "dir": "", + "degrees": 0 + }, + "avewind": { + "mph": 0, + "kph": 0, + "dir": "severn\u00ed", + "degrees": 0 + } + } + ] + } + } +} From 26340fd9df274b3a63b0a3ecbafb02dac37513ab Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 16 Feb 2018 15:06:46 -0800 Subject: [PATCH 090/173] Bump frontend to 20180216.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 7fa1634778d..af3c720c62c 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20180211.0', 'user-agents==1.1.0'] +REQUIREMENTS = ['home-assistant-frontend==20180216.0', 'user-agents==1.1.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 24156b517a8..c226adc6ae8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -350,7 +350,7 @@ hipnotify==1.0.8 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180211.0 +home-assistant-frontend==20180216.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4155fea78be..ab498e1408f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -72,7 +72,7 @@ hbmqtt==0.9.1 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180211.0 +home-assistant-frontend==20180216.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From dd7bffc28cb41a7dea4e0dec76defaa629bb1bfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20N=C3=B8rager=20S=C3=B8rensen?= <6843486+fattdev@users.noreply.github.com> Date: Sat, 17 Feb 2018 00:09:20 +0100 Subject: [PATCH 091/173] Add the Xiaomi TV platform. (#12359) * Added the Xiaomi TV platform. * Implemented a more efficient default name. * Fixed a few style errors that slipped past the eye. * Indicate that state is assumed. --- .coveragerc | 1 + CODEOWNERS | 1 + .../components/media_player/xiaomi_tv.py | 112 ++++++++++++++++++ requirements_all.txt | 3 + 4 files changed, 117 insertions(+) create mode 100644 homeassistant/components/media_player/xiaomi_tv.py diff --git a/.coveragerc b/.coveragerc index 4b19519038f..ec41cf05b7c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -462,6 +462,7 @@ omit = homeassistant/components/media_player/vizio.py homeassistant/components/media_player/vlc.py homeassistant/components/media_player/volumio.py + homeassistant/components/media_player/xiaomi_tv.py homeassistant/components/media_player/yamaha.py homeassistant/components/media_player/yamaha_musiccast.py homeassistant/components/media_player/ziggo_mediabox_xl.py diff --git a/CODEOWNERS b/CODEOWNERS index af887118923..e7811d7fdeb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -56,6 +56,7 @@ homeassistant/components/light/yeelight.py @rytilahti homeassistant/components/media_player/kodi.py @armills homeassistant/components/media_player/mediaroom.py @dgomes homeassistant/components/media_player/monoprice.py @etsinko +homeassistant/components/media_player/xiaomi_tv.py @fattdev homeassistant/components/media_player/yamaha_musiccast.py @jalmeroth homeassistant/components/plant.py @ChristianKuehnel homeassistant/components/sensor/airvisual.py @bachya diff --git a/homeassistant/components/media_player/xiaomi_tv.py b/homeassistant/components/media_player/xiaomi_tv.py new file mode 100644 index 00000000000..be40bf7d010 --- /dev/null +++ b/homeassistant/components/media_player/xiaomi_tv.py @@ -0,0 +1,112 @@ +""" +Add support for the Xiaomi TVs. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/xiaomi_tv/ +""" + +import logging +import voluptuous as vol +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON) +from homeassistant.components.media_player import ( + SUPPORT_TURN_ON, SUPPORT_TURN_OFF, MediaPlayerDevice, PLATFORM_SCHEMA, + SUPPORT_VOLUME_STEP) + +REQUIREMENTS = ['pymitv==1.0.0'] + +DEFAULT_NAME = "Xiaomi TV" + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_XIAOMI_TV = SUPPORT_VOLUME_STEP | SUPPORT_TURN_ON | \ + SUPPORT_TURN_OFF + +# No host is needed for configuration, however it can be set. +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Xiaomi TV platform.""" + from pymitv import Discover + + # If a hostname is set. Discovery is skipped. + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + + if host is not None: + # Check if there's a valid TV at the IP address. + if not Discover().checkIp(host): + _LOGGER.error( + "Could not find Xiaomi TV with specified IP: %s", host + ) + else: + # Register TV with Home Assistant. + add_devices([XiaomiTV(host, name)]) + else: + # Otherwise, discover TVs on network. + add_devices(XiaomiTV(tv, DEFAULT_NAME) for tv in Discover().scan()) + + +class XiaomiTV(MediaPlayerDevice): + """Represent the Xiaomi TV for Home Assistant.""" + + def __init__(self, ip, name): + """Receive IP address and name to construct class.""" + # Import pymitv library. + from pymitv import TV + + # Initialize the Xiaomi TV. + self._tv = TV(ip) + # Default name value, only to be overridden by user. + self._name = name + self._state = STATE_OFF + + @property + def name(self): + """Return the display name of this TV.""" + return self._name + + @property + def state(self): + """Return _state variable, containing the appropriate constant.""" + return self._state + + @property + def assumed_state(self): + """Indicate that state is assumed.""" + return True + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_XIAOMI_TV + + def turn_off(self): + """ + Instruct the TV to turn sleep. + + This is done instead of turning off, + because the TV won't accept any input when turned off. Thus, the user + would be unable to turn the TV back on, unless it's done manually. + """ + self._tv.sleep() + + self._state = STATE_OFF + + def turn_on(self): + """Wake the TV back up from sleep.""" + self._tv.wake() + + self._state = STATE_ON + + def volume_up(self): + """Increase volume by one.""" + self._tv.volume_up() + + def volume_down(self): + """Decrease volume by one.""" + self._tv.volume_down() diff --git a/requirements_all.txt b/requirements_all.txt index c226adc6ae8..5fbc448c015 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -787,6 +787,9 @@ pymailgunner==1.4 # homeassistant.components.media_player.mediaroom pymediaroom==0.5 +# homeassistant.components.media_player.xiaomi_tv +pymitv==1.0.0 + # homeassistant.components.mochad pymochad==0.2.0 From e4ef6b91d6552bb6f1d49b579f94a161944ce1a4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 16 Feb 2018 23:24:12 -0800 Subject: [PATCH 092/173] Typo --- homeassistant/components/config/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 7f2041249e0..16b455684f3 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -15,7 +15,7 @@ DOMAIN = 'config' DEPENDENCIES = ['http'] SECTIONS = ('core', 'customize', 'group', 'hassbian', 'automation', 'script') ON_DEMAND = ('zwave',) -FEATURE_FLAGS = ('hidden_entries',) +FEATURE_FLAGS = ('config_entries',) @asyncio.coroutine From 3fd61d8f456f14796e41dc05c77a7ab60861eb84 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 17 Feb 2018 01:29:14 -0800 Subject: [PATCH 093/173] Update voluputous (#12463) * Update voluputous * Fix http config * Fix optional with default=None * Optional, default=none * Fix defaults in voluptuous schemas * Fix tests * Fix update error * Lint --- homeassistant/components/alert.py | 4 ++-- homeassistant/components/alexa/__init__.py | 5 +--- homeassistant/components/amcrest.py | 2 +- homeassistant/components/android_ip_webcam.py | 12 +++++----- homeassistant/components/apple_tv.py | 2 +- .../components/binary_sensor/bloomsky.py | 2 +- .../components/binary_sensor/hikvision.py | 2 +- homeassistant/components/binary_sensor/ihc.py | 6 ++--- homeassistant/components/binary_sensor/knx.py | 2 +- .../components/binary_sensor/netatmo.py | 4 ++-- .../components/binary_sensor/octoprint.py | 2 +- .../components/binary_sensor/rfxtrx.py | 22 ++++++++--------- .../components/binary_sensor/rpi_pfio.py | 4 ++-- .../components/binary_sensor/workday.py | 2 +- homeassistant/components/camera/xeoma.py | 4 ++-- homeassistant/components/climate/daikin.py | 2 +- homeassistant/components/climate/knx.py | 10 ++++---- homeassistant/components/cloud/__init__.py | 5 +--- homeassistant/components/cover/mqtt.py | 10 ++++---- .../components/device_tracker/__init__.py | 3 +-- .../components/device_tracker/automatic.py | 5 ++-- .../components/device_tracker/mqtt_json.py | 4 ++-- .../components/device_tracker/nmap_tracker.py | 2 +- .../components/device_tracker/tomato.py | 10 ++++---- .../components/device_tracker/unifi.py | 4 +--- homeassistant/components/history.py | 8 +++---- .../components/homematic/__init__.py | 4 ++-- homeassistant/components/http/__init__.py | 17 ++++++------- homeassistant/components/ihc/__init__.py | 2 +- .../components/image_processing/opencv.py | 6 ++--- .../image_processing/seven_segments.py | 4 ++-- homeassistant/components/insteon_plm.py | 3 +-- homeassistant/components/light/flux_led.py | 4 ++-- homeassistant/components/light/lifx_legacy.py | 4 ++-- homeassistant/components/light/template.py | 14 +++++------ .../components/media_player/clementine.py | 2 +- .../components/media_player/denonavr.py | 6 ++--- homeassistant/components/media_player/emby.py | 2 +- homeassistant/components/media_player/kodi.py | 2 +- .../components/media_player/lg_netcast.py | 2 +- homeassistant/components/notify/apns.py | 2 +- homeassistant/components/notify/rest.py | 14 ++++------- homeassistant/components/recorder/__init__.py | 10 ++++---- homeassistant/components/remote/harmony.py | 2 +- homeassistant/components/rflink.py | 4 ++-- homeassistant/components/sensor/bom.py | 2 +- homeassistant/components/sensor/daikin.py | 4 ++-- homeassistant/components/sensor/dsmr.py | 4 ++-- .../components/sensor/dwd_weather_warnings.py | 5 ++-- homeassistant/components/sensor/envirophat.py | 2 +- homeassistant/components/sensor/fail2ban.py | 7 +++--- .../components/sensor/google_wifi.py | 5 ++-- homeassistant/components/sensor/gtfs.py | 3 +-- .../components/sensor/history_stats.py | 14 ++++------- homeassistant/components/sensor/hp_ilo.py | 9 +++---- .../components/sensor/irish_rail_transport.py | 6 ++--- homeassistant/components/sensor/loopenergy.py | 8 +++---- homeassistant/components/sensor/lyft.py | 2 +- homeassistant/components/sensor/miflora.py | 2 +- homeassistant/components/sensor/nut.py | 6 ++--- homeassistant/components/sensor/octoprint.py | 2 +- .../components/sensor/openweathermap.py | 2 +- homeassistant/components/sensor/pi_hole.py | 5 ++-- homeassistant/components/sensor/pilight.py | 2 +- homeassistant/components/sensor/qnap.py | 24 +++++-------------- homeassistant/components/sensor/rflink.py | 4 ++-- homeassistant/components/sensor/sensehat.py | 2 +- .../components/sensor/synologydsm.py | 16 ++++--------- .../components/sensor/systemmonitor.py | 2 +- homeassistant/components/sensor/vultr.py | 5 ++-- homeassistant/components/sensor/yweather.py | 2 +- homeassistant/components/sensor/zabbix.py | 4 ++-- homeassistant/components/statsd.py | 2 +- homeassistant/components/switch/broadlink.py | 4 ++-- homeassistant/components/switch/modbus.py | 6 ++--- homeassistant/components/switch/raspihats.py | 4 ++-- homeassistant/components/switch/rest.py | 5 ++-- homeassistant/components/switch/rpi_pfio.py | 4 ++-- homeassistant/components/weather/yweather.py | 2 +- homeassistant/components/websocket_api.py | 4 ++-- homeassistant/components/xiaomi_aqara.py | 6 ++--- homeassistant/components/zha/__init__.py | 3 +-- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- setup.py | 2 +- tests/components/test_history.py | 8 +++---- tests/components/test_rflink.py | 12 ++++------ tests/scripts/test_check_config.py | 5 ++-- 88 files changed, 207 insertions(+), 255 deletions(-) diff --git a/homeassistant/components/alert.py b/homeassistant/components/alert.py index eb941e22877..9d47e4bd322 100644 --- a/homeassistant/components/alert.py +++ b/homeassistant/components/alert.py @@ -34,7 +34,7 @@ DEFAULT_SKIP_FIRST = False ALERT_SCHEMA = vol.Schema({ vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_DONE_MESSAGE, default=None): cv.string, + vol.Optional(CONF_DONE_MESSAGE): cv.string, vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_STATE, default=STATE_ON): cv.string, vol.Required(CONF_REPEAT): vol.All(cv.ensure_list, [vol.Coerce(float)]), @@ -121,7 +121,7 @@ def async_setup(hass, config): # Setup alerts for entity_id, alert in alerts.items(): entity = Alert(hass, entity_id, - alert[CONF_NAME], alert[CONF_DONE_MESSAGE], + alert[CONF_NAME], alert.get(CONF_DONE_MESSAGE), alert[CONF_ENTITY_ID], alert[CONF_STATE], alert[CONF_REPEAT], alert[CONF_SKIP_FIRST], alert[CONF_NOTIFIERS], alert[CONF_CAN_ACK]) diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py index b683f5cfc7c..d120270650f 100644 --- a/homeassistant/components/alexa/__init__.py +++ b/homeassistant/components/alexa/__init__.py @@ -31,10 +31,7 @@ ALEXA_ENTITY_SCHEMA = vol.Schema({ }) SMART_HOME_SCHEMA = vol.Schema({ - vol.Optional( - CONF_FILTER, - default=lambda: entityfilter.generate_filter([], [], [], []) - ): entityfilter.FILTER_SCHEMA, + vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA, vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ALEXA_ENTITY_SCHEMA} }) diff --git a/homeassistant/components/amcrest.py b/homeassistant/components/amcrest.py index 9205846462f..b91f1fae565 100644 --- a/homeassistant/components/amcrest.py +++ b/homeassistant/components/amcrest.py @@ -79,7 +79,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string, vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): cv.time_period, - vol.Optional(CONF_SENSORS, default=None): + vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [vol.In(SENSORS)]), })]) }, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/android_ip_webcam.py b/homeassistant/components/android_ip_webcam.py index 5fbd5a764e9..13fa64438d3 100644 --- a/homeassistant/components/android_ip_webcam.py +++ b/homeassistant/components/android_ip_webcam.py @@ -140,11 +140,11 @@ CONFIG_SCHEMA = vol.Schema({ cv.time_period, vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, - vol.Optional(CONF_SWITCHES, default=None): + vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [vol.In(SWITCHES)]), - vol.Optional(CONF_SENSORS, default=None): + vol.Optional(CONF_SENSORS): vol.All(cv.ensure_list, [vol.In(SENSORS)]), - vol.Optional(CONF_MOTION_SENSOR, default=None): cv.boolean, + vol.Optional(CONF_MOTION_SENSOR): cv.boolean, })]) }, extra=vol.ALLOW_EXTRA) @@ -165,9 +165,9 @@ def async_setup(hass, config): password = cam_config.get(CONF_PASSWORD) name = cam_config[CONF_NAME] interval = cam_config[CONF_SCAN_INTERVAL] - switches = cam_config[CONF_SWITCHES] - sensors = cam_config[CONF_SENSORS] - motion = cam_config[CONF_MOTION_SENSOR] + switches = cam_config.get(CONF_SWITCHES) + sensors = cam_config.get(CONF_SENSORS) + motion = cam_config.get(CONF_MOTION_SENSOR) # Init ip webcam cam = PyDroidIPCam( diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index 230b0ea8a1b..a9bd5c9c8bc 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -60,7 +60,7 @@ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.All(ensure_list, [vol.Schema({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_LOGIN_ID): cv.string, - vol.Optional(CONF_CREDENTIALS, default=None): cv.string, + vol.Optional(CONF_CREDENTIALS): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_START_OFF, default=False): cv.boolean, })]) diff --git a/homeassistant/components/binary_sensor/bloomsky.py b/homeassistant/components/binary_sensor/bloomsky.py index 1d0849b255e..53f148fe97f 100644 --- a/homeassistant/components/binary_sensor/bloomsky.py +++ b/homeassistant/components/binary_sensor/bloomsky.py @@ -24,7 +24,7 @@ SENSOR_TYPES = { } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES): + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), }) diff --git a/homeassistant/components/binary_sensor/hikvision.py b/homeassistant/components/binary_sensor/hikvision.py index ec64bdf07b8..36ec8b7b61a 100644 --- a/homeassistant/components/binary_sensor/hikvision.py +++ b/homeassistant/components/binary_sensor/hikvision.py @@ -56,7 +56,7 @@ CUSTOMIZE_SCHEMA = vol.Schema({ }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=None): cv.string, + vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_SSL, default=False): cv.boolean, diff --git a/homeassistant/components/binary_sensor/ihc.py b/homeassistant/components/binary_sensor/ihc.py index 97de176753f..96efa6e6c19 100644 --- a/homeassistant/components/binary_sensor/ihc.py +++ b/homeassistant/components/binary_sensor/ihc.py @@ -25,7 +25,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.All({ vol.Required(CONF_ID): cv.positive_int, vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_TYPE, default=None): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_TYPE): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_INVERTING, default=False): cv.boolean, }, validate_name) ]) @@ -43,7 +43,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): product_cfg = device['product_cfg'] product = device['product'] sensor = IHCBinarySensor(ihc_controller, name, ihc_id, info, - product_cfg[CONF_TYPE], + product_cfg.get(CONF_TYPE), product_cfg[CONF_INVERTING], product) devices.append(sensor) @@ -52,7 +52,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for sensor_cfg in binary_sensors: ihc_id = sensor_cfg[CONF_ID] name = sensor_cfg[CONF_NAME] - sensor_type = sensor_cfg[CONF_TYPE] + sensor_type = sensor_cfg.get(CONF_TYPE) inverting = sensor_cfg[CONF_INVERTING] sensor = IHCBinarySensor(ihc_controller, name, ihc_id, info, sensor_type, inverting) diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py index 82463264f88..d63a5a5b400 100644 --- a/homeassistant/components/binary_sensor/knx.py +++ b/homeassistant/components/binary_sensor/knx.py @@ -49,7 +49,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_DEVICE_CLASS): cv.string, vol.Optional(CONF_SIGNIFICANT_BIT, default=CONF_DEFAULT_SIGNIFICANT_BIT): cv.positive_int, - vol.Optional(CONF_AUTOMATION, default=None): AUTOMATIONS_SCHEMA, + vol.Optional(CONF_AUTOMATION): AUTOMATIONS_SCHEMA, }) diff --git a/homeassistant/components/binary_sensor/netatmo.py b/homeassistant/components/binary_sensor/netatmo.py index dd7e0ee8d50..7997e4e60db 100644 --- a/homeassistant/components/binary_sensor/netatmo.py +++ b/homeassistant/components/binary_sensor/netatmo.py @@ -50,10 +50,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_CAMERAS, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_HOME): cv.string, - vol.Optional(CONF_PRESENCE_SENSORS, default=PRESENCE_SENSOR_TYPES): + vol.Optional(CONF_PRESENCE_SENSORS, default=list(PRESENCE_SENSOR_TYPES)): vol.All(cv.ensure_list, [vol.In(PRESENCE_SENSOR_TYPES)]), vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - vol.Optional(CONF_WELCOME_SENSORS, default=WELCOME_SENSOR_TYPES): + vol.Optional(CONF_WELCOME_SENSORS, default=list(WELCOME_SENSOR_TYPES)): vol.All(cv.ensure_list, [vol.In(WELCOME_SENSOR_TYPES)]), }) diff --git a/homeassistant/components/binary_sensor/octoprint.py b/homeassistant/components/binary_sensor/octoprint.py index 129b5250431..265fcec66fa 100644 --- a/homeassistant/components/binary_sensor/octoprint.py +++ b/homeassistant/components/binary_sensor/octoprint.py @@ -27,7 +27,7 @@ SENSOR_TYPES = { } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES): + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) diff --git a/homeassistant/components/binary_sensor/rfxtrx.py b/homeassistant/components/binary_sensor/rfxtrx.py index 2cc0aee2c7b..aedfc3364db 100644 --- a/homeassistant/components/binary_sensor/rfxtrx.py +++ b/homeassistant/components/binary_sensor/rfxtrx.py @@ -28,15 +28,15 @@ DEPENDENCIES = ['rfxtrx'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_DEVICES, default={}): { cv.string: vol.Schema({ - vol.Optional(CONF_NAME, default=None): cv.string, - vol.Optional(CONF_DEVICE_CLASS, default=None): + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, - vol.Optional(CONF_OFF_DELAY, default=None): + vol.Optional(CONF_OFF_DELAY): vol.Any(cv.time_period, cv.positive_timedelta), - vol.Optional(CONF_DATA_BITS, default=None): cv.positive_int, - vol.Optional(CONF_COMMAND_ON, default=None): cv.byte, - vol.Optional(CONF_COMMAND_OFF, default=None): cv.byte + vol.Optional(CONF_DATA_BITS): cv.positive_int, + vol.Optional(CONF_COMMAND_ON): cv.byte, + vol.Optional(CONF_COMMAND_OFF): cv.byte }) }, vol.Optional(CONF_AUTOMATIC_ADD, default=False): cv.boolean, @@ -48,7 +48,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): import RFXtrx as rfxtrxmod sensors = [] - for packet_id, entity in config['devices'].items(): + for packet_id, entity in config[CONF_DEVICES].items(): event = rfxtrx.get_rfx_object(packet_id) device_id = slugify(event.device.id_string.lower()) @@ -64,10 +64,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): entity[ATTR_NAME], entity[CONF_DEVICE_CLASS]) device = RfxtrxBinarySensor( - event, entity[ATTR_NAME], entity[CONF_DEVICE_CLASS], - entity[CONF_FIRE_EVENT], entity[CONF_OFF_DELAY], - entity[CONF_DATA_BITS], entity[CONF_COMMAND_ON], - entity[CONF_COMMAND_OFF]) + event, entity.get(CONF_NAME), entity.get(CONF_DEVICE_CLASS), + entity[CONF_FIRE_EVENT], entity.get(CONF_OFF_DELAY), + entity.get(CONF_DATA_BITS), entity.get(CONF_COMMAND_ON), + entity.get(CONF_COMMAND_OFF)) device.hass = hass sensors.append(device) rfxtrx.RFX_DEVICES[device_id] = device diff --git a/homeassistant/components/binary_sensor/rpi_pfio.py b/homeassistant/components/binary_sensor/rpi_pfio.py index 7acbadf873a..1abfa25c82b 100644 --- a/homeassistant/components/binary_sensor/rpi_pfio.py +++ b/homeassistant/components/binary_sensor/rpi_pfio.py @@ -26,7 +26,7 @@ DEFAULT_SETTLE_TIME = 20 DEPENDENCIES = ['rpi_pfio'] PORT_SCHEMA = vol.Schema({ - vol.Optional(CONF_NAME, default=None): cv.string, + vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_SETTLE_TIME, default=DEFAULT_SETTLE_TIME): cv.positive_int, vol.Optional(CONF_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean, @@ -44,7 +44,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): binary_sensors = [] ports = config.get(CONF_PORTS) for port, port_entity in ports.items(): - name = port_entity[CONF_NAME] + name = port_entity.get(CONF_NAME) settle_time = port_entity[CONF_SETTLE_TIME] / 1000 invert_logic = port_entity[CONF_INVERT_LOGIC] diff --git a/homeassistant/components/binary_sensor/workday.py b/homeassistant/components/binary_sensor/workday.py index af814cfd464..58599d3d3de 100644 --- a/homeassistant/components/binary_sensor/workday.py +++ b/homeassistant/components/binary_sensor/workday.py @@ -47,7 +47,7 @@ DEFAULT_OFFSET = 0 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_COUNTRY): vol.In(ALL_COUNTRIES), - vol.Optional(CONF_PROVINCE, default=None): cv.string, + vol.Optional(CONF_PROVINCE): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_OFFSET, default=DEFAULT_OFFSET): vol.Coerce(int), vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS): diff --git a/homeassistant/components/camera/xeoma.py b/homeassistant/components/camera/xeoma.py index 23342b94cdc..5836a9c94dc 100644 --- a/homeassistant/components/camera/xeoma.py +++ b/homeassistant/components/camera/xeoma.py @@ -33,7 +33,7 @@ CAMERAS_SCHEMA = vol.Schema({ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_CAMERAS, default={}): + vol.Optional(CONF_CAMERAS): vol.Schema(vol.All(cv.ensure_list, [CAMERAS_SCHEMA])), vol.Optional(CONF_NEW_VERSION, default=True): cv.boolean, vol.Optional(CONF_PASSWORD): cv.string, @@ -67,7 +67,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): for image_name, username, pw in discovered_image_names ] - for cam in config[CONF_CAMERAS]: + for cam in config.get(CONF_CAMERAS, []): # https://github.com/PyCQA/pylint/issues/1830 # pylint: disable=stop-iteration-return camera = next( diff --git a/homeassistant/components/climate/daikin.py b/homeassistant/components/climate/daikin.py index 531abd4b581..2c49b25a39d 100644 --- a/homeassistant/components/climate/daikin.py +++ b/homeassistant/components/climate/daikin.py @@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=None): cv.string, + vol.Optional(CONF_NAME): cv.string, }) HA_STATE_TO_DAIKIN = { diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index e9601f25564..d487d6a7a64 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -47,9 +47,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SETPOINT_SHIFT_STEP, default=DEFAULT_SETPOINT_SHIFT_STEP): vol.All( float, vol.Range(min=0, max=2)), - vol.Optional(CONF_SETPOINT_SHIFT_MAX, default=DEFAULT_SETPOINT_SHIFT_MAX): + vol.Optional(CONF_SETPOINT_SHIFT_MAX): vol.All(int, vol.Range(min=-32, max=0)), - vol.Optional(CONF_SETPOINT_SHIFT_MIN, default=DEFAULT_SETPOINT_SHIFT_MIN): + vol.Optional(CONF_SETPOINT_SHIFT_MIN): vol.All(int, vol.Range(min=0, max=32)), vol.Optional(CONF_OPERATION_MODE_ADDRESS): cv.string, vol.Optional(CONF_OPERATION_MODE_STATE_ADDRESS): cv.string, @@ -95,8 +95,10 @@ def async_add_devices_config(hass, config, async_add_devices): group_address_setpoint_shift_state=config.get( CONF_SETPOINT_SHIFT_STATE_ADDRESS), setpoint_shift_step=config.get(CONF_SETPOINT_SHIFT_STEP), - setpoint_shift_max=config.get(CONF_SETPOINT_SHIFT_MAX), - setpoint_shift_min=config.get(CONF_SETPOINT_SHIFT_MIN), + setpoint_shift_max=config.get(CONF_SETPOINT_SHIFT_MAX, + DEFAULT_SETPOINT_SHIFT_MAX), + setpoint_shift_min=config.get(CONF_SETPOINT_SHIFT_MIN, + DEFAULT_SETPOINT_SHIFT_MIN), group_address_operation_mode=config.get(CONF_OPERATION_MODE_ADDRESS), group_address_operation_mode_state=config.get( CONF_OPERATION_MODE_STATE_ADDRESS), diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index e17c9ee1b1e..7de4f5b57f8 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -56,10 +56,7 @@ GOOGLE_ENTITY_SCHEMA = vol.Schema({ }) ASSISTANT_SCHEMA = vol.Schema({ - vol.Optional( - CONF_FILTER, - default=lambda: entityfilter.generate_filter([], [], [], []) - ): entityfilter.FILTER_SCHEMA, + vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA, }) ALEXA_SCHEMA = ASSISTANT_SCHEMA.extend({ diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index e55072dbc73..0f31d3a9fe0 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -65,9 +65,9 @@ TILT_FEATURES = (SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_STOP_TILT | SUPPORT_SET_TILT_POSITION) PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_COMMAND_TOPIC, default=None): valid_publish_topic, - vol.Optional(CONF_POSITION_TOPIC, default=None): valid_publish_topic, - vol.Optional(CONF_SET_POSITION_TEMPLATE, default=None): cv.template, + vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_POSITION_TOPIC): valid_publish_topic, + vol.Optional(CONF_SET_POSITION_TEMPLATE): cv.template, vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, @@ -78,8 +78,8 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_STATE_OPEN, default=STATE_OPEN): cv.string, vol.Optional(CONF_STATE_CLOSED, default=STATE_CLOSED): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_TILT_COMMAND_TOPIC, default=None): valid_publish_topic, - vol.Optional(CONF_TILT_STATUS_TOPIC, default=None): valid_subscribe_topic, + vol.Optional(CONF_TILT_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_TILT_STATUS_TOPIC): valid_subscribe_topic, vol.Optional(CONF_TILT_CLOSED_POSITION, default=DEFAULT_TILT_CLOSED_POSITION): int, vol.Optional(CONF_TILT_OPEN_POSITION, diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 3fa87ad697a..19ab77350f3 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -649,8 +649,7 @@ def async_load_config(path: str, hass: HomeAssistantType, """ dev_schema = vol.Schema({ vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_ICON, default=False): - vol.Any(None, cv.icon), + vol.Optional(CONF_ICON, default=None): vol.Any(None, cv.icon), vol.Optional('track', default=False): cv.boolean, vol.Optional(CONF_MAC, default=None): vol.Any(None, vol.All(cv.string, vol.Upper)), diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index 9c04c6b40a5..e2bed755901 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -49,8 +49,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_SECRET): cv.string, vol.Optional(CONF_CURRENT_LOCATION, default=False): cv.boolean, - vol.Optional(CONF_DEVICES, default=None): - vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_DEVICES): vol.All(cv.ensure_list, [cv.string]), }) @@ -109,7 +108,7 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None): _write_refresh_token_to_file, hass, filename, session.refresh_token) data = AutomaticData( - hass, client, session, config[CONF_DEVICES], async_see) + hass, client, session, config.get(CONF_DEVICES), async_see) # Load the initial vehicle data vehicles = yield from session.get_vehicles() diff --git a/homeassistant/components/device_tracker/mqtt_json.py b/homeassistant/components/device_tracker/mqtt_json.py index 7bcad60236a..9a5532fc9f4 100644 --- a/homeassistant/components/device_tracker/mqtt_json.py +++ b/homeassistant/components/device_tracker/mqtt_json.py @@ -26,8 +26,8 @@ _LOGGER = logging.getLogger(__name__) GPS_JSON_PAYLOAD_SCHEMA = vol.Schema({ vol.Required(ATTR_LATITUDE): vol.Coerce(float), vol.Required(ATTR_LONGITUDE): vol.Coerce(float), - vol.Optional(ATTR_GPS_ACCURACY, default=None): vol.Coerce(int), - vol.Optional(ATTR_BATTERY_LEVEL, default=None): vol.Coerce(str), + vol.Optional(ATTR_GPS_ACCURACY): vol.Coerce(int), + vol.Optional(ATTR_BATTERY_LEVEL): vol.Coerce(str), }, extra=vol.ALLOW_EXTRA) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(mqtt.SCHEMA_BASE).extend({ diff --git a/homeassistant/components/device_tracker/nmap_tracker.py b/homeassistant/components/device_tracker/nmap_tracker.py index d21e416e153..23cb7ea8f9d 100644 --- a/homeassistant/components/device_tracker/nmap_tracker.py +++ b/homeassistant/components/device_tracker/nmap_tracker.py @@ -33,7 +33,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOSTS): cv.ensure_list, vol.Required(CONF_HOME_INTERVAL, default=0): cv.positive_int, vol.Optional(CONF_EXCLUDE, default=[]): - vol.All(cv.ensure_list, vol.Length(min=1)), + vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_OPTIONS, default=DEFAULT_OPTIONS): cv.string }) diff --git a/homeassistant/components/device_tracker/tomato.py b/homeassistant/components/device_tracker/tomato.py index 7cebf0abdf4..01ae2977f6d 100644 --- a/homeassistant/components/device_tracker/tomato.py +++ b/homeassistant/components/device_tracker/tomato.py @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=-1): cv.port, + vol.Optional(CONF_PORT): cv.port, vol.Optional(CONF_SSL, default=False): cv.boolean, vol.Optional(CONF_VERIFY_SSL, default=True): vol.Any( cv.boolean, cv.isfile), @@ -45,13 +45,11 @@ class TomatoDeviceScanner(DeviceScanner): def __init__(self, config): """Initialize the scanner.""" host, http_id = config[CONF_HOST], config[CONF_HTTP_ID] - port = config[CONF_PORT] + port = config.get(CONF_PORT) username, password = config[CONF_USERNAME], config[CONF_PASSWORD] self.ssl, self.verify_ssl = config[CONF_SSL], config[CONF_VERIFY_SSL] - if port == -1: - port = 80 - if self.ssl: - port = 443 + if port is None: + port = 443 if self.ssl else 80 self.req = requests.Request( 'POST', 'http{}://{}:{}/update.cgi'.format( diff --git a/homeassistant/components/device_tracker/unifi.py b/homeassistant/components/device_tracker/unifi.py index 72ea4f0902e..8663930c4e6 100644 --- a/homeassistant/components/device_tracker/unifi.py +++ b/homeassistant/components/device_tracker/unifi.py @@ -27,7 +27,6 @@ DEFAULT_HOST = 'localhost' DEFAULT_PORT = 8443 DEFAULT_VERIFY_SSL = True DEFAULT_DETECTION_TIME = timedelta(seconds=300) -DEFAULT_SSID_FILTER = None NOTIFICATION_ID = 'unifi_notification' NOTIFICATION_TITLE = 'Unifi Device Tracker Setup' @@ -42,8 +41,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ cv.boolean, cv.isfile), vol.Optional(CONF_DETECTION_TIME, default=DEFAULT_DETECTION_TIME): vol.All( cv.time_period, cv.positive_timedelta), - vol.Optional(CONF_SSID_FILTER, default=DEFAULT_SSID_FILTER): vol.All( - cv.ensure_list, [cv.string]) + vol.Optional(CONF_SSID_FILTER): vol.All(cv.ensure_list, [cv.string]) }) diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index 8f58f5f7e17..efcbb50a447 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -241,12 +241,12 @@ def async_setup(hass, config): filters = Filters() exclude = config[DOMAIN].get(CONF_EXCLUDE) if exclude: - filters.excluded_entities = exclude[CONF_ENTITIES] - filters.excluded_domains = exclude[CONF_DOMAINS] + filters.excluded_entities = exclude.get(CONF_ENTITIES, []) + filters.excluded_domains = exclude.get(CONF_DOMAINS, []) include = config[DOMAIN].get(CONF_INCLUDE) if include: - filters.included_entities = include[CONF_ENTITIES] - filters.included_domains = include[CONF_DOMAINS] + filters.included_entities = include.get(CONF_ENTITIES, []) + filters.included_domains = include.get(CONF_DOMAINS, []) use_include_order = config[DOMAIN].get(CONF_ORDER) hass.http.register_view(HistoryPeriodView(filters, use_include_order)) diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 9c08984a23e..38ce712b9b0 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -180,7 +180,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, }}, vol.Optional(CONF_LOCAL_IP, default=DEFAULT_LOCAL_IP): cv.string, - vol.Optional(CONF_LOCAL_PORT, default=DEFAULT_LOCAL_PORT): cv.port, + vol.Optional(CONF_LOCAL_PORT): cv.port, }), }, extra=vol.ALLOW_EXTRA) @@ -310,7 +310,7 @@ def setup(hass, config): bound_system_callback = partial(_system_callback_handler, hass, config) hass.data[DATA_HOMEMATIC] = homematic = HMConnection( local=config[DOMAIN].get(CONF_LOCAL_IP), - localport=config[DOMAIN].get(CONF_LOCAL_PORT), + localport=config[DOMAIN].get(CONF_LOCAL_PORT, DEFAULT_LOCAL_PORT), remotes=remotes, systemcallback=bound_system_callback, interface_id='homeassistant' diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index ac253b2821a..6472846cd13 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -73,22 +73,23 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_SERVER_HOST = '0.0.0.0' DEFAULT_DEVELOPMENT = '0' -DEFAULT_LOGIN_ATTEMPT_THRESHOLD = -1 +NO_LOGIN_ATTEMPT_THRESHOLD = -1 HTTP_SCHEMA = vol.Schema({ - vol.Optional(CONF_API_PASSWORD, default=None): cv.string, + vol.Optional(CONF_API_PASSWORD): cv.string, vol.Optional(CONF_SERVER_HOST, default=DEFAULT_SERVER_HOST): cv.string, vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port, vol.Optional(CONF_BASE_URL): cv.string, - vol.Optional(CONF_SSL_CERTIFICATE, default=None): cv.isfile, - vol.Optional(CONF_SSL_KEY, default=None): cv.isfile, + vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile, + vol.Optional(CONF_SSL_KEY): cv.isfile, vol.Optional(CONF_CORS_ORIGINS, default=[]): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_USE_X_FORWARDED_FOR, default=False): cv.boolean, vol.Optional(CONF_TRUSTED_NETWORKS, default=[]): vol.All(cv.ensure_list, [ip_network]), vol.Optional(CONF_LOGIN_ATTEMPTS_THRESHOLD, - default=DEFAULT_LOGIN_ATTEMPT_THRESHOLD): cv.positive_int, + default=NO_LOGIN_ATTEMPT_THRESHOLD): + vol.Any(cv.positive_int, NO_LOGIN_ATTEMPT_THRESHOLD), vol.Optional(CONF_IP_BAN_ENABLED, default=True): cv.boolean }) @@ -105,11 +106,11 @@ def async_setup(hass, config): if conf is None: conf = HTTP_SCHEMA({}) - api_password = conf[CONF_API_PASSWORD] + api_password = conf.get(CONF_API_PASSWORD) server_host = conf[CONF_SERVER_HOST] server_port = conf[CONF_SERVER_PORT] - ssl_certificate = conf[CONF_SSL_CERTIFICATE] - ssl_key = conf[CONF_SSL_KEY] + ssl_certificate = conf.get(CONF_SSL_CERTIFICATE) + ssl_key = conf.get(CONF_SSL_KEY) cors_origins = conf[CONF_CORS_ORIGINS] use_x_forwarded_for = conf[CONF_USE_X_FORWARDED_FOR] trusted_networks = conf[CONF_TRUSTED_NETWORKS] diff --git a/homeassistant/components/ihc/__init__.py b/homeassistant/components/ihc/__init__.py index f3cd9d79046..04be7dd5ab0 100644 --- a/homeassistant/components/ihc/__init__.py +++ b/homeassistant/components/ihc/__init__.py @@ -46,7 +46,7 @@ AUTO_SETUP_SCHEMA = vol.Schema({ vol.All({ vol.Required(CONF_XPATH): cv.string, vol.Required(CONF_NODE): cv.string, - vol.Optional(CONF_TYPE, default=None): cv.string, + vol.Optional(CONF_TYPE): cv.string, vol.Optional(CONF_INVERTING, default=False): cv.boolean, }) ]), diff --git a/homeassistant/components/image_processing/opencv.py b/homeassistant/components/image_processing/opencv.py index 0abc449afba..df58e2e9dc4 100644 --- a/homeassistant/components/image_processing/opencv.py +++ b/homeassistant/components/image_processing/opencv.py @@ -42,7 +42,7 @@ DEFAULT_TIMEOUT = 10 SCAN_INTERVAL = timedelta(seconds=2) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_CLASSIFIER, default=None): { + vol.Optional(CONF_CLASSIFIER): { cv.string: vol.Any( cv.isfile, vol.Schema({ @@ -60,7 +60,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def _create_processor_from_config(hass, camera_entity, config): """Create an OpenCV processor from configuration.""" - classifier_config = config[CONF_CLASSIFIER] + classifier_config = config.get(CONF_CLASSIFIER) name = '{} {}'.format( config[CONF_NAME], split_entity_id(camera_entity)[1].replace('_', ' ')) @@ -93,7 +93,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return entities = [] - if config[CONF_CLASSIFIER] is None: + if CONF_CLASSIFIER not in config: dest_path = hass.config.path(DEFAULT_CLASSIFIER_PATH) _get_default_classifier(dest_path) config[CONF_CLASSIFIER] = { diff --git a/homeassistant/components/image_processing/seven_segments.py b/homeassistant/components/image_processing/seven_segments.py index 1ef8a4bb847..b49739bcec3 100644 --- a/homeassistant/components/image_processing/seven_segments.py +++ b/homeassistant/components/image_processing/seven_segments.py @@ -33,7 +33,7 @@ DEFAULT_BINARY = 'ssocr' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_EXTRA_ARGUMENTS, default=''): cv.string, - vol.Optional(CONF_DIGITS, default=-1): cv.positive_int, + vol.Optional(CONF_DIGITS): cv.positive_int, vol.Optional(CONF_HEIGHT, default=0): cv.positive_int, vol.Optional(CONF_SSOCR_BIN, default=DEFAULT_BINARY): cv.string, vol.Optional(CONF_THRESHOLD, default=0): cv.positive_int, @@ -73,7 +73,7 @@ class ImageProcessingSsocr(ImageProcessingEntity): self.filepath = os.path.join(self.hass.config.config_dir, 'ocr.png') crop = ['crop', str(config[CONF_X_POS]), str(config[CONF_Y_POS]), str(config[CONF_WIDTH]), str(config[CONF_HEIGHT])] - digits = ['-d', str(config[CONF_DIGITS])] + digits = ['-d', str(config.get(CONF_DIGITS, -1))] rotate = ['rotate', str(config[CONF_ROTATE])] threshold = ['-t', str(config[CONF_THRESHOLD])] extra_arguments = config[CONF_EXTRA_ARGUMENTS].split(' ') diff --git a/homeassistant/components/insteon_plm.py b/homeassistant/components/insteon_plm.py index 94b70e47cba..4e2e8e02c7a 100644 --- a/homeassistant/components/insteon_plm.py +++ b/homeassistant/components/insteon_plm.py @@ -26,8 +26,7 @@ CONF_OVERRIDE = 'device_override' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_PORT): cv.string, - vol.Optional(CONF_OVERRIDE, default=[]): vol.All( - cv.ensure_list_csv, vol.Length(min=1)) + vol.Optional(CONF_OVERRIDE, default=[]): cv.ensure_list_csv, }) }, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/light/flux_led.py b/homeassistant/components/light/flux_led.py index 075b98117f8..2a239c9ae10 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -84,7 +84,7 @@ DEVICE_SCHEMA = vol.Schema({ vol.Optional(CONF_NAME): cv.string, vol.Optional(ATTR_MODE, default=MODE_RGBW): vol.All(cv.string, vol.In([MODE_RGBW, MODE_RGB])), - vol.Optional(CONF_PROTOCOL, default=None): + vol.Optional(CONF_PROTOCOL): vol.All(cv.string, vol.In(['ledenet'])), }) @@ -104,7 +104,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): device = {} device['name'] = device_config[CONF_NAME] device['ipaddr'] = ipaddr - device[CONF_PROTOCOL] = device_config[CONF_PROTOCOL] + device[CONF_PROTOCOL] = device_config.get(CONF_PROTOCOL) device[ATTR_MODE] = device_config[ATTR_MODE] light = FluxLight(device) lights.append(light) diff --git a/homeassistant/components/light/lifx_legacy.py b/homeassistant/components/light/lifx_legacy.py index cc48f4cf4c1..cf3dba848a8 100644 --- a/homeassistant/components/light/lifx_legacy.py +++ b/homeassistant/components/light/lifx_legacy.py @@ -41,8 +41,8 @@ SUPPORT_LIFX = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_RGB_COLOR | SUPPORT_TRANSITION) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_SERVER, default=None): cv.string, - vol.Optional(CONF_BROADCAST, default=None): cv.string, + vol.Optional(CONF_SERVER): cv.string, + vol.Optional(CONF_BROADCAST): cv.string, }) diff --git a/homeassistant/components/light/template.py b/homeassistant/components/light/template.py index cfd050f54f2..38cac649a1a 100644 --- a/homeassistant/components/light/template.py +++ b/homeassistant/components/light/template.py @@ -35,11 +35,11 @@ CONF_LEVEL_TEMPLATE = 'level_template' LIGHT_SCHEMA = vol.Schema({ vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_VALUE_TEMPLATE, default=None): cv.template, - vol.Optional(CONF_ICON_TEMPLATE, default=None): cv.template, - vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE, default=None): cv.template, - vol.Optional(CONF_LEVEL_ACTION, default=None): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_LEVEL_TEMPLATE, default=None): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_ICON_TEMPLATE): cv.template, + vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template, + vol.Optional(CONF_LEVEL_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_LEVEL_TEMPLATE): cv.template, vol.Optional(CONF_FRIENDLY_NAME): cv.string, vol.Optional(CONF_ENTITY_ID): cv.entity_ids }) @@ -56,14 +56,14 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): for device, device_config in config[CONF_LIGHTS].items(): friendly_name = device_config.get(CONF_FRIENDLY_NAME, device) - state_template = device_config[CONF_VALUE_TEMPLATE] + state_template = device_config.get(CONF_VALUE_TEMPLATE) icon_template = device_config.get(CONF_ICON_TEMPLATE) entity_picture_template = device_config.get( CONF_ENTITY_PICTURE_TEMPLATE) on_action = device_config[CONF_ON_ACTION] off_action = device_config[CONF_OFF_ACTION] level_action = device_config.get(CONF_LEVEL_ACTION) - level_template = device_config[CONF_LEVEL_TEMPLATE] + level_template = device_config.get(CONF_LEVEL_TEMPLATE) template_entity_ids = set() diff --git a/homeassistant/components/media_player/clementine.py b/homeassistant/components/media_player/clementine.py index 057a23579ca..6847b87e54f 100644 --- a/homeassistant/components/media_player/clementine.py +++ b/homeassistant/components/media_player/clementine.py @@ -37,7 +37,7 @@ SUPPORT_CLEMENTINE = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_ACCESS_TOKEN, default=None): cv.positive_int, + vol.Optional(CONF_ACCESS_TOKEN): cv.positive_int, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, }) diff --git a/homeassistant/components/media_player/denonavr.py b/homeassistant/components/media_player/denonavr.py index 0a03af0e1bf..2276603a910 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -43,12 +43,12 @@ SUPPORT_MEDIA_MODES = SUPPORT_PLAY_MEDIA | \ DENON_ZONE_SCHEMA = vol.Schema({ vol.Required(CONF_ZONE): vol.In(CONF_VALID_ZONES, CONF_INVALID_ZONES_ERR), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): cv.string, }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_SHOW_ALL_SOURCES, default=DEFAULT_SHOW_SOURCES): cv.boolean, vol.Optional(CONF_ZONES): @@ -80,7 +80,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if zones is not None: add_zones = {} for entry in zones: - add_zones[entry[CONF_ZONE]] = entry[CONF_NAME] + add_zones[entry[CONF_ZONE]] = entry.get(CONF_NAME) else: add_zones = None diff --git a/homeassistant/components/media_player/emby.py b/homeassistant/components/media_player/emby.py index a3fe62c5a42..e363ab12f92 100644 --- a/homeassistant/components/media_player/emby.py +++ b/homeassistant/components/media_player/emby.py @@ -45,7 +45,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_PORT, default=None): cv.port, + vol.Optional(CONF_PORT): cv.port, vol.Optional(CONF_AUTO_HIDE, default=DEFAULT_AUTO_HIDE): cv.boolean, }) diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index 2c428c6b833..d14bf0fadaf 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -86,7 +86,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port, vol.Optional(CONF_PROXY_SSL, default=DEFAULT_PROXY_SSL): cv.boolean, - vol.Optional(CONF_TURN_ON_ACTION, default=None): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_TURN_ON_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_TURN_OFF_ACTION): vol.Any(cv.SCRIPT_SCHEMA, vol.In(DEPRECATED_TURN_OFF_ACTIONS)), vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, diff --git a/homeassistant/components/media_player/lg_netcast.py b/homeassistant/components/media_player/lg_netcast.py index e657e1ce80d..edbd6546cca 100644 --- a/homeassistant/components/media_player/lg_netcast.py +++ b/homeassistant/components/media_player/lg_netcast.py @@ -38,7 +38,7 @@ SUPPORT_LGTV = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_ACCESS_TOKEN, default=None): + vol.Optional(CONF_ACCESS_TOKEN): vol.All(cv.string, vol.Length(max=6)), }) diff --git a/homeassistant/components/notify/apns.py b/homeassistant/components/notify/apns.py index e7a727bc5e2..dcbd1ce1317 100644 --- a/homeassistant/components/notify/apns.py +++ b/homeassistant/components/notify/apns.py @@ -39,7 +39,7 @@ PLATFORM_SCHEMA = vol.Schema({ REGISTER_SERVICE_SCHEMA = vol.Schema({ vol.Required(ATTR_PUSH_ID): cv.string, - vol.Optional(ATTR_NAME, default=None): cv.string, + vol.Optional(ATTR_NAME): cv.string, }) diff --git a/homeassistant/components/notify/rest.py b/homeassistant/components/notify/rest.py index 19339a2c7ec..73618c19502 100644 --- a/homeassistant/components/notify/rest.py +++ b/homeassistant/components/notify/rest.py @@ -22,8 +22,6 @@ CONF_TARGET_PARAMETER_NAME = 'target_param_name' CONF_TITLE_PARAMETER_NAME = 'title_param_name' DEFAULT_MESSAGE_PARAM_NAME = 'message' DEFAULT_METHOD = 'GET' -DEFAULT_TARGET_PARAM_NAME = None -DEFAULT_TITLE_PARAM_NAME = None PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_RESOURCE): cv.url, @@ -32,14 +30,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.In(['POST', 'GET', 'POST_JSON']), vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_TARGET_PARAMETER_NAME, - default=DEFAULT_TARGET_PARAM_NAME): cv.string, - vol.Optional(CONF_TITLE_PARAMETER_NAME, - default=DEFAULT_TITLE_PARAM_NAME): cv.string, - vol.Optional(CONF_DATA, - default=None): dict, - vol.Optional(CONF_DATA_TEMPLATE, - default=None): {cv.match_all: cv.template_complex} + vol.Optional(CONF_TARGET_PARAMETER_NAME): cv.string, + vol.Optional(CONF_TITLE_PARAMETER_NAME): cv.string, + vol.Optional(CONF_DATA): dict, + vol.Optional(CONF_DATA_TEMPLATE): {cv.match_all: cv.template_complex} }) _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index ddf98c1420b..6d58b25e976 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -63,15 +63,15 @@ CONNECT_RETRY_WAIT = 3 FILTER_SCHEMA = vol.Schema({ vol.Optional(CONF_EXCLUDE, default={}): vol.Schema({ - vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, - vol.Optional(CONF_DOMAINS, default=[]): + vol.Optional(CONF_ENTITIES): cv.entity_ids, + vol.Optional(CONF_DOMAINS): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_EVENT_TYPES, default=[]): + vol.Optional(CONF_EVENT_TYPES): vol.All(cv.ensure_list, [cv.string]) }), vol.Optional(CONF_INCLUDE, default={}): vol.Schema({ - vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, - vol.Optional(CONF_DOMAINS, default=[]): + vol.Optional(CONF_ENTITIES): cv.entity_ids, + vol.Optional(CONF_DOMAINS): vol.All(cv.ensure_list, [cv.string]) }) }) diff --git a/homeassistant/components/remote/harmony.py b/homeassistant/components/remote/harmony.py index ae48f269986..25a1a684d3c 100644 --- a/homeassistant/components/remote/harmony.py +++ b/homeassistant/components/remote/harmony.py @@ -31,7 +31,7 @@ CONF_DEVICE_CACHE = 'harmony_device_cache' SERVICE_SYNC = 'harmony_sync' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(ATTR_ACTIVITY, default=None): cv.string, + vol.Required(ATTR_ACTIVITY): cv.string, vol.Required(CONF_NAME): cv.string, vol.Optional(ATTR_DELAY_SECS, default=DEFAULT_DELAY_SECS): vol.Coerce(float), diff --git a/homeassistant/components/rflink.py b/homeassistant/components/rflink.py index db35b8caf9f..439f938beb3 100644 --- a/homeassistant/components/rflink.py +++ b/homeassistant/components/rflink.py @@ -74,7 +74,7 @@ DEVICE_DEFAULTS_SCHEMA = vol.Schema({ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_PORT): vol.Any(cv.port, cv.string), - vol.Optional(CONF_HOST, default=None): cv.string, + vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_WAIT_FOR_ACK, default=True): cv.boolean, vol.Optional(CONF_RECONNECT_INTERVAL, default=DEFAULT_RECONNECT_INTERVAL): int, @@ -175,7 +175,7 @@ def async_setup(hass, config): hass.data[DATA_DEVICE_REGISTER][event_type], event) # When connecting to tcp host instead of serial port (optional) - host = config[DOMAIN][CONF_HOST] + host = config[DOMAIN].get(CONF_HOST) # TCP port when host configured, otherwise serial port port = config[DOMAIN][CONF_PORT] diff --git a/homeassistant/components/sensor/bom.py b/homeassistant/components/sensor/bom.py index 545bef12d83..272d5d1e0b8 100644 --- a/homeassistant/components/sensor/bom.py +++ b/homeassistant/components/sensor/bom.py @@ -88,7 +88,7 @@ def validate_station(station): PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Inclusive(CONF_ZONE_ID, 'Deprecated partial station ID'): cv.string, vol.Inclusive(CONF_WMO_ID, 'Deprecated partial station ID'): cv.string, - vol.Optional(CONF_NAME, default=None): cv.string, + vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_STATION): validate_station, vol.Required(CONF_MONITORED_CONDITIONS, default=[]): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), diff --git a/homeassistant/components/sensor/daikin.py b/homeassistant/components/sensor/daikin.py index 0b2f6495b45..e045043e09c 100644 --- a/homeassistant/components/sensor/daikin.py +++ b/homeassistant/components/sensor/daikin.py @@ -23,8 +23,8 @@ from homeassistant.util.unit_system import UnitSystem PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=None): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES.keys()): + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), }) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index e712f5b3751..cea29d437ae 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -39,7 +39,7 @@ RECONNECT_INTERVAL = 5 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string, - vol.Optional(CONF_HOST, default=None): cv.string, + vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_DSMR_VERSION, default=DEFAULT_DSMR_VERSION): vol.All( cv.string, vol.In(['5', '4', '2.2'])), vol.Optional(CONF_RECONNECT_INTERVAL, default=30): int, @@ -96,7 +96,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): # Creates an asyncio.Protocol factory for reading DSMR telegrams from # serial and calls update_entities_telegram to update entities on arrival - if config[CONF_HOST]: + if CONF_HOST in config: reader_factory = partial( create_tcp_dsmr_reader, config[CONF_HOST], config[CONF_PORT], config[CONF_DSMR_VERSION], update_entities_telegram, diff --git a/homeassistant/components/sensor/dwd_weather_warnings.py b/homeassistant/components/sensor/dwd_weather_warnings.py index d7183494181..9105e30eb42 100644 --- a/homeassistant/components/sensor/dwd_weather_warnings.py +++ b/homeassistant/components/sensor/dwd_weather_warnings.py @@ -47,8 +47,9 @@ MONITORED_CONDITIONS = { PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_REGION_NAME): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=MONITORED_CONDITIONS): - vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), + vol.Optional(CONF_MONITORED_CONDITIONS, + default=list(MONITORED_CONDITIONS)): + vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), }) diff --git a/homeassistant/components/sensor/envirophat.py b/homeassistant/components/sensor/envirophat.py index ce5e2a81939..b11dae8e168 100644 --- a/homeassistant/components/sensor/envirophat.py +++ b/homeassistant/components/sensor/envirophat.py @@ -45,7 +45,7 @@ SENSOR_TYPES = { } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DISPLAY_OPTIONS, default=SENSOR_TYPES): + vol.Required(CONF_DISPLAY_OPTIONS, default=list(SENSOR_TYPES)): [vol.In(SENSOR_TYPES)], vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_USE_LEDS, default=False): cv.boolean diff --git a/homeassistant/components/sensor/fail2ban.py b/homeassistant/components/sensor/fail2ban.py index a343a59c314..87c301d34f5 100644 --- a/homeassistant/components/sensor/fail2ban.py +++ b/homeassistant/components/sensor/fail2ban.py @@ -33,9 +33,8 @@ STATE_CURRENT_BANS = 'current_bans' STATE_ALL_BANS = 'total_bans' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_JAILS, default=[]): - vol.All(cv.ensure_list, vol.Length(min=1)), - vol.Optional(CONF_FILE_PATH, default=DEFAULT_LOG): cv.isfile, + vol.Required(CONF_JAILS): vol.All(cv.ensure_list, vol.Length(min=1)), + vol.Optional(CONF_FILE_PATH): cv.isfile, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) @@ -46,7 +45,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): name = config.get(CONF_NAME) jails = config.get(CONF_JAILS) scan_interval = config.get(CONF_SCAN_INTERVAL) - log_file = config.get(CONF_FILE_PATH) + log_file = config.get(CONF_FILE_PATH, DEFAULT_LOG) device_list = [] log_parser = BanLogParser(scan_interval, log_file) diff --git a/homeassistant/components/sensor/google_wifi.py b/homeassistant/components/sensor/google_wifi.py index d377c03d710..c070a3e990f 100644 --- a/homeassistant/components/sensor/google_wifi.py +++ b/homeassistant/components/sensor/google_wifi.py @@ -69,8 +69,9 @@ MONITORED_CONDITIONS = { PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=MONITORED_CONDITIONS): - vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), + vol.Optional(CONF_MONITORED_CONDITIONS, + default=list(MONITORED_CONDITIONS)): + vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) diff --git a/homeassistant/components/sensor/gtfs.py b/homeassistant/components/sensor/gtfs.py index 9aa9f14663c..616144d2bc6 100644 --- a/homeassistant/components/sensor/gtfs.py +++ b/homeassistant/components/sensor/gtfs.py @@ -39,8 +39,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_DESTINATION): cv.string, vol.Required(CONF_DATA): cv.string, vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_OFFSET, default=datetime.timedelta(0)): - cv.time_period_dict, + vol.Optional(CONF_OFFSET, default=0): cv.time_period, }) diff --git a/homeassistant/components/sensor/history_stats.py b/homeassistant/components/sensor/history_stats.py index 175bdafd4a9..de7b7ebaf9e 100644 --- a/homeassistant/components/sensor/history_stats.py +++ b/homeassistant/components/sensor/history_stats.py @@ -49,13 +49,7 @@ ATTR_VALUE = 'value' def exactly_two_period_keys(conf): """Ensure exactly 2 of CONF_PERIOD_KEYS are provided.""" - provided = 0 - - for param in CONF_PERIOD_KEYS: - if param in conf and conf[param] is not None: - provided += 1 - - if provided != 2: + if sum(param in conf for param in CONF_PERIOD_KEYS) != 2: raise vol.Invalid('You must provide exactly 2 of the following:' ' start, end, duration') return conf @@ -64,9 +58,9 @@ def exactly_two_period_keys(conf): PLATFORM_SCHEMA = vol.All(PLATFORM_SCHEMA.extend({ vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_STATE): cv.string, - vol.Optional(CONF_START, default=None): cv.template, - vol.Optional(CONF_END, default=None): cv.template, - vol.Optional(CONF_DURATION, default=None): cv.time_period, + vol.Optional(CONF_START): cv.template, + vol.Optional(CONF_END): cv.template, + vol.Optional(CONF_DURATION): cv.time_period, vol.Optional(CONF_TYPE, default=CONF_TYPE_TIME): vol.In(CONF_TYPE_KEYS), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }), exactly_two_period_keys) diff --git a/homeassistant/components/sensor/hp_ilo.py b/homeassistant/components/sensor/hp_ilo.py index 387d0fae5a0..922ed04a8d9 100644 --- a/homeassistant/components/sensor/hp_ilo.py +++ b/homeassistant/components/sensor/hp_ilo.py @@ -51,8 +51,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_NAME): cv.string, vol.Required(CONF_SENSOR_TYPE): vol.All(cv.string, vol.In(SENSOR_TYPES)), - vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=None): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE, default=None): cv.template + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template })]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, @@ -85,8 +85,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): sensor_name='{} {}'.format( config.get(CONF_NAME), monitored_variable[CONF_NAME]), sensor_type=monitored_variable[CONF_SENSOR_TYPE], - sensor_value_template=monitored_variable[CONF_VALUE_TEMPLATE], - unit_of_measurement=monitored_variable[CONF_UNIT_OF_MEASUREMENT]) + sensor_value_template=monitored_variable.get(CONF_VALUE_TEMPLATE), + unit_of_measurement=monitored_variable.get( + CONF_UNIT_OF_MEASUREMENT)) devices.append(new_device) add_devices(devices, True) diff --git a/homeassistant/components/sensor/irish_rail_transport.py b/homeassistant/components/sensor/irish_rail_transport.py index fc012d9589a..603d82359de 100644 --- a/homeassistant/components/sensor/irish_rail_transport.py +++ b/homeassistant/components/sensor/irish_rail_transport.py @@ -42,9 +42,9 @@ TIME_STR_FORMAT = '%H:%M' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_STATION): cv.string, - vol.Optional(CONF_DIRECTION, default=None): cv.string, - vol.Optional(CONF_DESTINATION, default=None): cv.string, - vol.Optional(CONF_STOPS_AT, default=None): cv.string, + vol.Optional(CONF_DIRECTION): cv.string, + vol.Optional(CONF_DESTINATION): cv.string, + vol.Optional(CONF_STOPS_AT): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string }) diff --git a/homeassistant/components/sensor/loopenergy.py b/homeassistant/components/sensor/loopenergy.py index a2d6b0c3a0c..5be24b1532c 100644 --- a/homeassistant/components/sensor/loopenergy.py +++ b/homeassistant/components/sensor/loopenergy.py @@ -51,10 +51,8 @@ GAS_SCHEMA = vol.Schema({ }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_ELEC): vol.All( - dict, ELEC_SCHEMA), - vol.Optional(CONF_GAS, default={}): vol.All( - dict, GAS_SCHEMA) + vol.Required(CONF_ELEC): ELEC_SCHEMA, + vol.Optional(CONF_GAS): GAS_SCHEMA }) @@ -63,7 +61,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): import pyloopenergy elec_config = config.get(CONF_ELEC) - gas_config = config.get(CONF_GAS) + gas_config = config.get(CONF_GAS, {}) # pylint: disable=too-many-function-args controller = pyloopenergy.LoopEnergy( diff --git a/homeassistant/components/sensor/lyft.py b/homeassistant/components/sensor/lyft.py index 0efc4063dc2..c2f6412049c 100644 --- a/homeassistant/components/sensor/lyft.py +++ b/homeassistant/components/sensor/lyft.py @@ -37,7 +37,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_START_LONGITUDE): cv.longitude, vol.Optional(CONF_END_LATITUDE): cv.latitude, vol.Optional(CONF_END_LONGITUDE): cv.longitude, - vol.Optional(CONF_PRODUCT_IDS, default=None): + vol.Optional(CONF_PRODUCT_IDS): vol.All(cv.ensure_list, [cv.string]), }) diff --git a/homeassistant/components/sensor/miflora.py b/homeassistant/components/sensor/miflora.py index ec68588f241..37976151190 100644 --- a/homeassistant/components/sensor/miflora.py +++ b/homeassistant/components/sensor/miflora.py @@ -46,7 +46,7 @@ SENSOR_TYPES = { PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_MAC): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES): + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_MEDIAN, default=DEFAULT_MEDIAN): cv.positive_int, diff --git a/homeassistant/components/sensor/nut.py b/homeassistant/components/sensor/nut.py index a9fb3ae7a6f..e0d5b7250e9 100644 --- a/homeassistant/components/sensor/nut.py +++ b/homeassistant/components/sensor/nut.py @@ -126,9 +126,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_ALIAS, default=None): cv.string, - vol.Optional(CONF_USERNAME, default=None): cv.string, - vol.Optional(CONF_PASSWORD, default=None): cv.string, + vol.Optional(CONF_ALIAS): cv.string, + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, vol.Required(CONF_RESOURCES, default=[]): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), }) diff --git a/homeassistant/components/sensor/octoprint.py b/homeassistant/components/sensor/octoprint.py index 71b72b0a671..8a800e8616c 100644 --- a/homeassistant/components/sensor/octoprint.py +++ b/homeassistant/components/sensor/octoprint.py @@ -32,7 +32,7 @@ SENSOR_TYPES = { } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES): + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) diff --git a/homeassistant/components/sensor/openweathermap.py b/homeassistant/components/sensor/openweathermap.py index 49280efe718..96db4430d32 100644 --- a/homeassistant/components/sensor/openweathermap.py +++ b/homeassistant/components/sensor/openweathermap.py @@ -47,7 +47,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_FORECAST, default=False): cv.boolean, - vol.Optional(CONF_LANGUAGE, default=None): cv.string, + vol.Optional(CONF_LANGUAGE): cv.string, }) diff --git a/homeassistant/components/sensor/pi_hole.py b/homeassistant/components/sensor/pi_hole.py index 0b2f43195a6..027c12569a6 100644 --- a/homeassistant/components/sensor/pi_hole.py +++ b/homeassistant/components/sensor/pi_hole.py @@ -59,8 +59,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, vol.Optional(CONF_LOCATION, default=DEFAULT_LOCATION): cv.string, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, - vol.Optional(CONF_MONITORED_CONDITIONS, default=MONITORED_CONDITIONS): - vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), + vol.Optional(CONF_MONITORED_CONDITIONS, + default=list(MONITORED_CONDITIONS)): + vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), }) diff --git a/homeassistant/components/sensor/pilight.py b/homeassistant/components/sensor/pilight.py index 5b5385f14ef..596887998ec 100644 --- a/homeassistant/components/sensor/pilight.py +++ b/homeassistant/components/sensor/pilight.py @@ -26,7 +26,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_VARIABLE): cv.string, vol.Required(CONF_PAYLOAD): vol.Schema(dict), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=None): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, }) diff --git a/homeassistant/components/sensor/qnap.py b/homeassistant/components/sensor/qnap.py index badec6624d7..09c9938f1c1 100644 --- a/homeassistant/components/sensor/qnap.py +++ b/homeassistant/components/sensor/qnap.py @@ -97,9 +97,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list, [vol.In(_MONITORED_CONDITIONS)]), - vol.Optional(CONF_NICS, default=None): cv.ensure_list, - vol.Optional(CONF_DRIVES, default=None): cv.ensure_list, - vol.Optional(CONF_VOLUMES, default=None): cv.ensure_list, + vol.Optional(CONF_NICS): cv.ensure_list, + vol.Optional(CONF_DRIVES): cv.ensure_list, + vol.Optional(CONF_VOLUMES): cv.ensure_list, }) @@ -133,33 +133,21 @@ def setup_platform(hass, config, add_devices, discovery_info=None): api, variable, _MEMORY_MON_COND[variable])) # Network sensors - nics = config[CONF_NICS] - if nics is None: - nics = api.data["system_stats"]["nics"].keys() - - for nic in nics: + for nic in config.get(CONF_NICS, api.data["system_stats"]["nics"]): sensors += [QNAPNetworkSensor(api, variable, _NETWORK_MON_COND[variable], nic) for variable in config[CONF_MONITORED_CONDITIONS] if variable in _NETWORK_MON_COND] # Drive sensors - drives = config[CONF_DRIVES] - if drives is None: - drives = api.data["smart_drive_health"].keys() - - for drive in drives: + for drive in config.get(CONF_DRIVES, api.data["smart_drive_health"]): sensors += [QNAPDriveSensor(api, variable, _DRIVE_MON_COND[variable], drive) for variable in config[CONF_MONITORED_CONDITIONS] if variable in _DRIVE_MON_COND] # Volume sensors - volumes = config[CONF_VOLUMES] - if volumes is None: - volumes = api.data["volumes"].keys() - - for volume in volumes: + for volume in config.get(CONF_VOLUMES, api.data["volumes"]): sensors += [QNAPVolumeSensor(api, variable, _VOLUME_MON_COND[variable], volume) for variable in config[CONF_MONITORED_CONDITIONS] diff --git a/homeassistant/components/sensor/rflink.py b/homeassistant/components/sensor/rflink.py index 0d5fc283e32..80d77033bbb 100644 --- a/homeassistant/components/sensor/rflink.py +++ b/homeassistant/components/sensor/rflink.py @@ -35,7 +35,7 @@ PLATFORM_SCHEMA = vol.Schema({ cv.string: { vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_SENSOR_TYPE): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=None): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_ALIASES, default=[]): vol.All(cv.ensure_list, [cv.string]), # deprecated config options @@ -61,7 +61,7 @@ def devices_from_config(domain_config, hass=None): """Parse configuration and add Rflink sensor devices.""" devices = [] for device_id, config in domain_config[CONF_DEVICES].items(): - if not config[ATTR_UNIT_OF_MEASUREMENT]: + if ATTR_UNIT_OF_MEASUREMENT not in config: config[ATTR_UNIT_OF_MEASUREMENT] = lookup_unit_for_sensor_type( config[CONF_SENSOR_TYPE]) remove_deprecated(config) diff --git a/homeassistant/components/sensor/sensehat.py b/homeassistant/components/sensor/sensehat.py index db6d931d1b2..a50f4cdfd2c 100644 --- a/homeassistant/components/sensor/sensehat.py +++ b/homeassistant/components/sensor/sensehat.py @@ -32,7 +32,7 @@ SENSOR_TYPES = { } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DISPLAY_OPTIONS, default=SENSOR_TYPES): + vol.Required(CONF_DISPLAY_OPTIONS, default=list(SENSOR_TYPES)): [vol.In(SENSOR_TYPES)], vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_IS_HAT_ATTACHED, default=True): cv.boolean diff --git a/homeassistant/components/sensor/synologydsm.py b/homeassistant/components/sensor/synologydsm.py index f5a41c7b8ce..a0198169b6d 100644 --- a/homeassistant/components/sensor/synologydsm.py +++ b/homeassistant/components/sensor/synologydsm.py @@ -78,8 +78,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list, [vol.In(_MONITORED_CONDITIONS)]), - vol.Optional(CONF_DISKS, default=None): cv.ensure_list, - vol.Optional(CONF_VOLUMES, default=None): cv.ensure_list, + vol.Optional(CONF_DISKS): cv.ensure_list, + vol.Optional(CONF_VOLUMES): cv.ensure_list, }) @@ -106,22 +106,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if variable in _UTILISATION_MON_COND] # Handle all volumes - volumes = config['volumes'] - if volumes is None: - volumes = api.storage.volumes - - for volume in volumes: + for volume in config.get(CONF_VOLUMES, api.storage.volumes): sensors += [SynoNasStorageSensor( api, variable, _STORAGE_VOL_MON_COND[variable], volume) for variable in monitored_conditions if variable in _STORAGE_VOL_MON_COND] # Handle all disks - disks = config['disks'] - if disks is None: - disks = api.storage.disks - - for disk in disks: + for disk in config.get(CONF_DISKS, api.storage.disks): sensors += [SynoNasStorageSensor( api, variable, _STORAGE_DSK_MON_COND[variable], disk) for variable in monitored_conditions diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index ea8595e3991..3aed9d5a21b 100644 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -48,7 +48,7 @@ SENSOR_TYPES = { } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_RESOURCES, default=['disk_use']): + vol.Optional(CONF_RESOURCES, default={CONF_TYPE: 'disk_use'}): vol.All(cv.ensure_list, [vol.Schema({ vol.Required(CONF_TYPE): vol.In(SENSOR_TYPES), vol.Optional(CONF_ARG): cv.string, diff --git a/homeassistant/components/sensor/vultr.py b/homeassistant/components/sensor/vultr.py index 012c6eb7398..291639c81d6 100644 --- a/homeassistant/components/sensor/vultr.py +++ b/homeassistant/components/sensor/vultr.py @@ -30,8 +30,9 @@ MONITORED_CONDITIONS = { PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SUBSCRIPTION): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=MONITORED_CONDITIONS): - vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]) + vol.Optional(CONF_MONITORED_CONDITIONS, + default=list(MONITORED_CONDITIONS)): + vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]) }) diff --git a/homeassistant/components/sensor/yweather.py b/homeassistant/components/sensor/yweather.py index e066e38fb1e..df18e086ddd 100644 --- a/homeassistant/components/sensor/yweather.py +++ b/homeassistant/components/sensor/yweather.py @@ -42,7 +42,7 @@ SENSOR_TYPES = { } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_WOEID, default=None): cv.string, + vol.Optional(CONF_WOEID): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_FORECAST, default=0): vol.All(vol.Coerce(int), vol.Range(min=0, max=5)), diff --git a/homeassistant/components/sensor/zabbix.py b/homeassistant/components/sensor/zabbix.py index a47d466c07e..baeed391557 100644 --- a/homeassistant/components/sensor/zabbix.py +++ b/homeassistant/components/sensor/zabbix.py @@ -25,8 +25,8 @@ _CONF_INDIVIDUAL = 'individual' _ZABBIX_ID_LIST_SCHEMA = vol.Schema([int]) _ZABBIX_TRIGGER_SCHEMA = vol.Schema({ vol.Optional(_CONF_HOSTIDS, default=[]): _ZABBIX_ID_LIST_SCHEMA, - vol.Optional(_CONF_INDIVIDUAL, default=False): cv.boolean(True), - vol.Optional(CONF_NAME, default=None): cv.string, + vol.Optional(_CONF_INDIVIDUAL, default=False): cv.boolean, + vol.Optional(CONF_NAME): cv.string, }) # SCAN_INTERVAL = 30 diff --git a/homeassistant/components/statsd.py b/homeassistant/components/statsd.py index 3613f53c098..6b528733601 100644 --- a/homeassistant/components/statsd.py +++ b/homeassistant/components/statsd.py @@ -35,7 +35,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_PREFIX, default=DEFAULT_PREFIX): cv.string, vol.Optional(CONF_RATE, default=DEFAULT_RATE): vol.All(vol.Coerce(int), vol.Range(min=1)), - vol.Optional(CONF_VALUE_MAP, default=None): dict, + vol.Optional(CONF_VALUE_MAP): dict, }), }, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/switch/broadlink.py b/homeassistant/components/switch/broadlink.py index e79b7c3f34c..91ecc9c7111 100644 --- a/homeassistant/components/switch/broadlink.py +++ b/homeassistant/components/switch/broadlink.py @@ -45,8 +45,8 @@ MP1_TYPES = ['mp1'] SWITCH_TYPES = RM_TYPES + SP1_TYPES + SP2_TYPES + MP1_TYPES SWITCH_SCHEMA = vol.Schema({ - vol.Optional(CONF_COMMAND_OFF, default=None): cv.string, - vol.Optional(CONF_COMMAND_ON, default=None): cv.string, + vol.Optional(CONF_COMMAND_OFF): cv.string, + vol.Optional(CONF_COMMAND_ON): cv.string, vol.Optional(CONF_FRIENDLY_NAME): cv.string, }) diff --git a/homeassistant/components/switch/modbus.py b/homeassistant/components/switch/modbus.py index 211ff54d5a4..ca70c212774 100644 --- a/homeassistant/components/switch/modbus.py +++ b/homeassistant/components/switch/modbus.py @@ -37,12 +37,12 @@ REGISTERS_SCHEMA = vol.Schema({ vol.Required(CONF_COMMAND_ON): cv.positive_int, vol.Required(CONF_COMMAND_OFF): cv.positive_int, vol.Optional(CONF_VERIFY_STATE, default=True): cv.boolean, - vol.Optional(CONF_VERIFY_REGISTER, default=None): + vol.Optional(CONF_VERIFY_REGISTER): cv.positive_int, vol.Optional(CONF_REGISTER_TYPE, default=REGISTER_TYPE_HOLDING): vol.In([REGISTER_TYPE_HOLDING, REGISTER_TYPE_INPUT]), - vol.Optional(CONF_STATE_ON, default=None): cv.positive_int, - vol.Optional(CONF_STATE_OFF, default=None): cv.positive_int, + vol.Optional(CONF_STATE_ON): cv.positive_int, + vol.Optional(CONF_STATE_OFF): cv.positive_int, }) COILS_SCHEMA = vol.Schema({ diff --git a/homeassistant/components/switch/raspihats.py b/homeassistant/components/switch/raspihats.py index a8177c01792..7be3a6f0baa 100644 --- a/homeassistant/components/switch/raspihats.py +++ b/homeassistant/components/switch/raspihats.py @@ -25,7 +25,7 @@ _CHANNELS_SCHEMA = vol.Schema([{ vol.Required(CONF_INDEX): cv.positive_int, vol.Required(CONF_NAME): cv.string, vol.Optional(CONF_INVERT_LOGIC, default=False): cv.boolean, - vol.Optional(CONF_INITIAL_STATE, default=None): cv.boolean, + vol.Optional(CONF_INITIAL_STATE): cv.boolean, }]) _I2C_HATS_SCHEMA = vol.Schema([{ @@ -56,7 +56,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): board, address, channel_config[CONF_INDEX], channel_config[CONF_NAME], channel_config[CONF_INVERT_LOGIC], - channel_config[CONF_INITIAL_STATE] + channel_config.get(CONF_INITIAL_STATE) ) ) except I2CHatsException as ex: diff --git a/homeassistant/components/switch/rest.py b/homeassistant/components/switch/rest.py index c0f75509425..b68cc038e89 100644 --- a/homeassistant/components/switch/rest.py +++ b/homeassistant/components/switch/rest.py @@ -17,7 +17,6 @@ from homeassistant.const import ( CONF_PASSWORD) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.template import Template _LOGGER = logging.getLogger(__name__) @@ -26,8 +25,8 @@ CONF_BODY_ON = 'body_on' CONF_IS_ON_TEMPLATE = 'is_on_template' DEFAULT_METHOD = 'post' -DEFAULT_BODY_OFF = Template('OFF') -DEFAULT_BODY_ON = Template('ON') +DEFAULT_BODY_OFF = 'OFF' +DEFAULT_BODY_ON = 'ON' DEFAULT_NAME = 'REST Switch' DEFAULT_TIMEOUT = 10 diff --git a/homeassistant/components/switch/rpi_pfio.py b/homeassistant/components/switch/rpi_pfio.py index bd964e3d2ad..c10f417ba49 100644 --- a/homeassistant/components/switch/rpi_pfio.py +++ b/homeassistant/components/switch/rpi_pfio.py @@ -26,7 +26,7 @@ CONF_PORTS = 'ports' DEFAULT_INVERT_LOGIC = False PORT_SCHEMA = vol.Schema({ - vol.Optional(ATTR_NAME, default=None): cv.string, + vol.Optional(ATTR_NAME): cv.string, vol.Optional(ATTR_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean, }) @@ -42,7 +42,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): switches = [] ports = config.get(CONF_PORTS) for port, port_entity in ports.items(): - name = port_entity[ATTR_NAME] + name = port_entity.get(ATTR_NAME) invert_logic = port_entity[ATTR_INVERT_LOGIC] switches.append(RPiPFIOSwitch(port, name, invert_logic)) diff --git a/homeassistant/components/weather/yweather.py b/homeassistant/components/weather/yweather.py index bbf9f1ae590..f9610e469b2 100644 --- a/homeassistant/components/weather/yweather.py +++ b/homeassistant/components/weather/yweather.py @@ -50,7 +50,7 @@ CONDITION_CLASSES = { PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_WOEID, default=None): cv.string, + vol.Optional(CONF_WOEID): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index 030d1bee579..b79812a8dce 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -81,7 +81,7 @@ CALL_SERVICE_MESSAGE_SCHEMA = vol.Schema({ vol.Required('type'): TYPE_CALL_SERVICE, vol.Required('domain'): str, vol.Required('service'): str, - vol.Optional('service_data', default=None): dict + vol.Optional('service_data'): dict }) GET_STATES_MESSAGE_SCHEMA = vol.Schema({ @@ -451,7 +451,7 @@ class ActiveConnection: def call_service_helper(msg): """Call a service and fire complete message.""" yield from self.hass.services.async_call( - msg['domain'], msg['service'], msg['service_data'], True) + msg['domain'], msg['service'], msg.get('service_data'), True) self.send_message_outside(result_message(msg['id'])) self.hass.async_add_job(call_service_helper(msg)) diff --git a/homeassistant/components/xiaomi_aqara.py b/homeassistant/components/xiaomi_aqara.py index 0dcca28e228..bc7c982df3b 100644 --- a/homeassistant/components/xiaomi_aqara.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -68,7 +68,7 @@ SERVICE_SCHEMA_REMOVE_DEVICE = vol.Schema({ GATEWAY_CONFIG = vol.Schema({ vol.Optional(CONF_MAC, default=None): vol.Any(GW_MAC, None), - vol.Optional(CONF_KEY, default=None): + vol.Optional(CONF_KEY): vol.All(cv.string, vol.Length(min=16, max=16)), vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=9898): cv.port, @@ -90,11 +90,9 @@ def _fix_conf_defaults(config): return config -DEFAULT_GATEWAY_CONFIG = [{CONF_MAC: None, CONF_KEY: None}] - CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Optional(CONF_GATEWAYS, default=DEFAULT_GATEWAY_CONFIG): + vol.Optional(CONF_GATEWAYS, default={}): vol.All(cv.ensure_list, [GATEWAY_CONFIG], [_fix_conf_defaults]), vol.Optional(CONF_INTERFACE, default='any'): cv.string, vol.Optional(CONF_DISCOVERY_RETRY, default=3): cv.positive_int diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index e4f38549e32..61e8d1e6d73 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -45,8 +45,7 @@ DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Optional(CONF_RADIO_TYPE, default=RadioType.ezsp): - cv.enum(RadioType), + vol.Optional(CONF_RADIO_TYPE, default='ezsp'): cv.enum(RadioType), CONF_USB_PATH: cv.string, vol.Optional(CONF_BAUDRATE, default=57600): cv.positive_int, CONF_DATABASE: cv.string, diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7d182aebfa3..9cd5f12163f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,7 +3,7 @@ pyyaml>=3.11,<4 pytz>=2017.02 pip>=8.0.3 jinja2>=2.10 -voluptuous==0.10.5 +voluptuous==0.11.1 typing>=3,<4 aiohttp==2.3.10 yarl==1.1.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5fbc448c015..e04992126c5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -4,7 +4,7 @@ pyyaml>=3.11,<4 pytz>=2017.02 pip>=8.0.3 jinja2>=2.10 -voluptuous==0.10.5 +voluptuous==0.11.1 typing>=3,<4 aiohttp==2.3.10 yarl==1.1.0 diff --git a/setup.py b/setup.py index 0a454f9eb4d..d3c841f22df 100755 --- a/setup.py +++ b/setup.py @@ -53,7 +53,7 @@ REQUIRES = [ 'pytz>=2017.02', 'pip>=8.0.3', 'jinja2>=2.10', - 'voluptuous==0.10.5', + 'voluptuous==0.11.1', 'typing>=3,<4', 'aiohttp==2.3.10', # If updated, check if yarl also needs an update! 'yarl==1.1.0', diff --git a/tests/components/test_history.py b/tests/components/test_history.py index 0c6995cc1ad..4a759e7e0ac 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -401,12 +401,12 @@ class TestComponentHistory(unittest.TestCase): filters = history.Filters() exclude = config[history.DOMAIN].get(history.CONF_EXCLUDE) if exclude: - filters.excluded_entities = exclude[history.CONF_ENTITIES] - filters.excluded_domains = exclude[history.CONF_DOMAINS] + filters.excluded_entities = exclude.get(history.CONF_ENTITIES, []) + filters.excluded_domains = exclude.get(history.CONF_DOMAINS, []) include = config[history.DOMAIN].get(history.CONF_INCLUDE) if include: - filters.included_entities = include[history.CONF_ENTITIES] - filters.included_domains = include[history.CONF_DOMAINS] + filters.included_entities = include.get(history.CONF_ENTITIES, []) + filters.included_domains = include.get(history.CONF_DOMAINS, []) hist = history.get_significant_states( self.hass, zero, four, filters=filters) diff --git a/tests/components/test_rflink.py b/tests/components/test_rflink.py index 9f6573920ca..ccb88018c66 100644 --- a/tests/components/test_rflink.py +++ b/tests/components/test_rflink.py @@ -8,12 +8,10 @@ from homeassistant.components.rflink import ( CONF_RECONNECT_INTERVAL, SERVICE_SEND_COMMAND) from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_STOP_COVER) -from tests.common import assert_setup_component @asyncio.coroutine -def mock_rflink(hass, config, domain, monkeypatch, failures=None, - platform_count=1): +def mock_rflink(hass, config, domain, monkeypatch, failures=None): """Create mock Rflink asyncio protocol, test component setup.""" transport, protocol = (Mock(), Mock()) @@ -47,9 +45,7 @@ def mock_rflink(hass, config, domain, monkeypatch, failures=None, 'rflink.protocol.create_rflink_connection', mock_create) - # verify instantiation of component with given config - with assert_setup_component(platform_count, domain): - yield from async_setup_component(hass, domain, config) + yield from async_setup_component(hass, domain, config) # hook into mock config for injecting events event_callback = mock_create.call_args_list[0][1]['event_callback'] @@ -164,7 +160,7 @@ def test_send_command(hass, monkeypatch): # setup mocking rflink module _, _, protocol, _ = yield from mock_rflink( - hass, config, domain, monkeypatch, platform_count=5) + hass, config, domain, monkeypatch) hass.async_add_job( hass.services.async_call(domain, SERVICE_SEND_COMMAND, @@ -188,7 +184,7 @@ def test_send_command_invalid_arguments(hass, monkeypatch): # setup mocking rflink module _, _, protocol, _ = yield from mock_rflink( - hass, config, domain, monkeypatch, platform_count=5) + hass, config, domain, monkeypatch) # one argument missing hass.async_add_job( diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 9b37659090f..728e683a43a 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -50,6 +50,9 @@ class TestCheckConfig(unittest.TestCase): # Py34: AssertionError asyncio.set_event_loop(asyncio.new_event_loop()) + # Will allow seeing full diff + self.maxDiff = None + # pylint: disable=no-self-use,invalid-name def test_config_platform_valid(self): """Test a valid platform setup.""" @@ -176,8 +179,6 @@ class TestCheckConfig(unittest.TestCase): 'login_attempts_threshold': -1, 'server_host': '0.0.0.0', 'server_port': 8123, - 'ssl_certificate': None, - 'ssl_key': None, 'trusted_networks': [], 'use_x_forwarded_for': False}}, 'except': {}, From fab991bbf64ebee8000e9f28048853c9868d022e Mon Sep 17 00:00:00 2001 From: Richard Lucas Date: Sat, 17 Feb 2018 05:54:15 -0800 Subject: [PATCH 094/173] Map Alexa StepVolume responses to volume_up/down (#12467) It turns out I misunderstood which media_player services are available when a media player supports StepVolume. This PR maps the Alexa StepSpeaker messages to the volume_up and volume_down services. Currently Alexa allows you to specify the number of steps but the media player volume_up and volume_down services don't support this. For now I just look to see if the steps are +/- and call up/down accordingly. --- homeassistant/components/alexa/smart_home.py | 22 ++++++++++++-------- tests/components/alexa/test_smart_home.py | 6 ++---- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index a4f0225d22d..b2f8146bfcf 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -1178,20 +1178,24 @@ def async_api_adjust_volume(hass, config, request, entity): @asyncio.coroutine def async_api_adjust_volume_step(hass, config, request, entity): """Process an adjust volume step request.""" - volume_step = round(float(request[API_PAYLOAD]['volumeSteps'] / 100), 2) - - current_level = entity.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL) - - volume = current_level + volume_step + # media_player volume up/down service does not support specifying steps + # each component handles it differently e.g. via config. + # For now we use the volumeSteps returned to figure out if we + # should step up/down + volume_step = request[API_PAYLOAD]['volumeSteps'] data = { ATTR_ENTITY_ID: entity.entity_id, - media_player.ATTR_MEDIA_VOLUME_LEVEL: volume, } - yield from hass.services.async_call( - entity.domain, media_player.SERVICE_VOLUME_SET, - data, blocking=False) + if volume_step > 0: + yield from hass.services.async_call( + entity.domain, media_player.SERVICE_VOLUME_UP, + data, blocking=False) + elif volume_step < 0: + yield from hass.services.async_call( + entity.domain, media_player.SERVICE_VOLUME_DOWN, + data, blocking=False) return api_message(request) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 9654c667c5f..ca49950e2a1 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -515,17 +515,15 @@ def test_media_player(hass): call, _ = yield from assert_request_calls_service( 'Alexa.StepSpeaker', 'AdjustVolume', 'media_player#test', - 'media_player.volume_set', + 'media_player.volume_up', hass, payload={'volumeSteps': 20}) - assert call.data['volume_level'] == 0.95 call, _ = yield from assert_request_calls_service( 'Alexa.StepSpeaker', 'AdjustVolume', 'media_player#test', - 'media_player.volume_set', + 'media_player.volume_down', hass, payload={'volumeSteps': -20}) - assert call.data['volume_level'] == 0.55 @asyncio.coroutine From 66dcb6c9471e677b7430f551d510947fed7a566a Mon Sep 17 00:00:00 2001 From: Matthew Hilton Date: Sat, 17 Feb 2018 13:57:05 +0000 Subject: [PATCH 095/173] ONVIF Camera added Error handling and rtsp authentication. (#11129) Bugfixes for several issues with hass.io and non-venv installations Added passing of credentials to RTSP stream Changed from ONVIFService to ONVIFCamera as ONVIFService didn't contain the same error handling. Changed method to get Stream URL from camera to a more compatible method Added extra Error handling --- homeassistant/components/camera/onvif.py | 39 +++++++++++++++--------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/camera/onvif.py b/homeassistant/components/camera/onvif.py index 65f291bf41d..17108e73091 100644 --- a/homeassistant/components/camera/onvif.py +++ b/homeassistant/components/camera/onvif.py @@ -6,7 +6,6 @@ https://home-assistant.io/components/camera.onvif/ """ import asyncio import logging -import os import voluptuous as vol @@ -48,30 +47,40 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up a ONVIF camera.""" if not hass.data[DATA_FFMPEG].async_run_test(config.get(CONF_HOST)): return - async_add_devices([ONVIFCamera(hass, config)]) + async_add_devices([ONVIFHassCamera(hass, config)]) -class ONVIFCamera(Camera): +class ONVIFHassCamera(Camera): """An implementation of an ONVIF camera.""" def __init__(self, hass, config): """Initialize a ONVIF camera.""" - from onvif import ONVIFService - import onvif + from onvif import ONVIFCamera super().__init__() self._name = config.get(CONF_NAME) self._ffmpeg_arguments = config.get(CONF_EXTRA_ARGUMENTS) - media = ONVIFService( - 'http://{}:{}/onvif/device_service'.format( - config.get(CONF_HOST), config.get(CONF_PORT)), - config.get(CONF_USERNAME), - config.get(CONF_PASSWORD), - '{}/wsdl/media.wsdl'.format(os.path.dirname(onvif.__file__)) - ) - self._input = media.GetStreamUri().Uri - _LOGGER.debug("ONVIF Camera Using the following URL for %s: %s", - self._name, self._input) + self._input = None + try: + _LOGGER.debug("Connecting with ONVIF Camera: %s on port %s", + config.get(CONF_HOST), config.get(CONF_PORT)) + media_service = ONVIFCamera( + config.get(CONF_HOST), config.get(CONF_PORT), + config.get(CONF_USERNAME), config.get(CONF_PASSWORD) + ).create_media_service() + stream_uri = media_service.GetStreamUri( + {'StreamSetup': {'Stream': 'RTP-Unicast', 'Transport': 'RTSP'}} + ) + self._input = stream_uri.Uri.replace( + 'rtsp://', 'rtsp://{}:{}@'.format( + config.get(CONF_USERNAME), + config.get(CONF_PASSWORD)), 1) + _LOGGER.debug( + "ONVIF Camera Using the following URL for %s: %s", + self._name, self._input) + except Exception as err: + _LOGGER.error("Unable to communicate with ONVIF Camera: %s", err) + raise @asyncio.coroutine def async_camera_image(self): From 22a007a785a1551be5a0af54d8ad27a7e3f5a6fa Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Sat, 17 Feb 2018 14:13:27 -0500 Subject: [PATCH 096/173] Bump aioautomatic to 0.6.5 for voluptuous 0.11 (#12480) --- homeassistant/components/device_tracker/automatic.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index e2bed755901..607f236f920 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -23,7 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval -REQUIREMENTS = ['aioautomatic==0.6.4'] +REQUIREMENTS = ['aioautomatic==0.6.5'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index e04992126c5..33c5557d4dc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -60,7 +60,7 @@ YesssSMS==0.1.1b3 abodepy==0.12.2 # homeassistant.components.device_tracker.automatic -aioautomatic==0.6.4 +aioautomatic==0.6.5 # homeassistant.components.sensor.dnsip aiodns==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab498e1408f..1fe1d10a6b2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -25,7 +25,7 @@ PyJWT==1.5.3 SoCo==0.13 # homeassistant.components.device_tracker.automatic -aioautomatic==0.6.4 +aioautomatic==0.6.5 # homeassistant.components.emulated_hue # homeassistant.components.http From 371fe9c78f508e1c2227a4cfc440841d65eaa0b2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 17 Feb 2018 11:47:20 -0800 Subject: [PATCH 097/173] Add example in test how to create list or object in template (#12469) --- tests/helpers/test_script.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 385b0a5df05..a8ae20ad69b 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -71,13 +71,14 @@ class TestScriptHelper(unittest.TestCase): script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA({ 'event': event, 'event_data_template': { - 'hello': """ - {% if is_world == 'yes' %} - world - {% else %} - not world - {% endif %} - """ + 'dict': { + 1: '{{ is_world }}', + 2: '{{ is_world }}{{ is_world }}', + 3: '{{ is_world }}{{ is_world }}{{ is_world }}', + }, + 'list': [ + '{{ is_world }}', '{{ is_world }}{{ is_world }}' + ] } })) @@ -86,7 +87,14 @@ class TestScriptHelper(unittest.TestCase): self.hass.block_till_done() assert len(calls) == 1 - assert calls[0].data.get('hello') == 'world' + assert calls[0].data == { + 'dict': { + 1: 'yes', + 2: 'yesyes', + 3: 'yesyesyes', + }, + 'list': ['yes', 'yesyes'] + } assert not script_obj.can_cancel def test_calling_service(self): From 483725414692f5981661345b920969fc29a2ad93 Mon Sep 17 00:00:00 2001 From: Julius Mittenzwei Date: Sat, 17 Feb 2018 22:22:38 +0100 Subject: [PATCH 098/173] KNX/Climate: Fixed platform schema min/max values. (#12477) * Issue 10388: Fixed platform schema min/max values of CONF_SETPOINT_SHIFT_MIN and CONF_SETPOINT_SHIFT_MAX * readded default values for CONF_SETPOINT_SHIFT_MAX and CONF_SETPOINT_SHIFT_MIN --- homeassistant/components/climate/knx.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index d487d6a7a64..1bbc5b789fb 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -47,10 +47,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SETPOINT_SHIFT_STEP, default=DEFAULT_SETPOINT_SHIFT_STEP): vol.All( float, vol.Range(min=0, max=2)), - vol.Optional(CONF_SETPOINT_SHIFT_MAX): - vol.All(int, vol.Range(min=-32, max=0)), - vol.Optional(CONF_SETPOINT_SHIFT_MIN): + vol.Optional(CONF_SETPOINT_SHIFT_MAX, default=DEFAULT_SETPOINT_SHIFT_MAX): vol.All(int, vol.Range(min=0, max=32)), + vol.Optional(CONF_SETPOINT_SHIFT_MIN, default=DEFAULT_SETPOINT_SHIFT_MIN): + vol.All(int, vol.Range(min=-32, max=0)), vol.Optional(CONF_OPERATION_MODE_ADDRESS): cv.string, vol.Optional(CONF_OPERATION_MODE_STATE_ADDRESS): cv.string, vol.Optional(CONF_CONTROLLER_STATUS_ADDRESS): cv.string, @@ -95,10 +95,8 @@ def async_add_devices_config(hass, config, async_add_devices): group_address_setpoint_shift_state=config.get( CONF_SETPOINT_SHIFT_STATE_ADDRESS), setpoint_shift_step=config.get(CONF_SETPOINT_SHIFT_STEP), - setpoint_shift_max=config.get(CONF_SETPOINT_SHIFT_MAX, - DEFAULT_SETPOINT_SHIFT_MAX), - setpoint_shift_min=config.get(CONF_SETPOINT_SHIFT_MIN, - DEFAULT_SETPOINT_SHIFT_MIN), + setpoint_shift_max=config.get(CONF_SETPOINT_SHIFT_MAX), + setpoint_shift_min=config.get(CONF_SETPOINT_SHIFT_MIN), group_address_operation_mode=config.get(CONF_OPERATION_MODE_ADDRESS), group_address_operation_mode_state=config.get( CONF_OPERATION_MODE_STATE_ADDRESS), From eaba3b315c38861040a9a9df35c7cf35392e9415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Sat, 17 Feb 2018 22:33:41 +0100 Subject: [PATCH 099/173] Reduce the load on met.no servers, yr.no sensor (#12435) * Spread the load more for the yr.no sensor * Spread the load more for the yr.no sensor * Spread the load more for the yr.no sensor * Spread the load more for the yr.no sensor * Spread the load more for the yr.no sensor * Spread the load more for the yr.no sensor * Spread the load more for the yr.no sensor * Spread the load more for the yr.no sensor * Spread the load more for the yr.no sensor * fix comment * Spread the load more for the yr.no sensor * Spread the load more for the yr.no sensor * Spread the load more for the yr.no sensor * Update yr.py --- homeassistant/components/sensor/yr.py | 80 +++++++++++++-------------- 1 file changed, 38 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/sensor/yr.py b/homeassistant/components/sensor/yr.py index 244ad58eb9a..88c23771bd4 100644 --- a/homeassistant/components/sensor/yr.py +++ b/homeassistant/components/sensor/yr.py @@ -7,7 +7,6 @@ https://home-assistant.io/components/sensor.yr/ import asyncio import logging -from datetime import timedelta from random import randrange from xml.parsers.expat import ExpatError @@ -22,16 +21,17 @@ from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_NAME) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import ( - async_track_point_in_utc_time, async_track_utc_time_change) +from homeassistant.helpers.event import (async_track_utc_time_change, + async_call_later) from homeassistant.util import dt as dt_util REQUIREMENTS = ['xmltodict==0.11.0'] _LOGGER = logging.getLogger(__name__) -CONF_ATTRIBUTION = "Weather forecast from yr.no, delivered by the Norwegian " \ - "Meteorological Institute and the NRK." +CONF_ATTRIBUTION = "Weather forecast from met.no, delivered " \ + "by the Norwegian Meteorological Institute." +# https://api.met.no/license_data.html SENSOR_TYPES = { 'symbol': ['Symbol', None], @@ -91,11 +91,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): async_add_devices(dev) weather = YrData(hass, coordinates, forecast, dev) - # Update weather on the hour, spread seconds - async_track_utc_time_change( - hass, weather.async_update, minute=randrange(1, 10), - second=randrange(0, 59)) - yield from weather.async_update() + async_track_utc_time_change(hass, weather.updating_devices, minute=31) + yield from weather.fetching_data() class YrSensor(Entity): @@ -153,50 +150,49 @@ class YrData(object): self._url = 'https://aa015h6buqvih86i1.api.met.no/'\ 'weatherapi/locationforecast/1.9/' self._urlparams = coordinates - self._nextrun = None self._forecast = forecast self.devices = devices self.data = {} self.hass = hass @asyncio.coroutine - def async_update(self, *_): + def fetching_data(self, *_): """Get the latest data from yr.no.""" import xmltodict def try_again(err: str): - """Retry in 15 minutes.""" - _LOGGER.warning("Retrying in 15 minutes: %s", err) - self._nextrun = None - nxt = dt_util.utcnow() + timedelta(minutes=15) - if nxt.minute >= 15: - async_track_point_in_utc_time(self.hass, self.async_update, - nxt) - - if self._nextrun is None or dt_util.utcnow() >= self._nextrun: - try: - websession = async_get_clientsession(self.hass) - with async_timeout.timeout(10, loop=self.hass.loop): - resp = yield from websession.get( - self._url, params=self._urlparams) - if resp.status != 200: - try_again('{} returned {}'.format(resp.url, resp.status)) - return - text = yield from resp.text() - - except (asyncio.TimeoutError, aiohttp.ClientError) as err: - try_again(err) + """Retry in 15 to 20 minutes.""" + minutes = 15 + randrange(6) + _LOGGER.error("Retrying in %i minutes: %s", minutes, err) + async_call_later(self.hass, minutes*60, self.fetching_data) + try: + websession = async_get_clientsession(self.hass) + with async_timeout.timeout(10, loop=self.hass.loop): + resp = yield from websession.get( + self._url, params=self._urlparams) + if resp.status != 200: + try_again('{} returned {}'.format(resp.url, resp.status)) return + text = yield from resp.text() - try: - self.data = xmltodict.parse(text)['weatherdata'] - model = self.data['meta']['model'] - if '@nextrun' not in model: - model = model[0] - self._nextrun = dt_util.parse_datetime(model['@nextrun']) - except (ExpatError, IndexError) as err: - try_again(err) - return + except (asyncio.TimeoutError, aiohttp.ClientError) as err: + try_again(err) + return + + try: + self.data = xmltodict.parse(text)['weatherdata'] + except (ExpatError, IndexError) as err: + try_again(err) + return + + yield from self.updating_devices() + async_call_later(self.hass, 60*60, self.fetching_data) + + @asyncio.coroutine + def updating_devices(self, *_): + """Find the current data from self.data.""" + if not self.data: + return now = dt_util.utcnow() forecast_time = now + dt_util.dt.timedelta(hours=self._forecast) From 6299c054c8970f4acde8858b5f11baf2d4f80fb8 Mon Sep 17 00:00:00 2001 From: mjj4791 Date: Sun, 18 Feb 2018 01:19:27 +0100 Subject: [PATCH 100/173] Prevent error when no internet or DNS is available (#12486) --- homeassistant/components/sensor/buienradar.py | 2 +- homeassistant/components/weather/buienradar.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/buienradar.py b/homeassistant/components/sensor/buienradar.py index 1b5cfc4b491..5d74f038eaa 100644 --- a/homeassistant/components/sensor/buienradar.py +++ b/homeassistant/components/sensor/buienradar.py @@ -23,7 +23,7 @@ from homeassistant.helpers.event import ( async_track_point_in_utc_time) from homeassistant.util import dt as dt_util -REQUIREMENTS = ['buienradar==0.9'] +REQUIREMENTS = ['buienradar==0.91'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/weather/buienradar.py b/homeassistant/components/weather/buienradar.py index b06ae4dcea1..a49a1664eec 100644 --- a/homeassistant/components/weather/buienradar.py +++ b/homeassistant/components/weather/buienradar.py @@ -18,7 +18,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.components.sensor.buienradar import ( BrData) -REQUIREMENTS = ['buienradar==0.9'] +REQUIREMENTS = ['buienradar==0.91'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 33c5557d4dc..78c2ff23816 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -162,7 +162,7 @@ broadlink==0.5 # homeassistant.components.sensor.buienradar # homeassistant.components.weather.buienradar -buienradar==0.9 +buienradar==0.91 # homeassistant.components.calendar.caldav caldav==0.5.0 From 8840c227d253ed4f1880851bc63dd773be844b87 Mon Sep 17 00:00:00 2001 From: Sergio Viudes Date: Sun, 18 Feb 2018 06:12:11 +0100 Subject: [PATCH 101/173] Added doorbird_last_motion to DoorBird camera platform (#12457) --- homeassistant/components/camera/doorbird.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/camera/doorbird.py b/homeassistant/components/camera/doorbird.py index 2ca962a8450..034ddc2fabb 100644 --- a/homeassistant/components/camera/doorbird.py +++ b/homeassistant/components/camera/doorbird.py @@ -18,8 +18,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession DEPENDENCIES = ['doorbird'] _CAMERA_LAST_VISITOR = "DoorBird Last Ring" +_CAMERA_LAST_MOTION = "DoorBird Last Motion" _CAMERA_LIVE = "DoorBird Live" _LAST_VISITOR_INTERVAL = datetime.timedelta(minutes=1) +_LAST_MOTION_INTERVAL = datetime.timedelta(minutes=1) _LIVE_INTERVAL = datetime.timedelta(seconds=1) _LOGGER = logging.getLogger(__name__) _TIMEOUT = 10 # seconds @@ -34,6 +36,9 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): DoorBirdCamera( device.history_image_url(1, 'doorbell'), _CAMERA_LAST_VISITOR, _LAST_VISITOR_INTERVAL), + DoorBirdCamera( + device.history_image_url(1, 'motionsensor'), _CAMERA_LAST_MOTION, + _LAST_MOTION_INTERVAL), ]) From 909a06566efece7ed6074256384f3ed96c6566cf Mon Sep 17 00:00:00 2001 From: Philip Rosenberg-Watt Date: Sat, 17 Feb 2018 22:13:05 -0700 Subject: [PATCH 102/173] Fail gracefully with unreachable LaMetric (#12451) Accounts with multiple LaMetric devices at unreachable IPs (for example at a different location, on a different/unroutable subnet, etc.) may cause the notify.lametric service to fail. This update wraps the message sending routine in a try/except clause and outputs log messages indicating the problem. Fixes #12450 --- homeassistant/components/notify/lametric.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/notify/lametric.py b/homeassistant/components/notify/lametric.py index 2f967dcdda4..f4c9c391408 100644 --- a/homeassistant/components/notify/lametric.py +++ b/homeassistant/components/notify/lametric.py @@ -93,6 +93,11 @@ class LaMetricNotificationService(BaseNotificationService): devices = lmn.get_devices() for dev in devices: if targets is None or dev["name"] in targets: - lmn.set_device(dev) - lmn.send_notification(model, lifetime=self._lifetime) - _LOGGER.debug("Sent notification to LaMetric %s", dev["name"]) + try: + lmn.set_device(dev) + lmn.send_notification(model, lifetime=self._lifetime) + _LOGGER.debug("Sent notification to LaMetric %s", + dev["name"]) + except OSError: + _LOGGER.warning("Cannot connect to LaMetric %s", + dev["name"]) From 92aeef82ef26f16512e55bbaf95930d79124721c Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sun, 18 Feb 2018 06:32:08 +0100 Subject: [PATCH 103/173] Enable compression when sending json to client (#11165) * Enable compression when sending json to client Make server compress json content when transmitting to client. Json is quite verbose and compresses well. A real world example is history_graph requested data for in my case 4 temperature sensors updating every half a second for a graph over 10 days lead to 6MB json which compressed to 200KB using deflate compression. * Rename variable to request * Name the variable response instead of request --- homeassistant/components/http/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 6472846cd13..450d802e408 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -340,9 +340,11 @@ class HomeAssistantView(object): """Return a JSON response.""" msg = json.dumps( result, sort_keys=True, cls=rem.JSONEncoder).encode('UTF-8') - return web.Response( + response = web.Response( body=msg, content_type=CONTENT_TYPE_JSON, status=status_code, headers=headers) + response.enable_compression() + return response def json_message(self, message, status_code=200, message_code=None, headers=None): From 02c05e24904fe6a6bbbec62c0e655c5e0a290c36 Mon Sep 17 00:00:00 2001 From: happyleavesaoc Date: Sun, 18 Feb 2018 00:49:32 -0500 Subject: [PATCH 104/173] bump usps version (#12465) --- homeassistant/components/usps.py | 8 ++++++-- requirements_all.txt | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/usps.py b/homeassistant/components/usps.py index 58f858b0975..364562f1119 100644 --- a/homeassistant/components/usps.py +++ b/homeassistant/components/usps.py @@ -15,7 +15,7 @@ from homeassistant.helpers import (config_validation as cv, discovery) from homeassistant.util import Throttle from homeassistant.util.dt import now -REQUIREMENTS = ['myusps==1.2.2'] +REQUIREMENTS = ['myusps==1.3.2'] _LOGGER = logging.getLogger(__name__) @@ -24,6 +24,7 @@ DATA_USPS = 'data_usps' MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) COOKIE = 'usps_cookies.pickle' CACHE = 'usps_cache' +CONF_DRIVER = 'driver' USPS_TYPE = ['sensor', 'camera'] @@ -32,6 +33,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_NAME, default=DOMAIN): cv.string, + vol.Optional(CONF_DRIVER): cv.string }), }, extra=vol.ALLOW_EXTRA) @@ -42,13 +44,15 @@ def setup(hass, config): username = conf.get(CONF_USERNAME) password = conf.get(CONF_PASSWORD) name = conf.get(CONF_NAME) + driver = conf.get(CONF_DRIVER) import myusps try: cookie = hass.config.path(COOKIE) cache = hass.config.path(CACHE) session = myusps.get_session(username, password, - cookie_path=cookie, cache_path=cache) + cookie_path=cookie, cache_path=cache, + driver=driver) except myusps.USPSError: _LOGGER.exception('Could not connect to My USPS') return False diff --git a/requirements_all.txt b/requirements_all.txt index 78c2ff23816..86368c45b93 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -501,7 +501,7 @@ mychevy==0.1.1 mycroftapi==2.0 # homeassistant.components.usps -myusps==1.2.2 +myusps==1.3.2 # homeassistant.components.media_player.nad # homeassistant.components.media_player.nadtcp From e8d8b75c07fdddd36d051dcce62e0350314651dd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 17 Feb 2018 23:20:28 -0800 Subject: [PATCH 105/173] Try deflaking recorder tests (#12492) * Try deflaking recorder tests * Remove run_coroutine_threadsafe * Lint --- homeassistant/components/recorder/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 6d58b25e976..01d3f76bb77 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -87,13 +87,14 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) +@asyncio.coroutine def wait_connection_ready(hass): """ Wait till the connection is ready. Returns a coroutine object. """ - return hass.data[DATA_INSTANCE].async_db_ready + return (yield from hass.data[DATA_INSTANCE].async_db_ready) def run_information(hass, point_in_time: Optional[datetime] = None): From a8444b22e76fbb2581a5da367aff477df604085e Mon Sep 17 00:00:00 2001 From: Joe Lu Date: Sun, 18 Feb 2018 00:24:51 -0800 Subject: [PATCH 106/173] Support for August doorbell (#11124) * Add support for August doorbell * Address PR comment for August platform * Address PR comment for August binary sensor * Address PR comment for August camera * Addressed PR comment for August lock * - Fixed houndci-bot error * - Updated configurator description * - Fixed stale docstring * Added august module to .coveragerc --- .coveragerc | 3 + homeassistant/components/august.py | 257 ++++++++++++++++++ .../components/binary_sensor/august.py | 97 +++++++ homeassistant/components/camera/august.py | 76 ++++++ homeassistant/components/lock/august.py | 82 ++++++ requirements_all.txt | 3 + 6 files changed, 518 insertions(+) create mode 100644 homeassistant/components/august.py create mode 100644 homeassistant/components/binary_sensor/august.py create mode 100644 homeassistant/components/camera/august.py create mode 100644 homeassistant/components/lock/august.py diff --git a/.coveragerc b/.coveragerc index ec41cf05b7c..ada79ca8f27 100644 --- a/.coveragerc +++ b/.coveragerc @@ -38,6 +38,9 @@ omit = homeassistant/components/asterisk_mbox.py homeassistant/components/*/asterisk_mbox.py + homeassistant/components/august.py + homeassistant/components/*/august.py + homeassistant/components/axis.py homeassistant/components/*/axis.py diff --git a/homeassistant/components/august.py b/homeassistant/components/august.py new file mode 100644 index 00000000000..c12e18ef09c --- /dev/null +++ b/homeassistant/components/august.py @@ -0,0 +1,257 @@ +""" +Support for August devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/august/ +""" + +import logging +from datetime import timedelta + +import voluptuous as vol +from requests import RequestException + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_PASSWORD, CONF_USERNAME, CONF_TIMEOUT) +from homeassistant.helpers import discovery +from homeassistant.util import Throttle + +_LOGGER = logging.getLogger(__name__) + +_CONFIGURING = {} + +REQUIREMENTS = ['py-august==0.3.0'] + +DEFAULT_TIMEOUT = 10 +ACTIVITY_FETCH_LIMIT = 10 +ACTIVITY_INITIAL_FETCH_LIMIT = 20 + +CONF_LOGIN_METHOD = 'login_method' +CONF_INSTALL_ID = 'install_id' + +NOTIFICATION_ID = 'august_notification' +NOTIFICATION_TITLE = "August Setup" + +AUGUST_CONFIG_FILE = '.august.conf' + +DATA_AUGUST = 'august' +DOMAIN = 'august' +DEFAULT_ENTITY_NAMESPACE = 'august' +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) +DEFAULT_SCAN_INTERVAL = timedelta(seconds=5) +LOGIN_METHODS = ['phone', 'email'] + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_LOGIN_METHOD): vol.In(LOGIN_METHODS), + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_INSTALL_ID): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + }) +}, extra=vol.ALLOW_EXTRA) + +AUGUST_COMPONENTS = [ + 'camera', 'binary_sensor', 'lock' +] + + +def request_configuration(hass, config, api, authenticator): + """Request configuration steps from the user.""" + configurator = hass.components.configurator + + def august_configuration_callback(data): + """Run when the configuration callback is called.""" + from august.authenticator import ValidationResult + + result = authenticator.validate_verification_code( + data.get('verification_code')) + + if result == ValidationResult.INVALID_VERIFICATION_CODE: + configurator.notify_errors(_CONFIGURING[DOMAIN], + "Invalid verification code") + elif result == ValidationResult.VALIDATED: + setup_august(hass, config, api, authenticator) + + if DOMAIN not in _CONFIGURING: + authenticator.send_verification_code() + + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + login_method = conf.get(CONF_LOGIN_METHOD) + + _CONFIGURING[DOMAIN] = configurator.request_config( + NOTIFICATION_TITLE, + august_configuration_callback, + description="Please check your {} ({}) and enter the verification " + "code below".format(login_method, username), + submit_caption='Verify', + fields=[{ + 'id': 'verification_code', + 'name': "Verification code", + 'type': 'string'}] + ) + + +def setup_august(hass, config, api, authenticator): + """Set up the August component.""" + from august.authenticator import AuthenticationState + + authentication = None + try: + authentication = authenticator.authenticate() + except RequestException as ex: + _LOGGER.error("Unable to connect to August service: %s", str(ex)) + + hass.components.persistent_notification.create( + "Error: {}
" + "You will need to restart hass after fixing." + "".format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + + state = authentication.state + + if state == AuthenticationState.AUTHENTICATED: + if DOMAIN in _CONFIGURING: + hass.components.configurator.request_done(_CONFIGURING.pop(DOMAIN)) + + hass.data[DATA_AUGUST] = AugustData(api, authentication.access_token) + + for component in AUGUST_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, config) + + return True + elif state == AuthenticationState.BAD_PASSWORD: + return False + elif state == AuthenticationState.REQUIRES_VALIDATION: + request_configuration(hass, config, api, authenticator) + return True + + return False + + +def setup(hass, config): + """Set up the August component.""" + from august.api import Api + from august.authenticator import Authenticator + + conf = config[DOMAIN] + api = Api(timeout=conf.get(CONF_TIMEOUT)) + + authenticator = Authenticator( + api, + conf.get(CONF_LOGIN_METHOD), + conf.get(CONF_USERNAME), + conf.get(CONF_PASSWORD), + install_id=conf.get(CONF_INSTALL_ID), + access_token_cache_file=hass.config.path(AUGUST_CONFIG_FILE)) + + return setup_august(hass, config, api, authenticator) + + +class AugustData: + """August data object.""" + + def __init__(self, api, access_token): + """Init August data object.""" + self._api = api + self._access_token = access_token + self._doorbells = self._api.get_doorbells(self._access_token) or [] + self._locks = self._api.get_locks(self._access_token) or [] + self._house_ids = [d.house_id for d in self._doorbells + self._locks] + + self._doorbell_detail_by_id = {} + self._lock_status_by_id = {} + self._lock_detail_by_id = {} + self._activities_by_id = {} + + @property + def house_ids(self): + """Return a list of house_ids.""" + return self._house_ids + + @property + def doorbells(self): + """Return a list of doorbells.""" + return self._doorbells + + @property + def locks(self): + """Return a list of locks.""" + return self._locks + + def get_device_activities(self, device_id, *activity_types): + """Return a list of activities.""" + self._update_device_activities() + + activities = self._activities_by_id.get(device_id, []) + if activity_types: + return [a for a in activities if a.activity_type in activity_types] + return activities + + def get_latest_device_activity(self, device_id, *activity_types): + """Return latest activity.""" + activities = self.get_device_activities(device_id, *activity_types) + return next(iter(activities or []), None) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def _update_device_activities(self, limit=ACTIVITY_FETCH_LIMIT): + """Update data object with latest from August API.""" + for house_id in self.house_ids: + activities = self._api.get_house_activities(self._access_token, + house_id, + limit=limit) + + device_ids = {a.device_id for a in activities} + for device_id in device_ids: + self._activities_by_id[device_id] = [a for a in activities if + a.device_id == device_id] + + def get_doorbell_detail(self, doorbell_id): + """Return doorbell detail.""" + self._update_doorbells() + return self._doorbell_detail_by_id.get(doorbell_id) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def _update_doorbells(self): + detail_by_id = {} + + for doorbell in self._doorbells: + detail_by_id[doorbell.device_id] = self._api.get_doorbell_detail( + self._access_token, doorbell.device_id) + + self._doorbell_detail_by_id = detail_by_id + + def get_lock_status(self, lock_id): + """Return lock status.""" + self._update_locks() + return self._lock_status_by_id.get(lock_id) + + def get_lock_detail(self, lock_id): + """Return lock detail.""" + self._update_locks() + return self._lock_detail_by_id.get(lock_id) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def _update_locks(self): + status_by_id = {} + detail_by_id = {} + + for lock in self._locks: + status_by_id[lock.device_id] = self._api.get_lock_status( + self._access_token, lock.device_id) + detail_by_id[lock.device_id] = self._api.get_lock_detail( + self._access_token, lock.device_id) + + self._lock_status_by_id = status_by_id + self._lock_detail_by_id = detail_by_id + + def lock(self, device_id): + """Lock the device.""" + return self._api.lock(self._access_token, device_id) + + def unlock(self, device_id): + """Unlock the device.""" + return self._api.unlock(self._access_token, device_id) diff --git a/homeassistant/components/binary_sensor/august.py b/homeassistant/components/binary_sensor/august.py new file mode 100644 index 00000000000..8df50a1bfb6 --- /dev/null +++ b/homeassistant/components/binary_sensor/august.py @@ -0,0 +1,97 @@ +""" +Support for August binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.august/ +""" +from datetime import timedelta, datetime + +from homeassistant.components.august import DATA_AUGUST +from homeassistant.components.binary_sensor import (BinarySensorDevice) + +DEPENDENCIES = ['august'] + +SCAN_INTERVAL = timedelta(seconds=5) + + +def _retrieve_online_state(data, doorbell): + """Get the latest state of the sensor.""" + detail = data.get_doorbell_detail(doorbell.device_id) + return detail.is_online + + +def _retrieve_motion_state(data, doorbell): + from august.activity import ActivityType + return _activity_time_based_state(data, doorbell, + [ActivityType.DOORBELL_MOTION, + ActivityType.DOORBELL_DING]) + + +def _retrieve_ding_state(data, doorbell): + from august.activity import ActivityType + return _activity_time_based_state(data, doorbell, + [ActivityType.DOORBELL_DING]) + + +def _activity_time_based_state(data, doorbell, activity_types): + """Get the latest state of the sensor.""" + latest = data.get_latest_device_activity(doorbell.device_id, + *activity_types) + + if latest is not None: + start = latest.activity_start_time + end = latest.activity_end_time + timedelta(seconds=30) + return start <= datetime.now() <= end + return None + + +# Sensor types: Name, device_class, state_provider +SENSOR_TYPES = { + 'doorbell_ding': ['Ding', 'occupancy', _retrieve_ding_state], + 'doorbell_motion': ['Motion', 'motion', _retrieve_motion_state], + 'doorbell_online': ['Online', 'connectivity', _retrieve_online_state], +} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the August binary sensors.""" + data = hass.data[DATA_AUGUST] + devices = [] + + for doorbell in data.doorbells: + for sensor_type in SENSOR_TYPES: + devices.append(AugustBinarySensor(data, sensor_type, doorbell)) + + add_devices(devices, True) + + +class AugustBinarySensor(BinarySensorDevice): + """Representation of an August binary sensor.""" + + def __init__(self, data, sensor_type, doorbell): + """Initialize the sensor.""" + self._data = data + self._sensor_type = sensor_type + self._doorbell = doorbell + self._state = None + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return SENSOR_TYPES[self._sensor_type][1] + + @property + def name(self): + """Return the name of the binary sensor.""" + return "{} {}".format(self._doorbell.device_name, + SENSOR_TYPES[self._sensor_type][0]) + + def update(self): + """Get the latest state of the sensor.""" + state_provider = SENSOR_TYPES[self._sensor_type][2] + self._state = state_provider(self._data, self._doorbell) diff --git a/homeassistant/components/camera/august.py b/homeassistant/components/camera/august.py new file mode 100644 index 00000000000..d3bc080bfc6 --- /dev/null +++ b/homeassistant/components/camera/august.py @@ -0,0 +1,76 @@ +""" +Support for August camera. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.august/ +""" +from datetime import timedelta + +import requests + +from homeassistant.components.august import DATA_AUGUST, DEFAULT_TIMEOUT +from homeassistant.components.camera import Camera + +DEPENDENCIES = ['august'] + +SCAN_INTERVAL = timedelta(seconds=5) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up August cameras.""" + data = hass.data[DATA_AUGUST] + devices = [] + + for doorbell in data.doorbells: + devices.append(AugustCamera(data, doorbell, DEFAULT_TIMEOUT)) + + add_devices(devices, True) + + +class AugustCamera(Camera): + """An implementation of a Canary security camera.""" + + def __init__(self, data, doorbell, timeout): + """Initialize a Canary security camera.""" + super().__init__() + self._data = data + self._doorbell = doorbell + self._timeout = timeout + self._image_url = None + self._image_content = None + + @property + def name(self): + """Return the name of this device.""" + return self._doorbell.device_name + + @property + def is_recording(self): + """Return true if the device is recording.""" + return self._doorbell.has_subscription + + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return True + + @property + def brand(self): + """Return the camera brand.""" + return 'August' + + @property + def model(self): + """Return the camera model.""" + return 'Doorbell' + + def camera_image(self): + """Return bytes of camera image.""" + latest = self._data.get_doorbell_detail(self._doorbell.device_id) + + if self._image_url is not latest.image_url: + self._image_url = latest.image_url + self._image_content = requests.get(self._image_url, + timeout=self._timeout).content + + return self._image_content diff --git a/homeassistant/components/lock/august.py b/homeassistant/components/lock/august.py new file mode 100644 index 00000000000..9ca63cb493b --- /dev/null +++ b/homeassistant/components/lock/august.py @@ -0,0 +1,82 @@ +""" +Support for August lock. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lock.august/ +""" +from datetime import timedelta + +from homeassistant.components.august import DATA_AUGUST +from homeassistant.components.lock import LockDevice +from homeassistant.const import ATTR_BATTERY_LEVEL + +DEPENDENCIES = ['august'] + +SCAN_INTERVAL = timedelta(seconds=5) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up August locks.""" + data = hass.data[DATA_AUGUST] + devices = [] + + for lock in data.locks: + devices.append(AugustLock(data, lock)) + + add_devices(devices, True) + + +class AugustLock(LockDevice): + """Representation of an August lock.""" + + def __init__(self, data, lock): + """Initialize the lock.""" + self._data = data + self._lock = lock + self._lock_status = None + self._lock_detail = None + self._changed_by = None + + def lock(self, **kwargs): + """Lock the device.""" + self._data.lock(self._lock.device_id) + + def unlock(self, **kwargs): + """Unlock the device.""" + self._data.unlock(self._lock.device_id) + + def update(self): + """Get the latest state of the sensor.""" + self._lock_status = self._data.get_lock_status(self._lock.device_id) + self._lock_detail = self._data.get_lock_detail(self._lock.device_id) + + from august.activity import ActivityType + activity = self._data.get_latest_device_activity( + self._lock.device_id, + ActivityType.LOCK_OPERATION) + + if activity is not None: + self._changed_by = activity.operated_by + + @property + def name(self): + """Return the name of this device.""" + return self._lock.device_name + + @property + def is_locked(self): + """Return true if device is on.""" + from august.lock import LockStatus + return self._lock_status is LockStatus.LOCKED + + @property + def changed_by(self): + """Last change triggered by.""" + return self._changed_by + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + return { + ATTR_BATTERY_LEVEL: self._lock_detail.battery_level, + } diff --git a/requirements_all.txt b/requirements_all.txt index 86368c45b93..e2c1b321090 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -612,6 +612,9 @@ pushetta==1.0.15 # homeassistant.components.light.rpi_gpio_pwm pwmled==1.2.1 +# homeassistant.components.august +py-august==0.3.0 + # homeassistant.components.canary py-canary==0.4.0 From 0d0e0b8ba32f875206904724a2824bbb1a327260 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sun, 18 Feb 2018 17:06:33 +0100 Subject: [PATCH 107/173] Avoid warnings when purging an empty database (#12494) --- homeassistant/components/recorder/purge.py | 40 +++++++++++++--------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 81c28bb94d9..d2afb6076e3 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -18,38 +18,44 @@ def purge_old_data(instance, purge_days, repack): _LOGGER.debug("Purging events before %s", purge_before) with session_scope(session=instance.get_session()) as session: + delete_states = session.query(States) \ + .filter((States.last_updated < purge_before)) + # For each entity, the most recent state is protected from deletion # s.t. we can properly restore state even if the entity has not been # updated in a long time protected_states = session.query(func.max(States.state_id)) \ .group_by(States.entity_id).all() - protected_state_ids = tuple((state[0] for state in protected_states)) + protected_state_ids = tuple(state[0] for state in protected_states) - deleted_rows = session.query(States) \ - .filter((States.last_updated < purge_before)) \ - .filter(~States.state_id.in_( - protected_state_ids)) \ - .delete(synchronize_session=False) + if protected_state_ids: + delete_states = delete_states \ + .filter(~States.state_id.in_(protected_state_ids)) + + deleted_rows = delete_states.delete(synchronize_session=False) _LOGGER.debug("Deleted %s states", deleted_rows) + delete_events = session.query(Events) \ + .filter((Events.time_fired < purge_before)) + # We also need to protect the events belonging to the protected states. # Otherwise, if the SQL server has "ON DELETE CASCADE" as default, it # will delete the protected state when deleting its associated # event. Also, we would be producing NULLed foreign keys otherwise. - protected_events = session.query(States.event_id) \ - .filter(States.state_id.in_(protected_state_ids)) \ - .filter(States.event_id.isnot(None)) \ - .all() + if protected_state_ids: + protected_events = session.query(States.event_id) \ + .filter(States.state_id.in_(protected_state_ids)) \ + .filter(States.event_id.isnot(None)) \ + .all() - protected_event_ids = tuple((state[0] for state in protected_events)) + protected_event_ids = tuple(state[0] for state in protected_events) - deleted_rows = session.query(Events) \ - .filter((Events.time_fired < purge_before)) \ - .filter(~Events.event_id.in_( - protected_event_ids - )) \ - .delete(synchronize_session=False) + if protected_event_ids: + delete_events = delete_events \ + .filter(~Events.event_id.in_(protected_event_ids)) + + deleted_rows = delete_events.delete(synchronize_session=False) _LOGGER.debug("Deleted %s events", deleted_rows) # Execute sqlite vacuum command to free up space on disk From 2280dc2a347d3f192e6ff963980a6d35a8614e0a Mon Sep 17 00:00:00 2001 From: karlkar Date: Sun, 18 Feb 2018 17:08:56 +0100 Subject: [PATCH 108/173] Support for PTZ in Onvif cameras (#11630) * Service PTZ added * Removed description loading during setup * Fixed hound issues * Changed attribute names * Fixed pylint error * Cleaning up the code * Changed access to protected member to dict * Removed new line added by mistake * Fixed pylint error * Fixed minors * Fixed pylint caused by usage of create_type function * Code made more concise * Fixed string intendation problem * Service name changed * Update code to fit with the new version * Set ptz to None if PTZ setup failed * more precise exception used --- homeassistant/components/camera/onvif.py | 80 +++++++++++++++++-- homeassistant/components/camera/services.yaml | 17 ++++ 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/camera/onvif.py b/homeassistant/components/camera/onvif.py index 17108e73091..1340c52459d 100644 --- a/homeassistant/components/camera/onvif.py +++ b/homeassistant/components/camera/onvif.py @@ -10,13 +10,15 @@ import logging import voluptuous as vol from homeassistant.const import ( - CONF_NAME, CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT) -from homeassistant.components.camera import Camera, PLATFORM_SCHEMA + CONF_NAME, CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, + ATTR_ENTITY_ID) +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA, DOMAIN from homeassistant.components.ffmpeg import ( DATA_FFMPEG, CONF_EXTRA_ARGUMENTS) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.aiohttp_client import ( async_aiohttp_proxy_stream) +from homeassistant.helpers.service import extract_entity_ids _LOGGER = logging.getLogger(__name__) @@ -32,6 +34,22 @@ DEFAULT_USERNAME = 'admin' DEFAULT_PASSWORD = '888888' DEFAULT_ARGUMENTS = '-q:v 2' +ATTR_PAN = "pan" +ATTR_TILT = "tilt" +ATTR_ZOOM = "zoom" + +DIR_UP = "UP" +DIR_DOWN = "DOWN" +DIR_LEFT = "LEFT" +DIR_RIGHT = "RIGHT" +ZOOM_OUT = "ZOOM_OUT" +ZOOM_IN = "ZOOM_IN" + +SERVICE_PTZ = "onvif_ptz" + +ONVIF_DATA = "onvif" +ENTITIES = "entities" + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -41,12 +59,38 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_EXTRA_ARGUMENTS, default=DEFAULT_ARGUMENTS): cv.string, }) +SERVICE_PTZ_SCHEMA = vol.Schema({ + ATTR_ENTITY_ID: cv.entity_ids, + ATTR_PAN: vol.In([DIR_LEFT, DIR_RIGHT]), + ATTR_TILT: vol.In([DIR_UP, DIR_DOWN]), + ATTR_ZOOM: vol.In([ZOOM_OUT, ZOOM_IN]) +}) + @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up a ONVIF camera.""" if not hass.data[DATA_FFMPEG].async_run_test(config.get(CONF_HOST)): return + + def handle_ptz(service): + """Handle PTZ service call.""" + pan = service.data.get(ATTR_PAN, None) + tilt = service.data.get(ATTR_TILT, None) + zoom = service.data.get(ATTR_ZOOM, None) + all_cameras = hass.data[ONVIF_DATA][ENTITIES] + entity_ids = extract_entity_ids(hass, service) + target_cameras = [] + if not entity_ids: + target_cameras = all_cameras + else: + target_cameras = [camera for camera in all_cameras + if camera.entity_id in entity_ids] + for camera in target_cameras: + camera.perform_ptz(pan, tilt, zoom) + + hass.services.async_register(DOMAIN, SERVICE_PTZ, handle_ptz, + schema=SERVICE_PTZ_SCHEMA) async_add_devices([ONVIFHassCamera(hass, config)]) @@ -55,19 +99,21 @@ class ONVIFHassCamera(Camera): def __init__(self, hass, config): """Initialize a ONVIF camera.""" - from onvif import ONVIFCamera + from onvif import ONVIFCamera, exceptions super().__init__() self._name = config.get(CONF_NAME) self._ffmpeg_arguments = config.get(CONF_EXTRA_ARGUMENTS) self._input = None + camera = None try: _LOGGER.debug("Connecting with ONVIF Camera: %s on port %s", config.get(CONF_HOST), config.get(CONF_PORT)) - media_service = ONVIFCamera( + camera = ONVIFCamera( config.get(CONF_HOST), config.get(CONF_PORT), config.get(CONF_USERNAME), config.get(CONF_PASSWORD) - ).create_media_service() + ) + media_service = camera.create_media_service() stream_uri = media_service.GetStreamUri( {'StreamSetup': {'Stream': 'RTP-Unicast', 'Transport': 'RTSP'}} ) @@ -81,6 +127,30 @@ class ONVIFHassCamera(Camera): except Exception as err: _LOGGER.error("Unable to communicate with ONVIF Camera: %s", err) raise + try: + self._ptz = camera.create_ptz_service() + except exceptions.ONVIFError as err: + self._ptz = None + _LOGGER.warning("Unable to setup PTZ for ONVIF Camera: %s", err) + + def perform_ptz(self, pan, tilt, zoom): + """Perform a PTZ action on the camera.""" + if self._ptz: + pan_val = 1 if pan == DIR_RIGHT else -1 if pan == DIR_LEFT else 0 + tilt_val = 1 if tilt == DIR_UP else -1 if tilt == DIR_DOWN else 0 + zoom_val = 1 if zoom == ZOOM_IN else -1 if zoom == ZOOM_OUT else 0 + req = {"Velocity": { + "PanTilt": {"_x": pan_val, "_y": tilt_val}, + "Zoom": {"_x": zoom_val}}} + self._ptz.ContinuousMove(req) + + @asyncio.coroutine + def async_added_to_hass(self): + """Callback when entity is added to hass.""" + if ONVIF_DATA not in self.hass.data: + self.hass.data[ONVIF_DATA] = {} + self.hass.data[ONVIF_DATA][ENTITIES] = [] + self.hass.data[ONVIF_DATA][ENTITIES].append(self) @asyncio.coroutine def async_camera_image(self): diff --git a/homeassistant/components/camera/services.yaml b/homeassistant/components/camera/services.yaml index 926af582cc7..b548f3d1ada 100644 --- a/homeassistant/components/camera/services.yaml +++ b/homeassistant/components/camera/services.yaml @@ -23,3 +23,20 @@ snapshot: filename: description: Template of a Filename. Variable is entity_id. example: '/tmp/snapshot_{{ entity_id }}' + +onvif_ptz: + description: Pan/Tilt/Zoom service for ONVIF camera. + fields: + entity_id: + description: Name(s) of entities to pan, tilt or zoom. + example: 'camera.living_room_camera' + pan: + description: "Direction of pan. Allowed values: LEFT, RIGHT." + example: 'LEFT' + tilt: + description: "Direction of tilt. Allowed values: DOWN, UP." + example: 'DOWN' + zoom: + description: "Zoom. Allowed values: ZOOM_IN, ZOOM_OUT" + example: "ZOOM_IN" + From 635d36c6baaa44d3368a22981020e1e723b2043d Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sun, 18 Feb 2018 20:05:20 +0100 Subject: [PATCH 109/173] Rework Sonos media player platform (#12126) * Rework Sonos media player platform for push * Ignore play_mode from events where it is missing * Remove unused preload helper * Freeze SoCo version * Updates for entity registry * Add codeowner * Use real soco release --- CODEOWNERS | 1 + .../components/media_player/__init__.py | 7 - .../components/media_player/sonos.py | 1023 +++++++---------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/media_player/test_sonos.py | 122 +- 6 files changed, 461 insertions(+), 696 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index e7811d7fdeb..ff07940d9cb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -56,6 +56,7 @@ homeassistant/components/light/yeelight.py @rytilahti homeassistant/components/media_player/kodi.py @armills homeassistant/components/media_player/mediaroom.py @dgomes homeassistant/components/media_player/monoprice.py @etsinko +homeassistant/components/media_player/sonos.py @amelchio homeassistant/components/media_player/xiaomi_tv.py @fattdev homeassistant/components/media_player/yamaha_musiccast.py @jalmeroth homeassistant/components/plant.py @ChristianKuehnel diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 06e89548785..37536bf5586 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -31,7 +31,6 @@ from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.loader import bind_hass -from homeassistant.util.async import run_coroutine_threadsafe _LOGGER = logging.getLogger(__name__) _RND = SystemRandom() @@ -878,12 +877,6 @@ class MediaPlayerDevice(Entity): return state_attr - def preload_media_image_url(self, url): - """Preload and cache a media image for future use.""" - run_coroutine_threadsafe( - _async_fetch_image(self.hass, url), self.hass.loop - ).result() - @asyncio.coroutine def _async_fetch_image(hass, url): diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index d4a7fd3adb5..0fbd88ffc54 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -10,6 +10,7 @@ import functools as ft import logging import socket import urllib +import threading import voluptuous as vol @@ -25,23 +26,17 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow -REQUIREMENTS = ['SoCo==0.13'] +REQUIREMENTS = ['SoCo==0.14'] _LOGGER = logging.getLogger(__name__) -# The soco library is excessively chatty when it comes to logging and -# causes a LOT of spam in the logs due to making a http connection to each -# speaker every 10 seconds. Quiet it down a bit to just actual problems. -_SOCO_LOGGER = logging.getLogger('soco') -_SOCO_LOGGER.setLevel(logging.ERROR) +# Quiet down soco logging to just actual problems. +logging.getLogger('soco').setLevel(logging.WARNING) _SOCO_SERVICES_LOGGER = logging.getLogger('soco.services') -_REQUESTS_LOGGER = logging.getLogger('requests') -_REQUESTS_LOGGER.setLevel(logging.ERROR) -SUPPORT_SONOS = SUPPORT_STOP | SUPPORT_PAUSE | SUPPORT_VOLUME_SET |\ - SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK |\ +SUPPORT_SONOS = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\ SUPPORT_PLAY_MEDIA | SUPPORT_SEEK | SUPPORT_CLEAR_PLAYLIST |\ - SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_SHUFFLE_SET + SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_STOP SERVICE_JOIN = 'sonos_join' SERVICE_UNJOIN = 'sonos_unjoin' @@ -54,8 +49,8 @@ SERVICE_SET_OPTION = 'sonos_set_option' DATA_SONOS = 'sonos' -SUPPORT_SOURCE_LINEIN = 'Line-in' -SUPPORT_SOURCE_TV = 'TV' +SOURCE_LINEIN = 'Line-in' +SOURCE_TV = 'TV' CONF_ADVERTISE_ADDR = 'advertise_addr' CONF_INTERFACE_ADDR = 'interface_addr' @@ -112,12 +107,21 @@ SONOS_SET_OPTION_SCHEMA = SONOS_SCHEMA.extend({ }) +class SonosData: + """Storage class for platform global data.""" + + def __init__(self): + """Initialize the data.""" + self.devices = [] + self.topology_lock = threading.Lock() + + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Sonos platform.""" import soco if DATA_SONOS not in hass.data: - hass.data[DATA_SONOS] = [] + hass.data[DATA_SONOS] = SonosData() advertise_addr = config.get(CONF_ADVERTISE_ADDR, None) if advertise_addr: @@ -127,14 +131,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): player = soco.SoCo(discovery_info.get('host')) # If device already exists by config - if player.uid in [x.unique_id for x in hass.data[DATA_SONOS]]: + if player.uid in [x.unique_id for x in hass.data[DATA_SONOS].devices]: return if player.is_visible: device = SonosDevice(player) - add_devices([device], True) - hass.data[DATA_SONOS].append(device) - if len(hass.data[DATA_SONOS]) > 1: + hass.data[DATA_SONOS].devices.append(device) + add_devices([device]) + if len(hass.data[DATA_SONOS].devices) > 1: return else: players = None @@ -159,14 +163,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.warning("No Sonos speakers found") return - # Add coordinators first so they can be queried by slaves - coordinators = [SonosDevice(p) for p in players if p.is_coordinator] - slaves = [SonosDevice(p) for p in players if not p.is_coordinator] - hass.data[DATA_SONOS] = coordinators + slaves - if coordinators: - add_devices(coordinators, True) - if slaves: - add_devices(slaves, True) + hass.data[DATA_SONOS].devices = [SonosDevice(p) for p in players] + add_devices(hass.data[DATA_SONOS].devices) _LOGGER.debug("Added %s Sonos speakers", len(players)) def service_handle(service): @@ -174,16 +172,20 @@ def setup_platform(hass, config, add_devices, discovery_info=None): entity_ids = service.data.get('entity_id') if entity_ids: - devices = [device for device in hass.data[DATA_SONOS] + devices = [device for device in hass.data[DATA_SONOS].devices if device.entity_id in entity_ids] else: - devices = hass.data[DATA_SONOS] + devices = hass.data[DATA_SONOS].devices + + if service.service == SERVICE_JOIN: + master = [device for device in hass.data[DATA_SONOS].devices + if device.entity_id == service.data[ATTR_MASTER]] + if master: + master[0].join(devices) + return for device in devices: - if service.service == SERVICE_JOIN: - if device.entity_id != service.data[ATTR_MASTER]: - device.join(service.data[ATTR_MASTER]) - elif service.service == SERVICE_UNJOIN: + if service.service == SERVICE_UNJOIN: device.unjoin() elif service.service == SERVICE_SNAPSHOT: device.snapshot(service.data[ATTR_WITH_GROUP]) @@ -233,35 +235,24 @@ def setup_platform(hass, config, add_devices, discovery_info=None): schema=SONOS_SET_OPTION_SCHEMA) -def _parse_timespan(timespan): - """Parse a time-span into number of seconds.""" - if timespan in ('', 'NOT_IMPLEMENTED', None): - return None - - return sum(60 ** x[0] * int(x[1]) for x in enumerate( - reversed(timespan.split(':')))) - - -class _ProcessSonosEventQueue(object): +class _ProcessSonosEventQueue: """Queue like object for dispatching sonos events.""" - def __init__(self, sonos_device): + def __init__(self, handler): """Initialize Sonos event queue.""" - self._sonos_device = sonos_device + self._handler = handler def put(self, item, block=True, timeout=None): - """Queue up event for processing.""" - # Instead of putting events on a queue, dispatch them to the event - # processing method. - self._sonos_device.process_sonos_event(item) + """Process event.""" + self._handler(item) -def _get_entity_from_soco(hass, soco): - """Return SonosDevice from SoCo.""" - for device in hass.data[DATA_SONOS]: - if soco == device.soco: - return device - raise ValueError("No entity for SoCo device") +def _get_entity_from_soco_uid(hass, uid): + """Return SonosDevice from SoCo uid.""" + for entity in hass.data[DATA_SONOS].devices: + if uid == entity.soco.uid: + return entity + return None def soco_error(errorcodes=None): @@ -305,21 +296,37 @@ def soco_coordinator(funct): return wrapper +def _timespan_secs(timespan): + """Parse a time-span into number of seconds.""" + if timespan in ('', 'NOT_IMPLEMENTED', None): + return None + + return sum(60 ** x[0] * int(x[1]) for x in enumerate( + reversed(timespan.split(':')))) + + +def _is_radio_uri(uri): + """Return whether the URI is a radio stream.""" + return uri.startswith('x-rincon-mp3radio:') or \ + uri.startswith('x-sonosapi-stream:') + + class SonosDevice(MediaPlayerDevice): """Representation of a Sonos device.""" def __init__(self, player): """Initialize the Sonos device.""" - self.volume_increment = 5 + self._volume_increment = 5 self._unique_id = player.uid self._player = player + self._model = None self._player_volume = None self._player_volume_muted = None - self._speaker_info = None + self._play_mode = None self._name = None - self._status = None self._coordinator = None - self._media_content_id = None + self._status = None + self._extra_features = 0 self._media_duration = None self._media_position = None self._media_position_updated_at = None @@ -327,37 +334,21 @@ class SonosDevice(MediaPlayerDevice): self._media_artist = None self._media_album_name = None self._media_title = None - self._media_radio_show = None - self._available = True - self._support_previous_track = False - self._support_next_track = False - self._support_play = False - self._support_shuffle_set = True - self._support_stop = False - self._support_pause = False self._night_sound = None self._speech_enhance = None - self._current_track_uri = None - self._current_track_is_radio_stream = False - self._queue = None - self._last_avtransport_event = None - self._is_playing_line_in = None - self._is_playing_tv = None - self._favorite_sources = None self._source_name = None + self._available = True + self._favorites = None self._soco_snapshot = None self._snapshot_group = None + self._set_basic_information() + @asyncio.coroutine def async_added_to_hass(self): """Subscribe sonos events.""" self.hass.async_add_job(self._subscribe_to_player_events) - @property - def should_poll(self): - """Return the polling state.""" - return True - @property def unique_id(self): """Return an unique ID.""" @@ -369,10 +360,9 @@ class SonosDevice(MediaPlayerDevice): return self._name @property + @soco_coordinator def state(self): """Return the state of the device.""" - if self._coordinator: - return self._coordinator.state if self._status in ('PAUSED_PLAYBACK', 'STOPPED'): return STATE_PAUSED if self._status in ('PLAYING', 'TRANSITIONING'): @@ -401,260 +391,285 @@ class SonosDevice(MediaPlayerDevice): """Return True if entity is available.""" return self._available - def _is_available(self): + def _check_available(self): + """Check that we can still connect to the player.""" try: sock = socket.create_connection( - address=(self._player.ip_address, 1443), timeout=3) + address=(self.soco.ip_address, 1443), timeout=3) sock.close() return True except socket.error: return False - # pylint: disable=invalid-name + def _set_basic_information(self): + """Set initial device information.""" + speaker_info = self.soco.get_speaker_info(True) + self._name = speaker_info['zone_name'] + self._model = speaker_info['model_name'] + self._player_volume = self.soco.volume + self._player_volume_muted = self.soco.mute + self._play_mode = self.soco.play_mode + self._night_sound = self.soco.night_mode + self._speech_enhance = self.soco.dialog_mode + self._favorites = self.soco.music_library.get_sonos_favorites() + def _subscribe_to_player_events(self): - if self._queue is None: - self._queue = _ProcessSonosEventQueue(self) - self._player.avTransport.subscribe( - auto_renew=True, - event_queue=self._queue) - self._player.renderingControl.subscribe( - auto_renew=True, - event_queue=self._queue) + """Add event subscriptions.""" + player = self.soco + + queue = _ProcessSonosEventQueue(self.process_avtransport_event) + player.avTransport.subscribe(auto_renew=True, event_queue=queue) + + queue = _ProcessSonosEventQueue(self.process_rendering_event) + player.renderingControl.subscribe(auto_renew=True, event_queue=queue) + + queue = _ProcessSonosEventQueue(self.process_zonegrouptopology_event) + player.zoneGroupTopology.subscribe(auto_renew=True, event_queue=queue) def update(self): """Retrieve latest state.""" - if self._speaker_info is None: - self._speaker_info = self._player.get_speaker_info(True) - self._name = self._speaker_info['zone_name'].replace( - ' (R)', '').replace(' (L)', '') - self._favorite_sources = \ - self._player.get_sonos_favorites()['favorites'] - - if self._last_avtransport_event: - self._available = True - else: - self._available = self._is_available() - - if not self._available: - self._player_volume = None - self._player_volume_muted = None - self._status = 'OFF' - self._coordinator = None - self._media_content_id = None - self._media_duration = None - self._media_position = None - self._media_position_updated_at = None - self._media_image_url = None - self._media_artist = None - self._media_album_name = None - self._media_title = None - self._media_radio_show = None - self._current_track_uri = None - self._current_track_is_radio_stream = False - self._support_previous_track = False - self._support_next_track = False - self._support_play = False - self._support_shuffle_set = False - self._support_stop = False - self._support_pause = False - self._night_sound = None - self._speech_enhance = None - self._is_playing_tv = False - self._is_playing_line_in = False - self._source_name = None - self._last_avtransport_event = None - return - - # set group coordinator - if self._player.is_coordinator: - self._coordinator = None - else: - try: - self._coordinator = _get_entity_from_soco( - self.hass, self._player.group.coordinator) - - # protect for loop - if not self._coordinator.is_coordinator: - # pylint: disable=protected-access - self._coordinator._coordinator = None - except ValueError: + available = self._check_available() + if self._available != available: + self._available = available + if available: + self._set_basic_information() + self._subscribe_to_player_events() + else: + self._player_volume = None + self._player_volume_muted = None + self._status = 'OFF' self._coordinator = None + self._media_duration = None + self._media_position = None + self._media_position_updated_at = None + self._media_image_url = None + self._media_artist = None + self._media_album_name = None + self._media_title = None + self._extra_features = 0 + self._source_name = None - track_info = None - if self._last_avtransport_event: - variables = self._last_avtransport_event.variables - current_track_metadata = variables.get( - 'current_track_meta_data', {} - ) + def process_avtransport_event(self, event): + """Process a track change event coming from a coordinator.""" + variables = event.variables - self._status = variables.get('transport_state') - - if current_track_metadata: - # no need to ask speaker for information we already have - current_track_metadata = current_track_metadata.__dict__ - - track_info = { - 'uri': variables.get('current_track_uri'), - 'artist': current_track_metadata.get('creator'), - 'album': current_track_metadata.get('album'), - 'title': current_track_metadata.get('title'), - 'playlist_position': variables.get('current_track'), - 'duration': variables.get('current_track_duration') - } - else: - self._player_volume = self._player.volume - self._player_volume_muted = self._player.mute - transport_info = self._player.get_current_transport_info() - self._status = transport_info.get('current_transport_state') - - if not track_info: - track_info = self._player.get_current_track_info() - - if self._coordinator: - self._last_avtransport_event = None + # Ignore transitions, we should get the target state soon + new_status = variables.get('transport_state') + if new_status == 'TRANSITIONING': return - is_playing_tv = self._player.is_playing_tv - is_playing_line_in = self._player.is_playing_line_in - - media_info = self._player.avTransport.GetMediaInfo( - [('InstanceID', 0)] - ) - - current_media_uri = media_info['CurrentURI'] - media_artist = track_info.get('artist') - media_album_name = track_info.get('album') - media_title = track_info.get('title') - media_image_url = track_info.get('album_art', None) - - media_position = None - media_position_updated_at = None - source_name = None - - night_sound = self._player.night_mode - speech_enhance = self._player.dialog_mode - - is_radio_stream = \ - current_media_uri.startswith('x-sonosapi-stream:') or \ - current_media_uri.startswith('x-rincon-mp3radio:') - - if is_playing_tv or is_playing_line_in: - # playing from line-in/tv. - - support_previous_track = False - support_next_track = False - support_play = False - support_stop = True - support_pause = False - support_shuffle_set = False - - if is_playing_tv: - media_artist = SUPPORT_SOURCE_TV - else: - media_artist = SUPPORT_SOURCE_LINEIN - - source_name = media_artist - - media_album_name = None - media_title = None - media_image_url = None - - elif is_radio_stream: - media_image_url = self._format_media_image_url( - media_image_url, - current_media_uri - ) - support_previous_track = False - support_next_track = False - support_play = True - support_stop = True - support_pause = False - support_shuffle_set = False - - source_name = 'Radio' - # Check if currently playing radio station is in favorites - favc = [fav for fav in self._favorite_sources - if fav['uri'] == current_media_uri] - if len(favc) == 1: - src = favc.pop() - source_name = src['title'] - - # for radio streams we set the radio station name as the - # title. - if media_artist and media_title: - # artist and album name are in the data, concatenate - # that do display as artist. - # "Information" field in the sonos pc app - - media_artist = '{artist} - {title}'.format( - artist=media_artist, - title=media_title - ) - else: - # "On Now" field in the sonos pc app - media_artist = self._media_radio_show - - current_uri_metadata = media_info["CurrentURIMetaData"] - if current_uri_metadata not in ('', 'NOT_IMPLEMENTED', None): - - # currently soco does not have an API for this - import soco - current_uri_metadata = soco.xml.XML.fromstring( - soco.utils.really_utf8(current_uri_metadata)) - - md_title = current_uri_metadata.findtext( - './/{http://purl.org/dc/elements/1.1/}title') - - if md_title not in ('', 'NOT_IMPLEMENTED', None): - media_title = md_title - - if media_artist and media_title: - # some radio stations put their name into the artist - # name, e.g.: - # media_title = "Station" - # media_artist = "Station - Artist - Title" - # detect this case and trim from the front of - # media_artist for cosmetics - str_to_trim = '{title} - '.format( - title=media_title - ) - chars = min(len(media_artist), len(str_to_trim)) - - if media_artist[:chars].upper() == str_to_trim[:chars].upper(): - media_artist = media_artist[chars:] + self._play_mode = variables.get('current_play_mode', self._play_mode) + if self.soco.is_playing_tv: + self._refresh_linein(SOURCE_TV) + elif self.soco.is_playing_line_in: + self._refresh_linein(SOURCE_LINEIN) else: - # not a radio stream - media_image_url = self._format_media_image_url( - media_image_url, - track_info['uri'] - ) - support_previous_track = True - support_next_track = True - support_play = True - support_stop = True - support_pause = True - support_shuffle_set = True + track_info = self.soco.get_current_track_info() - position_info = self._player.avTransport.GetPositionInfo( - [('InstanceID', 0), - ('Channel', 'Master')] - ) - rel_time = _parse_timespan( - position_info.get("RelTime") + media_info = self.soco.avTransport.GetMediaInfo( + [('InstanceID', 0)] ) - # player no longer reports position? - update_media_position = rel_time is None and \ - self._media_position is not None + if _is_radio_uri(track_info['uri']): + self._refresh_radio(variables, media_info, track_info) + else: + self._refresh_music(variables, media_info, track_info) - # player started reporting position? - update_media_position |= rel_time is not None and \ - self._media_position is None + if new_status: + self._status = new_status - # position changed? + self.schedule_update_ha_state() + + # Also update slaves + for entity in self.hass.data[DATA_SONOS].devices: + coordinator = entity.coordinator + if coordinator and coordinator.unique_id == self.unique_id: + entity.schedule_update_ha_state() + + def process_rendering_event(self, event): + """Process a volume change event coming from a player.""" + variables = event.variables + + if 'volume' in variables: + self._player_volume = int(variables['volume']['Master']) + + if 'mute' in variables: + self._player_volume_muted = (variables['mute']['Master'] == '1') + + if 'night_mode' in variables: + self._night_sound = (variables['night_mode'] == '1') + + if 'dialog_level' in variables: + self._speech_enhance = (variables['dialog_level'] == '1') + + self.schedule_update_ha_state() + + def process_zonegrouptopology_event(self, event): + """Process a zone group topology event coming from a player.""" + if not hasattr(event, 'zone_player_uui_ds_in_group'): + return + + with self.hass.data[DATA_SONOS].topology_lock: + group = event.zone_player_uui_ds_in_group + if group: + # New group information is pushed + coordinator_uid, *slave_uids = group.split(',') + else: + # Use SoCo cache for existing topology + coordinator_uid = self.soco.group.coordinator.uid + slave_uids = [p.uid for p in self.soco.group.members + if p.uid != coordinator_uid] + + if self.unique_id == coordinator_uid: + self._coordinator = None + self.schedule_update_ha_state() + + for slave_uid in slave_uids: + slave = _get_entity_from_soco_uid(self.hass, slave_uid) + if slave: + # pylint: disable=protected-access + slave._coordinator = self + slave.schedule_update_ha_state() + + def _radio_artwork(self, url): + """Return the private URL with artwork for a radio stream.""" + if url not in ('', 'NOT_IMPLEMENTED', None): + if url.find('tts_proxy') > 0: + # If the content is a tts don't try to fetch an image from it. + return None + url = 'http://{host}:{port}/getaa?s=1&u={uri}'.format( + host=self.soco.ip_address, + port=1400, + uri=urllib.parse.quote(url, safe='') + ) + return url + + def _refresh_linein(self, source): + """Update state when playing from line-in/tv.""" + self._extra_features = 0 + + self._media_duration = None + self._media_position = None + self._media_position_updated_at = None + + self._media_image_url = None + + self._media_artist = source + self._media_album_name = None + self._media_title = None + + self._source_name = source + + def _refresh_radio(self, variables, media_info, track_info): + """Update state when streaming radio.""" + self._extra_features = 0 + + self._media_duration = None + self._media_position = None + self._media_position_updated_at = None + + self._media_image_url = self._radio_artwork(media_info['CurrentURI']) + + self._media_artist = track_info.get('artist') + self._media_album_name = None + self._media_title = track_info.get('title') + + if self._media_artist and self._media_title: + # artist and album name are in the data, concatenate + # that do display as artist. + # "Information" field in the sonos pc app + self._media_artist = '{artist} - {title}'.format( + artist=self._media_artist, + title=self._media_title + ) + else: + # "On Now" field in the sonos pc app + current_track_metadata = variables.get( + 'current_track_meta_data' + ) + if current_track_metadata: + self._media_artist = \ + current_track_metadata.radio_show.split(',')[0] + + # For radio streams we set the radio station name as the title. + current_uri_metadata = media_info["CurrentURIMetaData"] + if current_uri_metadata not in ('', 'NOT_IMPLEMENTED', None): + # currently soco does not have an API for this + import soco + current_uri_metadata = soco.xml.XML.fromstring( + soco.utils.really_utf8(current_uri_metadata)) + + md_title = current_uri_metadata.findtext( + './/{http://purl.org/dc/elements/1.1/}title') + + if md_title not in ('', 'NOT_IMPLEMENTED', None): + self._media_title = md_title + + if self._media_artist and self._media_title: + # some radio stations put their name into the artist + # name, e.g.: + # media_title = "Station" + # media_artist = "Station - Artist - Title" + # detect this case and trim from the front of + # media_artist for cosmetics + trim = '{title} - '.format(title=self._media_title) + chars = min(len(self._media_artist), len(trim)) + + if self._media_artist[:chars].upper() == trim[:chars].upper(): + self._media_artist = self._media_artist[chars:] + + # Check if currently playing radio station is in favorites + self._source_name = None + for fav in self._favorites: + if fav.reference.get_uri() == media_info['CurrentURI']: + self._source_name = fav.title + + def _refresh_music(self, variables, media_info, track_info): + """Update state when playing music tracks.""" + self._extra_features = SUPPORT_PAUSE | SUPPORT_SHUFFLE_SET |\ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK + + playlist_position = track_info.get('playlist_position') + if playlist_position in ('', 'NOT_IMPLEMENTED', None): + playlist_position = None + else: + playlist_position = int(playlist_position) + + playlist_size = media_info.get('NrTracks') + if playlist_size in ('', 'NOT_IMPLEMENTED', None): + playlist_size = None + else: + playlist_size = int(playlist_size) + + if playlist_position is not None and playlist_size is not None: + if playlist_position <= 1: + self._extra_features &= ~SUPPORT_PREVIOUS_TRACK + + if playlist_position == playlist_size: + self._extra_features &= ~SUPPORT_NEXT_TRACK + + self._media_duration = _timespan_secs(track_info.get('duration')) + + position_info = self.soco.avTransport.GetPositionInfo( + [('InstanceID', 0), + ('Channel', 'Master')] + ) + rel_time = _timespan_secs(position_info.get("RelTime")) + + # player no longer reports position? + update_media_position = rel_time is None and \ + self._media_position is not None + + # player started reporting position? + update_media_position |= rel_time is not None and \ + self._media_position is None + + if self._status != variables.get('transport_state'): + update_media_position = True + else: + # position jumped? if rel_time is not None and self._media_position is not None: - time_diff = utcnow() - self._media_position_updated_at time_diff = time_diff.total_seconds() @@ -663,115 +678,22 @@ class SonosDevice(MediaPlayerDevice): update_media_position = \ abs(calculated_position - rel_time) > 1.5 - if update_media_position and self.state == STATE_PLAYING: - media_position = rel_time - media_position_updated_at = utcnow() - else: - # don't update media_position (don't want unneeded - # state transitions) - media_position = self._media_position - media_position_updated_at = self._media_position_updated_at + if update_media_position: + self._media_position = rel_time + self._media_position_updated_at = utcnow() - playlist_position = track_info.get('playlist_position') - if playlist_position in ('', 'NOT_IMPLEMENTED', None): - playlist_position = None - else: - playlist_position = int(playlist_position) + self._media_image_url = track_info.get('album_art') - playlist_size = media_info.get('NrTracks') - if playlist_size in ('', 'NOT_IMPLEMENTED', None): - playlist_size = None - else: - playlist_size = int(playlist_size) + self._media_artist = track_info.get('artist') + self._media_album_name = track_info.get('album') + self._media_title = track_info.get('title') - if playlist_position is not None and playlist_size is not None: - - if playlist_position <= 1: - support_previous_track = False - - if playlist_position == playlist_size: - support_next_track = False - - self._media_content_id = track_info.get('title') - self._media_duration = _parse_timespan( - track_info.get('duration') - ) - self._media_position = media_position - self._media_position_updated_at = media_position_updated_at - self._media_image_url = media_image_url - self._media_artist = media_artist - self._media_album_name = media_album_name - self._media_title = media_title - self._current_track_uri = track_info['uri'] - self._current_track_is_radio_stream = is_radio_stream - self._support_previous_track = support_previous_track - self._support_next_track = support_next_track - self._support_play = support_play - self._support_shuffle_set = support_shuffle_set - self._support_stop = support_stop - self._support_pause = support_pause - self._night_sound = night_sound - self._speech_enhance = speech_enhance - self._is_playing_tv = is_playing_tv - self._is_playing_line_in = is_playing_line_in - self._source_name = source_name - self._last_avtransport_event = None - - def _format_media_image_url(self, url, fallback_uri): - if url in ('', 'NOT_IMPLEMENTED', None): - if fallback_uri in ('', 'NOT_IMPLEMENTED', None): - return None - if fallback_uri.find('tts_proxy') > 0: - # If the content is a tts don't try to fetch an image from it. - return None - return 'http://{host}:{port}/getaa?s=1&u={uri}'.format( - host=self._player.ip_address, - port=1400, - uri=urllib.parse.quote(fallback_uri) - ) - return url - - def process_sonos_event(self, event): - """Process a service event coming from the speaker.""" - next_track_image_url = None - if event.service == self._player.avTransport: - self._last_avtransport_event = event - - self._media_radio_show = None - if self._current_track_is_radio_stream: - current_track_metadata = event.variables.get( - 'current_track_meta_data' - ) - if current_track_metadata: - self._media_radio_show = \ - current_track_metadata.radio_show.split(',')[0] - - next_track_uri = event.variables.get('next_track_uri') - if next_track_uri: - next_track_image_url = self._format_media_image_url( - None, - next_track_uri - ) - - elif event.service == self._player.renderingControl: - if 'volume' in event.variables: - self._player_volume = int( - event.variables['volume'].get('Master') - ) - - if 'mute' in event.variables: - self._player_volume_muted = \ - event.variables['mute'].get('Master') == '1' - - self.schedule_update_ha_state(True) - - if next_track_image_url: - self.preload_media_image_url(next_track_image_url) + self._source_name = None @property def volume_level(self): """Volume level of the media player (0..1).""" - return self._player_volume / 100.0 + return self._player_volume / 100 @property def is_volume_muted(self): @@ -779,17 +701,10 @@ class SonosDevice(MediaPlayerDevice): return self._player_volume_muted @property + @soco_coordinator def shuffle(self): """Shuffling state.""" - return True if self._player.play_mode == 'SHUFFLE' else False - - @property - def media_content_id(self): - """Content ID of current playing media.""" - if self._coordinator: - return self._coordinator.media_content_id - - return self._media_content_id + return 'SHUFFLE' in self._play_mode @property def media_content_type(self): @@ -797,260 +712,170 @@ class SonosDevice(MediaPlayerDevice): return MEDIA_TYPE_MUSIC @property + @soco_coordinator def media_duration(self): """Duration of current playing media in seconds.""" - if self._coordinator: - return self._coordinator.media_duration - return self._media_duration @property + @soco_coordinator def media_position(self): """Position of current playing media in seconds.""" - if self._coordinator: - return self._coordinator.media_position - return self._media_position @property + @soco_coordinator def media_position_updated_at(self): - """When was the position of the current playing media valid. - - Returns value from homeassistant.util.dt.utcnow(). - """ - if self._coordinator: - return self._coordinator.media_position_updated_at - + """When was the position of the current playing media valid.""" return self._media_position_updated_at @property + @soco_coordinator def media_image_url(self): """Image url of current playing media.""" - if self._coordinator: - return self._coordinator.media_image_url - - return self._media_image_url + return self._media_image_url or None @property + @soco_coordinator def media_artist(self): """Artist of current playing media, music track only.""" - if self._coordinator: - return self._coordinator.media_artist - return self._media_artist @property + @soco_coordinator def media_album_name(self): """Album name of current playing media, music track only.""" - if self._coordinator: - return self._coordinator.media_album_name - return self._media_album_name @property + @soco_coordinator def media_title(self): """Title of current playing media.""" - if self._coordinator: - return self._coordinator.media_title - return self._media_title @property - def night_sound(self): - """Get status of Night Sound.""" - return self._night_sound - - @property - def speech_enhance(self): - """Get status of Speech Enhancement.""" - return self._speech_enhance + @soco_coordinator + def source(self): + """Name of the current input source.""" + return self._source_name @property + @soco_coordinator def supported_features(self): """Flag media player features that are supported.""" - if self._coordinator: - return self._coordinator.supported_features - - supported = SUPPORT_SONOS - - if not self._support_previous_track: - supported = supported ^ SUPPORT_PREVIOUS_TRACK - - if not self._support_next_track: - supported = supported ^ SUPPORT_NEXT_TRACK - - if not self._support_play: - supported = supported ^ SUPPORT_PLAY - if not self._support_shuffle_set: - supported = supported ^ SUPPORT_SHUFFLE_SET - if not self._support_stop: - supported = supported ^ SUPPORT_STOP - - if not self._support_pause: - supported = supported ^ SUPPORT_PAUSE - - return supported + return SUPPORT_SONOS | self._extra_features @soco_error() def volume_up(self): """Volume up media player.""" - self._player.volume += self.volume_increment + self._player.volume += self._volume_increment @soco_error() def volume_down(self): """Volume down media player.""" - self._player.volume -= self.volume_increment + self._player.volume -= self._volume_increment @soco_error() def set_volume_level(self, volume): """Set volume level, range 0..1.""" - self._player.volume = str(int(volume * 100)) + self.soco.volume = str(int(volume * 100)) @soco_error() + @soco_coordinator def set_shuffle(self, shuffle): """Enable/Disable shuffle mode.""" - self._player.play_mode = 'SHUFFLE' if shuffle else 'NORMAL' + self.soco.play_mode = 'SHUFFLE_NOREPEAT' if shuffle else 'NORMAL' @soco_error() def mute_volume(self, mute): """Mute (true) or unmute (false) media player.""" - self._player.mute = mute + self.soco.mute = mute @soco_error() @soco_coordinator def select_source(self, source): """Select input source.""" - if source == SUPPORT_SOURCE_LINEIN: - self._source_name = SUPPORT_SOURCE_LINEIN - self._player.switch_to_line_in() - elif source == SUPPORT_SOURCE_TV: - self._source_name = SUPPORT_SOURCE_TV - self._player.switch_to_tv() + if source == SOURCE_LINEIN: + self.soco.switch_to_line_in() + elif source == SOURCE_TV: + self.soco.switch_to_tv() else: - fav = [fav for fav in self._favorite_sources - if fav['title'] == source] + fav = [fav for fav in self._favorites + if fav.title == source] if len(fav) == 1: src = fav.pop() - self._source_name = src['title'] - - if ('object.container.playlistContainer' in src['meta'] or - 'object.container.album.musicAlbum' in src['meta']): - self._replace_queue_with_playlist(src) - self._player.play_from_queue(0) + uri = src.reference.get_uri() + if _is_radio_uri(uri): + self.soco.play_uri(uri, title=source) else: - self._player.play_uri(src['uri'], src['meta'], - src['title']) - - def _replace_queue_with_playlist(self, src): - """Replace queue with playlist represented by src. - - Playlists can't be played directly with the self._player.play_uri - API as they are actually composed of multiple URLs. Until soco has - support for playing a playlist, we'll need to parse the playlist item - and replace the current queue in order to play it. - """ - import soco - import xml.etree.ElementTree as ET - - root = ET.fromstring(src['meta']) - namespaces = {'item': - 'urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/', - 'desc': 'urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/'} - desc = root.find('item:item', namespaces).find('desc:desc', - namespaces).text - - res = [soco.data_structures.DidlResource(uri=src['uri'], - protocol_info="DUMMY")] - didl = soco.data_structures.DidlItem(title="DUMMY", - parent_id="DUMMY", - item_id=src['uri'], - desc=desc, - resources=res) - - self._player.stop() - self._player.clear_queue() - self._player.add_to_queue(didl) + self.soco.clear_queue() + self.soco.add_to_queue(src.reference) + self.soco.play_from_queue(0) @property + @soco_coordinator def source_list(self): """List of available input sources.""" - if self._coordinator: - return self._coordinator.source_list + sources = [fav.title for fav in self._favorites] - model_name = self._speaker_info['model_name'] - sources = [] + if 'PLAY:5' in self._model or 'CONNECT' in self._model: + sources += [SOURCE_LINEIN] + elif 'PLAYBAR' in self._model: + sources += [SOURCE_LINEIN, SOURCE_TV] - if self._favorite_sources: - for fav in self._favorite_sources: - sources.append(fav['title']) - - if 'PLAY:5' in model_name: - sources += [SUPPORT_SOURCE_LINEIN] - elif 'PLAYBAR' in model_name: - sources += [SUPPORT_SOURCE_LINEIN, SUPPORT_SOURCE_TV] return sources - @property - def source(self): - """Name of the current input source.""" - if self._coordinator: - return self._coordinator.source - - return self._source_name + @soco_error() + def turn_on(self): + """Turn the media player on.""" + self.media_play() @soco_error() def turn_off(self): """Turn off media player.""" - if self._support_stop: - self.media_stop() + self.media_stop() @soco_error(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_play(self): """Send play command.""" - self._player.play() + self.soco.play() @soco_error(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_stop(self): """Send stop command.""" - self._player.stop() + self.soco.stop() @soco_error(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_pause(self): """Send pause command.""" - self._player.pause() + self.soco.pause() @soco_error() @soco_coordinator def media_next_track(self): """Send next track command.""" - self._player.next() + self.soco.next() @soco_error() @soco_coordinator def media_previous_track(self): """Send next track command.""" - self._player.previous() + self.soco.previous() @soco_error() @soco_coordinator def media_seek(self, position): """Send seek command.""" - self._player.seek(str(datetime.timedelta(seconds=int(position)))) + self.soco.seek(str(datetime.timedelta(seconds=int(position)))) @soco_error() @soco_coordinator def clear_playlist(self): """Clear players playlist.""" - self._player.clear_queue() - - @soco_error() - def turn_on(self): - """Turn the media player on.""" - if self.support_play: - self.media_play() + self.soco.clear_queue() @soco_error() @soco_coordinator @@ -1063,45 +888,38 @@ class SonosDevice(MediaPlayerDevice): if kwargs.get(ATTR_MEDIA_ENQUEUE): from soco.exceptions import SoCoUPnPException try: - self._player.add_uri_to_queue(media_id) + self.soco.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) + self.soco.play_uri(media_id) @soco_error() - def join(self, master): - """Join the player to a group.""" - coord = [device for device in self.hass.data[DATA_SONOS] - if device.entity_id == master] + def join(self, slaves): + """Form a group with other players.""" + if self._coordinator: + self.soco.unjoin() - if coord and master != self.entity_id: - coord = coord[0] - if coord.soco.group.coordinator != coord.soco: - coord.soco.unjoin() - self._player.join(coord.soco) - self._coordinator = coord - else: - _LOGGER.error("Master not found %s", master) + for slave in slaves: + slave.soco.join(self.soco) @soco_error() def unjoin(self): """Unjoin the player from a group.""" - self._player.unjoin() - self._coordinator = None + self.soco.unjoin() @soco_error() def snapshot(self, with_group=True): """Snapshot the player.""" from soco.snapshot import Snapshot - self._soco_snapshot = Snapshot(self._player) + self._soco_snapshot = Snapshot(self.soco) self._soco_snapshot.snapshot() if with_group: - self._snapshot_group = self._player.group + self._snapshot_group = self.soco.group if self._coordinator: self._coordinator.snapshot(False) else: @@ -1121,12 +939,12 @@ class SonosDevice(MediaPlayerDevice): # restore groups if with_group and self._snapshot_group: old = self._snapshot_group - actual = self._player.group + actual = self.soco.group ## # Master have not change, update group if old.coordinator == actual.coordinator: - if self._player is not old.coordinator: + if self.soco is not old.coordinator: # restore state of the groups self._coordinator.restore(False) remove = actual.members - old.members @@ -1144,13 +962,14 @@ class SonosDevice(MediaPlayerDevice): ## # old is already master, rejoin if old.coordinator.group.coordinator == old.coordinator: - self._player.join(old.coordinator) + self.soco.join(old.coordinator) return ## # restore old master, update group old.coordinator.unjoin() - coordinator = _get_entity_from_soco(self.hass, old.coordinator) + coordinator = _get_entity_from_soco_uid( + self.hass, old.coordinator.uid) coordinator.restore(False) for s_dev in list(old.members): @@ -1161,45 +980,45 @@ class SonosDevice(MediaPlayerDevice): @soco_coordinator def set_sleep_timer(self, sleep_time): """Set the timer on the player.""" - self._player.set_sleep_timer(sleep_time) + self.soco.set_sleep_timer(sleep_time) @soco_error() @soco_coordinator def clear_sleep_timer(self): """Clear the timer on the player.""" - self._player.set_sleep_timer(None) + self.soco.set_sleep_timer(None) @soco_error() @soco_coordinator def update_alarm(self, **data): """Set the alarm clock on the player.""" from soco import alarms - a = None - for alarm in alarms.get_alarms(self.soco): + alarm = None + for one_alarm in alarms.get_alarms(self.soco): # pylint: disable=protected-access - if alarm._alarm_id == str(data[ATTR_ALARM_ID]): - a = alarm - if a is None: + if one_alarm._alarm_id == str(data[ATTR_ALARM_ID]): + alarm = one_alarm + if alarm is None: _LOGGER.warning("did not find alarm with id %s", data[ATTR_ALARM_ID]) return if ATTR_TIME in data: - a.start_time = data[ATTR_TIME] + alarm.start_time = data[ATTR_TIME] if ATTR_VOLUME in data: - a.volume = int(data[ATTR_VOLUME] * 100) + alarm.volume = int(data[ATTR_VOLUME] * 100) if ATTR_ENABLED in data: - a.enabled = data[ATTR_ENABLED] + alarm.enabled = data[ATTR_ENABLED] if ATTR_INCLUDE_LINKED_ZONES in data: - a.include_linked_zones = data[ATTR_INCLUDE_LINKED_ZONES] - a.save() + alarm.include_linked_zones = data[ATTR_INCLUDE_LINKED_ZONES] + alarm.save() @soco_error() def update_option(self, **data): """Modify playback options.""" - if ATTR_NIGHT_SOUND in data and self.night_sound is not None: + if ATTR_NIGHT_SOUND in data and self._night_sound is not None: self.soco.night_mode = data[ATTR_NIGHT_SOUND] - if ATTR_SPEECH_ENHANCE in data and self.speech_enhance is not None: + if ATTR_SPEECH_ENHANCE in data and self._speech_enhance is not None: self.soco.dialog_mode = data[ATTR_SPEECH_ENHANCE] @property @@ -1207,10 +1026,10 @@ class SonosDevice(MediaPlayerDevice): """Return device specific state attributes.""" attributes = {ATTR_IS_COORDINATOR: self.is_coordinator} - if self.night_sound is not None: - attributes[ATTR_NIGHT_SOUND] = self.night_sound + if self._night_sound is not None: + attributes[ATTR_NIGHT_SOUND] = self._night_sound - if self.speech_enhance is not None: - attributes[ATTR_SPEECH_ENHANCE] = self.speech_enhance + if self._speech_enhance is not None: + attributes[ATTR_SPEECH_ENHANCE] = self._speech_enhance return attributes diff --git a/requirements_all.txt b/requirements_all.txt index e2c1b321090..50ee20f11d4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -45,7 +45,7 @@ PyXiaomiGateway==0.8.0 RtmAPI==0.7.0 # homeassistant.components.media_player.sonos -SoCo==0.13 +SoCo==0.14 # homeassistant.components.sensor.travisci TravisPy==0.3.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1fe1d10a6b2..c1d776c110d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -22,7 +22,7 @@ asynctest>=0.11.1 PyJWT==1.5.3 # homeassistant.components.media_player.sonos -SoCo==0.13 +SoCo==0.14 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.5 diff --git a/tests/components/media_player/test_sonos.py b/tests/components/media_player/test_sonos.py index d3ebc67931f..f1a0f4a82fc 100644 --- a/tests/components/media_player/test_sonos.py +++ b/tests/components/media_player/test_sonos.py @@ -41,6 +41,14 @@ class AvTransportMock(): } +class MusicLibraryMock(): + """Mock class for the music_library property on soco.SoCo object.""" + + def get_sonos_favorites(self): + """Return favorites.""" + return [] + + class SoCoMock(): """Mock class for the soco.SoCo object.""" @@ -48,6 +56,12 @@ class SoCoMock(): """Initialize soco object.""" self.ip_address = ip self.is_visible = True + self.volume = 50 + self.mute = False + self.play_mode = 'NORMAL' + self.night_mode = False + self.dialog_mode = False + self.music_library = MusicLibraryMock() self.avTransport = AvTransportMock() def get_sonos_favorites(self): @@ -62,6 +76,7 @@ class SoCoMock(): 'zone_icon': 'x-rincon-roomicon:kitchen', 'mac_address': 'B8:E9:37:BO:OC:BA', 'zone_name': 'Kitchen', + 'model_name': 'Sonos PLAY:1', 'hardware_version': '1.8.1.2-1'} def get_current_transport_info(self): @@ -145,8 +160,9 @@ class TestSonosMediaPlayer(unittest.TestCase): 'host': '192.0.2.1' }) - self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 1) - self.assertEqual(self.hass.data[sonos.DATA_SONOS][0].name, 'Kitchen') + devices = self.hass.data[sonos.DATA_SONOS].devices + self.assertEqual(len(devices), 1) + self.assertEqual(devices[0].name, 'Kitchen') @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) @@ -164,7 +180,7 @@ class TestSonosMediaPlayer(unittest.TestCase): assert setup_component(self.hass, DOMAIN, config) - self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 1) + self.assertEqual(len(self.hass.data[sonos.DATA_SONOS].devices), 1) self.assertEqual(discover_mock.call_count, 1) @mock.patch('soco.SoCo', new=SoCoMock) @@ -184,7 +200,7 @@ class TestSonosMediaPlayer(unittest.TestCase): assert setup_component(self.hass, DOMAIN, config) - self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 1) + self.assertEqual(len(self.hass.data[sonos.DATA_SONOS].devices), 1) self.assertEqual(discover_mock.call_count, 1) self.assertEqual(soco.config.EVENT_ADVERTISE_IP, '192.0.1.1') @@ -201,8 +217,9 @@ class TestSonosMediaPlayer(unittest.TestCase): assert setup_component(self.hass, DOMAIN, config) - self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 1) - self.assertEqual(self.hass.data[sonos.DATA_SONOS][0].name, 'Kitchen') + devices = self.hass.data[sonos.DATA_SONOS].devices + self.assertEqual(len(devices), 1) + self.assertEqual(devices[0].name, 'Kitchen') @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) @@ -217,8 +234,9 @@ class TestSonosMediaPlayer(unittest.TestCase): assert setup_component(self.hass, DOMAIN, config) - self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 2) - self.assertEqual(self.hass.data[sonos.DATA_SONOS][0].name, 'Kitchen') + devices = self.hass.data[sonos.DATA_SONOS].devices + self.assertEqual(len(devices), 2) + self.assertEqual(devices[0].name, 'Kitchen') @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) @@ -233,8 +251,9 @@ class TestSonosMediaPlayer(unittest.TestCase): assert setup_component(self.hass, DOMAIN, config) - self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 2) - self.assertEqual(self.hass.data[sonos.DATA_SONOS][0].name, 'Kitchen') + devices = self.hass.data[sonos.DATA_SONOS].devices + self.assertEqual(len(devices), 2) + self.assertEqual(devices[0].name, 'Kitchen') @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch.object(soco, 'discover', new=socoDiscoverMock.discover) @@ -242,58 +261,9 @@ class TestSonosMediaPlayer(unittest.TestCase): def test_ensure_setup_sonos_discovery(self, *args): """Test a single device using the autodiscovery provided by Sonos.""" sonos.setup_platform(self.hass, {}, fake_add_device) - self.assertEqual(len(self.hass.data[sonos.DATA_SONOS]), 1) - self.assertEqual(self.hass.data[sonos.DATA_SONOS][0].name, 'Kitchen') - - @mock.patch('soco.SoCo', new=SoCoMock) - @mock.patch('socket.create_connection', side_effect=socket.error()) - @mock.patch.object(SoCoMock, 'join') - def test_sonos_group_players(self, join_mock, *args): - """Ensuring soco methods called for sonos_group_players service.""" - sonos.setup_platform(self.hass, {}, fake_add_device, { - 'host': '192.0.2.1' - }) - device = self.hass.data[sonos.DATA_SONOS][-1] - device.hass = self.hass - - device_master = mock.MagicMock() - device_master.entity_id = "media_player.test" - device_master.soco_device = mock.MagicMock() - self.hass.data[sonos.DATA_SONOS].append(device_master) - - join_mock.return_value = True - device.join("media_player.test") - self.assertEqual(join_mock.call_count, 1) - - @mock.patch('soco.SoCo', new=SoCoMock) - @mock.patch('socket.create_connection', side_effect=socket.error()) - @mock.patch.object(SoCoMock, 'unjoin') - def test_sonos_unjoin(self, unjoinMock, *args): - """Ensuring soco methods called for sonos_unjoin service.""" - sonos.setup_platform(self.hass, {}, fake_add_device, { - 'host': '192.0.2.1' - }) - device = self.hass.data[sonos.DATA_SONOS][-1] - device.hass = self.hass - - unjoinMock.return_value = True - device.unjoin() - self.assertEqual(unjoinMock.call_count, 1) - self.assertEqual(unjoinMock.call_args, mock.call()) - - @mock.patch('soco.SoCo', new=SoCoMock) - @mock.patch('socket.create_connection', side_effect=socket.error()) - def test_set_shuffle(self, shuffle_set_mock, *args): - """Ensuring soco methods called for sonos_snapshot service.""" - sonos.setup_platform(self.hass, {}, fake_add_device, { - 'host': '192.0.2.1' - }) - device = self.hass.data[sonos.DATA_SONOS][-1] - device.hass = self.hass - - device.set_shuffle(True) - self.assertEqual(shuffle_set_mock.call_count, 1) - self.assertEqual(device._player.play_mode, 'SHUFFLE') + devices = self.hass.data[sonos.DATA_SONOS].devices + self.assertEqual(len(devices), 1) + self.assertEqual(devices[0].name, 'Kitchen') @mock.patch('soco.SoCo', new=SoCoMock) @mock.patch('socket.create_connection', side_effect=socket.error()) @@ -303,7 +273,7 @@ class TestSonosMediaPlayer(unittest.TestCase): sonos.setup_platform(self.hass, {}, fake_add_device, { 'host': '192.0.2.1' }) - device = self.hass.data[sonos.DATA_SONOS][-1] + device = self.hass.data[sonos.DATA_SONOS].devices[-1] device.hass = self.hass device.set_sleep_timer(30) @@ -317,7 +287,7 @@ class TestSonosMediaPlayer(unittest.TestCase): sonos.setup_platform(self.hass, {}, mock.MagicMock(), { 'host': '192.0.2.1' }) - device = self.hass.data[sonos.DATA_SONOS][-1] + device = self.hass.data[sonos.DATA_SONOS].devices[-1] device.hass = self.hass device.set_sleep_timer(None) @@ -331,7 +301,7 @@ class TestSonosMediaPlayer(unittest.TestCase): sonos.setup_platform(self.hass, {}, fake_add_device, { 'host': '192.0.2.1' }) - device = self.hass.data[sonos.DATA_SONOS][-1] + device = self.hass.data[sonos.DATA_SONOS].devices[-1] device.hass = self.hass alarm1 = alarms.Alarm(soco_mock) alarm1.configure_mock(_alarm_id="1", start_time=None, enabled=False, @@ -361,7 +331,7 @@ class TestSonosMediaPlayer(unittest.TestCase): sonos.setup_platform(self.hass, {}, fake_add_device, { 'host': '192.0.2.1' }) - device = self.hass.data[sonos.DATA_SONOS][-1] + device = self.hass.data[sonos.DATA_SONOS].devices[-1] device.hass = self.hass snapshotMock.return_value = True @@ -379,7 +349,7 @@ class TestSonosMediaPlayer(unittest.TestCase): sonos.setup_platform(self.hass, {}, fake_add_device, { 'host': '192.0.2.1' }) - device = self.hass.data[sonos.DATA_SONOS][-1] + device = self.hass.data[sonos.DATA_SONOS].devices[-1] device.hass = self.hass restoreMock.return_value = True @@ -389,21 +359,3 @@ class TestSonosMediaPlayer(unittest.TestCase): device.restore() self.assertEqual(restoreMock.call_count, 1) self.assertEqual(restoreMock.call_args, mock.call(False)) - - @mock.patch('soco.SoCo', new=SoCoMock) - @mock.patch('socket.create_connection', side_effect=socket.error()) - def test_sonos_set_option(self, option_mock, *args): - """Ensuring soco methods called for sonos_set_option service.""" - sonos.setup_platform(self.hass, {}, fake_add_device, { - 'host': '192.0.2.1' - }) - device = self.hass.data[sonos.DATA_SONOS][-1] - device.hass = self.hass - - option_mock.return_value = True - device._snapshot_coordinator = mock.MagicMock() - device._snapshot_coordinator.soco_device = SoCoMock('192.0.2.17') - - device.update_option(night_sound=True, speech_enhance=True) - - self.assertEqual(option_mock.call_count, 1) From 60148f3e83809df80d3f950d8502a19e7c9c8189 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Sun, 18 Feb 2018 22:11:24 +0100 Subject: [PATCH 110/173] Converted shopping list to use json util and added default override for json util (#12478) * Converted shopping list to use json util, Added default override for json util * Reverted accidental revert * Fixed pylint issue --- homeassistant/components/shopping_list.py | 13 +++---------- homeassistant/util/json.py | 7 +++++-- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/shopping_list.py b/homeassistant/components/shopping_list.py index 416fdd3f6d0..2452188a889 100644 --- a/homeassistant/components/shopping_list.py +++ b/homeassistant/components/shopping_list.py @@ -1,8 +1,6 @@ """Component to manage a shopping list.""" import asyncio -import json import logging -import os import uuid import voluptuous as vol @@ -14,7 +12,7 @@ from homeassistant.components.http.data_validator import ( RequestDataValidator) from homeassistant.helpers import intent import homeassistant.helpers.config_validation as cv - +from homeassistant.util.json import load_json, save_json DOMAIN = 'shopping_list' DEPENDENCIES = ['http'] @@ -101,18 +99,13 @@ class ShoppingData: """Load items.""" def load(): """Load the items synchronously.""" - path = self.hass.config.path(PERSISTENCE) - if not os.path.isfile(path): - return [] - with open(path) as file: - return json.loads(file.read()) + return load_json(self.hass.config.path(PERSISTENCE), default=[]) self.items = yield from self.hass.async_add_job(load) def save(self): """Save the items.""" - with open(self.hass.config.path(PERSISTENCE), 'wt') as file: - file.write(json.dumps(self.items, sort_keys=True, indent=4)) + save_json(self.hass.config.path(PERSISTENCE), self.items) class AddItemIntent(intent.IntentHandler): diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 810463260fd..7a326c34f15 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -8,8 +8,11 @@ from homeassistant.exceptions import HomeAssistantError _LOGGER = logging.getLogger(__name__) +_UNDEFINED = object() -def load_json(filename: str) -> Union[List, Dict]: + +def load_json(filename: str, default: Union[List, Dict] = _UNDEFINED) \ + -> Union[List, Dict]: """Load JSON data from a file and return as dict or list. Defaults to returning empty dict if file is not found. @@ -26,7 +29,7 @@ def load_json(filename: str) -> Union[List, Dict]: except OSError as error: _LOGGER.exception('JSON file reading failed: %s', filename) raise HomeAssistantError(error) - return {} # (also evaluates to False) + return {} if default is _UNDEFINED else default def save_json(filename: str, config: Union[List, Dict]): From 72fa1702650842ae5202166104e7d46674fd3021 Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Sun, 18 Feb 2018 23:34:28 +0100 Subject: [PATCH 111/173] added smappee component (#11491) * added smappee component * Fixed pylint errors and a few use cases when starting up with invalid credentials Added coverage omit * Added support to run only locally Added a few more sensors Added more error handling Better parsing and debug message * fixed smappee switch after local/remote support was added * Smappee - update switches for local support (#3) * Merged with local version * Updated smappy library with the patched one Fixed lint, added merge missing param Fixed missing run for requirements_all.txt Fixed lint * Fixed on/off based on library. Reverted change used for testing stacktrace * Fixed switches to work with both remote and local active Fixed lint Fixed switches Fixed lint * nothing to update per switch as the states are not saved by smappee system * added better error handling for communication errors with smappee * fixed lint errors * fixed comment * fixed lint error * fixed lint error * update smappee module with reviewer comments - update smappy module - cache cloud api requests - added actuator info - updated return states --- .coveragerc | 3 + homeassistant/components/sensor/smappee.py | 162 ++++++++++ homeassistant/components/smappee.py | 337 +++++++++++++++++++++ homeassistant/components/switch/smappee.py | 92 ++++++ requirements_all.txt | 3 + 5 files changed, 597 insertions(+) create mode 100644 homeassistant/components/sensor/smappee.py create mode 100644 homeassistant/components/smappee.py create mode 100644 homeassistant/components/switch/smappee.py diff --git a/.coveragerc b/.coveragerc index ada79ca8f27..97be3406b37 100644 --- a/.coveragerc +++ b/.coveragerc @@ -208,6 +208,9 @@ omit = homeassistant/components/skybell.py homeassistant/components/*/skybell.py + homeassistant/components/smappee.py + homeassistant/components/*/smappee.py + homeassistant/components/tado.py homeassistant/components/*/tado.py diff --git a/homeassistant/components/sensor/smappee.py b/homeassistant/components/sensor/smappee.py new file mode 100644 index 00000000000..51595d19b1a --- /dev/null +++ b/homeassistant/components/sensor/smappee.py @@ -0,0 +1,162 @@ +""" +Support for monitoring a Smappee energy sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.smappee/ +""" +import logging +from datetime import timedelta + +from homeassistant.components.smappee import DATA_SMAPPEE +from homeassistant.helpers.entity import Entity + +DEPENDENCIES = ['smappee'] + +_LOGGER = logging.getLogger(__name__) + +SENSOR_PREFIX = 'Smappee' +SENSOR_TYPES = { + 'solar': + ['Solar', 'mdi:white-balance-sunny', 'local', 'W', 'solar'], + 'active_power': + ['Active Power', 'mdi:power-plug', 'local', 'W', 'active_power'], + 'current': + ['Current', 'mdi:gauge', 'local', 'Amps', 'current'], + 'voltage': + ['Voltage', 'mdi:gauge', 'local', 'V', 'voltage'], + 'active_cosfi': + ['Power Factor', 'mdi:gauge', 'local', '%', 'active_cosfi'], + 'alwayson_today': + ['Always On Today', 'mdi:gauge', 'remote', 'kW', 'alwaysOn'], + 'solar_today': + ['Solar Today', 'mdi:white-balance-sunny', 'remote', 'kW', 'solar'], + 'power_today': + ['Power Today', 'mdi:power-plug', 'remote', 'kW', 'consumption'] +} + +SCAN_INTERVAL = timedelta(seconds=30) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Smappee sensor.""" + smappee = hass.data[DATA_SMAPPEE] + + dev = [] + if smappee.is_remote_active: + for sensor in SENSOR_TYPES: + if 'remote' in SENSOR_TYPES[sensor]: + for location_id in smappee.locations.keys(): + dev.append(SmappeeSensor(smappee, location_id, sensor)) + + if smappee.is_local_active: + for sensor in SENSOR_TYPES: + if 'local' in SENSOR_TYPES[sensor]: + if smappee.is_remote_active: + for location_id in smappee.locations.keys(): + dev.append(SmappeeSensor(smappee, location_id, sensor)) + else: + dev.append(SmappeeSensor(smappee, None, sensor)) + add_devices(dev, True) + + +class SmappeeSensor(Entity): + """Implementation of a Smappee sensor.""" + + def __init__(self, smappee, location_id, sensor): + """Initialize the sensor.""" + self._smappee = smappee + self._location_id = location_id + self._sensor = sensor + self.data = None + self._state = None + self._name = SENSOR_TYPES[self._sensor][0] + self._icon = SENSOR_TYPES[self._sensor][1] + self._unit_of_measurement = SENSOR_TYPES[self._sensor][3] + self._smappe_name = SENSOR_TYPES[self._sensor][4] + + @property + def name(self): + """Return the name of the sensor.""" + if self._location_id: + location_name = self._smappee.locations[self._location_id] + else: + location_name = 'Local' + + return "{} {} {}".format(SENSOR_PREFIX, + location_name, + self._name) + + @property + def icon(self): + """Icon to use in the frontend.""" + return self._icon + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attr = {} + if self._location_id: + attr['Location Id'] = self._location_id + attr['Location Name'] = self._smappee.locations[self._location_id] + return attr + + def update(self): + """Get the latest data from Smappee and update the state.""" + self._smappee.update() + + if self._sensor in ['alwayson_today', 'solar_today', 'power_today']: + data = self._smappee.consumption[self._location_id] + if data: + consumption = data.get('consumptions')[-1] + _LOGGER.debug("%s %s", self._sensor, consumption) + value = consumption.get(self._smappe_name) + self._state = round(value / 1000, 2) + elif self._sensor == 'active_cosfi': + cosfi = self._smappee.active_cosfi() + _LOGGER.debug("%s %s", self._sensor, cosfi) + if cosfi: + self._state = round(cosfi, 2) + elif self._sensor == 'current': + current = self._smappee.active_current() + _LOGGER.debug("%s %s", self._sensor, current) + if current: + self._state = round(current, 2) + elif self._sensor == 'voltage': + voltage = self._smappee.active_voltage() + _LOGGER.debug("%s %s", self._sensor, voltage) + if voltage: + self._state = round(voltage, 3) + elif self._sensor == 'active_power': + data = self._smappee.instantaneous + _LOGGER.debug("%s %s", self._sensor, data) + if data: + value1 = [float(i['value']) for i in data + if i['key'].endswith('phase0ActivePower')] + value2 = [float(i['value']) for i in data + if i['key'].endswith('phase1ActivePower')] + value3 = [float(i['value']) for i in data + if i['key'].endswith('phase2ActivePower')] + active_power = sum(value1 + value2 + value3) / 1000 + self._state = round(active_power, 2) + elif self._sensor == 'solar': + data = self._smappee.instantaneous + _LOGGER.debug("%s %s", self._sensor, data) + if data: + value1 = [float(i['value']) for i in data + if i['key'].endswith('phase3ActivePower')] + value2 = [float(i['value']) for i in data + if i['key'].endswith('phase4ActivePower')] + value3 = [float(i['value']) for i in data + if i['key'].endswith('phase5ActivePower')] + power = sum(value1 + value2 + value3) / 1000 + self._state = round(power, 2) diff --git a/homeassistant/components/smappee.py b/homeassistant/components/smappee.py new file mode 100644 index 00000000000..0111e0437fb --- /dev/null +++ b/homeassistant/components/smappee.py @@ -0,0 +1,337 @@ +""" +Support for Smappee energy monitor. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/smappee/ +""" +import logging +from datetime import datetime, timedelta +import re +import voluptuous as vol +from requests.exceptions import RequestException +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, CONF_HOST +) +from homeassistant.util import Throttle +from homeassistant.helpers.discovery import load_platform +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['smappy==0.2.15'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Smappee' +DEFAULT_HOST_PASSWORD = 'admin' + +CONF_CLIENT_ID = 'client_id' +CONF_CLIENT_SECRET = 'client_secret' +CONF_HOST_PASSWORD = 'host_password' + +DOMAIN = 'smappee' +DATA_SMAPPEE = 'SMAPPEE' + +_SENSOR_REGEX = re.compile( + r'(?P([A-Za-z]+))\=' + + r'(?P([0-9\.]+))') + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Inclusive(CONF_CLIENT_ID, 'Server credentials'): cv.string, + vol.Inclusive(CONF_CLIENT_SECRET, 'Server credentials'): cv.string, + vol.Inclusive(CONF_USERNAME, 'Server credentials'): cv.string, + vol.Inclusive(CONF_PASSWORD, 'Server credentials'): cv.string, + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_HOST_PASSWORD, default=DEFAULT_HOST_PASSWORD): + cv.string + }), +}, extra=vol.ALLOW_EXTRA) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) + + +def setup(hass, config): + """Set up the Smapee component.""" + client_id = config.get(DOMAIN).get(CONF_CLIENT_ID) + client_secret = config.get(DOMAIN).get(CONF_CLIENT_SECRET) + username = config.get(DOMAIN).get(CONF_USERNAME) + password = config.get(DOMAIN).get(CONF_PASSWORD) + host = config.get(DOMAIN).get(CONF_HOST) + host_password = config.get(DOMAIN).get(CONF_HOST_PASSWORD) + + smappee = Smappee(client_id, client_secret, username, + password, host, host_password) + + if not smappee.is_local_active and not smappee.is_remote_active: + _LOGGER.error("Neither Smappee server or local component enabled.") + return False + + hass.data[DATA_SMAPPEE] = smappee + load_platform(hass, 'switch', DOMAIN) + load_platform(hass, 'sensor', DOMAIN) + return True + + +class Smappee(object): + """Stores data retrieved from Smappee sensor.""" + + def __init__(self, client_id, client_secret, username, + password, host, host_password): + """Initialize the data.""" + import smappy + + self._remote_active = False + self._local_active = False + if client_id is not None: + try: + self._smappy = smappy.Smappee(client_id, client_secret) + self._smappy.authenticate(username, password) + self._remote_active = True + except RequestException as error: + self._smappy = None + _LOGGER.exception( + "Smappee server authentication failed (%s)", + error) + else: + _LOGGER.warning("Smappee server component init skipped.") + + if host is not None: + try: + self._localsmappy = smappy.LocalSmappee(host) + self._localsmappy.logon(host_password) + self._local_active = True + except RequestException as error: + self._localsmappy = None + _LOGGER.exception( + "Local Smappee device authentication failed (%s)", + error) + else: + _LOGGER.warning("Smappee local component init skipped.") + + self.locations = {} + self.info = {} + self.consumption = {} + self.instantaneous = {} + + if self._remote_active or self._local_active: + self.update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update data from Smappee API.""" + if self.is_remote_active: + service_locations = self._smappy.get_service_locations() \ + .get('serviceLocations') + for location in service_locations: + location_id = location.get('serviceLocationId') + if location_id is not None: + self.locations[location_id] = location.get('name') + self.info[location_id] = self._smappy \ + .get_service_location_info(location_id) + _LOGGER.debug("Remote info %s %s", + self.locations, self.info) + + self.consumption[location_id] = self.get_consumption( + location_id, aggregation=3, delta=1440) + _LOGGER.debug("Remote consumption %s %s", + self.locations, + self.consumption[location_id]) + + if self.is_local_active: + self.local_devices = self.get_switches() + _LOGGER.debug("Local switches %s", self.local_devices) + + self.instantaneous = self.load_instantaneous() + _LOGGER.debug("Local values %s", self.instantaneous) + + @property + def is_remote_active(self): + """Return true if Smappe server is configured and working.""" + return self._remote_active + + @property + def is_local_active(self): + """Return true if Smappe local device is configured and working.""" + return self._local_active + + def get_switches(self): + """Get switches from local Smappee.""" + if not self.is_local_active: + return + + try: + return self._localsmappy.load_command_control_config() + except RequestException as error: + _LOGGER.error( + "Error getting switches from local Smappee. (%s)", + error) + + def get_consumption(self, location_id, aggregation, delta): + """Update data from Smappee.""" + # Start & End accept epoch (in milliseconds), + # datetime and pandas timestamps + # Aggregation: + # 1 = 5 min values (only available for the last 14 days), + # 2 = hourly values, + # 3 = daily values, + # 4 = monthly values, + # 5 = quarterly values + if not self.is_remote_active: + return + + end = datetime.utcnow() + start = end - timedelta(minutes=delta) + try: + return self._smappy.get_consumption(location_id, + start, + end, + aggregation) + except RequestException as error: + _LOGGER.error( + "Error getting comsumption from Smappee cloud. (%s)", + error) + + def get_sensor_consumption(self, location_id, sensor_id): + """Update data from Smappee.""" + # Start & End accept epoch (in milliseconds), + # datetime and pandas timestamps + # Aggregation: + # 1 = 5 min values (only available for the last 14 days), + # 2 = hourly values, + # 3 = daily values, + # 4 = monthly values, + # 5 = quarterly values + if not self.is_remote_active: + return + + start = datetime.utcnow() - timedelta(minutes=30) + end = datetime.utcnow() + try: + return self._smappy.get_sensor_consumption(location_id, + sensor_id, + start, + end, 1) + except RequestException as error: + _LOGGER.error( + "Error getting comsumption from Smappee cloud. (%s)", + error) + + def actuator_on(self, location_id, actuator_id, + is_remote_switch, duration=None): + """Turn on actuator.""" + # Duration = 300,900,1800,3600 + # or any other value for an undetermined period of time. + # + # The comport plugs have a tendency to ignore the on/off signal. + # And because you can't read the status of a plug, it's more + # reliable to execute the command twice. + try: + if is_remote_switch: + self._smappy.actuator_on(location_id, actuator_id, duration) + self._smappy.actuator_on(location_id, actuator_id, duration) + else: + self._localsmappy.on_command_control(actuator_id) + self._localsmappy.on_command_control(actuator_id) + except RequestException as error: + _LOGGER.error( + "Error turning actuator on. (%s)", + error) + return False + + return True + + def actuator_off(self, location_id, actuator_id, + is_remote_switch, duration=None): + """Turn off actuator.""" + # Duration = 300,900,1800,3600 + # or any other value for an undetermined period of time. + # + # The comport plugs have a tendency to ignore the on/off signal. + # And because you can't read the status of a plug, it's more + # reliable to execute the command twice. + try: + if is_remote_switch: + self._smappy.actuator_off(location_id, actuator_id, duration) + self._smappy.actuator_off(location_id, actuator_id, duration) + else: + self._localsmappy.off_command_control(actuator_id) + self._localsmappy.off_command_control(actuator_id) + except RequestException as error: + _LOGGER.error( + "Error turning actuator on. (%s)", + error) + return False + + return True + + def active_power(self): + """Get sum of all instantanious active power values from local hub.""" + if not self.is_local_active: + return + + try: + return self._localsmappy.active_power() + except RequestException as error: + _LOGGER.error( + "Error getting data from Local Smappee unit. (%s)", + error) + + def active_cosfi(self): + """Get the average of all instantaneous cosfi values.""" + if not self.is_local_active: + return + + try: + return self._localsmappy.active_cosfi() + except RequestException as error: + _LOGGER.error( + "Error getting data from Local Smappee unit. (%s)", + error) + + def instantaneous_values(self): + """ReportInstantaneousValues.""" + if not self.is_local_active: + return + + report_instantaneous_values = \ + self._localsmappy.report_instantaneous_values() + + report_result = \ + report_instantaneous_values['report'].split('
') + properties = {} + for lines in report_result: + lines_result = lines.split(',') + for prop in lines_result: + match = _SENSOR_REGEX.search(prop) + if match: + properties[match.group('key')] = \ + match.group('value') + _LOGGER.debug(properties) + return properties + + def active_current(self): + """Get current active Amps.""" + if not self.is_local_active: + return + + properties = self.instantaneous_values() + return float(properties['current']) + + def active_voltage(self): + """Get current active Voltage.""" + if not self.is_local_active: + return + + properties = self.instantaneous_values() + return float(properties['voltage']) + + def load_instantaneous(self): + """LoadInstantaneous.""" + if not self.is_local_active: + return + + try: + return self._localsmappy.load_instantaneous() + except RequestException as error: + _LOGGER.error( + "Error getting data from Local Smappee unit. (%s)", + error) diff --git a/homeassistant/components/switch/smappee.py b/homeassistant/components/switch/smappee.py new file mode 100644 index 00000000000..fd8f141500b --- /dev/null +++ b/homeassistant/components/switch/smappee.py @@ -0,0 +1,92 @@ +""" +Support for interacting with Smappee Comport Plugs. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/switch.smappee/ +""" +import logging + +from homeassistant.components.smappee import DATA_SMAPPEE +from homeassistant.components.switch import (SwitchDevice) + +DEPENDENCIES = ['smappee'] + +_LOGGER = logging.getLogger(__name__) + +ICON = 'mdi:power-plug' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Smappee Comfort Plugs.""" + smappee = hass.data[DATA_SMAPPEE] + + dev = [] + if smappee.is_remote_active: + for location_id in smappee.locations.keys(): + for items in smappee.info[location_id].get('actuators'): + if items.get('name') != '': + _LOGGER.debug("Remote actuator %s", items) + dev.append(SmappeeSwitch(smappee, + items.get('name'), + location_id, + items.get('id'))) + elif smappee.is_local_active: + for items in smappee.local_devices: + _LOGGER.debug("Local actuator %s", items) + dev.append(SmappeeSwitch(smappee, + items.get('value'), + None, + items.get('key'))) + add_devices(dev) + + +class SmappeeSwitch(SwitchDevice): + """Representation of a Smappee Comport Plug.""" + + def __init__(self, smappee, name, location_id, switch_id): + """Initialize a new Smappee Comfort Plug.""" + self._name = name + self._state = False + self._smappee = smappee + self._location_id = location_id + self._switch_id = switch_id + self._remoteswitch = True + if location_id is None: + self._remoteswitch = False + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def is_on(self): + """Return true if switch is on.""" + return self._state + + @property + def icon(self): + """Icon to use in the frontend.""" + return ICON + + def turn_on(self, **kwargs): + """Turn on Comport Plug.""" + if self._smappee.actuator_on(self._location_id, self._switch_id, + self._remoteswitch): + self._state = True + + def turn_off(self, **kwargs): + """Turn off Comport Plug.""" + if self._smappee.actuator_off(self._location_id, self._switch_id, + self._remoteswitch): + self._state = False + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attr = {} + if self._remoteswitch: + attr['Location Id'] = self._location_id + attr['Location Name'] = self._smappee.locations[self._location_id] + attr['Switch Id'] = self._switch_id + return attr diff --git a/requirements_all.txt b/requirements_all.txt index 50ee20f11d4..35a2e824da7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1112,6 +1112,9 @@ sleekxmpp==1.3.2 # homeassistant.components.sleepiq sleepyq==0.6 +# homeassistant.components.smappee +smappy==0.2.15 + # homeassistant.components.raspihats # homeassistant.components.sensor.bh1750 # homeassistant.components.sensor.bme280 From 1143499301bc1c253df00c41b3aacd466b4b4df8 Mon Sep 17 00:00:00 2001 From: thrawnarn Date: Sun, 18 Feb 2018 23:59:26 +0100 Subject: [PATCH 112/173] More features for the Bluesound component (#11450) * Added support for join and unjoin * Added support for sleep functionality * Fixed supported features * Removed long lines and fixed documentation strings * Fixed D401, imperative mood * Added shuffle support * Removed unnecessary log row * Removed model, modelname and brand * Removed descriptions * Removed polling command on method run. This change is not needed * Fixed merge errors * Removed unused usings * Pylint fixes * Hound fixes * Remove attr Sleep and removed white space in services.xml --- .../components/media_player/bluesound.py | 333 +++++++++++++++--- .../components/media_player/services.yaml | 32 +- 2 files changed, 307 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/media_player/bluesound.py b/homeassistant/components/media_player/bluesound.py index d7664d68ce5..4bd3e43794f 100644 --- a/homeassistant/components/media_player/bluesound.py +++ b/homeassistant/components/media_player/bluesound.py @@ -16,14 +16,16 @@ import async_timeout import voluptuous as vol from homeassistant.components.media_player import ( - MEDIA_TYPE_MUSIC, PLATFORM_SCHEMA, SUPPORT_CLEAR_PLAYLIST, - SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, - SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_SELECT_SOURCE, SUPPORT_STOP, + ATTR_MEDIA_ENQUEUE, DOMAIN, MEDIA_TYPE_MUSIC, PLATFORM_SCHEMA, + SUPPORT_CLEAR_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, + SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, + SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_STOP, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, MediaPlayerDevice) from homeassistant.const import ( - CONF_HOST, CONF_HOSTS, CONF_NAME, CONF_PORT, EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_PAUSED, STATE_PLAYING) + ATTR_ENTITY_ID, CONF_HOST, CONF_HOSTS, CONF_NAME, CONF_PORT, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, STATE_IDLE, + STATE_OFF, STATE_PAUSED, STATE_PLAYING) from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -35,10 +37,14 @@ REQUIREMENTS = ['xmltodict==0.11.0'] _LOGGER = logging.getLogger(__name__) -STATE_OFFLINE = 'offline' -ATTR_MODEL = 'model' -ATTR_MODEL_NAME = 'model_name' -ATTR_BRAND = 'brand' +STATE_GROUPED = 'grouped' + +ATTR_MASTER = 'master' + +SERVICE_JOIN = 'bluesound_join' +SERVICE_UNJOIN = 'bluesound_unjoin' +SERVICE_SET_TIMER = 'bluesound_set_sleep_timer' +SERVICE_CLEAR_TIMER = 'bluesound_clear_sleep_timer' DATA_BLUESOUND = 'bluesound' DEFAULT_PORT = 11000 @@ -58,6 +64,29 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }]) }) +BS_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +BS_JOIN_SCHEMA = BS_SCHEMA.extend({ + vol.Required(ATTR_MASTER): cv.entity_id, +}) + +SERVICE_TO_METHOD = { + SERVICE_JOIN: { + 'method': 'async_join', + 'schema': BS_JOIN_SCHEMA}, + SERVICE_UNJOIN: { + 'method': 'async_unjoin', + 'schema': BS_SCHEMA}, + SERVICE_SET_TIMER: { + 'method': 'async_increase_timer', + 'schema': BS_SCHEMA}, + SERVICE_CLEAR_TIMER: { + 'method': 'async_clear_timer', + 'schema': BS_SCHEMA} +} + def _add_player(hass, async_add_devices, host, port=None, name=None): """Add Bluesound players.""" @@ -120,6 +149,30 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): hass, async_add_devices, host.get(CONF_HOST), host.get(CONF_PORT), host.get(CONF_NAME)) + @asyncio.coroutine + def async_service_handler(service): + """Map services to method of Bluesound devices.""" + method = SERVICE_TO_METHOD.get(service.service) + if not method: + return + + params = {key: value for key, value in service.data.items() + if key != ATTR_ENTITY_ID} + entity_ids = service.data.get(ATTR_ENTITY_ID) + if entity_ids: + target_players = [player for player in hass.data[DATA_BLUESOUND] + if player.entity_id in entity_ids] + else: + target_players = hass.data[DATA_BLUESOUND] + + for player in target_players: + yield from getattr(player, method['method'])(**params) + + for service in SERVICE_TO_METHOD: + schema = SERVICE_TO_METHOD[service]['schema'] + hass.services.async_register( + DOMAIN, service, async_service_handler, schema=schema) + class BluesoundPlayer(MediaPlayerDevice): """Representation of a Bluesound Player.""" @@ -128,13 +181,10 @@ class BluesoundPlayer(MediaPlayerDevice): """Initialize the media player.""" self.host = host self._hass = hass - self._port = port + self.port = port self._polling_session = async_get_clientsession(hass) self._polling_task = None # The actual polling task. self._name = name - self._brand = None - self._model = None - self._model_name = None self._icon = None self._capture_items = [] self._services_items = [] @@ -145,9 +195,13 @@ class BluesoundPlayer(MediaPlayerDevice): self._is_online = False self._retry_remove = None self._lastvol = None + self._master = None + self._is_master = False + self._group_name = None + self._init_callback = init_callback - if self._port is None: - self._port = DEFAULT_PORT + if self.port is None: + self.port = DEFAULT_PORT @staticmethod def _try_get_index(string, search_string): @@ -158,7 +212,7 @@ class BluesoundPlayer(MediaPlayerDevice): return -1 @asyncio.coroutine - def _internal_update_sync_status( + def force_update_sync_status( self, on_updated_cb=None, raise_timeout=False): """Update the internal status.""" resp = None @@ -174,14 +228,27 @@ class BluesoundPlayer(MediaPlayerDevice): if not self._name: self._name = self._sync_status.get('@name', self.host) - if not self._brand: - self._brand = self._sync_status.get('@brand', self.host) - if not self._model: - self._model = self._sync_status.get('@model', self.host) if not self._icon: self._icon = self._sync_status.get('@icon', self.host) - if not self._model_name: - self._model_name = self._sync_status.get('@modelName', self.host) + + master = self._sync_status.get('master', None) + if master is not None: + self._is_master = False + master_host = master.get('#text') + master_device = [device for device in + self._hass.data[DATA_BLUESOUND] + if device.host == master_host] + + if master_device and master_host != self.host: + self._master = master_device[0] + else: + self._master = None + _LOGGER.error("Master not found %s", master_host) + else: + if self._master is not None: + self._master = None + slaves = self._sync_status.get('slave', None) + self._is_master = slaves is not None if on_updated_cb: on_updated_cb() @@ -223,7 +290,7 @@ class BluesoundPlayer(MediaPlayerDevice): self._retry_remove() self._retry_remove = None - yield from self._internal_update_sync_status( + yield from self.force_update_sync_status( self._init_callback, True) except (asyncio.TimeoutError, ClientError): _LOGGER.info("Node %s is offline, retrying later", self.host) @@ -256,7 +323,7 @@ class BluesoundPlayer(MediaPlayerDevice): if method[0] == '/': method = method[1:] - url = "http://{}:{}/{}".format(self.host, self._port, method) + url = "http://{}:{}/{}".format(self.host, self.port, method) _LOGGER.debug("Calling URL: %s", url) response = None @@ -297,26 +364,47 @@ class BluesoundPlayer(MediaPlayerDevice): etag = self._status.get('@etag', '') if etag != '': - url = 'Status?etag={}&timeout=60.0'.format(etag) - url = "http://{}:{}/{}".format(self.host, self._port, url) + url = 'Status?etag={}&timeout=120.0'.format(etag) + url = "http://{}:{}/{}".format(self.host, self.port, url) _LOGGER.debug("Calling URL: %s", url) try: - with async_timeout.timeout(65, loop=self._hass.loop): + with async_timeout.timeout(125, loop=self._hass.loop): response = yield from self._polling_session.get( url, headers={CONNECTION: KEEP_ALIVE}) if response.status != 200: - _LOGGER.error("Error %s on %s", response.status, url) + _LOGGER.error("Error %s on %s. Trying one more time.", + response.status, url) + else: + result = yield from response.text() + self._is_online = True + self._last_status_update = dt_util.utcnow() + self._status = xmltodict.parse(result)['status'].copy() - result = yield from response.text() - self._is_online = True - self._last_status_update = dt_util.utcnow() - self._status = xmltodict.parse(result)['status'].copy() - self.schedule_update_ha_state() + group_name = self._status.get('groupName', None) + if group_name != self._group_name: + _LOGGER.debug('Group name change detected on device: %s', + self.host) + self._group_name = group_name + # the sleep is needed to make sure that the + # devices is synced + yield from asyncio.sleep(1, loop=self._hass.loop) + yield from self.async_trigger_sync_on_all() + elif self.is_grouped: + # when player is grouped we need to fetch volume from + # sync_status. We will force an update if the player is + # grouped this isn't a foolproof solution. A better + # solution would be to fetch sync_status more often when + # the device is playing. This would solve alot of + # problems. This change will be done when the + # communication is moved to a separate library + yield from self.force_update_sync_status() + + self.schedule_update_ha_state() except (asyncio.TimeoutError, ClientError): self._is_online = False @@ -327,12 +415,20 @@ class BluesoundPlayer(MediaPlayerDevice): self._name) raise + @asyncio.coroutine + def async_trigger_sync_on_all(self): + """Trigger sync status update on all devices.""" + _LOGGER.debug("Trigger sync status on all devices") + + for player in self._hass.data[DATA_BLUESOUND]: + yield from player.force_update_sync_status() + @asyncio.coroutine @Throttle(SYNC_STATUS_INTERVAL) def async_update_sync_status(self, on_updated_cb=None, raise_timeout=False): """Update sync status.""" - yield from self._internal_update_sync_status( + yield from self.force_update_sync_status( on_updated_cb, raise_timeout=False) @asyncio.coroutine @@ -433,7 +529,10 @@ class BluesoundPlayer(MediaPlayerDevice): def state(self): """Return the state of the device.""" if self._status is None: - return STATE_OFFLINE + return STATE_OFF + + if self.is_grouped and not self.is_master: + return STATE_GROUPED status = self._status.get('state', None) if status == 'pause' or status == 'stop': @@ -445,7 +544,8 @@ class BluesoundPlayer(MediaPlayerDevice): @property def media_title(self): """Title of current playing media.""" - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None return self._status.get('title1', None) @@ -456,6 +556,9 @@ class BluesoundPlayer(MediaPlayerDevice): if self._status is None: return None + if self.is_grouped and not self.is_master: + return self._group_name + artist = self._status.get('artist', None) if not artist: artist = self._status.get('title2', None) @@ -464,7 +567,8 @@ class BluesoundPlayer(MediaPlayerDevice): @property def media_album_name(self): """Artist of current playing media (Music track only).""" - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None album = self._status.get('album', None) @@ -475,21 +579,23 @@ class BluesoundPlayer(MediaPlayerDevice): @property def media_image_url(self): """Image url of current playing media.""" - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None url = self._status.get('image', None) if not url: return if url[0] == '/': - url = "http://{}:{}{}".format(self.host, self._port, url) + url = "http://{}:{}{}".format(self.host, self.port, url) return url @property def media_position(self): """Position of current playing media in seconds.""" - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None mediastate = self.state @@ -510,7 +616,8 @@ class BluesoundPlayer(MediaPlayerDevice): @property def media_duration(self): """Duration of current playing media in seconds.""" - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None duration = self._status.get('totlen', None) @@ -526,10 +633,10 @@ class BluesoundPlayer(MediaPlayerDevice): @property def volume_level(self): """Volume level of the media player (0..1).""" - if self._status is None: - return None - volume = self._status.get('volume', None) + if self.is_grouped: + volume = self._sync_status.get('@volume', None) + if volume is not None: return int(volume) / 100 return None @@ -537,9 +644,6 @@ class BluesoundPlayer(MediaPlayerDevice): @property def is_volume_muted(self): """Boolean if volume is currently muted.""" - if not self._status: - return None - volume = self.volume_level if not volume: return None @@ -558,7 +662,8 @@ class BluesoundPlayer(MediaPlayerDevice): @property def source_list(self): """List of available input sources.""" - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None sources = [] @@ -581,7 +686,8 @@ class BluesoundPlayer(MediaPlayerDevice): """Name of the current input source.""" from urllib import parse - if self._status is None: + if (self._status is None or + (self.is_grouped and not self.is_master)): return None current_service = self._status.get('service', '') @@ -649,12 +755,17 @@ class BluesoundPlayer(MediaPlayerDevice): if self._status is None: return None + if self.is_grouped and not self.is_master: + return SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_SET | \ + SUPPORT_VOLUME_MUTE + supported = SUPPORT_CLEAR_PLAYLIST if self._status.get('indexing', '0') == '0': supported = supported | SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | \ SUPPORT_NEXT_TRACK | SUPPORT_PLAY_MEDIA | \ - SUPPORT_STOP | SUPPORT_PLAY | SUPPORT_SELECT_SOURCE + SUPPORT_STOP | SUPPORT_PLAY | SUPPORT_SELECT_SOURCE | \ + SUPPORT_SHUFFLE_SET current_vol = self.volume_level if current_vol is not None and current_vol >= 0: @@ -667,17 +778,87 @@ class BluesoundPlayer(MediaPlayerDevice): return supported @property - def device_state_attributes(self): - """Return the state attributes.""" - return { - ATTR_MODEL: self._model, - ATTR_MODEL_NAME: self._model_name, - ATTR_BRAND: self._brand, - } + def is_master(self): + """Return true if player is a coordinator.""" + return self._is_master + + @property + def is_grouped(self): + """Return true if player is a coordinator.""" + return self._master is not None or self._is_master + + @property + def shuffle(self): + """Return true if shuffle is active.""" + return True if self._status.get('shuffle', '0') == '1' else False + + @asyncio.coroutine + def async_join(self, master): + """Join the player to a group.""" + master_device = [device for device in self.hass.data[DATA_BLUESOUND] + if device.entity_id == master] + + if master_device: + _LOGGER.debug("Trying to join player: %s to master: %s", + self.host, master_device[0].host) + + yield from master_device[0].async_add_slave(self) + else: + _LOGGER.error("Master not found %s", master_device) + + @asyncio.coroutine + def async_unjoin(self): + """Unjoin the player from a group.""" + if self._master is None: + return + + _LOGGER.debug("Trying to unjoin player: %s", self.host) + yield from self._master.async_remove_slave(self) + + @asyncio.coroutine + def async_add_slave(self, slave_device): + """Add slave to master.""" + return self.send_bluesound_command('/AddSlave?slave={}&port={}' + .format(slave_device.host, + slave_device.port)) + + @asyncio.coroutine + def async_remove_slave(self, slave_device): + """Remove slave to master.""" + return self.send_bluesound_command('/RemoveSlave?slave={}&port={}' + .format(slave_device.host, + slave_device.port)) + + @asyncio.coroutine + def async_increase_timer(self): + """Increase sleep time on player.""" + sleep_time = yield from self.send_bluesound_command('/Sleep') + if sleep_time is None: + _LOGGER.error('Error while increasing sleep time on player: %s', + self.host) + return 0 + + return int(sleep_time.get('sleep', '0')) + + @asyncio.coroutine + def async_clear_timer(self): + """Clear sleep timer on player.""" + sleep = 1 + while sleep > 0: + sleep = yield from self.async_increase_timer() + + @asyncio.coroutine + def async_set_shuffle(self, shuffle): + """Enable or disable shuffle mode.""" + return self.send_bluesound_command('/Shuffle?state={}' + .format('1' if shuffle else '0')) @asyncio.coroutine def async_select_source(self, source): """Select input source.""" + if self.is_grouped and not self.is_master: + return + items = [x for x in self._preset_items if x['title'] == source] if len(items) < 1: @@ -700,11 +881,17 @@ class BluesoundPlayer(MediaPlayerDevice): @asyncio.coroutine def async_clear_playlist(self): """Clear players playlist.""" + if self.is_grouped and not self.is_master: + return + return self.send_bluesound_command('Clear') @asyncio.coroutine def async_media_next_track(self): """Send media_next command to media player.""" + if self.is_grouped and not self.is_master: + return + cmd = 'Skip' if self._status and 'actions' in self._status: for action in self._status['actions']['action']: @@ -717,6 +904,9 @@ class BluesoundPlayer(MediaPlayerDevice): @asyncio.coroutine def async_media_previous_track(self): """Send media_previous command to media player.""" + if self.is_grouped and not self.is_master: + return + cmd = 'Back' if self._status and 'actions' in self._status: for action in self._status['actions']['action']: @@ -729,23 +919,52 @@ class BluesoundPlayer(MediaPlayerDevice): @asyncio.coroutine def async_media_play(self): """Send media_play command to media player.""" + if self.is_grouped and not self.is_master: + return + return self.send_bluesound_command('Play') @asyncio.coroutine def async_media_pause(self): """Send media_pause command to media player.""" + if self.is_grouped and not self.is_master: + return + return self.send_bluesound_command('Pause') @asyncio.coroutine def async_media_stop(self): """Send stop command.""" + if self.is_grouped and not self.is_master: + return + return self.send_bluesound_command('Pause') @asyncio.coroutine def async_media_seek(self, position): """Send media_seek command to media player.""" + if self.is_grouped and not self.is_master: + return + return self.send_bluesound_command('Play?seek=' + str(float(position))) + @asyncio.coroutine + def async_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 self.is_grouped and not self.is_master: + return + + url = 'Play?url={}'.format(media_id) + + if kwargs.get(ATTR_MEDIA_ENQUEUE): + return self.send_bluesound_command(url) + + return self.send_bluesound_command(url) + @asyncio.coroutine def async_volume_up(self): """Volume up the media player.""" diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 3e5ee57cb2f..7ac250b1d30 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -323,7 +323,6 @@ squeezebox_call_method: yamaha_enable_output: description: Enable or disable an output port - fields: entity_id: description: Name(s) of entites to enable/disable port on. @@ -334,3 +333,34 @@ yamaha_enable_output: enabled: description: Boolean indicating if port should be enabled or not. example: true + +bluesound_join: + description: Group player together. + fields: + master: + description: Entity ID of the player that should become the master of the group. + example: 'media_player.bluesound_livingroom' + entity_id: + description: Name(s) of entities that will coordinate the grouping. Platform dependent. + example: 'media_player.bluesound_livingroom' + +bluesound_unjoin: + description: Unjoin the player from a group. + fields: + entity_id: + description: Name(s) of entities that will be unjoined from their group. Platform dependent. + example: 'media_player.bluesound_livingroom' + +bluesound_set_sleep_timer: + description: "Set a Bluesound timer. It will increase timer in steps: 15, 30, 45, 60, 90, 0" + fields: + entity_id: + description: Name(s) of entities that will have a timer set. + example: 'media_player.bluesound_livingroom' + +bluesound_clear_sleep_timer: + description: Clear a Bluesound timer. + fields: + entity_id: + description: Name(s) of entities that will have the timer cleared. + example: 'media_player.bluesound_livingroom' From 17b57099ae1af74f5931c0deb964c88edafad896 Mon Sep 17 00:00:00 2001 From: Russell Cloran Date: Sun, 18 Feb 2018 15:02:34 -0800 Subject: [PATCH 113/173] zha: Simplify unique ID (#12495) Also fixes entity IDs generated for devices on the same endpoint. --- homeassistant/components/zha/__init__.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 61e8d1e6d73..bb29cb28b0f 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -183,7 +183,7 @@ class ApplicationListener: component = None profile_clusters = ([], []) - device_key = "{}-{}".format(str(device.ieee), endpoint_id) + device_key = "{}-{}".format(device.ieee, endpoint_id) node_config = self._config[DOMAIN][CONF_DEVICE_CONFIG].get( device_key, {}) @@ -213,7 +213,7 @@ class ApplicationListener: 'in_clusters': {c.cluster_id: c for c in in_clusters}, 'out_clusters': {c.cluster_id: c for c in out_clusters}, 'new_join': join, - 'unique_id': "{}-{}".format(device.ieee, endpoint_id), + 'unique_id': device_key, } discovery_info.update(discovered_info) self._hass.data[DISCOVERY_KEY][device_key] = discovery_info @@ -234,17 +234,17 @@ class ApplicationListener: continue component = zha_const.SINGLE_CLUSTER_DEVICE_CLASS[cluster_type] + cluster_key = "{}-{}".format(device_key, cluster_id) discovery_info = { 'application_listener': self, 'endpoint': endpoint, 'in_clusters': {cluster.cluster_id: cluster}, 'out_clusters': {}, 'new_join': join, - 'unique_id': "{}-{}-{}".format( - device.ieee, endpoint_id, cluster_id), + 'unique_id': cluster_key, + 'entity_suffix': '_{}'.format(cluster_id), } discovery_info.update(discovered_info) - cluster_key = "{}-{}".format(device_key, cluster_id) self._hass.data[DISCOVERY_KEY][cluster_key] = discovery_info yield from discovery.async_load_platform( @@ -272,23 +272,26 @@ class Entity(entity.Entity): ieee = endpoint.device.ieee ieeetail = ''.join(['%02x' % (o, ) for o in ieee[-4:]]) if manufacturer and model is not None: - self.entity_id = "{}.{}_{}_{}_{}".format( + self.entity_id = "{}.{}_{}_{}_{}{}".format( self._domain, slugify(manufacturer), slugify(model), ieeetail, endpoint.endpoint_id, + kwargs.get('entity_suffix', ''), ) self._device_state_attributes['friendly_name'] = "{} {}".format( manufacturer, model, ) else: - self.entity_id = "{}.zha_{}_{}".format( + self.entity_id = "{}.zha_{}_{}{}".format( self._domain, ieeetail, endpoint.endpoint_id, + kwargs.get('entity_suffix', ''), ) + for cluster in in_clusters.values(): cluster.add_listener(self) for cluster in out_clusters.values(): @@ -370,8 +373,7 @@ def get_discovery_info(hass, discovery_info): discovery_key = discovery_info.get('discovery_key', None) all_discovery_info = hass.data.get(DISCOVERY_KEY, {}) - discovery_info = all_discovery_info.get(discovery_key, None) - return discovery_info + return all_discovery_info.get(discovery_key, None) @asyncio.coroutine From 63fcf9d4253ca2be430179c46c5c25e518334543 Mon Sep 17 00:00:00 2001 From: Russell Cloran Date: Sun, 18 Feb 2018 15:03:18 -0800 Subject: [PATCH 114/173] zha: Add support for humidity sensors (#12496) * zha: Add support for humidity sensors * Fix lint issue --- homeassistant/components/sensor/zha.py | 30 ++++++++++++++++++++++---- homeassistant/components/zha/const.py | 1 + 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/zha.py b/homeassistant/components/sensor/zha.py index a1820f7d7dd..36cdca2e638 100644 --- a/homeassistant/components/sensor/zha.py +++ b/homeassistant/components/sensor/zha.py @@ -31,19 +31,22 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): @asyncio.coroutine def make_sensor(discovery_info): """Create ZHA sensors factory.""" - from zigpy.zcl.clusters.measurement import TemperatureMeasurement + from zigpy.zcl.clusters.measurement import ( + RelativeHumidity, TemperatureMeasurement + ) in_clusters = discovery_info['in_clusters'] - if TemperatureMeasurement.cluster_id in in_clusters: + if RelativeHumidity.cluster_id in in_clusters: + sensor = RelativeHumiditySensor(**discovery_info) + elif TemperatureMeasurement.cluster_id in in_clusters: sensor = TemperatureSensor(**discovery_info) else: sensor = Sensor(**discovery_info) - attr = sensor.value_attribute if discovery_info['new_join']: cluster = list(in_clusters.values())[0] yield from cluster.bind() yield from cluster.configure_reporting( - attr, 300, 600, sensor.min_reportable_change, + sensor.value_attribute, 300, 600, sensor.min_reportable_change, ) return sensor @@ -89,3 +92,22 @@ class TemperatureSensor(Sensor): celsius = round(float(self._state) / 100, 1) return convert_temperature( celsius, TEMP_CELSIUS, self.unit_of_measurement) + + +class RelativeHumiditySensor(Sensor): + """ZHA relative humidity sensor.""" + + min_reportable_change = 50 # 0.5% + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return '%' + + @property + def state(self): + """Return the state of the entity.""" + if self._state == 'unknown': + return 'unknown' + + return round(float(self._state) / 100, 1) diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py index 05716da58ce..deaa1257396 100644 --- a/homeassistant/components/zha/const.py +++ b/homeassistant/components/zha/const.py @@ -33,6 +33,7 @@ def populate_data(): SINGLE_CLUSTER_DEVICE_CLASS.update({ zcl.clusters.general.OnOff: 'switch', + zcl.clusters.measurement.RelativeHumidity: 'sensor', zcl.clusters.measurement.TemperatureMeasurement: 'sensor', zcl.clusters.security.IasZone: 'binary_sensor', }) From a378e18a3fd52b704f3c2bf2d8125cfe054720f2 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 19 Feb 2018 00:37:42 +0100 Subject: [PATCH 115/173] bump python-eq3bt version, fixes #12499 (#12510) --- homeassistant/components/climate/eq3btsmart.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/climate/eq3btsmart.py b/homeassistant/components/climate/eq3btsmart.py index cbfb35d06e5..5c0a3530006 100644 --- a/homeassistant/components/climate/eq3btsmart.py +++ b/homeassistant/components/climate/eq3btsmart.py @@ -15,7 +15,7 @@ from homeassistant.const import ( CONF_MAC, CONF_DEVICES, TEMP_CELSIUS, ATTR_TEMPERATURE, PRECISION_HALVES) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-eq3bt==0.1.8'] +REQUIREMENTS = ['python-eq3bt==0.1.9'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 35a2e824da7..31300232c8f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -895,7 +895,7 @@ python-digitalocean==1.13.2 python-ecobee-api==0.0.15 # homeassistant.components.climate.eq3btsmart -# python-eq3bt==0.1.8 +# python-eq3bt==0.1.9 # homeassistant.components.sensor.etherscan python-etherscan-api==0.0.3 From 6f2ee9a34c641d117a92ba0e4ec3c80bd08b8b7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Mon, 19 Feb 2018 12:40:05 +0100 Subject: [PATCH 116/173] new version of xiaomi lib (#12513) --- homeassistant/components/xiaomi_aqara.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara.py b/homeassistant/components/xiaomi_aqara.py index bc7c982df3b..9b108b2b47f 100644 --- a/homeassistant/components/xiaomi_aqara.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -22,7 +22,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow -REQUIREMENTS = ['PyXiaomiGateway==0.8.0'] +REQUIREMENTS = ['PyXiaomiGateway==0.8.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 31300232c8f..1e90c5f8937 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -36,7 +36,7 @@ PyMVGLive==1.1.4 PyMata==2.14 # homeassistant.components.xiaomi_aqara -PyXiaomiGateway==0.8.0 +PyXiaomiGateway==0.8.1 # homeassistant.components.rpi_gpio # RPi.GPIO==0.6.1 From da9c0a1fd77df6c4e0a32a9ebf0b2dd34f8e592f Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Mon, 19 Feb 2018 20:53:20 +0100 Subject: [PATCH 117/173] python-miio version bumped. (Closes: #12471) (#12481) --- homeassistant/components/fan/xiaomi_miio.py | 2 +- homeassistant/components/light/xiaomi_miio.py | 2 +- homeassistant/components/remote/xiaomi_miio.py | 2 +- homeassistant/components/switch/xiaomi_miio.py | 2 +- homeassistant/components/vacuum/xiaomi_miio.py | 2 +- requirements_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index ce3582599c1..2538e8fcd1f 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -29,7 +29,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-miio==0.3.6'] +REQUIREMENTS = ['python-miio==0.3.7'] ATTR_TEMPERATURE = 'temperature' ATTR_HUMIDITY = 'humidity' diff --git a/homeassistant/components/light/xiaomi_miio.py b/homeassistant/components/light/xiaomi_miio.py index 1ede40baf56..eaf41691903 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -30,7 +30,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-miio==0.3.6'] +REQUIREMENTS = ['python-miio==0.3.7'] # The light does not accept cct values < 1 CCT_MIN = 1 diff --git a/homeassistant/components/remote/xiaomi_miio.py b/homeassistant/components/remote/xiaomi_miio.py index 423fd99eb73..a44934d0a39 100644 --- a/homeassistant/components/remote/xiaomi_miio.py +++ b/homeassistant/components/remote/xiaomi_miio.py @@ -21,7 +21,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow -REQUIREMENTS = ['python-miio==0.3.6'] +REQUIREMENTS = ['python-miio==0.3.7'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/xiaomi_miio.py b/homeassistant/components/switch/xiaomi_miio.py index ad71b3944cf..7defc3d3b2b 100644 --- a/homeassistant/components/switch/xiaomi_miio.py +++ b/homeassistant/components/switch/xiaomi_miio.py @@ -25,7 +25,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-miio==0.3.6'] +REQUIREMENTS = ['python-miio==0.3.7'] ATTR_POWER = 'power' ATTR_TEMPERATURE = 'temperature' diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index 82753fcf7bc..55f166c4004 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-miio==0.3.6'] +REQUIREMENTS = ['python-miio==0.3.7'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 1e90c5f8937..b18f38dffd4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -925,7 +925,7 @@ python-juicenet==0.0.5 # homeassistant.components.remote.xiaomi_miio # homeassistant.components.switch.xiaomi_miio # homeassistant.components.vacuum.xiaomi_miio -python-miio==0.3.6 +python-miio==0.3.7 # homeassistant.components.media_player.mpd python-mpd2==0.5.5 From dc21c61a440197a82af0dc8c32423f79dceefc63 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Mon, 19 Feb 2018 23:11:21 +0100 Subject: [PATCH 118/173] LimitlessLED assumed state (#12475) * Use assumed state for LimitlessLED * Replace inheritance with feature tests * Clean up * Clean up conversion methods * Clamp temperature --- .../components/light/limitlessled.py | 297 ++++++------------ 1 file changed, 92 insertions(+), 205 deletions(-) diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index ad3aa4e92e8..5619e54f123 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -4,17 +4,20 @@ Support for LimitlessLED bulbs. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.limitlessled/ """ +import asyncio import logging import voluptuous as vol -from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_PORT, CONF_TYPE) +from homeassistant.const import ( + CONF_NAME, CONF_HOST, CONF_PORT, CONF_TYPE, STATE_ON) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_RGB_COLOR, ATTR_TRANSITION, EFFECT_COLORLOOP, EFFECT_WHITE, FLASH_LONG, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, Light, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.restore_state import async_get_last_state REQUIREMENTS = ['limitlessled==1.0.8'] @@ -115,7 +118,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): group_conf.get(CONF_NUMBER), group_conf.get(CONF_NAME), group_conf.get(CONF_TYPE, DEFAULT_LED_TYPE)) - lights.append(LimitlessLEDGroup.factory(group, { + lights.append(LimitlessLEDGroup(group, { 'fade': group_conf[CONF_FADE] })) add_devices(lights) @@ -138,9 +141,6 @@ def state(new_state): if self.repeating: self.repeating = False self.group.stop() - # Not on and should be? Turn on. - if not self.is_on and new_state is True: - pipeline.on() # Set transition time. if ATTR_TRANSITION in kwargs: transition_time = int(kwargs[ATTR_TRANSITION]) @@ -159,30 +159,44 @@ class LimitlessLEDGroup(Light): def __init__(self, group, config): """Initialize a group.""" - self.group = group - self.repeating = False - self._is_on = False - self._brightness = None - self.config = config - - @staticmethod - def factory(group, config): - """Produce LimitlessLEDGroup objects.""" from limitlessled.group.rgbw import RgbwGroup from limitlessled.group.white import WhiteGroup from limitlessled.group.rgbww import RgbwwGroup if isinstance(group, WhiteGroup): - return LimitlessLEDWhiteGroup(group, config) + self._supported = SUPPORT_LIMITLESSLED_WHITE elif isinstance(group, RgbwGroup): - return LimitlessLEDRGBWGroup(group, config) + self._supported = SUPPORT_LIMITLESSLED_RGB elif isinstance(group, RgbwwGroup): - return LimitlessLEDRGBWWGroup(group, config) + self._supported = SUPPORT_LIMITLESSLED_RGBWW + + self.group = group + self.config = config + self.repeating = False + self._is_on = False + self._brightness = None + self._temperature = None + self._color = None + + @asyncio.coroutine + def async_added_to_hass(self): + """Called when entity is about to be added to hass.""" + last_state = yield from async_get_last_state(self.hass, self.entity_id) + if last_state: + self._is_on = (last_state.state == STATE_ON) + self._brightness = last_state.attributes.get('brightness') + self._temperature = last_state.attributes.get('color_temp') + self._color = last_state.attributes.get('rgb_color') @property def should_poll(self): """No polling needed.""" return False + @property + def assumed_state(self): + """Return True because unable to access real state of the entity.""" + return True + @property def name(self): """Return the name of the group.""" @@ -198,219 +212,92 @@ class LimitlessLEDGroup(Light): """Return the brightness property.""" return self._brightness + @property + def color_temp(self): + """Return the temperature property.""" + return self._temperature + + @property + def rgb_color(self): + """Return the color property.""" + return self._color + + @property + def supported_features(self): + """Flag supported features.""" + return self._supported + # pylint: disable=arguments-differ @state(False) def turn_off(self, transition_time, pipeline, **kwargs): """Turn off a group.""" - if self.is_on: - if self.config[CONF_FADE]: - pipeline.transition(transition_time, brightness=0.0) - pipeline.off() - - -class LimitlessLEDWhiteGroup(LimitlessLEDGroup): - """Representation of a LimitlessLED White group.""" - - def __init__(self, group, config): - """Initialize White group.""" - super().__init__(group, config) - # Initialize group with known values. - self.group.on = True - self.group.temperature = 1.0 - self.group.brightness = 0.0 - self._brightness = _to_hass_brightness(1.0) - self._temperature = _to_hass_temperature(self.group.temperature) - self.group.on = False - - @property - def color_temp(self): - """Return the temperature property.""" - return self._temperature - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_LIMITLESSLED_WHITE + if self.config[CONF_FADE]: + pipeline.transition(transition_time, brightness=0.0) + pipeline.off() # pylint: disable=arguments-differ @state(True) def turn_on(self, transition_time, pipeline, **kwargs): """Turn on (or adjust property of) a group.""" - # Check arguments. + pipeline.on() + + # Set up transition. + args = {} + if self.config[CONF_FADE] and not self.is_on and self._brightness: + args['brightness'] = self.limitlessled_brightness() + if ATTR_BRIGHTNESS in kwargs: self._brightness = kwargs[ATTR_BRIGHTNESS] + args['brightness'] = self.limitlessled_brightness() + + if ATTR_RGB_COLOR in kwargs and self._supported & SUPPORT_RGB_COLOR: + self._color = kwargs[ATTR_RGB_COLOR] + # White is a special case. + if min(self._color) > 256 - RGB_BOUNDARY: + pipeline.white() + self._color = WHITE + else: + args['color'] = self.limitlessled_color() + if ATTR_COLOR_TEMP in kwargs: - self._temperature = kwargs[ATTR_COLOR_TEMP] - # Set up transition. - pipeline.transition( - transition_time, - brightness=_from_hass_brightness(self._brightness), - temperature=_from_hass_temperature(self._temperature) - ) - - -class LimitlessLEDRGBWGroup(LimitlessLEDGroup): - """Representation of a LimitlessLED RGBW group.""" - - def __init__(self, group, config): - """Initialize RGBW group.""" - super().__init__(group, config) - # Initialize group with known values. - self.group.on = True - self.group.white() - self._color = WHITE - self.group.brightness = 0.0 - self._brightness = _to_hass_brightness(1.0) - self.group.on = False - - @property - def rgb_color(self): - """Return the color property.""" - return self._color - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_LIMITLESSLED_RGB - - # pylint: disable=arguments-differ - @state(True) - def turn_on(self, transition_time, pipeline, **kwargs): - """Turn on (or adjust property of) a group.""" - from limitlessled.presets import COLORLOOP - # Check arguments. - if ATTR_BRIGHTNESS in kwargs: - self._brightness = kwargs[ATTR_BRIGHTNESS] - if ATTR_RGB_COLOR in kwargs: - self._color = kwargs[ATTR_RGB_COLOR] - # White is a special case. - if min(self._color) > 256 - RGB_BOUNDARY: - pipeline.white() + if self._supported & SUPPORT_RGB_COLOR: + pipeline.white() self._color = WHITE - # Set up transition. - pipeline.transition( - transition_time, - brightness=_from_hass_brightness(self._brightness), - color=_from_hass_color(self._color) - ) + if self._supported & SUPPORT_COLOR_TEMP: + self._temperature = kwargs[ATTR_COLOR_TEMP] + args['temperature'] = self.limitlessled_temperature() + + if args: + pipeline.transition(transition_time, **args) + # Flash. - if ATTR_FLASH in kwargs: + if ATTR_FLASH in kwargs and self._supported & SUPPORT_FLASH: duration = 0 if kwargs[ATTR_FLASH] == FLASH_LONG: duration = 1 pipeline.flash(duration=duration) + # Add effects. - if ATTR_EFFECT in kwargs: + if ATTR_EFFECT in kwargs and self._supported & SUPPORT_EFFECT: if kwargs[ATTR_EFFECT] == EFFECT_COLORLOOP: + from limitlessled.presets import COLORLOOP self.repeating = True pipeline.append(COLORLOOP) if kwargs[ATTR_EFFECT] == EFFECT_WHITE: pipeline.white() self._color = WHITE + def limitlessled_temperature(self): + """Convert Home Assistant color temperature units to percentage.""" + width = self.max_mireds - self.min_mireds + temperature = 1 - (self._temperature - self.min_mireds) / width + return max(0, min(1, temperature)) -class LimitlessLEDRGBWWGroup(LimitlessLEDGroup): - """Representation of a LimitlessLED RGBWW group.""" + def limitlessled_brightness(self): + """Convert Home Assistant brightness units to percentage.""" + return self._brightness / 255 - def __init__(self, group, config): - """Initialize RGBWW group.""" - super().__init__(group, config) - # Initialize group with known values. - self.group.on = True - self.group.white() - self.group.temperature = 0.0 - self._color = WHITE - self.group.brightness = 0.0 - self._brightness = _to_hass_brightness(1.0) - self._temperature = _to_hass_temperature(self.group.temperature) - self.group.on = False - - @property - def rgb_color(self): - """Return the color property.""" - return self._color - - @property - def color_temp(self): - """Return the temperature property.""" - return self._temperature - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_LIMITLESSLED_RGBWW - - # pylint: disable=arguments-differ - @state(True) - def turn_on(self, transition_time, pipeline, **kwargs): - """Turn on (or adjust property of) a group.""" - from limitlessled.presets import COLORLOOP - # Check arguments. - if ATTR_BRIGHTNESS in kwargs: - self._brightness = kwargs[ATTR_BRIGHTNESS] - if ATTR_RGB_COLOR in kwargs: - self._color = kwargs[ATTR_RGB_COLOR] - elif ATTR_COLOR_TEMP in kwargs: - self._temperature = kwargs[ATTR_COLOR_TEMP] - # White is a special case. - if min(self._color) > 256 - RGB_BOUNDARY: - pipeline.white() - self._color = WHITE - # Set up transition. - if self._color == WHITE: - pipeline.transition( - transition_time, - brightness=_from_hass_brightness(self._brightness), - temperature=_from_hass_temperature(self._temperature) - ) - else: - pipeline.transition( - transition_time, - brightness=_from_hass_brightness(self._brightness), - color=_from_hass_color(self._color) - ) - # Flash. - if ATTR_FLASH in kwargs: - duration = 0 - if kwargs[ATTR_FLASH] == FLASH_LONG: - duration = 1 - pipeline.flash(duration=duration) - # Add effects. - if ATTR_EFFECT in kwargs: - if kwargs[ATTR_EFFECT] == EFFECT_COLORLOOP: - self.repeating = True - pipeline.append(COLORLOOP) - if kwargs[ATTR_EFFECT] == EFFECT_WHITE: - pipeline.white() - self._color = WHITE - - -def _from_hass_temperature(temperature): - """Convert Home Assistant color temperature units to percentage.""" - return 1 - (temperature - 154) / 346 - - -def _to_hass_temperature(temperature): - """Convert percentage to Home Assistant color temperature units.""" - return 500 - int(temperature * 346) - - -def _from_hass_brightness(brightness): - """Convert Home Assistant brightness units to percentage.""" - return brightness / 255 - - -def _to_hass_brightness(brightness): - """Convert percentage to Home Assistant brightness units.""" - return int(brightness * 255) - - -def _from_hass_color(color): - """Convert Home Assistant RGB list to Color tuple.""" - from limitlessled import Color - return Color(*tuple(color)) - - -def _to_hass_color(color): - """Convert from Color tuple to Home Assistant RGB list.""" - return list([int(c) for c in color]) + def limitlessled_color(self): + """Convert Home Assistant RGB list to Color tuple.""" + from limitlessled import Color + return Color(*tuple(self._color)) From eec3bad94f82cbe2590a1615060a28fc77cefbdc Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Mon, 19 Feb 2018 23:46:22 +0100 Subject: [PATCH 119/173] Add support for HomeKit (#12488) * Basic Homekit support * Added Temperatur Sensor * Added Window Cover * Code refactored * Added class HomeAccessory(Accessory) * Added class HomeBridge(Bridge) * Changed homekit imports to relative, to enable use in custom_components * Updated requirements * Added docs * Other smaller changes * Changed Homekit from entity to class * Changes based on feedback * Updated config schema * Add only covers that support set_cover_position * Addressed comments, updated to pyhap==1.1.5 * For lint: added files to gen_requirements_all * Added codeowner * Small change to Wrapper classes * Moved imports to import_types, small changes * Small changes, added tests * Homekit class: removed add_accessory since it's already covered by pyhap * Added test requirement: HAP-python * Added test suit for homekit setup and interaction between HA and pyhap * Added test suit for get_accessories function * Test bugfix * Added validate pincode, tests for cover and sensor types --- CODEOWNERS | 1 + homeassistant/components/homekit/__init__.py | 133 ++++++++++++++++++ .../components/homekit/accessories.py | 55 ++++++++ homeassistant/components/homekit/const.py | 18 +++ homeassistant/components/homekit/covers.py | 84 +++++++++++ homeassistant/components/homekit/sensors.py | 50 +++++++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 4 + tests/components/homekit/__init__.py | 1 + tests/components/homekit/test_covers.py | 83 +++++++++++ .../homekit/test_get_accessories.py | 46 ++++++ tests/components/homekit/test_homekit.py | 124 ++++++++++++++++ tests/components/homekit/test_sensors.py | 37 +++++ 14 files changed, 642 insertions(+) create mode 100644 homeassistant/components/homekit/__init__.py create mode 100644 homeassistant/components/homekit/accessories.py create mode 100644 homeassistant/components/homekit/const.py create mode 100644 homeassistant/components/homekit/covers.py create mode 100644 homeassistant/components/homekit/sensors.py create mode 100644 tests/components/homekit/__init__.py create mode 100644 tests/components/homekit/test_covers.py create mode 100644 tests/components/homekit/test_get_accessories.py create mode 100644 tests/components/homekit/test_homekit.py create mode 100644 tests/components/homekit/test_sensors.py diff --git a/CODEOWNERS b/CODEOWNERS index ff07940d9cb..846eb20b3fe 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -77,6 +77,7 @@ homeassistant/components/*/axis.py @kane610 homeassistant/components/*/broadlink.py @danielhiversen homeassistant/components/hive.py @Rendili @KJonline homeassistant/components/*/hive.py @Rendili @KJonline +homeassistant/components/homekit/* @cdce8p homeassistant/components/*/deconz.py @kane610 homeassistant/components/*/rfxtrx.py @danielhiversen homeassistant/components/velux.py @Julius2342 diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py new file mode 100644 index 00000000000..021c682466e --- /dev/null +++ b/homeassistant/components/homekit/__init__.py @@ -0,0 +1,133 @@ +"""Support for Apple Homekit. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/homekit/ +""" +import asyncio +import logging +import re + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, CONF_PORT, + TEMP_CELSIUS, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) +from homeassistant.util import get_local_ip +from homeassistant.util.decorator import Registry + +TYPES = Registry() +_LOGGER = logging.getLogger(__name__) + +_RE_VALID_PINCODE = re.compile(r"^(\d{3}-\d{2}-\d{3})$") + +DOMAIN = 'homekit' +REQUIREMENTS = ['HAP-python==1.1.5'] + +BRIDGE_NAME = 'Home Assistant' +CONF_PIN_CODE = 'pincode' + +HOMEKIT_FILE = '.homekit.state' + + +def valid_pin(value): + """Validate pincode value.""" + match = _RE_VALID_PINCODE.findall(value.strip()) + if match == []: + raise vol.Invalid("Pin must be in the format: '123-45-678'") + return match[0] + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All({ + vol.Optional(CONF_PORT, default=51826): vol.Coerce(int), + vol.Optional(CONF_PIN_CODE, default='123-45-678'): valid_pin, + }) +}, extra=vol.ALLOW_EXTRA) + + +@asyncio.coroutine +def async_setup(hass, config): + """Setup the homekit component.""" + _LOGGER.debug("Begin setup homekit") + + conf = config[DOMAIN] + port = conf.get(CONF_PORT) + pin = str.encode(conf.get(CONF_PIN_CODE)) + + homekit = Homekit(hass, port) + homekit.setup_bridge(pin) + + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, homekit.start_driver) + return True + + +def import_types(): + """Import all types from files in the homekit dir.""" + _LOGGER.debug("Import type files.") + # pylint: disable=unused-variable + from .covers import Window # noqa F401 + # pylint: disable=unused-variable + from .sensors import TemperatureSensor # noqa F401 + + +def get_accessory(hass, state): + """Take state and return an accessory object if supported.""" + if state.domain == 'sensor': + if state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS: + _LOGGER.debug("Add \"%s\" as \"%s\"", + state.entity_id, 'TemperatureSensor') + return TYPES['TemperatureSensor'](hass, state.entity_id, + state.name) + + elif state.domain == 'cover': + # Only add covers that support set_cover_position + if state.attributes.get(ATTR_SUPPORTED_FEATURES) & 4: + _LOGGER.debug("Add \"%s\" as \"%s\"", + state.entity_id, 'Window') + return TYPES['Window'](hass, state.entity_id, state.name) + + return None + + +class Homekit(): + """Class to handle all actions between homekit and Home Assistant.""" + + def __init__(self, hass, port): + """Initialize a homekit object.""" + self._hass = hass + self._port = port + self.bridge = None + self.driver = None + + def setup_bridge(self, pin): + """Setup the bridge component to track all accessories.""" + from .accessories import HomeBridge + self.bridge = HomeBridge(BRIDGE_NAME, pincode=pin) + self.bridge.set_accessory_info('homekit.bridge') + + def start_driver(self, event): + """Start the accessory driver.""" + from pyhap.accessory_driver import AccessoryDriver + self._hass.bus.listen_once( + EVENT_HOMEASSISTANT_STOP, self.stop_driver) + + import_types() + _LOGGER.debug("Start adding accessories.") + for state in self._hass.states.all(): + acc = get_accessory(self._hass, state) + if acc is not None: + self.bridge.add_accessory(acc) + + ip_address = get_local_ip() + path = self._hass.config.path(HOMEKIT_FILE) + self.driver = AccessoryDriver(self.bridge, self._port, + ip_address, path) + _LOGGER.debug("Driver started") + self.driver.start() + + def stop_driver(self, event): + """Stop the accessory driver.""" + _LOGGER.debug("Driver stop") + if self.driver is not None: + self.driver.stop() diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py new file mode 100644 index 00000000000..e1a25a2c976 --- /dev/null +++ b/homeassistant/components/homekit/accessories.py @@ -0,0 +1,55 @@ +"""Extend the basic Accessory and Bridge functions.""" +from pyhap.accessory import Accessory, Bridge, Category + +from .const import ( + SERVICES_ACCESSORY_INFO, MANUFACTURER, + CHAR_MODEL, CHAR_MANUFACTURER, CHAR_SERIAL_NUMBER) + + +class HomeAccessory(Accessory): + """Class to extend the Accessory class.""" + + ALL_CATEGORIES = Category + + def __init__(self, display_name): + """Initialize a Accessory object.""" + super().__init__(display_name) + + def set_category(self, category): + """Set the category of the accessory.""" + self.category = category + + def add_preload_service(self, service): + """Define the services to be available for the accessory.""" + from pyhap.loader import get_serv_loader + self.add_service(get_serv_loader().get(service)) + + def set_accessory_info(self, model, manufacturer=MANUFACTURER, + serial_number='0000'): + """Set the default accessory information.""" + service_info = self.get_service(SERVICES_ACCESSORY_INFO) + service_info.get_characteristic(CHAR_MODEL) \ + .set_value(model) + service_info.get_characteristic(CHAR_MANUFACTURER) \ + .set_value(manufacturer) + service_info.get_characteristic(CHAR_SERIAL_NUMBER) \ + .set_value(serial_number) + + +class HomeBridge(Bridge): + """Class to extend the Bridge class.""" + + def __init__(self, display_name, pincode): + """Initialize a Bridge object.""" + super().__init__(display_name, pincode=pincode) + + def set_accessory_info(self, model, manufacturer=MANUFACTURER, + serial_number='0000'): + """Set the default accessory information.""" + service_info = self.get_service(SERVICES_ACCESSORY_INFO) + service_info.get_characteristic(CHAR_MODEL) \ + .set_value(model) + service_info.get_characteristic(CHAR_MANUFACTURER) \ + .set_value(manufacturer) + service_info.get_characteristic(CHAR_SERIAL_NUMBER) \ + .set_value(serial_number) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py new file mode 100644 index 00000000000..6c58b7fe45f --- /dev/null +++ b/homeassistant/components/homekit/const.py @@ -0,0 +1,18 @@ +"""Constants used be the homekit component.""" +MANUFACTURER = 'HomeAssistant' + +# Service: AccessoryInfomation +SERVICES_ACCESSORY_INFO = 'AccessoryInformation' +CHAR_MODEL = 'Model' +CHAR_MANUFACTURER = 'Manufacturer' +CHAR_SERIAL_NUMBER = 'SerialNumber' + +# Service: TemperatureSensor +SERVICES_TEMPERATURE_SENSOR = 'TemperatureSensor' +CHAR_CURRENT_TEMPERATURE = 'CurrentTemperature' + +# Service: WindowCovering +SERVICES_WINDOW_COVERING = 'WindowCovering' +CHAR_CURRENT_POSITION = 'CurrentPosition' +CHAR_TARGET_POSITION = 'TargetPosition' +CHAR_POSITION_STATE = 'PositionState' diff --git a/homeassistant/components/homekit/covers.py b/homeassistant/components/homekit/covers.py new file mode 100644 index 00000000000..1068b1e0e3f --- /dev/null +++ b/homeassistant/components/homekit/covers.py @@ -0,0 +1,84 @@ +"""Class to hold all cover accessories.""" +import logging + +from homeassistant.components.cover import ATTR_CURRENT_POSITION +from homeassistant.helpers.event import async_track_state_change + +from . import TYPES +from .accessories import HomeAccessory +from .const import ( + SERVICES_WINDOW_COVERING, CHAR_CURRENT_POSITION, + CHAR_TARGET_POSITION, CHAR_POSITION_STATE) + + +_LOGGER = logging.getLogger(__name__) + + +@TYPES.register('Window') +class Window(HomeAccessory): + """Generate a Window accessory for a cover entity. + + The cover entity must support: set_cover_position. + """ + + def __init__(self, hass, entity_id, display_name): + """Initialize a Window accessory object.""" + super().__init__(display_name) + self.set_category(self.ALL_CATEGORIES.WINDOW) + self.set_accessory_info(entity_id) + self.add_preload_service(SERVICES_WINDOW_COVERING) + + self._hass = hass + self._entity_id = entity_id + + self.current_position = None + self.homekit_target = None + + self.service_cover = self.get_service(SERVICES_WINDOW_COVERING) + self.char_current_position = self.service_cover. \ + get_characteristic(CHAR_CURRENT_POSITION) + self.char_target_position = self.service_cover. \ + get_characteristic(CHAR_TARGET_POSITION) + self.char_position_state = self.service_cover. \ + get_characteristic(CHAR_POSITION_STATE) + + self.char_target_position.setter_callback = self.move_cover + + def run(self): + """Method called be object after driver is started.""" + state = self._hass.states.get(self._entity_id) + self.update_cover_position(new_state=state) + + async_track_state_change( + self._hass, self._entity_id, self.update_cover_position) + + def move_cover(self, value): + """Move cover to value if call came from homekit.""" + if value != self.current_position: + _LOGGER.debug("%s: Set position to %d", self._entity_id, value) + self.homekit_target = value + if value > self.current_position: + self.char_position_state.set_value(1) + elif value < self.current_position: + self.char_position_state.set_value(0) + self._hass.services.call( + 'cover', 'set_cover_position', + {'entity_id': self._entity_id, 'position': value}) + + def update_cover_position(self, entity_id=None, old_state=None, + new_state=None): + """Update cover position after state changed.""" + if new_state is None: + return + + current_position = new_state.attributes[ATTR_CURRENT_POSITION] + if current_position is None: + return + self.current_position = int(current_position) + self.char_current_position.set_value(self.current_position) + + if self.homekit_target is None or \ + abs(self.current_position - self.homekit_target) < 6: + self.char_target_position.set_value(self.current_position) + self.char_position_state.set_value(2) + self.homekit_target = None diff --git a/homeassistant/components/homekit/sensors.py b/homeassistant/components/homekit/sensors.py new file mode 100644 index 00000000000..dcbb25c9e15 --- /dev/null +++ b/homeassistant/components/homekit/sensors.py @@ -0,0 +1,50 @@ +"""Class to hold all sensor accessories.""" +import logging + +from homeassistant.const import STATE_UNKNOWN +from homeassistant.helpers.event import async_track_state_change + +from . import TYPES +from .accessories import HomeAccessory +from .const import ( + SERVICES_TEMPERATURE_SENSOR, CHAR_CURRENT_TEMPERATURE) + + +_LOGGER = logging.getLogger(__name__) + + +@TYPES.register('TemperatureSensor') +class TemperatureSensor(HomeAccessory): + """Generate a TemperatureSensor accessory for a temperature sensor. + + Sensor entity must return either temperature in °C or STATE_UNKNOWN. + """ + + def __init__(self, hass, entity_id, display_name): + """Initialize a TemperatureSensor accessory object.""" + super().__init__(display_name) + self.set_category(self.ALL_CATEGORIES.SENSOR) + self.set_accessory_info(entity_id) + self.add_preload_service(SERVICES_TEMPERATURE_SENSOR) + + self._hass = hass + self._entity_id = entity_id + + self.service_temp = self.get_service(SERVICES_TEMPERATURE_SENSOR) + self.char_temp = self.service_temp. \ + get_characteristic(CHAR_CURRENT_TEMPERATURE) + + def run(self): + """Method called be object after driver is started.""" + state = self._hass.states.get(self._entity_id) + self.update_temperature(new_state=state) + + async_track_state_change( + self._hass, self._entity_id, self.update_temperature) + + def update_temperature(self, entity_id=None, old_state=None, + new_state=None): + """Update temperature after state changed.""" + temperature = new_state.state + if temperature != STATE_UNKNOWN: + self.char_temp.set_value(float(temperature)) diff --git a/requirements_all.txt b/requirements_all.txt index b18f38dffd4..abf4dea14e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -23,6 +23,9 @@ attrs==17.4.0 # homeassistant.components.doorbird DoorBirdPy==0.1.2 +# homeassistant.components.homekit +HAP-python==1.1.5 + # homeassistant.components.isy994 PyISY==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c1d776c110d..9b054da50d1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -18,6 +18,9 @@ flake8-docstrings==1.0.3 asynctest>=0.11.1 +# homeassistant.components.homekit +HAP-python==1.1.5 + # homeassistant.components.notify.html5 PyJWT==1.5.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 42acee96206..317cecfb164 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -47,6 +47,7 @@ TEST_REQUIREMENTS = ( 'evohomeclient', 'feedparser', 'gTTS-token', + 'HAP-python', 'ha-ffmpeg', 'haversine', 'hbmqtt', @@ -92,6 +93,9 @@ TEST_REQUIREMENTS = ( IGNORE_PACKAGES = ( 'homeassistant.components.recorder.models', + 'homeassistant.components.homekit.accessories', + 'homeassistant.components.homekit.covers', + 'homeassistant.components.homekit.sensors' ) IGNORE_PIN = ('colorlog>2.1,<3', 'keyring>=9.3,<10.0', 'urllib3') diff --git a/tests/components/homekit/__init__.py b/tests/components/homekit/__init__.py new file mode 100644 index 00000000000..61a60cee2ac --- /dev/null +++ b/tests/components/homekit/__init__.py @@ -0,0 +1 @@ +"""The tests for the homekit component.""" diff --git a/tests/components/homekit/test_covers.py b/tests/components/homekit/test_covers.py new file mode 100644 index 00000000000..8810beb6210 --- /dev/null +++ b/tests/components/homekit/test_covers.py @@ -0,0 +1,83 @@ +"""Test different accessory types: Covers.""" +import unittest + +from homeassistant.core import callback +from homeassistant.components.cover import ( + ATTR_POSITION, ATTR_CURRENT_POSITION) +from homeassistant.components.homekit.covers import Window +from homeassistant.const import ( + STATE_UNKNOWN, STATE_OPEN, + ATTR_SERVICE, ATTR_SERVICE_DATA, EVENT_CALL_SERVICE) + +from tests.common import get_test_home_assistant + + +class TestHomekitSensors(unittest.TestCase): + """Test class for all accessory types regarding covers.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.events = [] + + @callback + def record_event(event): + """Track called event.""" + self.events.append(event) + + self.hass.bus.listen(EVENT_CALL_SERVICE, record_event) + + def tearDown(self): + """Stop down everthing that was started.""" + self.hass.stop() + + def test_window_set_cover_position(self): + """Test if accessory and HA are updated accordingly.""" + window_cover = 'cover.window' + + acc = Window(self.hass, window_cover, 'Cover') + acc.run() + + self.assertEqual(acc.char_current_position.value, 0) + self.assertEqual(acc.char_target_position.value, 0) + self.assertEqual(acc.char_position_state.value, 0) + + self.hass.states.set(window_cover, STATE_UNKNOWN, + {ATTR_CURRENT_POSITION: None}) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_position.value, 0) + self.assertEqual(acc.char_target_position.value, 0) + self.assertEqual(acc.char_position_state.value, 0) + + self.hass.states.set(window_cover, STATE_OPEN, + {ATTR_CURRENT_POSITION: 50}) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_position.value, 50) + self.assertEqual(acc.char_target_position.value, 50) + self.assertEqual(acc.char_position_state.value, 2) + + # Set from HomeKit + acc.char_target_position.set_value(25) + self.hass.block_till_done() + self.assertEqual( + self.events[0].data[ATTR_SERVICE], 'set_cover_position') + self.assertEqual( + self.events[0].data[ATTR_SERVICE_DATA][ATTR_POSITION], 25) + + self.assertEqual(acc.char_current_position.value, 50) + self.assertEqual(acc.char_target_position.value, 25) + self.assertEqual(acc.char_position_state.value, 0) + + # Set from HomeKit + acc.char_target_position.set_value(75) + self.hass.block_till_done() + self.assertEqual( + self.events[0].data[ATTR_SERVICE], 'set_cover_position') + self.assertEqual( + self.events[1].data[ATTR_SERVICE_DATA][ATTR_POSITION], 75) + + self.assertEqual(acc.char_current_position.value, 50) + self.assertEqual(acc.char_target_position.value, 75) + self.assertEqual(acc.char_position_state.value, 1) diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py new file mode 100644 index 00000000000..e20e87871b8 --- /dev/null +++ b/tests/components/homekit/test_get_accessories.py @@ -0,0 +1,46 @@ +"""Package to test the get_accessory method.""" +from unittest.mock import patch, MagicMock + +from homeassistant.core import State +from homeassistant.components.homekit import ( + TYPES, get_accessory, import_types) +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, ATTR_SUPPORTED_FEATURES, + TEMP_CELSIUS, STATE_UNKNOWN) + + +def test_import_types(): + """Test if all type files are imported correctly.""" + try: + import_types() + assert True + # pylint: disable=broad-except + except Exception: + assert False + + +def test_component_not_supported(): + """Test with unsupported component.""" + state = State('demo.unsupported', STATE_UNKNOWN) + + assert True if get_accessory(None, state) is None else False + + +def test_sensor_temperatur_celsius(): + """Test temperature sensor with celsius as unit.""" + mock_type = MagicMock() + with patch.dict(TYPES, {'TemperatureSensor': mock_type}): + state = State('sensor.temperatur', '23', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + get_accessory(None, state) + assert len(mock_type.mock_calls) == 1 + + +def test_cover_set_position(): + """Test cover with support for set_cover_position.""" + mock_type = MagicMock() + with patch.dict(TYPES, {'Window': mock_type}): + state = State('cover.setposition', 'open', + {ATTR_SUPPORTED_FEATURES: 4}) + get_accessory(None, state) + assert len(mock_type.mock_calls) == 1 diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py new file mode 100644 index 00000000000..7182fd6f7d9 --- /dev/null +++ b/tests/components/homekit/test_homekit.py @@ -0,0 +1,124 @@ +"""Tests for the homekit component.""" + +import unittest +from unittest.mock import patch + +import voluptuous as vol + +from homeassistant import setup +from homeassistant.core import Event +from homeassistant.components.homekit import ( + CONF_PIN_CODE, BRIDGE_NAME, Homekit, valid_pin) +from homeassistant.components.homekit.covers import Window +from homeassistant.components.homekit.sensors import TemperatureSensor +from homeassistant.const import ( + CONF_PORT, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) + +from tests.common import get_test_home_assistant + +HOMEKIT_PATH = 'homeassistant.components.homekit' + +CONFIG_MIN = {'homekit': {}} +CONFIG = { + 'homekit': { + CONF_PORT: 11111, + CONF_PIN_CODE: '987-65-432', + } +} + + +class TestHomekit(unittest.TestCase): + """Test the Multicover component.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop down everthing that was started.""" + self.hass.stop() + + @patch(HOMEKIT_PATH + '.Homekit.start_driver') + @patch(HOMEKIT_PATH + '.Homekit.setup_bridge') + @patch(HOMEKIT_PATH + '.Homekit.__init__') + def test_setup_min(self, mock_homekit, mock_setup_bridge, + mock_start_driver): + """Test async_setup with minimal config option.""" + mock_homekit.return_value = None + + self.assertTrue(setup.setup_component( + self.hass, 'homekit', CONFIG_MIN)) + + mock_homekit.assert_called_once_with(self.hass, 51826) + mock_setup_bridge.assert_called_with(b'123-45-678') + mock_start_driver.assert_not_called() + + self.hass.start() + self.hass.block_till_done() + mock_start_driver.assert_called_once() + + @patch(HOMEKIT_PATH + '.Homekit.start_driver') + @patch(HOMEKIT_PATH + '.Homekit.setup_bridge') + @patch(HOMEKIT_PATH + '.Homekit.__init__') + def test_setup_parameters(self, mock_homekit, mock_setup_bridge, + mock_start_driver): + """Test async_setup with full config option.""" + mock_homekit.return_value = None + + self.assertTrue(setup.setup_component( + self.hass, 'homekit', CONFIG)) + + mock_homekit.assert_called_once_with(self.hass, 11111) + mock_setup_bridge.assert_called_with(b'987-65-432') + + def test_validate_pincode(self): + """Test async_setup with invalid config option.""" + schema = vol.Schema(valid_pin) + + for value in ('', '123-456-78', 'a23-45-678', '12345678'): + with self.assertRaises(vol.MultipleInvalid): + schema(value) + + for value in ('123-45-678', '234-56-789'): + self.assertTrue(schema(value)) + + @patch('pyhap.accessory_driver.AccessoryDriver.persist') + @patch('pyhap.accessory_driver.AccessoryDriver.stop') + @patch('pyhap.accessory_driver.AccessoryDriver.start') + @patch(HOMEKIT_PATH + '.import_types') + @patch(HOMEKIT_PATH + '.get_accessory') + def test_homekit_pyhap_interaction( + self, mock_get_accessory, mock_import_types, + mock_driver_start, mock_driver_stop, mock_file_persist): + """Test the interaction between the homekit class and pyhap.""" + acc1 = TemperatureSensor(self.hass, 'sensor.temp', 'Temperature') + acc2 = Window(self.hass, 'cover.hall_window', 'Cover') + mock_get_accessory.side_effect = [acc1, acc2] + + homekit = Homekit(self.hass, 51826) + homekit.setup_bridge(b'123-45-678') + + self.assertEqual(homekit.bridge.display_name, BRIDGE_NAME) + + self.hass.states.set('demo.demo1', 'on') + self.hass.states.set('demo.demo2', 'off') + + self.hass.start() + self.hass.block_till_done() + + homekit.start_driver(Event(EVENT_HOMEASSISTANT_START)) + + self.assertEqual(mock_get_accessory.call_count, 2) + mock_import_types.assert_called_once() + mock_driver_start.assert_called_once() + + accessories = homekit.bridge.accessories + self.assertEqual(accessories[2], acc1) + self.assertEqual(accessories[3], acc2) + + mock_driver_stop.assert_not_called() + + self.hass.bus.fire(EVENT_HOMEASSISTANT_STOP) + self.hass.block_till_done() + + mock_driver_stop.assert_called_once() diff --git a/tests/components/homekit/test_sensors.py b/tests/components/homekit/test_sensors.py new file mode 100644 index 00000000000..cebbd4f5aea --- /dev/null +++ b/tests/components/homekit/test_sensors.py @@ -0,0 +1,37 @@ +"""Test different accessory types: Sensors.""" +import unittest + +from homeassistant.components.homekit.sensors import TemperatureSensor +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, STATE_UNKNOWN) + +from tests.common import get_test_home_assistant + + +class TestHomekitSensors(unittest.TestCase): + """Test class for all accessory types regarding sensors.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop down everthing that was started.""" + self.hass.stop() + + def test_temperature_celsius(self): + """Test if accessory is updated after state change.""" + temperature_sensor = 'sensor.temperature' + + self.hass.states.set(temperature_sensor, STATE_UNKNOWN, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + + acc = TemperatureSensor(self.hass, temperature_sensor, 'Temperature') + acc.run() + + self.assertEqual(acc.char_temp.value, 0.0) + + self.hass.states.set(temperature_sensor, '20') + self.hass.block_till_done() + self.assertEqual(acc.char_temp.value, 20) From f3748cc4fadeae3c7678d6fd99e1527646e55e11 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 19 Feb 2018 23:49:52 +0100 Subject: [PATCH 120/173] Add password support (#12525) --- homeassistant/components/hassio.py | 3 +++ tests/components/test_hassio.py | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio.py b/homeassistant/components/hassio.py index 510b08e766f..f8730f14a1a 100644 --- a/homeassistant/components/hassio.py +++ b/homeassistant/components/hassio.py @@ -58,6 +58,7 @@ ATTR_ADDONS = 'addons' ATTR_FOLDERS = 'folders' ATTR_HOMEASSISTANT = 'homeassistant' ATTR_NAME = 'name' +ATTR_PASSWORD = 'password' NO_TIMEOUT = { re.compile(r'^homeassistant/update$'), @@ -87,6 +88,7 @@ SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend({ SCHEMA_SNAPSHOT_FULL = vol.Schema({ vol.Optional(ATTR_NAME): cv.string, + vol.Optional(ATTR_PASSWORD): cv.string, }) SCHEMA_SNAPSHOT_PARTIAL = SCHEMA_SNAPSHOT_FULL.extend({ @@ -96,6 +98,7 @@ SCHEMA_SNAPSHOT_PARTIAL = SCHEMA_SNAPSHOT_FULL.extend({ SCHEMA_RESTORE_FULL = vol.Schema({ vol.Required(ATTR_SNAPSHOT): cv.slug, + vol.Optional(ATTR_PASSWORD): cv.string, }) SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend({ diff --git a/tests/components/test_hassio.py b/tests/components/test_hassio.py index 8fb017309de..4511930a6df 100644 --- a/tests/components/test_hassio.py +++ b/tests/components/test_hassio.py @@ -276,12 +276,13 @@ def test_service_calls(hassio_env, hass, aioclient_mock): yield from hass.services.async_call('hassio', 'snapshot_partial', { 'addons': ['test'], 'folders': ['ssl'], + 'password': "123456", }) yield from hass.async_block_till_done() assert aioclient_mock.call_count == 8 assert aioclient_mock.mock_calls[-1][2] == { - 'addons': ['test'], 'folders': ['ssl']} + 'addons': ['test'], 'folders': ['ssl'], 'password': "123456"} yield from hass.services.async_call('hassio', 'restore_full', { 'snapshot': 'test', @@ -291,12 +292,15 @@ def test_service_calls(hassio_env, hass, aioclient_mock): 'homeassistant': False, 'addons': ['test'], 'folders': ['ssl'], + 'password': "123456", }) yield from hass.async_block_till_done() assert aioclient_mock.call_count == 10 assert aioclient_mock.mock_calls[-1][2] == { - 'addons': ['test'], 'folders': ['ssl'], 'homeassistant': False} + 'addons': ['test'], 'folders': ['ssl'], 'homeassistant': False, + 'password': "123456" + } @asyncio.coroutine From 722b9ba49b0cacbb5a4e7549206d18e0d9c80e07 Mon Sep 17 00:00:00 2001 From: thrawnarn Date: Tue, 20 Feb 2018 00:43:14 +0100 Subject: [PATCH 121/173] Changed to async_schedule_update_ha_state (#12518) * Changed to async schedule update ha state * Changed to async schedule update ha state * Fixed my fetch error * Added new line at the end --- homeassistant/components/media_player/bluesound.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/bluesound.py b/homeassistant/components/media_player/bluesound.py index 4bd3e43794f..d308b94e64c 100644 --- a/homeassistant/components/media_player/bluesound.py +++ b/homeassistant/components/media_player/bluesound.py @@ -404,13 +404,13 @@ class BluesoundPlayer(MediaPlayerDevice): # communication is moved to a separate library yield from self.force_update_sync_status() - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() except (asyncio.TimeoutError, ClientError): self._is_online = False self._last_status_update = None self._status = None - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() _LOGGER.info("Client connection error, marking %s as offline", self._name) raise From 42ab4e1366bec86675bc2b682d2a37c7f1bff967 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Tue, 20 Feb 2018 01:40:56 +0100 Subject: [PATCH 122/173] Homekit component test bugfixes for py3.5 --- homeassistant/components/homekit/sensors.py | 3 +++ tests/components/homekit/test_covers.py | 6 ++++-- tests/components/homekit/test_homekit.py | 8 ++++---- tests/components/homekit/test_sensors.py | 8 ++++---- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/homekit/sensors.py b/homeassistant/components/homekit/sensors.py index dcbb25c9e15..db9ba2d628a 100644 --- a/homeassistant/components/homekit/sensors.py +++ b/homeassistant/components/homekit/sensors.py @@ -45,6 +45,9 @@ class TemperatureSensor(HomeAccessory): def update_temperature(self, entity_id=None, old_state=None, new_state=None): """Update temperature after state changed.""" + if new_state is None: + return + temperature = new_state.state if temperature != STATE_UNKNOWN: self.char_temp.set_value(float(temperature)) diff --git a/tests/components/homekit/test_covers.py b/tests/components/homekit/test_covers.py index 8810beb6210..b6e8334346a 100644 --- a/tests/components/homekit/test_covers.py +++ b/tests/components/homekit/test_covers.py @@ -40,7 +40,8 @@ class TestHomekitSensors(unittest.TestCase): self.assertEqual(acc.char_current_position.value, 0) self.assertEqual(acc.char_target_position.value, 0) - self.assertEqual(acc.char_position_state.value, 0) + # Temporarily disabled due to bug in HAP-python==1.15 with py3.5 + # self.assertEqual(acc.char_position_state.value, 0) self.hass.states.set(window_cover, STATE_UNKNOWN, {ATTR_CURRENT_POSITION: None}) @@ -48,7 +49,8 @@ class TestHomekitSensors(unittest.TestCase): self.assertEqual(acc.char_current_position.value, 0) self.assertEqual(acc.char_target_position.value, 0) - self.assertEqual(acc.char_position_state.value, 0) + # Temporarily disabled due to bug in HAP-python==1.15 with py3.5 + # self.assertEqual(acc.char_position_state.value, 0) self.hass.states.set(window_cover, STATE_OPEN, {ATTR_CURRENT_POSITION: 50}) diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 7182fd6f7d9..06cb8096140 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -55,7 +55,7 @@ class TestHomekit(unittest.TestCase): self.hass.start() self.hass.block_till_done() - mock_start_driver.assert_called_once() + self.assertEqual(mock_start_driver.call_count, 1) @patch(HOMEKIT_PATH + '.Homekit.start_driver') @patch(HOMEKIT_PATH + '.Homekit.setup_bridge') @@ -109,8 +109,8 @@ class TestHomekit(unittest.TestCase): homekit.start_driver(Event(EVENT_HOMEASSISTANT_START)) self.assertEqual(mock_get_accessory.call_count, 2) - mock_import_types.assert_called_once() - mock_driver_start.assert_called_once() + self.assertEqual(mock_import_types.call_count, 1) + self.assertEqual(mock_driver_start.call_count, 1) accessories = homekit.bridge.accessories self.assertEqual(accessories[2], acc1) @@ -121,4 +121,4 @@ class TestHomekit(unittest.TestCase): self.hass.bus.fire(EVENT_HOMEASSISTANT_STOP) self.hass.block_till_done() - mock_driver_stop.assert_called_once() + self.assertEqual(mock_driver_stop.call_count, 1) diff --git a/tests/components/homekit/test_sensors.py b/tests/components/homekit/test_sensors.py index cebbd4f5aea..b7d3de4e90b 100644 --- a/tests/components/homekit/test_sensors.py +++ b/tests/components/homekit/test_sensors.py @@ -23,15 +23,15 @@ class TestHomekitSensors(unittest.TestCase): """Test if accessory is updated after state change.""" temperature_sensor = 'sensor.temperature' - self.hass.states.set(temperature_sensor, STATE_UNKNOWN, - {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) - self.hass.block_till_done() - acc = TemperatureSensor(self.hass, temperature_sensor, 'Temperature') acc.run() self.assertEqual(acc.char_temp.value, 0.0) + self.hass.states.set(temperature_sensor, STATE_UNKNOWN, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + self.hass.states.set(temperature_sensor, '20') self.hass.block_till_done() self.assertEqual(acc.char_temp.value, 20) From 336b00765dfad3ae4798b3c9a3ef21ab66510764 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 19 Feb 2018 20:51:05 -0800 Subject: [PATCH 123/173] Fix Sphinx build (#12535) --- docs/source/conf.py | 21 +++++++++++++++++---- setup.py | 6 ++---- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 595c15717eb..b5428ede8fa 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -22,10 +22,23 @@ import os import inspect from homeassistant.const import __version__, __short_version__ -from setup import ( - PROJECT_NAME, PROJECT_LONG_DESCRIPTION, PROJECT_COPYRIGHT, PROJECT_AUTHOR, - PROJECT_GITHUB_USERNAME, PROJECT_GITHUB_REPOSITORY, GITHUB_PATH, - GITHUB_URL) + +PROJECT_NAME = 'Home Assistant' +PROJECT_PACKAGE_NAME = 'homeassistant' +PROJECT_AUTHOR = 'The Home Assistant Authors' +PROJECT_COPYRIGHT = ' 2013-2018, {}'.format(PROJECT_AUTHOR) +PROJECT_LONG_DESCRIPTION = ('Home Assistant is an open-source ' + 'home automation platform running on Python 3. ' + 'Track and control all devices at home and ' + 'automate control. ' + 'Installation in less than a minute.') +PROJECT_GITHUB_USERNAME = 'home-assistant' +PROJECT_GITHUB_REPOSITORY = 'home-assistant' + +GITHUB_PATH = '{}/{}'.format( + PROJECT_GITHUB_USERNAME, PROJECT_GITHUB_REPOSITORY) +GITHUB_URL = 'https://github.com/{}'.format(GITHUB_PATH) + sys.path.insert(0, os.path.abspath('_ext')) sys.path.insert(0, os.path.abspath('../homeassistant')) diff --git a/setup.py b/setup.py index d3c841f22df..bca49d33647 100755 --- a/setup.py +++ b/setup.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 """Home Assistant setup script.""" -import os -from setuptools import setup, find_packages import sys +from setuptools import setup, find_packages + import homeassistant.const as hass_const @@ -41,8 +41,6 @@ GITHUB_PATH = '{}/{}'.format( PROJECT_GITHUB_USERNAME, PROJECT_GITHUB_REPOSITORY) GITHUB_URL = 'https://github.com/{}'.format(GITHUB_PATH) - -HERE = os.path.abspath(os.path.dirname(__file__)) DOWNLOAD_URL = '{}/archive/{}.zip'.format(GITHUB_URL, hass_const.__version__) PACKAGES = find_packages(exclude=['tests', 'tests.*']) From d68a24b3b809788d344f4caa278fd0cd3b16013a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 19 Feb 2018 22:12:39 -0800 Subject: [PATCH 124/173] Update voluptuous serialize (#12538) --- homeassistant/components/config/config_entries.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index d33e97b9e88..ebfa095372a 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -8,7 +8,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator -REQUIREMENTS = ['voluptuous-serialize==0.1'] +REQUIREMENTS = ['voluptuous-serialize==1'] @asyncio.coroutine diff --git a/requirements_all.txt b/requirements_all.txt index abf4dea14e1..831cead3383 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1211,7 +1211,7 @@ uvcclient==0.10.1 venstarcolortouch==0.6 # homeassistant.components.config.config_entries -voluptuous-serialize==0.1 +voluptuous-serialize==1 # homeassistant.components.volvooncall volvooncall==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9b054da50d1..d862a888048 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -179,7 +179,7 @@ statsd==3.2.1 uvcclient==0.10.1 # homeassistant.components.config.config_entries -voluptuous-serialize==0.1 +voluptuous-serialize==1 # homeassistant.components.vultr vultr==0.1.2 From 46ce11406601968733ba82867b70f21020d58a65 Mon Sep 17 00:00:00 2001 From: Arvind Prasanna <1108710+aprasanna@users.noreply.github.com> Date: Tue, 20 Feb 2018 01:41:13 -0500 Subject: [PATCH 125/173] Clarify a comment regarding python versions (#12537) --- homeassistant/package_constraints.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9cd5f12163f..2eb42b94389 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,5 +13,5 @@ astral==1.5 certifi>=2017.4.17 attrs==17.4.0 -# Breaks Python 3.6 and is not needed for our supported Pythons +# Breaks Python 3.6 and is not needed for our supported Python versions enum34==1000000000.0.0 From e37974c5fc7683919fad093e05e1899fb1ff4514 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 19 Feb 2018 23:10:44 -0800 Subject: [PATCH 126/173] Lint --- script/gen_requirements_all.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 317cecfb164..460c998f556 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -111,7 +111,7 @@ URL_PIN = ('https://home-assistant.io/developers/code_review_platform/' CONSTRAINT_PATH = os.path.join(os.path.dirname(__file__), '../homeassistant/package_constraints.txt') CONSTRAINT_BASE = """ -# Breaks Python 3.6 and is not needed for our supported Pythons +# Breaks Python 3.6 and is not needed for our supported Python versions enum34==1000000000.0.0 """ From 17bdcac61be79b651f495336c90f3815774d5ab4 Mon Sep 17 00:00:00 2001 From: Robin Date: Tue, 20 Feb 2018 07:55:54 +0000 Subject: [PATCH 127/173] Adds filesize component (#12211) * Create filesize.py * Update filesize.py * Updates filesize Addresses issues raised in review * Update .coveragerc * Addresses requested changes Addresses the changes requested by @baloob. Additionally the file size in bytes is now available in attributes. * Create test_filesize.py This isn't working yet * Update test_filesize.py * Update test_filesize.py * Update test_filesize.py * Update test_filesize.py * Update test_filesize.py * Update test_filesize.py * fixed valid file test * Update test_filesize.py * Fix indentation Fix incorrect indentation in setup * Update filesize.py * Update filesize.py --- .coveragerc | 1 + homeassistant/components/sensor/filesize.py | 92 +++++++++++++++++++++ tests/components/sensor/test_filesize.py | 58 +++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 homeassistant/components/sensor/filesize.py create mode 100644 tests/components/sensor/test_filesize.py diff --git a/.coveragerc b/.coveragerc index 97be3406b37..34e9ddbd5d2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -558,6 +558,7 @@ omit = homeassistant/components/sensor/etherscan.py homeassistant/components/sensor/fastdotcom.py homeassistant/components/sensor/fedex.py + homeassistant/components/sensor/filesize.py homeassistant/components/sensor/fitbit.py homeassistant/components/sensor/fixer.py homeassistant/components/sensor/fritzbox_callmonitor.py diff --git a/homeassistant/components/sensor/filesize.py b/homeassistant/components/sensor/filesize.py new file mode 100644 index 00000000000..a5a65f9bb5e --- /dev/null +++ b/homeassistant/components/sensor/filesize.py @@ -0,0 +1,92 @@ +""" +Sensor for monitoring the size of a file. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.filesize/ +""" +import datetime +import logging +import os + +import voluptuous as vol + +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA + +_LOGGER = logging.getLogger(__name__) + + +CONF_FILE_PATHS = 'file_paths' +ICON = 'mdi:file' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_FILE_PATHS): + vol.All(cv.ensure_list, [cv.isfile]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the file size sensor.""" + sensors = [] + for path in config.get(CONF_FILE_PATHS): + if not hass.config.is_allowed_path(path): + _LOGGER.error( + "Filepath %s is not valid or allowed", path) + continue + else: + sensors.append(Filesize(path)) + + if sensors: + add_devices(sensors, True) + + +class Filesize(Entity): + """Encapsulates file size information.""" + + def __init__(self, path): + """Initialize the data object.""" + self._path = path # Need to check its a valid path + self._size = None + self._last_updated = None + self._name = path.split("/")[-1] + self._unit_of_measurement = 'MB' + + def update(self): + """Update the sensor.""" + statinfo = os.stat(self._path) + self._size = statinfo.st_size + last_updated = datetime.datetime.fromtimestamp(statinfo.st_mtime) + self._last_updated = last_updated.isoformat() + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the size of the file in MB.""" + decimals = 2 + state_mb = round(self._size/1e6, decimals) + return state_mb + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON + + @property + def device_state_attributes(self): + """Return other details about the sensor state.""" + attr = { + 'path': self._path, + 'last_updated': self._last_updated, + 'bytes': self._size, + } + return attr + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement diff --git a/tests/components/sensor/test_filesize.py b/tests/components/sensor/test_filesize.py new file mode 100644 index 00000000000..23ef1c6081b --- /dev/null +++ b/tests/components/sensor/test_filesize.py @@ -0,0 +1,58 @@ +"""The tests for the filesize sensor.""" +import unittest +import os + +from homeassistant.components.sensor.filesize import CONF_FILE_PATHS +from homeassistant.setup import setup_component +from tests.common import get_test_home_assistant + + +TEST_DIR = os.path.join(os.path.dirname(__file__)) +TEST_FILE = os.path.join(TEST_DIR, 'mock_file_test_filesize.txt') + + +def create_file(path): + """Create a test file.""" + with open(path, 'w') as test_file: + test_file.write("test") + + +class TestFileSensor(unittest.TestCase): + """Test the filesize sensor.""" + + def setup_method(self, method): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.whitelist_external_dirs = set((TEST_DIR)) + + def teardown_method(self, method): + """Stop everything that was started.""" + if os.path.isfile(TEST_FILE): + os.remove(TEST_FILE) + self.hass.stop() + + def test_invalid_path(self): + """Test that an invalid path is caught.""" + config = { + 'sensor': { + 'platform': 'filesize', + CONF_FILE_PATHS: ['invalid_path']} + } + self.assertTrue( + setup_component(self.hass, 'sensor', config)) + assert len(self.hass.states.entity_ids()) == 0 + + def test_valid_path(self): + """Test for a valid path.""" + create_file(TEST_FILE) + config = { + 'sensor': { + 'platform': 'filesize', + CONF_FILE_PATHS: [TEST_FILE]} + } + self.assertTrue( + setup_component(self.hass, 'sensor', config)) + assert len(self.hass.states.entity_ids()) == 1 + state = self.hass.states.get('sensor.mock_file_test_filesizetxt') + assert state.state == '0.0' + assert state.attributes.get('bytes') == 4 From 39847ea651c438a819f52d6abaf6bbc63da4e67e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 20 Feb 2018 03:31:43 -0800 Subject: [PATCH 128/173] Clarify cloud error (#12540) * Clarify cloud error * Fix tests --- homeassistant/components/cloud/auth_api.py | 3 --- homeassistant/components/cloud/iot.py | 12 ++++++------ tests/components/cloud/test_iot.py | 8 ++++---- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py index c7679c0f262..118a9857158 100644 --- a/homeassistant/components/cloud/auth_api.py +++ b/homeassistant/components/cloud/auth_api.py @@ -1,7 +1,4 @@ """Package to communicate with the authentication API.""" -import logging - -_LOGGER = logging.getLogger(__name__) class CloudError(Exception): diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index 2d3ab025e43..5f61263824b 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -90,9 +90,7 @@ class CloudIoT: while not client.closed: msg = yield from client.receive() - if msg.type in (WSMsgType.ERROR, WSMsgType.CLOSED, - WSMsgType.CLOSING): - disconnect_warn = 'Connection cancelled.' + if msg.type in (WSMsgType.CLOSED, WSMsgType.CLOSING): break elif msg.type != WSMsgType.TEXT: @@ -131,8 +129,8 @@ class CloudIoT: _LOGGER.debug("Publishing message: %s", response) yield from client.send_json(response) - except auth_api.CloudError: - _LOGGER.warning("Unable to connect: Unable to refresh token.") + except auth_api.CloudError as err: + _LOGGER.warning("Unable to connect: %s", err) except client_exceptions.WSServerHandshakeError as err: if err.code == 401: @@ -150,7 +148,9 @@ class CloudIoT: _LOGGER.exception("Unexpected error") finally: - if disconnect_warn is not None: + if disconnect_warn is None: + _LOGGER.info("Connection closed") + else: _LOGGER.warning("Connection closed: %s", disconnect_warn) if remove_hass_stop_listener is not None: diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index 53340ecede1..ff382b697cf 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -162,7 +162,7 @@ def test_cloud_getting_disconnected_by_server(mock_client, caplog, mock_cloud): yield from conn.connect() - assert 'Connection closed: Connection cancelled.' in caplog.text + assert 'Connection closed' in caplog.text assert 'connect' in str(mock_cloud.hass.async_add_job.mock_calls[-1][1][0]) @@ -197,13 +197,13 @@ def test_cloud_sending_invalid_json(mock_client, caplog, mock_cloud): @asyncio.coroutine def test_cloud_check_token_raising(mock_client, caplog, mock_cloud): - """Test cloud sending invalid JSON.""" + """Test cloud unable to check token.""" conn = iot.CloudIoT(mock_cloud) - mock_client.receive.side_effect = auth_api.CloudError + mock_client.receive.side_effect = auth_api.CloudError("BLA") yield from conn.connect() - assert 'Unable to connect: Unable to refresh token.' in caplog.text + assert 'Unable to connect: BLA' in caplog.text assert 'connect' in str(mock_cloud.hass.async_add_job.mock_calls[-1][1][0]) From fb985e2909d8ffc5b7927af9b7c95a88a63ea21a Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Tue, 20 Feb 2018 12:37:01 +0100 Subject: [PATCH 129/173] Build JSON in executor (#12536) --- homeassistant/components/history.py | 9 ++++++--- homeassistant/components/logbook.py | 3 ++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index efcbb50a447..dd14bbf6811 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -303,8 +303,10 @@ class HistoryPeriodView(HomeAssistantView): entity_ids = entity_ids.lower().split(',') include_start_time_state = 'skip_initial_state' not in request.query - result = yield from request.app['hass'].async_add_job( - get_significant_states, request.app['hass'], start_time, end_time, + hass = request.app['hass'] + + result = yield from hass.async_add_job( + get_significant_states, hass, start_time, end_time, entity_ids, self.filters, include_start_time_state) result = result.values() if _LOGGER.isEnabledFor(logging.DEBUG): @@ -327,7 +329,8 @@ class HistoryPeriodView(HomeAssistantView): sorted_result.extend(result) result = sorted_result - return self.json(result) + response = yield from hass.async_add_job(self.json, result) + return response class Filters(object): diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index 9e1e2e54ad9..b3477099918 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -136,7 +136,8 @@ class LogbookView(HomeAssistantView): events = yield from hass.async_add_job( _get_events, hass, self.config, start_day, end_day) - return self.json(events) + response = yield from hass.async_add_job(self.json, events) + return response class Entry(object): From 7829e613614d1df9986d08fbfbdbfb9dbcabf293 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Tue, 20 Feb 2018 16:21:35 +0100 Subject: [PATCH 130/173] Bugfix: Input Datetime config schema (#12552) * Bugfix for Input Datatime config schema * Has_at_least_one_key_value only works if parameter is optional * Added default parameters --- homeassistant/components/input_datetime.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/input_datetime.py b/homeassistant/components/input_datetime.py index fecc31f14ae..a77b67792f5 100644 --- a/homeassistant/components/input_datetime.py +++ b/homeassistant/components/input_datetime.py @@ -43,8 +43,8 @@ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ cv.slug: vol.All({ vol.Optional(CONF_NAME): cv.string, - vol.Required(CONF_HAS_DATE): cv.boolean, - vol.Required(CONF_HAS_TIME): cv.boolean, + vol.Optional(CONF_HAS_DATE, default=False): cv.boolean, + vol.Optional(CONF_HAS_TIME, default=False): cv.boolean, vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_INITIAL): cv.string, }, cv.has_at_least_one_key_value((CONF_HAS_DATE, True), From 3077444d629d8290ef75c2d0b506e39eb5621305 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Tue, 20 Feb 2018 17:02:27 +0100 Subject: [PATCH 131/173] Fix numeric_state condition spamming on unavailable (#12550) --- homeassistant/helpers/condition.py | 5 ++++- tests/helpers/test_condition.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 382a7c27d78..bad6bfe83c3 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -13,7 +13,7 @@ from homeassistant.const import ( CONF_ENTITY_ID, CONF_VALUE_TEMPLATE, CONF_CONDITION, WEEKDAYS, CONF_STATE, CONF_ZONE, CONF_BEFORE, CONF_AFTER, CONF_WEEKDAY, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, - CONF_BELOW, CONF_ABOVE) + CONF_BELOW, CONF_ABOVE, STATE_UNAVAILABLE, STATE_UNKNOWN) from homeassistant.exceptions import TemplateError, HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.sun import get_astral_event_date @@ -160,6 +160,9 @@ def async_numeric_state(hass: HomeAssistant, entity, below=None, above=None, _LOGGER.error("Template error: %s", ex) return False + if value in (STATE_UNAVAILABLE, STATE_UNKNOWN): + return False + try: value = float(value) except ValueError: diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 2991e07a464..aa7b5170648 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -146,3 +146,21 @@ class TestConditionHelper: return_value=dt.now().replace(hour=21)): assert not condition.time(after=sixam, before=sixpm) assert condition.time(after=sixpm, before=sixam) + + def test_if_numeric_state_not_raise_on_unavailable(self): + """Test numeric_state doesn't raise on unavailable/unknown state.""" + test = condition.from_config({ + 'condition': 'numeric_state', + 'entity_id': 'sensor.temperature', + 'below': 42 + }) + + with patch('homeassistant.helpers.condition._LOGGER.warning') \ + as logwarn: + self.hass.states.set('sensor.temperature', 'unavailable') + assert not test(self.hass) + assert len(logwarn.mock_calls) == 0 + + self.hass.states.set('sensor.temperature', 'unknown') + assert not test(self.hass) + assert len(logwarn.mock_calls) == 0 From 1d8a5147e90c8bc99a556f1348ee01cdc9749933 Mon Sep 17 00:00:00 2001 From: Krasimir Zhelev Date: Tue, 20 Feb 2018 17:14:34 +0100 Subject: [PATCH 132/173] Frontier silicon async (#12503) * Moving frontier_silicon platform to afspai(async fsapi) * updated requirements_all.txt * uses afsapi 0.0.2, which supports timeout * uses afsapi 0.0.3, forward(before next) and rewind(before prev) renamed * Moving frontier_silicon platform to afspai(async fsapi) * updated requirements_all.txt * uses afsapi 0.0.2, which supports timeout * uses afsapi 0.0.3, forward(before next) and rewind(before prev) renamed * Removing debug message * removed time import * Update frontier_silicon.py --- .../media_player/frontier_silicon.py | 126 +++++++++++------- requirements_all.txt | 6 +- 2 files changed, 79 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/media_player/frontier_silicon.py b/homeassistant/components/media_player/frontier_silicon.py index f46d0657604..6d95ea675fb 100644 --- a/homeassistant/components/media_player/frontier_silicon.py +++ b/homeassistant/components/media_player/frontier_silicon.py @@ -4,6 +4,7 @@ Support for Frontier Silicon Devices (Medion, Hama, Auna,...). For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.frontier_silicon/ """ +import asyncio import logging import voluptuous as vol @@ -19,7 +20,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PORT, CONF_PASSWORD) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['fsapi==0.0.7'] +REQUIREMENTS = ['afsapi==0.0.3'] _LOGGER = logging.getLogger(__name__) @@ -41,14 +42,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ # pylint: disable=unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Frontier Silicon platform.""" import requests if discovery_info is not None: - add_devices( - [FSAPIDevice(discovery_info['ssdp_description'], - DEFAULT_PASSWORD)], + async_add_devices( + [AFSAPIDevice(discovery_info['ssdp_description'], + DEFAULT_PASSWORD)], update_before_add=True) return True @@ -57,8 +59,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): password = config.get(CONF_PASSWORD) try: - add_devices( - [FSAPIDevice(DEVICE_URL.format(host, port), password)], + async_add_devices( + [AFSAPIDevice(DEVICE_URL.format(host, port), password)], update_before_add=True) _LOGGER.debug("FSAPI device %s:%s -> %s", host, port, password) return True @@ -69,7 +71,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return False -class FSAPIDevice(MediaPlayerDevice): +class AFSAPIDevice(MediaPlayerDevice): """Representation of a Frontier Silicon device on the network.""" def __init__(self, device_url, password): @@ -97,9 +99,9 @@ class FSAPIDevice(MediaPlayerDevice): connected to the device in between the updates and invalidated the existing session (i.e UNDOK). """ - from fsapi import FSAPI + from afsapi import AFSAPI - return FSAPI(self._device_url, self._password) + return AFSAPI(self._device_url, self._password) @property def should_poll(self): @@ -157,17 +159,18 @@ class FSAPIDevice(MediaPlayerDevice): """Image url of current playing media.""" return self._media_image_url - def update(self): + @asyncio.coroutine + def async_update(self): """Get the latest date and update device state.""" fs_device = self.fs_device if not self._name: - self._name = fs_device.friendly_name + self._name = yield from fs_device.get_friendly_name() if not self._source_list: - self._source_list = fs_device.mode_list + self._source_list = yield from fs_device.get_mode_list() - status = fs_device.play_status + status = yield from fs_device.get_play_status() self._state = { 'playing': STATE_PLAYING, 'paused': STATE_PAUSED, @@ -176,54 +179,70 @@ class FSAPIDevice(MediaPlayerDevice): None: STATE_OFF, }.get(status, STATE_UNKNOWN) - info_name = fs_device.play_info_name - info_text = fs_device.play_info_text + if self._state != STATE_OFF: + info_name = yield from fs_device.get_play_name() + info_text = yield from fs_device.get_play_text() - self._title = ' - '.join(filter(None, [info_name, info_text])) - self._artist = fs_device.play_info_artist - self._album_name = fs_device.play_info_album + self._title = ' - '.join(filter(None, [info_name, info_text])) + self._artist = yield from fs_device.get_play_artist() + self._album_name = yield from fs_device.get_play_album() - self._source = fs_device.mode - self._mute = fs_device.mute - self._media_image_url = fs_device.play_info_graphics + self._source = yield from fs_device.get_mode() + self._mute = yield from fs_device.get_mute() + self._media_image_url = yield from fs_device.get_play_graphic() + else: + self._title = None + self._artist = None + self._album_name = None + + self._source = None + self._mute = None + self._media_image_url = None # Management actions - # power control - def turn_on(self): + @asyncio.coroutine + def async_turn_on(self): """Turn on the device.""" - self.fs_device.power = True + yield from self.fs_device.set_power(True) - def turn_off(self): + @asyncio.coroutine + def async_turn_off(self): """Turn off the device.""" - self.fs_device.power = False + yield from self.fs_device.set_power(False) - def media_play(self): + @asyncio.coroutine + def async_media_play(self): """Send play command.""" - self.fs_device.play() + yield from self.fs_device.play() - def media_pause(self): + @asyncio.coroutine + def async_media_pause(self): """Send pause command.""" - self.fs_device.pause() + yield from self.fs_device.pause() - def media_play_pause(self): + @asyncio.coroutine + def async_media_play_pause(self): """Send play/pause command.""" if 'playing' in self._state: - self.fs_device.pause() + yield from self.fs_device.pause() else: - self.fs_device.play() + yield from self.fs_device.play() - def media_stop(self): + @asyncio.coroutine + def async_media_stop(self): """Send play/pause command.""" - self.fs_device.pause() + yield from self.fs_device.pause() - def media_previous_track(self): + @asyncio.coroutine + def async_media_previous_track(self): """Send previous track command (results in rewind).""" - self.fs_device.prev() + yield from self.fs_device.rewind() - def media_next_track(self): + @asyncio.coroutine + def async_media_next_track(self): """Send next track command (results in fast-forward).""" - self.fs_device.next() + yield from self.fs_device.forward() # mute @property @@ -231,23 +250,30 @@ class FSAPIDevice(MediaPlayerDevice): """Boolean if volume is currently muted.""" return self._mute - def mute_volume(self, mute): + @asyncio.coroutine + def async_mute_volume(self, mute): """Send mute command.""" - self.fs_device.mute = mute + yield from self.fs_device.set_mute(mute) # volume - def volume_up(self): + @asyncio.coroutine + def async_volume_up(self): """Send volume up command.""" - self.fs_device.volume += 1 + volume = yield from self.fs_device.get_volume() + yield from self.fs_device.set_volume(volume+1) - def volume_down(self): + @asyncio.coroutine + def async_volume_down(self): """Send volume down command.""" - self.fs_device.volume -= 1 + volume = yield from self.fs_device.get_volume() + yield from self.fs_device.set_volume(volume-1) - def set_volume_level(self, volume): + @asyncio.coroutine + def async_set_volume_level(self, volume): """Set volume command.""" - self.fs_device.volume = volume + yield from self.fs_device.set_volume(volume) - def select_source(self, source): + @asyncio.coroutine + def async_select_source(self, source): """Select input source.""" - self.fs_device.mode = source + yield from self.fs_device.set_mode(source) diff --git a/requirements_all.txt b/requirements_all.txt index 831cead3383..1a98e25caed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -62,6 +62,9 @@ YesssSMS==0.1.1b3 # homeassistant.components.abode abodepy==0.12.2 +# homeassistant.components.media_player.frontier_silicon +afsapi==0.0.3 + # homeassistant.components.device_tracker.automatic aioautomatic==0.6.5 @@ -295,9 +298,6 @@ freesms==0.1.2 # homeassistant.components.switch.fritzdect fritzhome==1.0.4 -# homeassistant.components.media_player.frontier_silicon -fsapi==0.0.7 - # homeassistant.components.tts.google gTTS-token==1.1.1 From f2a2727a1583adaaa3469a9f15905f23ce36eaf8 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Tue, 20 Feb 2018 18:01:34 +0100 Subject: [PATCH 133/173] Fix WUnderground spamming logs (#12548) --- homeassistant/components/sensor/wunderground.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/sensor/wunderground.py b/homeassistant/components/sensor/wunderground.py index e9e0c00d47d..edcc1c92bf9 100644 --- a/homeassistant/components/sensor/wunderground.py +++ b/homeassistant/components/sensor/wunderground.py @@ -790,7 +790,5 @@ class WUndergroundData(object): self.data = result except ValueError as err: _LOGGER.error("Check WUnderground API %s", err.args) - self.data = None except (asyncio.TimeoutError, aiohttp.ClientError) as err: _LOGGER.error("Error fetching WUnderground data: %s", repr(err)) - self.data = None From 210226daac24a26d3027674e0a3a2977591b03de Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 20 Feb 2018 09:08:15 -0800 Subject: [PATCH 134/173] Update frontend --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index af3c720c62c..6d42452d7e1 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20180216.0', 'user-agents==1.1.0'] +REQUIREMENTS = ['home-assistant-frontend==20180220.0', 'user-agents==1.1.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 1a98e25caed..5d3864f1dfe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -353,7 +353,7 @@ hipnotify==1.0.8 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180216.0 +home-assistant-frontend==20180220.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d862a888048..18605c268c4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -75,7 +75,7 @@ hbmqtt==0.9.1 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180216.0 +home-assistant-frontend==20180220.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From 5d29d88888085bf147c5a22806473486419f905f Mon Sep 17 00:00:00 2001 From: rubenverhoef Date: Tue, 20 Feb 2018 22:30:19 +0100 Subject: [PATCH 135/173] Added support for milight single channel dimmer (#12558) --- homeassistant/components/light/limitlessled.py | 8 ++++++-- requirements_all.txt | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index 5619e54f123..910e3aebcfb 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -19,7 +19,7 @@ from homeassistant.components.light import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.restore_state import async_get_last_state -REQUIREMENTS = ['limitlessled==1.0.8'] +REQUIREMENTS = ['limitlessled==1.1.0'] _LOGGER = logging.getLogger(__name__) @@ -35,7 +35,7 @@ DEFAULT_TRANSITION = 0 DEFAULT_VERSION = 6 DEFAULT_FADE = False -LED_TYPE = ['rgbw', 'rgbww', 'white', 'bridge-led'] +LED_TYPE = ['rgbw', 'rgbww', 'white', 'bridge-led', 'dimmer'] RGB_BOUNDARY = 40 @@ -43,6 +43,7 @@ WHITE = [255, 255, 255] SUPPORT_LIMITLESSLED_WHITE = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_TRANSITION) +SUPPORT_LIMITLESSLED_DIMMER = (SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION) SUPPORT_LIMITLESSLED_RGB = (SUPPORT_BRIGHTNESS | SUPPORT_EFFECT | SUPPORT_FLASH | SUPPORT_RGB_COLOR | SUPPORT_TRANSITION) @@ -161,9 +162,12 @@ class LimitlessLEDGroup(Light): """Initialize a group.""" from limitlessled.group.rgbw import RgbwGroup from limitlessled.group.white import WhiteGroup + from limitlessled.group.dimmer import DimmerGroup from limitlessled.group.rgbww import RgbwwGroup if isinstance(group, WhiteGroup): self._supported = SUPPORT_LIMITLESSLED_WHITE + elif isinstance(group, DimmerGroup): + self._supported = SUPPORT_LIMITLESSLED_DIMMER elif isinstance(group, RgbwGroup): self._supported = SUPPORT_LIMITLESSLED_RGB elif isinstance(group, RgbwwGroup): diff --git a/requirements_all.txt b/requirements_all.txt index 5d3864f1dfe..eb10e28fc43 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -451,7 +451,7 @@ liffylights==0.9.4 lightify==1.0.6.1 # homeassistant.components.light.limitlessled -limitlessled==1.0.8 +limitlessled==1.1.0 # homeassistant.components.linode linode-api==4.1.4b2 From 316eb59de2a0de0efcd6db3fb358f112c3e66eb8 Mon Sep 17 00:00:00 2001 From: ChristianKuehnel Date: Tue, 20 Feb 2018 23:02:08 +0100 Subject: [PATCH 136/173] Add new component: BMW connected drive (#12277) * first working version of BMW connected drive sensor * extended coveragerc * fixed blank line * fixed pylint * major refactoring after major refactoring in bimmer_connected * Update are now triggered from BMWConnectedDriveVehicle. * removed polling from sensor and device_tracker * backend URL is not detected automatically based on current country * vehicles are discovered automatically * updates are async now resolves: * https://github.com/ChristianKuehnel/bimmer_connected/issues/3 * https://github.com/ChristianKuehnel/bimmer_connected/issues/5 * improved exception handing * fixed static analysis findings * fixed review comments from @MartinHjelmare * improved startup, data is updated right after sensors were created. * fixed pylint issue * updated to latest release of the bimmer_connected library * updated requirements-all.txt * fixed comments from @MartinHjelmare * calling self.update from async_add_job * removed unused attribute "account" --- .coveragerc | 3 + CODEOWNERS | 2 + .../components/bmw_connected_drive.py | 105 ++++++++++++++++++ .../device_tracker/bmw_connected_drive.py | 51 +++++++++ .../components/sensor/bmw_connected_drive.py | 99 +++++++++++++++++ requirements_all.txt | 3 + 6 files changed, 263 insertions(+) create mode 100644 homeassistant/components/bmw_connected_drive.py create mode 100644 homeassistant/components/device_tracker/bmw_connected_drive.py create mode 100644 homeassistant/components/sensor/bmw_connected_drive.py diff --git a/.coveragerc b/.coveragerc index 34e9ddbd5d2..563ea2b5387 100644 --- a/.coveragerc +++ b/.coveragerc @@ -29,6 +29,9 @@ omit = homeassistant/components/arduino.py homeassistant/components/*/arduino.py + homeassistant/components/bmw_connected_drive.py + homeassistant/components/*/bmw_connected_drive.py + homeassistant/components/android_ip_webcam.py homeassistant/components/*/android_ip_webcam.py diff --git a/CODEOWNERS b/CODEOWNERS index 846eb20b3fe..a5b5cfcb32c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -43,6 +43,7 @@ homeassistant/components/hassio.py @home-assistant/hassio # Individual components homeassistant/components/alarm_control_panel/egardia.py @jeroenterheerdt +homeassistant/components/bmw_connected_drive.py @ChristianKuehnel homeassistant/components/camera/yi.py @bachya homeassistant/components/climate/ephember.py @ttroy50 homeassistant/components/climate/eq3btsmart.py @rytilahti @@ -74,6 +75,7 @@ homeassistant/components/switch/tplink.py @rytilahti homeassistant/components/xiaomi_aqara.py @danielhiversen @syssi homeassistant/components/*/axis.py @kane610 +homeassistant/components/*/bmw_connected_drive.py @ChristianKuehnel homeassistant/components/*/broadlink.py @danielhiversen homeassistant/components/hive.py @Rendili @KJonline homeassistant/components/*/hive.py @Rendili @KJonline diff --git a/homeassistant/components/bmw_connected_drive.py b/homeassistant/components/bmw_connected_drive.py new file mode 100644 index 00000000000..98c25df79f6 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive.py @@ -0,0 +1,105 @@ +""" +Reads vehicle status from BMW connected drive portal. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/bmw_connected_drive/ +""" +import logging +import datetime + +import voluptuous as vol +from homeassistant.helpers import discovery +from homeassistant.helpers.event import track_utc_time_change + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD +) + +REQUIREMENTS = ['bimmer_connected==0.3.0'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'bmw_connected_drive' +CONF_VALUES = 'values' +CONF_COUNTRY = 'country' + +ACCOUNT_SCHEMA = vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_COUNTRY): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: { + cv.string: ACCOUNT_SCHEMA + }, +}, extra=vol.ALLOW_EXTRA) + + +BMW_COMPONENTS = ['device_tracker', 'sensor'] +UPDATE_INTERVAL = 5 # in minutes + + +def setup(hass, config): + """Set up the BMW connected drive components.""" + accounts = [] + for name, account_config in config[DOMAIN].items(): + username = account_config[CONF_USERNAME] + password = account_config[CONF_PASSWORD] + country = account_config[CONF_COUNTRY] + _LOGGER.debug('Adding new account %s', name) + bimmer = BMWConnectedDriveAccount(username, password, country, name) + accounts.append(bimmer) + + # update every UPDATE_INTERVAL minutes, starting now + # this should even out the load on the servers + + now = datetime.datetime.now() + track_utc_time_change( + hass, bimmer.update, + minute=range(now.minute % UPDATE_INTERVAL, 60, UPDATE_INTERVAL), + second=now.second) + + hass.data[DOMAIN] = accounts + + for account in accounts: + account.update() + + for component in BMW_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, config) + + return True + + +class BMWConnectedDriveAccount(object): + """Representation of a BMW vehicle.""" + + def __init__(self, username: str, password: str, country: str, + name: str) -> None: + """Constructor.""" + from bimmer_connected.account import ConnectedDriveAccount + + self.account = ConnectedDriveAccount(username, password, country) + self.name = name + self._update_listeners = [] + + def update(self, *_): + """Update the state of all vehicles. + + Notify all listeners about the update. + """ + _LOGGER.debug('Updating vehicle state for account %s, ' + 'notifying %d listeners', + self.name, len(self._update_listeners)) + try: + self.account.update_vehicle_states() + for listener in self._update_listeners: + listener() + except IOError as exception: + _LOGGER.error('Error updating the vehicle state.') + _LOGGER.exception(exception) + + def add_update_listener(self, listener): + """Add a listener for update notifications.""" + self._update_listeners.append(listener) diff --git a/homeassistant/components/device_tracker/bmw_connected_drive.py b/homeassistant/components/device_tracker/bmw_connected_drive.py new file mode 100644 index 00000000000..6ba2681e4cd --- /dev/null +++ b/homeassistant/components/device_tracker/bmw_connected_drive.py @@ -0,0 +1,51 @@ +"""Device tracker for BMW Connected Drive vehicles. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.bmw_connected_drive/ +""" +import logging + +from homeassistant.components.bmw_connected_drive import DOMAIN \ + as BMW_DOMAIN +from homeassistant.util import slugify + +DEPENDENCIES = ['bmw_connected_drive'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_scanner(hass, config, see, discovery_info=None): + """Set up the BMW tracker.""" + accounts = hass.data[BMW_DOMAIN] + _LOGGER.debug('Found BMW accounts: %s', + ', '.join([a.name for a in accounts])) + for account in accounts: + for vehicle in account.account.vehicles: + tracker = BMWDeviceTracker(see, vehicle) + account.add_update_listener(tracker.update) + tracker.update() + return True + + +class BMWDeviceTracker(object): + """BMW Connected Drive device tracker.""" + + def __init__(self, see, vehicle): + """Initialize the Tracker.""" + self._see = see + self.vehicle = vehicle + + def update(self) -> None: + """Update the device info.""" + dev_id = slugify(self.vehicle.modelName) + _LOGGER.debug('Updating %s', dev_id) + attrs = { + 'trackr_id': dev_id, + 'id': dev_id, + 'name': self.vehicle.modelName + } + self._see( + dev_id=dev_id, host_name=self.vehicle.modelName, + gps=self.vehicle.state.gps_position, attributes=attrs, + icon='mdi:car' + ) diff --git a/homeassistant/components/sensor/bmw_connected_drive.py b/homeassistant/components/sensor/bmw_connected_drive.py new file mode 100644 index 00000000000..26bfd19e6fc --- /dev/null +++ b/homeassistant/components/sensor/bmw_connected_drive.py @@ -0,0 +1,99 @@ +""" +Reads vehicle status from BMW connected drive portal. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.bmw_connected_drive/ +""" +import logging +import asyncio + +from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN +from homeassistant.helpers.entity import Entity + +DEPENDENCIES = ['bmw_connected_drive'] + +_LOGGER = logging.getLogger(__name__) + +LENGTH_ATTRIBUTES = [ + 'remaining_range_fuel', + 'mileage', + ] + +VALID_ATTRIBUTES = LENGTH_ATTRIBUTES + [ + 'remaining_fuel', +] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the BMW sensors.""" + accounts = hass.data[BMW_DOMAIN] + _LOGGER.debug('Found BMW accounts: %s', + ', '.join([a.name for a in accounts])) + devices = [] + for account in accounts: + for vehicle in account.account.vehicles: + for sensor in VALID_ATTRIBUTES: + device = BMWConnectedDriveSensor(account, vehicle, sensor) + devices.append(device) + add_devices(devices) + + +class BMWConnectedDriveSensor(Entity): + """Representation of a BMW vehicle sensor.""" + + def __init__(self, account, vehicle, attribute: str): + """Constructor.""" + self._vehicle = vehicle + self._account = account + self._attribute = attribute + self._state = None + self._unit_of_measurement = None + self._name = '{} {}'.format(self._vehicle.modelName, self._attribute) + + @property + def should_poll(self) -> bool: + """Data update is triggered from BMWConnectedDriveEntity.""" + return False + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor. + + The return type of this call depends on the attribute that + is configured. + """ + return self._state + + @property + def unit_of_measurement(self) -> str: + """Get the unit of measurement.""" + return self._unit_of_measurement + + def update(self) -> None: + """Read new state data from the library.""" + _LOGGER.debug('Updating %s', self.entity_id) + vehicle_state = self._vehicle.state + self._state = getattr(vehicle_state, self._attribute) + + if self._attribute in LENGTH_ATTRIBUTES: + self._unit_of_measurement = vehicle_state.unit_of_length + elif self._attribute == 'remaining_fuel': + self._unit_of_measurement = vehicle_state.unit_of_volume + else: + self._unit_of_measurement = None + + self.schedule_update_ha_state() + + @asyncio.coroutine + def async_added_to_hass(self): + """Add callback after being added to hass. + + Show latest data after startup. + """ + self._account.add_update_listener(self.update) + yield from self.hass.async_add_job(self.update) diff --git a/requirements_all.txt b/requirements_all.txt index eb10e28fc43..d90078ad402 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -135,6 +135,9 @@ beautifulsoup4==4.6.0 # homeassistant.components.zha bellows==0.5.0 +# homeassistant.components.bmw_connected_drive +bimmer_connected==0.3.0 + # homeassistant.components.blink blinkpy==0.6.0 From 4f96eeb06e230775f6f1848a39d69cd67f962f24 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 21 Feb 2018 00:24:31 +0100 Subject: [PATCH 137/173] Cleanup hass.io component (#12556) * Cleanup hass.io component * fix lint * Fix all tests * Fix lint * fix lint * fix doc lint --- .../{hassio.py => hassio/__init__.py} | 227 +-------- homeassistant/components/hassio/handler.py | 117 +++++ homeassistant/components/hassio/http.py | 140 ++++++ tests/components/hassio/__init__.py | 3 + tests/components/hassio/conftest.py | 40 ++ tests/components/hassio/test_handler.py | 151 ++++++ tests/components/hassio/test_http.py | 133 +++++ tests/components/hassio/test_init.py | 174 +++++++ tests/components/test_hassio.py | 474 ------------------ 9 files changed, 761 insertions(+), 698 deletions(-) rename homeassistant/components/{hassio.py => hassio/__init__.py} (52%) create mode 100644 homeassistant/components/hassio/handler.py create mode 100644 homeassistant/components/hassio/http.py create mode 100644 tests/components/hassio/__init__.py create mode 100644 tests/components/hassio/conftest.py create mode 100644 tests/components/hassio/test_handler.py create mode 100644 tests/components/hassio/test_http.py create mode 100644 tests/components/hassio/test_init.py delete mode 100644 tests/components/test_hassio.py diff --git a/homeassistant/components/hassio.py b/homeassistant/components/hassio/__init__.py similarity index 52% rename from homeassistant/components/hassio.py rename to homeassistant/components/hassio/__init__.py index f8730f14a1a..9b229bd7a85 100644 --- a/homeassistant/components/hassio.py +++ b/homeassistant/components/hassio/__init__.py @@ -8,35 +8,25 @@ import asyncio from datetime import timedelta import logging import os -import re -import aiohttp -from aiohttp import web -from aiohttp.hdrs import CONTENT_TYPE -from aiohttp.web_exceptions import HTTPBadGateway -import async_timeout import voluptuous as vol from homeassistant.components import SERVICE_CHECK_CONFIG -from homeassistant.components.http import ( - CONF_API_PASSWORD, CONF_SERVER_HOST, CONF_SERVER_PORT, - CONF_SSL_CERTIFICATE, KEY_AUTHENTICATED, HomeAssistantView) from homeassistant.const import ( - CONF_TIME_ZONE, CONTENT_TYPE_TEXT_PLAIN, SERVER_PORT, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP) from homeassistant.core import DOMAIN as HASS_DOMAIN from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.loader import bind_hass from homeassistant.util.dt import utcnow +from .handler import HassIO +from .http import HassIOView _LOGGER = logging.getLogger(__name__) DOMAIN = 'hassio' DEPENDENCIES = ['http'] -X_HASSIO = 'X-HASSIO-KEY' - DATA_HOMEASSISTANT_VERSION = 'hassio_hass_version' HASSIO_UPDATE_INTERVAL = timedelta(minutes=55) @@ -60,22 +50,6 @@ ATTR_HOMEASSISTANT = 'homeassistant' ATTR_NAME = 'name' ATTR_PASSWORD = 'password' -NO_TIMEOUT = { - re.compile(r'^homeassistant/update$'), - re.compile(r'^host/update$'), - re.compile(r'^supervisor/update$'), - re.compile(r'^addons/[^/]*/update$'), - re.compile(r'^addons/[^/]*/install$'), - re.compile(r'^addons/[^/]*/rebuild$'), - re.compile(r'^snapshots/.*/full$'), - re.compile(r'^snapshots/.*/partial$'), -} - -NO_AUTH = { - re.compile(r'^app-(es5|latest)/(index|hassio-app).html$'), - re.compile(r'^addons/[^/]*/logo$') -} - SCHEMA_NO_DATA = vol.Schema({}) SCHEMA_ADDON = vol.Schema({ @@ -178,7 +152,7 @@ def async_setup(hass, config): _LOGGER.error("Not connected with Hass.io") return False - hass.http.register_view(HassIOView(hassio)) + hass.http.register_view(HassIOView(host, websession)) if 'frontend' in hass.config.components: yield from hass.components.frontend.async_register_built_in_panel( @@ -257,198 +231,3 @@ def async_setup(hass, config): HASS_DOMAIN, service, async_handle_core_service) return True - - -def _api_bool(funct): - """Return a boolean.""" - @asyncio.coroutine - def _wrapper(*argv, **kwargs): - """Wrap function.""" - data = yield from funct(*argv, **kwargs) - return data and data['result'] == "ok" - - return _wrapper - - -class HassIO(object): - """Small API wrapper for Hass.io.""" - - def __init__(self, loop, websession, ip): - """Initialize Hass.io API.""" - self.loop = loop - self.websession = websession - self._ip = ip - - @_api_bool - def is_connected(self): - """Return true if it connected to Hass.io supervisor. - - This method return a coroutine. - """ - return self.send_command("/supervisor/ping", method="get") - - def get_homeassistant_info(self): - """Return data for Home Assistant. - - This method return a coroutine. - """ - return self.send_command("/homeassistant/info", method="get") - - @_api_bool - def update_hass_api(self, http_config): - """Update Home Assistant API data on Hass.io. - - This method return a coroutine. - """ - port = http_config.get(CONF_SERVER_PORT) or SERVER_PORT - options = { - 'ssl': CONF_SSL_CERTIFICATE in http_config, - 'port': port, - 'password': http_config.get(CONF_API_PASSWORD), - 'watchdog': True, - } - - if CONF_SERVER_HOST in http_config: - options['watchdog'] = False - _LOGGER.warning("Don't use 'server_host' options with Hass.io") - - return self.send_command("/homeassistant/options", payload=options) - - @_api_bool - def update_hass_timezone(self, core_config): - """Update Home-Assistant timezone data on Hass.io. - - This method return a coroutine. - """ - return self.send_command("/supervisor/options", payload={ - 'timezone': core_config.get(CONF_TIME_ZONE) - }) - - @asyncio.coroutine - def send_command(self, command, method="post", payload=None, timeout=10): - """Send API command to Hass.io. - - This method is a coroutine. - """ - try: - with async_timeout.timeout(timeout, loop=self.loop): - request = yield from self.websession.request( - method, "http://{}{}".format(self._ip, command), - json=payload, headers={ - X_HASSIO: os.environ.get('HASSIO_TOKEN', "") - }) - - if request.status not in (200, 400): - _LOGGER.error( - "%s return code %d.", command, request.status) - return None - - answer = yield from request.json() - return answer - - except asyncio.TimeoutError: - _LOGGER.error("Timeout on %s request", command) - - except aiohttp.ClientError as err: - _LOGGER.error("Client error on %s request %s", command, err) - - return None - - @asyncio.coroutine - def command_proxy(self, path, request): - """Return a client request with proxy origin for Hass.io supervisor. - - This method is a coroutine. - """ - read_timeout = _get_timeout(path) - - try: - data = None - headers = {X_HASSIO: os.environ.get('HASSIO_TOKEN', "")} - with async_timeout.timeout(10, loop=self.loop): - data = yield from request.read() - if data: - headers[CONTENT_TYPE] = request.content_type - else: - data = None - - method = getattr(self.websession, request.method.lower()) - client = yield from method( - "http://{}/{}".format(self._ip, path), data=data, - headers=headers, timeout=read_timeout - ) - - return client - - except aiohttp.ClientError as err: - _LOGGER.error("Client error on api %s request %s", path, err) - - except asyncio.TimeoutError: - _LOGGER.error("Client timeout error on API request %s", path) - - raise HTTPBadGateway() - - -class HassIOView(HomeAssistantView): - """Hass.io view to handle base part.""" - - name = "api:hassio" - url = "/api/hassio/{path:.+}" - requires_auth = False - - def __init__(self, hassio): - """Initialize a Hass.io base view.""" - self.hassio = hassio - - @asyncio.coroutine - def _handle(self, request, path): - """Route data to Hass.io.""" - if _need_auth(path) and not request[KEY_AUTHENTICATED]: - return web.Response(status=401) - - client = yield from self.hassio.command_proxy(path, request) - - data = yield from client.read() - if path.endswith('/logs'): - return _create_response_log(client, data) - return _create_response(client, data) - - get = _handle - post = _handle - - -def _create_response(client, data): - """Convert a response from client request.""" - return web.Response( - body=data, - status=client.status, - content_type=client.content_type, - ) - - -def _create_response_log(client, data): - """Convert a response from client request.""" - # Remove color codes - log = re.sub(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))", "", data.decode()) - - return web.Response( - text=log, - status=client.status, - content_type=CONTENT_TYPE_TEXT_PLAIN, - ) - - -def _get_timeout(path): - """Return timeout for a URL path.""" - for re_path in NO_TIMEOUT: - if re_path.match(path): - return 0 - return 300 - - -def _need_auth(path): - """Return if a path need authentication.""" - for re_path in NO_AUTH: - if re_path.match(path): - return False - return True diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py new file mode 100644 index 00000000000..125622f063c --- /dev/null +++ b/homeassistant/components/hassio/handler.py @@ -0,0 +1,117 @@ +""" +Exposes regular REST commands as services. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/hassio/ +""" +import asyncio +import logging +import os + +import aiohttp +import async_timeout + +from homeassistant.components.http import ( + CONF_API_PASSWORD, CONF_SERVER_HOST, CONF_SERVER_PORT, + CONF_SSL_CERTIFICATE) +from homeassistant.const import CONF_TIME_ZONE, SERVER_PORT + +_LOGGER = logging.getLogger(__name__) + +X_HASSIO = 'X-HASSIO-KEY' + + +def _api_bool(funct): + """Return a boolean.""" + @asyncio.coroutine + def _wrapper(*argv, **kwargs): + """Wrap function.""" + data = yield from funct(*argv, **kwargs) + return data and data['result'] == "ok" + + return _wrapper + + +class HassIO(object): + """Small API wrapper for Hass.io.""" + + def __init__(self, loop, websession, ip): + """Initialize Hass.io API.""" + self.loop = loop + self.websession = websession + self._ip = ip + + @_api_bool + def is_connected(self): + """Return true if it connected to Hass.io supervisor. + + This method return a coroutine. + """ + return self.send_command("/supervisor/ping", method="get") + + def get_homeassistant_info(self): + """Return data for Home Assistant. + + This method return a coroutine. + """ + return self.send_command("/homeassistant/info", method="get") + + @_api_bool + def update_hass_api(self, http_config): + """Update Home Assistant API data on Hass.io. + + This method return a coroutine. + """ + port = http_config.get(CONF_SERVER_PORT) or SERVER_PORT + options = { + 'ssl': CONF_SSL_CERTIFICATE in http_config, + 'port': port, + 'password': http_config.get(CONF_API_PASSWORD), + 'watchdog': True, + } + + if CONF_SERVER_HOST in http_config: + options['watchdog'] = False + _LOGGER.warning("Don't use 'server_host' options with Hass.io") + + return self.send_command("/homeassistant/options", payload=options) + + @_api_bool + def update_hass_timezone(self, core_config): + """Update Home-Assistant timezone data on Hass.io. + + This method return a coroutine. + """ + return self.send_command("/supervisor/options", payload={ + 'timezone': core_config.get(CONF_TIME_ZONE) + }) + + @asyncio.coroutine + def send_command(self, command, method="post", payload=None, timeout=10): + """Send API command to Hass.io. + + This method is a coroutine. + """ + try: + with async_timeout.timeout(timeout, loop=self.loop): + request = yield from self.websession.request( + method, "http://{}{}".format(self._ip, command), + json=payload, headers={ + X_HASSIO: os.environ.get('HASSIO_TOKEN', "") + }) + + if request.status not in (200, 400): + _LOGGER.error( + "%s return code %d.", command, request.status) + return None + + answer = yield from request.json() + return answer + + except asyncio.TimeoutError: + _LOGGER.error("Timeout on %s request", command) + + except aiohttp.ClientError as err: + _LOGGER.error("Client error on %s request %s", command, err) + + return None diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py new file mode 100644 index 00000000000..d94826653e8 --- /dev/null +++ b/homeassistant/components/hassio/http.py @@ -0,0 +1,140 @@ +""" +Exposes regular REST commands as services. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/hassio/ +""" +import asyncio +import logging +import os +import re + +import async_timeout +import aiohttp +from aiohttp import web +from aiohttp.hdrs import CONTENT_TYPE +from aiohttp.web_exceptions import HTTPBadGateway + +from homeassistant.const import CONTENT_TYPE_TEXT_PLAIN +from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView + +_LOGGER = logging.getLogger(__name__) + +X_HASSIO = 'X-HASSIO-KEY' + +NO_TIMEOUT = { + re.compile(r'^homeassistant/update$'), + re.compile(r'^host/update$'), + re.compile(r'^supervisor/update$'), + re.compile(r'^addons/[^/]*/update$'), + re.compile(r'^addons/[^/]*/install$'), + re.compile(r'^addons/[^/]*/rebuild$'), + re.compile(r'^snapshots/.*/full$'), + re.compile(r'^snapshots/.*/partial$'), +} + +NO_AUTH = { + re.compile(r'^app-(es5|latest)/(index|hassio-app).html$'), + re.compile(r'^addons/[^/]*/logo$') +} + + +class HassIOView(HomeAssistantView): + """Hass.io view to handle base part.""" + + name = "api:hassio" + url = "/api/hassio/{path:.+}" + requires_auth = False + + def __init__(self, host, websession): + """Initialize a Hass.io base view.""" + self._host = host + self._websession = websession + + @asyncio.coroutine + def _handle(self, request, path): + """Route data to Hass.io.""" + if _need_auth(path) and not request[KEY_AUTHENTICATED]: + return web.Response(status=401) + + client = yield from self._command_proxy(path, request) + + data = yield from client.read() + if path.endswith('/logs'): + return _create_response_log(client, data) + return _create_response(client, data) + + get = _handle + post = _handle + + @asyncio.coroutine + def _command_proxy(self, path, request): + """Return a client request with proxy origin for Hass.io supervisor. + + This method is a coroutine. + """ + read_timeout = _get_timeout(path) + hass = request.app['hass'] + + try: + data = None + headers = {X_HASSIO: os.environ.get('HASSIO_TOKEN', "")} + with async_timeout.timeout(10, loop=hass.loop): + data = yield from request.read() + if data: + headers[CONTENT_TYPE] = request.content_type + else: + data = None + + method = getattr(self._websession, request.method.lower()) + client = yield from method( + "http://{}/{}".format(self._host, path), data=data, + headers=headers, timeout=read_timeout + ) + + return client + + except aiohttp.ClientError as err: + _LOGGER.error("Client error on api %s request %s", path, err) + + except asyncio.TimeoutError: + _LOGGER.error("Client timeout error on API request %s", path) + + raise HTTPBadGateway() + + +def _create_response(client, data): + """Convert a response from client request.""" + return web.Response( + body=data, + status=client.status, + content_type=client.content_type, + ) + + +def _create_response_log(client, data): + """Convert a response from client request.""" + # Remove color codes + log = re.sub(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))", "", data.decode()) + + return web.Response( + text=log, + status=client.status, + content_type=CONTENT_TYPE_TEXT_PLAIN, + ) + + +def _get_timeout(path): + """Return timeout for a URL path.""" + for re_path in NO_TIMEOUT: + if re_path.match(path): + return 0 + return 300 + + +def _need_auth(path): + """Return if a path need authentication.""" + for re_path in NO_AUTH: + if re_path.match(path): + return False + return True diff --git a/tests/components/hassio/__init__.py b/tests/components/hassio/__init__.py new file mode 100644 index 00000000000..34fd4ad23e5 --- /dev/null +++ b/tests/components/hassio/__init__.py @@ -0,0 +1,3 @@ +"""Tests for Hassio component.""" + +API_PASSWORD = 'pass1234' diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py new file mode 100644 index 00000000000..852ec1aaa15 --- /dev/null +++ b/tests/components/hassio/conftest.py @@ -0,0 +1,40 @@ +"""Fixtures for Hass.io.""" +import os +from unittest.mock import patch, Mock + +import pytest + +from homeassistant.setup import async_setup_component + +from tests.common import mock_coro +from . import API_PASSWORD + + +@pytest.fixture +def hassio_env(): + """Fixture to inject hassio env.""" + with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}), \ + patch('homeassistant.components.hassio.HassIO.is_connected', + Mock(return_value=mock_coro( + {"result": "ok", "data": {}}))), \ + patch.dict(os.environ, {'HASSIO_TOKEN': "123456"}), \ + patch('homeassistant.components.hassio.HassIO.' + 'get_homeassistant_info', + Mock(return_value=mock_coro(None))): + yield + + +@pytest.fixture +def hassio_client(hassio_env, hass, test_client): + """Create mock hassio http client.""" + with patch('homeassistant.components.hassio.HassIO.update_hass_api', + Mock(return_value=mock_coro({"result": "ok"}))), \ + patch('homeassistant.components.hassio.HassIO.' + 'get_homeassistant_info', + Mock(return_value=mock_coro(None))): + hass.loop.run_until_complete(async_setup_component(hass, 'hassio', { + 'http': { + 'api_password': API_PASSWORD + } + })) + yield hass.loop.run_until_complete(test_client(hass.http.app)) diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py new file mode 100644 index 00000000000..39cfa689c59 --- /dev/null +++ b/tests/components/hassio/test_handler.py @@ -0,0 +1,151 @@ +"""The tests for the hassio component.""" +import asyncio +import os +from unittest.mock import patch + +from homeassistant.setup import async_setup_component + + +@asyncio.coroutine +def test_setup_api_ping(hass, aioclient_mock): + """Test setup with API ping.""" + aioclient_mock.get( + "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={ + 'result': 'ok', 'data': {'last_version': '10.0'}}) + + with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + result = yield from async_setup_component(hass, 'hassio', {}) + assert result + + assert aioclient_mock.call_count == 2 + assert hass.components.hassio.get_homeassistant_version() == "10.0" + assert hass.components.hassio.is_hassio() + + +@asyncio.coroutine +def test_setup_api_push_api_data(hass, aioclient_mock): + """Test setup with API push.""" + aioclient_mock.get( + "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={ + 'result': 'ok', 'data': {'last_version': '10.0'}}) + aioclient_mock.post( + "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) + + with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + result = yield from async_setup_component(hass, 'hassio', { + 'http': { + 'api_password': "123456", + 'server_port': 9999 + }, + 'hassio': {} + }) + assert result + + assert aioclient_mock.call_count == 3 + assert not aioclient_mock.mock_calls[1][2]['ssl'] + assert aioclient_mock.mock_calls[1][2]['password'] == "123456" + assert aioclient_mock.mock_calls[1][2]['port'] == 9999 + assert aioclient_mock.mock_calls[1][2]['watchdog'] + + +@asyncio.coroutine +def test_setup_api_push_api_data_server_host(hass, aioclient_mock): + """Test setup with API push with active server host.""" + aioclient_mock.get( + "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={ + 'result': 'ok', 'data': {'last_version': '10.0'}}) + aioclient_mock.post( + "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) + + with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + result = yield from async_setup_component(hass, 'hassio', { + 'http': { + 'api_password': "123456", + 'server_port': 9999, + 'server_host': "127.0.0.1" + }, + 'hassio': {} + }) + assert result + + assert aioclient_mock.call_count == 3 + assert not aioclient_mock.mock_calls[1][2]['ssl'] + assert aioclient_mock.mock_calls[1][2]['password'] == "123456" + assert aioclient_mock.mock_calls[1][2]['port'] == 9999 + assert not aioclient_mock.mock_calls[1][2]['watchdog'] + + +@asyncio.coroutine +def test_setup_api_push_api_data_default(hass, aioclient_mock): + """Test setup with API push default data.""" + aioclient_mock.get( + "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={ + 'result': 'ok', 'data': {'last_version': '10.0'}}) + aioclient_mock.post( + "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) + + with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + result = yield from async_setup_component(hass, 'hassio', { + 'http': {}, + 'hassio': {} + }) + assert result + + assert aioclient_mock.call_count == 3 + assert not aioclient_mock.mock_calls[1][2]['ssl'] + assert aioclient_mock.mock_calls[1][2]['password'] is None + assert aioclient_mock.mock_calls[1][2]['port'] == 8123 + + +@asyncio.coroutine +def test_setup_core_push_timezone(hass, aioclient_mock): + """Test setup with API push default data.""" + aioclient_mock.get( + "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={ + 'result': 'ok', 'data': {'last_version': '10.0'}}) + aioclient_mock.post( + "http://127.0.0.1/supervisor/options", json={'result': 'ok'}) + + with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + result = yield from async_setup_component(hass, 'hassio', { + 'hassio': {}, + 'homeassistant': { + 'time_zone': 'testzone', + }, + }) + assert result + + assert aioclient_mock.call_count == 3 + assert aioclient_mock.mock_calls[1][2]['timezone'] == "testzone" + + +@asyncio.coroutine +def test_setup_hassio_no_additional_data(hass, aioclient_mock): + """Test setup with API push default data.""" + aioclient_mock.get( + "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={ + 'result': 'ok', 'data': {'last_version': '10.0'}}) + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={'result': 'ok'}) + + with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}), \ + patch.dict(os.environ, {'HASSIO_TOKEN': "123456"}): + result = yield from async_setup_component(hass, 'hassio', { + 'hassio': {}, + }) + assert result + + assert aioclient_mock.call_count == 2 + assert aioclient_mock.mock_calls[-1][3]['X-HASSIO-KEY'] == "123456" diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py new file mode 100644 index 00000000000..ed425ad8cca --- /dev/null +++ b/tests/components/hassio/test_http.py @@ -0,0 +1,133 @@ +"""The tests for the hassio component.""" +import asyncio +from unittest.mock import patch, Mock, MagicMock + +import pytest + +from homeassistant.const import HTTP_HEADER_HA_AUTH + +from tests.common import mock_coro +from . import API_PASSWORD + + +@asyncio.coroutine +def test_forward_request(hassio_client): + """Test fetching normal path.""" + response = MagicMock() + response.read.return_value = mock_coro('data') + + with patch('homeassistant.components.hassio.HassIOView._command_proxy', + Mock(return_value=mock_coro(response))), \ + patch('homeassistant.components.hassio.http' + '._create_response') as mresp: + mresp.return_value = 'response' + resp = yield from hassio_client.post('/api/hassio/beer', headers={ + HTTP_HEADER_HA_AUTH: API_PASSWORD + }) + + # Check we got right response + assert resp.status == 200 + body = yield from resp.text() + assert body == 'response' + + # Check we forwarded command + assert len(mresp.mock_calls) == 1 + assert mresp.mock_calls[0][1] == (response, 'data') + + +@asyncio.coroutine +def test_auth_required_forward_request(hassio_client): + """Test auth required for normal request.""" + resp = yield from hassio_client.post('/api/hassio/beer') + + # Check we got right response + assert resp.status == 401 + + +@asyncio.coroutine +@pytest.mark.parametrize( + 'build_type', [ + 'es5/index.html', 'es5/hassio-app.html', 'latest/index.html', + 'latest/hassio-app.html' + ]) +def test_forward_request_no_auth_for_panel(hassio_client, build_type): + """Test no auth needed for .""" + response = MagicMock() + response.read.return_value = mock_coro('data') + + with patch('homeassistant.components.hassio.HassIOView._command_proxy', + Mock(return_value=mock_coro(response))), \ + patch('homeassistant.components.hassio.http.' + '_create_response') as mresp: + mresp.return_value = 'response' + resp = yield from hassio_client.get( + '/api/hassio/app-{}'.format(build_type)) + + # Check we got right response + assert resp.status == 200 + body = yield from resp.text() + assert body == 'response' + + # Check we forwarded command + assert len(mresp.mock_calls) == 1 + assert mresp.mock_calls[0][1] == (response, 'data') + + +@asyncio.coroutine +def test_forward_request_no_auth_for_logo(hassio_client): + """Test no auth needed for .""" + response = MagicMock() + response.read.return_value = mock_coro('data') + + with patch('homeassistant.components.hassio.HassIOView._command_proxy', + Mock(return_value=mock_coro(response))), \ + patch('homeassistant.components.hassio.http.' + '_create_response') as mresp: + mresp.return_value = 'response' + resp = yield from hassio_client.get('/api/hassio/addons/bl_b392/logo') + + # Check we got right response + assert resp.status == 200 + body = yield from resp.text() + assert body == 'response' + + # Check we forwarded command + assert len(mresp.mock_calls) == 1 + assert mresp.mock_calls[0][1] == (response, 'data') + + +@asyncio.coroutine +def test_forward_log_request(hassio_client): + """Test fetching normal log path.""" + response = MagicMock() + response.read.return_value = mock_coro('data') + + with patch('homeassistant.components.hassio.HassIOView._command_proxy', + Mock(return_value=mock_coro(response))), \ + patch('homeassistant.components.hassio.http.' + '_create_response_log') as mresp: + mresp.return_value = 'response' + resp = yield from hassio_client.get('/api/hassio/beer/logs', headers={ + HTTP_HEADER_HA_AUTH: API_PASSWORD + }) + + # Check we got right response + assert resp.status == 200 + body = yield from resp.text() + assert body == 'response' + + # Check we forwarded command + assert len(mresp.mock_calls) == 1 + assert mresp.mock_calls[0][1] == (response, 'data') + + +@asyncio.coroutine +def test_bad_gateway_when_cannot_find_supervisor(hassio_client): + """Test we get a bad gateway error if we can't find supervisor.""" + with patch('homeassistant.components.hassio.http.async_timeout.timeout', + side_effect=asyncio.TimeoutError): + resp = yield from hassio_client.get( + '/api/hassio/addons/test/info', headers={ + HTTP_HEADER_HA_AUTH: API_PASSWORD + }) + assert resp.status == 502 diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py new file mode 100644 index 00000000000..313623bc40d --- /dev/null +++ b/tests/components/hassio/test_init.py @@ -0,0 +1,174 @@ +"""The tests for the hassio component.""" +import asyncio +import os +from unittest.mock import patch, Mock + +from homeassistant.setup import async_setup_component +from homeassistant.components.hassio import async_check_config + +from tests.common import mock_coro + + +@asyncio.coroutine +def test_fail_setup_without_environ_var(hass): + """Fail setup if no environ variable set.""" + with patch.dict(os.environ, {}, clear=True): + result = yield from async_setup_component(hass, 'hassio', {}) + assert not result + + +@asyncio.coroutine +def test_fail_setup_cannot_connect(hass): + """Fail setup if cannot connect.""" + with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}), \ + patch('homeassistant.components.hassio.HassIO.is_connected', + Mock(return_value=mock_coro(None))): + result = yield from async_setup_component(hass, 'hassio', {}) + assert not result + + assert not hass.components.hassio.is_hassio() + + +@asyncio.coroutine +def test_service_register(hassio_env, hass): + """Check if service will be setup.""" + assert (yield from async_setup_component(hass, 'hassio', {})) + assert hass.services.has_service('hassio', 'addon_start') + assert hass.services.has_service('hassio', 'addon_stop') + assert hass.services.has_service('hassio', 'addon_restart') + assert hass.services.has_service('hassio', 'addon_stdin') + assert hass.services.has_service('hassio', 'host_shutdown') + assert hass.services.has_service('hassio', 'host_reboot') + assert hass.services.has_service('hassio', 'host_reboot') + assert hass.services.has_service('hassio', 'snapshot_full') + assert hass.services.has_service('hassio', 'snapshot_partial') + assert hass.services.has_service('hassio', 'restore_full') + assert hass.services.has_service('hassio', 'restore_partial') + + +@asyncio.coroutine +def test_service_calls(hassio_env, hass, aioclient_mock): + """Call service and check the API calls behind that.""" + assert (yield from async_setup_component(hass, 'hassio', {})) + + aioclient_mock.post( + "http://127.0.0.1/addons/test/start", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/addons/test/stop", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/addons/test/restart", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/addons/test/stdin", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/host/shutdown", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/host/reboot", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/snapshots/new/full", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/snapshots/new/partial", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/snapshots/test/restore/full", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/snapshots/test/restore/partial", + json={'result': 'ok'}) + + yield from hass.services.async_call( + 'hassio', 'addon_start', {'addon': 'test'}) + yield from hass.services.async_call( + 'hassio', 'addon_stop', {'addon': 'test'}) + yield from hass.services.async_call( + 'hassio', 'addon_restart', {'addon': 'test'}) + yield from hass.services.async_call( + 'hassio', 'addon_stdin', {'addon': 'test', 'input': 'test'}) + yield from hass.async_block_till_done() + + assert aioclient_mock.call_count == 4 + assert aioclient_mock.mock_calls[-1][2] == 'test' + + yield from hass.services.async_call('hassio', 'host_shutdown', {}) + yield from hass.services.async_call('hassio', 'host_reboot', {}) + yield from hass.async_block_till_done() + + assert aioclient_mock.call_count == 6 + + yield from hass.services.async_call('hassio', 'snapshot_full', {}) + yield from hass.services.async_call('hassio', 'snapshot_partial', { + 'addons': ['test'], + 'folders': ['ssl'], + 'password': "123456", + }) + yield from hass.async_block_till_done() + + assert aioclient_mock.call_count == 8 + assert aioclient_mock.mock_calls[-1][2] == { + 'addons': ['test'], 'folders': ['ssl'], 'password': "123456"} + + yield from hass.services.async_call('hassio', 'restore_full', { + 'snapshot': 'test', + }) + yield from hass.services.async_call('hassio', 'restore_partial', { + 'snapshot': 'test', + 'homeassistant': False, + 'addons': ['test'], + 'folders': ['ssl'], + 'password': "123456", + }) + yield from hass.async_block_till_done() + + assert aioclient_mock.call_count == 10 + assert aioclient_mock.mock_calls[-1][2] == { + 'addons': ['test'], 'folders': ['ssl'], 'homeassistant': False, + 'password': "123456" + } + + +@asyncio.coroutine +def test_service_calls_core(hassio_env, hass, aioclient_mock): + """Call core service and check the API calls behind that.""" + assert (yield from async_setup_component(hass, 'hassio', {})) + + aioclient_mock.post( + "http://127.0.0.1/homeassistant/restart", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/homeassistant/stop", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/homeassistant/check", json={'result': 'ok'}) + + yield from hass.services.async_call('homeassistant', 'stop') + yield from hass.async_block_till_done() + + assert aioclient_mock.call_count == 1 + + yield from hass.services.async_call('homeassistant', 'check_config') + yield from hass.async_block_till_done() + + assert aioclient_mock.call_count == 2 + + yield from hass.services.async_call('homeassistant', 'restart') + yield from hass.async_block_till_done() + + assert aioclient_mock.call_count == 4 + + +@asyncio.coroutine +def test_check_config_ok(hassio_env, hass, aioclient_mock): + """Check Config that is okay.""" + assert (yield from async_setup_component(hass, 'hassio', {})) + + aioclient_mock.post( + "http://127.0.0.1/homeassistant/check", json={'result': 'ok'}) + + assert (yield from async_check_config(hass)) is None + + +@asyncio.coroutine +def test_check_config_fail(hassio_env, hass, aioclient_mock): + """Check Config that is wrong.""" + assert (yield from async_setup_component(hass, 'hassio', {})) + + aioclient_mock.post( + "http://127.0.0.1/homeassistant/check", json={ + 'result': 'error', 'message': "Error"}) + + assert (yield from async_check_config(hass)) == "Error" diff --git a/tests/components/test_hassio.py b/tests/components/test_hassio.py deleted file mode 100644 index 4511930a6df..00000000000 --- a/tests/components/test_hassio.py +++ /dev/null @@ -1,474 +0,0 @@ -"""The tests for the hassio component.""" -import asyncio -import os -from unittest.mock import patch, Mock, MagicMock - -import pytest - -from homeassistant.const import HTTP_HEADER_HA_AUTH -from homeassistant.setup import async_setup_component -from homeassistant.components.hassio import async_check_config - -from tests.common import mock_coro - -API_PASSWORD = 'pass1234' - - -@pytest.fixture -def hassio_env(): - """Fixture to inject hassio env.""" - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}), \ - patch('homeassistant.components.hassio.HassIO.is_connected', - Mock(return_value=mock_coro( - {"result": "ok", "data": {}}))), \ - patch.dict(os.environ, {'HASSIO_TOKEN': "123456"}), \ - patch('homeassistant.components.hassio.HassIO.' - 'get_homeassistant_info', - Mock(return_value=mock_coro(None))): - yield - - -@pytest.fixture -def hassio_client(hassio_env, hass, test_client): - """Create mock hassio http client.""" - with patch('homeassistant.components.hassio.HassIO.update_hass_api', - Mock(return_value=mock_coro({"result": "ok"}))), \ - patch('homeassistant.components.hassio.HassIO.' - 'get_homeassistant_info', - Mock(return_value=mock_coro(None))): - hass.loop.run_until_complete(async_setup_component(hass, 'hassio', { - 'http': { - 'api_password': API_PASSWORD - } - })) - yield hass.loop.run_until_complete(test_client(hass.http.app)) - - -@asyncio.coroutine -def test_fail_setup_without_environ_var(hass): - """Fail setup if no environ variable set.""" - with patch.dict(os.environ, {}, clear=True): - result = yield from async_setup_component(hass, 'hassio', {}) - assert not result - - -@asyncio.coroutine -def test_fail_setup_cannot_connect(hass): - """Fail setup if cannot connect.""" - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}), \ - patch('homeassistant.components.hassio.HassIO.is_connected', - Mock(return_value=mock_coro(None))): - result = yield from async_setup_component(hass, 'hassio', {}) - assert not result - - assert not hass.components.hassio.is_hassio() - - -@asyncio.coroutine -def test_setup_api_ping(hass, aioclient_mock): - """Test setup with API ping.""" - aioclient_mock.get( - "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) - aioclient_mock.get( - "http://127.0.0.1/homeassistant/info", json={ - 'result': 'ok', 'data': {'last_version': '10.0'}}) - - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): - result = yield from async_setup_component(hass, 'hassio', {}) - assert result - - assert aioclient_mock.call_count == 2 - assert hass.components.hassio.get_homeassistant_version() == "10.0" - assert hass.components.hassio.is_hassio() - - -@asyncio.coroutine -def test_setup_api_push_api_data(hass, aioclient_mock): - """Test setup with API push.""" - aioclient_mock.get( - "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) - aioclient_mock.get( - "http://127.0.0.1/homeassistant/info", json={ - 'result': 'ok', 'data': {'last_version': '10.0'}}) - aioclient_mock.post( - "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) - - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): - result = yield from async_setup_component(hass, 'hassio', { - 'http': { - 'api_password': "123456", - 'server_port': 9999 - }, - 'hassio': {} - }) - assert result - - assert aioclient_mock.call_count == 3 - assert not aioclient_mock.mock_calls[1][2]['ssl'] - assert aioclient_mock.mock_calls[1][2]['password'] == "123456" - assert aioclient_mock.mock_calls[1][2]['port'] == 9999 - assert aioclient_mock.mock_calls[1][2]['watchdog'] - - -@asyncio.coroutine -def test_setup_api_push_api_data_server_host(hass, aioclient_mock): - """Test setup with API push with active server host.""" - aioclient_mock.get( - "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) - aioclient_mock.get( - "http://127.0.0.1/homeassistant/info", json={ - 'result': 'ok', 'data': {'last_version': '10.0'}}) - aioclient_mock.post( - "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) - - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): - result = yield from async_setup_component(hass, 'hassio', { - 'http': { - 'api_password': "123456", - 'server_port': 9999, - 'server_host': "127.0.0.1" - }, - 'hassio': {} - }) - assert result - - assert aioclient_mock.call_count == 3 - assert not aioclient_mock.mock_calls[1][2]['ssl'] - assert aioclient_mock.mock_calls[1][2]['password'] == "123456" - assert aioclient_mock.mock_calls[1][2]['port'] == 9999 - assert not aioclient_mock.mock_calls[1][2]['watchdog'] - - -@asyncio.coroutine -def test_setup_api_push_api_data_default(hass, aioclient_mock): - """Test setup with API push default data.""" - aioclient_mock.get( - "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) - aioclient_mock.get( - "http://127.0.0.1/homeassistant/info", json={ - 'result': 'ok', 'data': {'last_version': '10.0'}}) - aioclient_mock.post( - "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) - - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): - result = yield from async_setup_component(hass, 'hassio', { - 'http': {}, - 'hassio': {} - }) - assert result - - assert aioclient_mock.call_count == 3 - assert not aioclient_mock.mock_calls[1][2]['ssl'] - assert aioclient_mock.mock_calls[1][2]['password'] is None - assert aioclient_mock.mock_calls[1][2]['port'] == 8123 - - -@asyncio.coroutine -def test_setup_core_push_timezone(hass, aioclient_mock): - """Test setup with API push default data.""" - aioclient_mock.get( - "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) - aioclient_mock.get( - "http://127.0.0.1/homeassistant/info", json={ - 'result': 'ok', 'data': {'last_version': '10.0'}}) - aioclient_mock.post( - "http://127.0.0.1/supervisor/options", json={'result': 'ok'}) - - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): - result = yield from async_setup_component(hass, 'hassio', { - 'hassio': {}, - 'homeassistant': { - 'time_zone': 'testzone', - }, - }) - assert result - - assert aioclient_mock.call_count == 3 - assert aioclient_mock.mock_calls[1][2]['timezone'] == "testzone" - - -@asyncio.coroutine -def test_setup_hassio_no_additional_data(hass, aioclient_mock): - """Test setup with API push default data.""" - aioclient_mock.get( - "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) - aioclient_mock.get( - "http://127.0.0.1/homeassistant/info", json={ - 'result': 'ok', 'data': {'last_version': '10.0'}}) - aioclient_mock.get( - "http://127.0.0.1/homeassistant/info", json={'result': 'ok'}) - - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}), \ - patch.dict(os.environ, {'HASSIO_TOKEN': "123456"}): - result = yield from async_setup_component(hass, 'hassio', { - 'hassio': {}, - }) - assert result - - assert aioclient_mock.call_count == 2 - assert aioclient_mock.mock_calls[-1][3]['X-HASSIO-KEY'] == "123456" - - -@asyncio.coroutine -def test_service_register(hassio_env, hass): - """Check if service will be setup.""" - assert (yield from async_setup_component(hass, 'hassio', {})) - assert hass.services.has_service('hassio', 'addon_start') - assert hass.services.has_service('hassio', 'addon_stop') - assert hass.services.has_service('hassio', 'addon_restart') - assert hass.services.has_service('hassio', 'addon_stdin') - assert hass.services.has_service('hassio', 'host_shutdown') - assert hass.services.has_service('hassio', 'host_reboot') - assert hass.services.has_service('hassio', 'host_reboot') - assert hass.services.has_service('hassio', 'snapshot_full') - assert hass.services.has_service('hassio', 'snapshot_partial') - assert hass.services.has_service('hassio', 'restore_full') - assert hass.services.has_service('hassio', 'restore_partial') - - -@asyncio.coroutine -def test_service_calls(hassio_env, hass, aioclient_mock): - """Call service and check the API calls behind that.""" - assert (yield from async_setup_component(hass, 'hassio', {})) - - aioclient_mock.post( - "http://127.0.0.1/addons/test/start", json={'result': 'ok'}) - aioclient_mock.post( - "http://127.0.0.1/addons/test/stop", json={'result': 'ok'}) - aioclient_mock.post( - "http://127.0.0.1/addons/test/restart", json={'result': 'ok'}) - aioclient_mock.post( - "http://127.0.0.1/addons/test/stdin", json={'result': 'ok'}) - aioclient_mock.post( - "http://127.0.0.1/host/shutdown", json={'result': 'ok'}) - aioclient_mock.post( - "http://127.0.0.1/host/reboot", json={'result': 'ok'}) - aioclient_mock.post( - "http://127.0.0.1/snapshots/new/full", json={'result': 'ok'}) - aioclient_mock.post( - "http://127.0.0.1/snapshots/new/partial", json={'result': 'ok'}) - aioclient_mock.post( - "http://127.0.0.1/snapshots/test/restore/full", json={'result': 'ok'}) - aioclient_mock.post( - "http://127.0.0.1/snapshots/test/restore/partial", - json={'result': 'ok'}) - - yield from hass.services.async_call( - 'hassio', 'addon_start', {'addon': 'test'}) - yield from hass.services.async_call( - 'hassio', 'addon_stop', {'addon': 'test'}) - yield from hass.services.async_call( - 'hassio', 'addon_restart', {'addon': 'test'}) - yield from hass.services.async_call( - 'hassio', 'addon_stdin', {'addon': 'test', 'input': 'test'}) - yield from hass.async_block_till_done() - - assert aioclient_mock.call_count == 4 - assert aioclient_mock.mock_calls[-1][2] == 'test' - - yield from hass.services.async_call('hassio', 'host_shutdown', {}) - yield from hass.services.async_call('hassio', 'host_reboot', {}) - yield from hass.async_block_till_done() - - assert aioclient_mock.call_count == 6 - - yield from hass.services.async_call('hassio', 'snapshot_full', {}) - yield from hass.services.async_call('hassio', 'snapshot_partial', { - 'addons': ['test'], - 'folders': ['ssl'], - 'password': "123456", - }) - yield from hass.async_block_till_done() - - assert aioclient_mock.call_count == 8 - assert aioclient_mock.mock_calls[-1][2] == { - 'addons': ['test'], 'folders': ['ssl'], 'password': "123456"} - - yield from hass.services.async_call('hassio', 'restore_full', { - 'snapshot': 'test', - }) - yield from hass.services.async_call('hassio', 'restore_partial', { - 'snapshot': 'test', - 'homeassistant': False, - 'addons': ['test'], - 'folders': ['ssl'], - 'password': "123456", - }) - yield from hass.async_block_till_done() - - assert aioclient_mock.call_count == 10 - assert aioclient_mock.mock_calls[-1][2] == { - 'addons': ['test'], 'folders': ['ssl'], 'homeassistant': False, - 'password': "123456" - } - - -@asyncio.coroutine -def test_service_calls_core(hassio_env, hass, aioclient_mock): - """Call core service and check the API calls behind that.""" - assert (yield from async_setup_component(hass, 'hassio', {})) - - aioclient_mock.post( - "http://127.0.0.1/homeassistant/restart", json={'result': 'ok'}) - aioclient_mock.post( - "http://127.0.0.1/homeassistant/stop", json={'result': 'ok'}) - aioclient_mock.post( - "http://127.0.0.1/homeassistant/check", json={'result': 'ok'}) - - yield from hass.services.async_call('homeassistant', 'stop') - yield from hass.async_block_till_done() - - assert aioclient_mock.call_count == 1 - - yield from hass.services.async_call('homeassistant', 'check_config') - yield from hass.async_block_till_done() - - assert aioclient_mock.call_count == 2 - - yield from hass.services.async_call('homeassistant', 'restart') - yield from hass.async_block_till_done() - - assert aioclient_mock.call_count == 4 - - -@asyncio.coroutine -def test_check_config_ok(hassio_env, hass, aioclient_mock): - """Check Config that is okay.""" - assert (yield from async_setup_component(hass, 'hassio', {})) - - aioclient_mock.post( - "http://127.0.0.1/homeassistant/check", json={'result': 'ok'}) - - assert (yield from async_check_config(hass)) is None - - -@asyncio.coroutine -def test_check_config_fail(hassio_env, hass, aioclient_mock): - """Check Config that is wrong.""" - assert (yield from async_setup_component(hass, 'hassio', {})) - - aioclient_mock.post( - "http://127.0.0.1/homeassistant/check", json={ - 'result': 'error', 'message': "Error"}) - - assert (yield from async_check_config(hass)) == "Error" - - -@asyncio.coroutine -def test_forward_request(hassio_client): - """Test fetching normal path.""" - response = MagicMock() - response.read.return_value = mock_coro('data') - - with patch('homeassistant.components.hassio.HassIO.command_proxy', - Mock(return_value=mock_coro(response))), \ - patch('homeassistant.components.hassio._create_response') as mresp: - mresp.return_value = 'response' - resp = yield from hassio_client.post('/api/hassio/beer', headers={ - HTTP_HEADER_HA_AUTH: API_PASSWORD - }) - - # Check we got right response - assert resp.status == 200 - body = yield from resp.text() - assert body == 'response' - - # Check we forwarded command - assert len(mresp.mock_calls) == 1 - assert mresp.mock_calls[0][1] == (response, 'data') - - -@asyncio.coroutine -def test_auth_required_forward_request(hassio_client): - """Test auth required for normal request.""" - resp = yield from hassio_client.post('/api/hassio/beer') - - # Check we got right response - assert resp.status == 401 - - -@asyncio.coroutine -@pytest.mark.parametrize( - 'build_type', [ - 'es5/index.html', 'es5/hassio-app.html', 'latest/index.html', - 'latest/hassio-app.html' - ]) -def test_forward_request_no_auth_for_panel(hassio_client, build_type): - """Test no auth needed for .""" - response = MagicMock() - response.read.return_value = mock_coro('data') - - with patch('homeassistant.components.hassio.HassIO.command_proxy', - Mock(return_value=mock_coro(response))), \ - patch('homeassistant.components.hassio._create_response') as mresp: - mresp.return_value = 'response' - resp = yield from hassio_client.get( - '/api/hassio/app-{}'.format(build_type)) - - # Check we got right response - assert resp.status == 200 - body = yield from resp.text() - assert body == 'response' - - # Check we forwarded command - assert len(mresp.mock_calls) == 1 - assert mresp.mock_calls[0][1] == (response, 'data') - - -@asyncio.coroutine -def test_forward_request_no_auth_for_logo(hassio_client): - """Test no auth needed for .""" - response = MagicMock() - response.read.return_value = mock_coro('data') - - with patch('homeassistant.components.hassio.HassIO.command_proxy', - Mock(return_value=mock_coro(response))), \ - patch('homeassistant.components.hassio._create_response') as mresp: - mresp.return_value = 'response' - resp = yield from hassio_client.get('/api/hassio/addons/bl_b392/logo') - - # Check we got right response - assert resp.status == 200 - body = yield from resp.text() - assert body == 'response' - - # Check we forwarded command - assert len(mresp.mock_calls) == 1 - assert mresp.mock_calls[0][1] == (response, 'data') - - -@asyncio.coroutine -def test_forward_log_request(hassio_client): - """Test fetching normal log path.""" - response = MagicMock() - response.read.return_value = mock_coro('data') - - with patch('homeassistant.components.hassio.HassIO.command_proxy', - Mock(return_value=mock_coro(response))), \ - patch('homeassistant.components.hassio.' - '_create_response_log') as mresp: - mresp.return_value = 'response' - resp = yield from hassio_client.get('/api/hassio/beer/logs', headers={ - HTTP_HEADER_HA_AUTH: API_PASSWORD - }) - - # Check we got right response - assert resp.status == 200 - body = yield from resp.text() - assert body == 'response' - - # Check we forwarded command - assert len(mresp.mock_calls) == 1 - assert mresp.mock_calls[0][1] == (response, 'data') - - -@asyncio.coroutine -def test_bad_gateway_when_cannot_find_supervisor(hassio_client): - """Test we get a bad gateway error if we can't find supervisor.""" - with patch('homeassistant.components.hassio.async_timeout.timeout', - side_effect=asyncio.TimeoutError): - resp = yield from hassio_client.get( - '/api/hassio/addons/test/info', headers={ - HTTP_HEADER_HA_AUTH: API_PASSWORD - }) - assert resp.status == 502 From c898fb1f166a5bb14e0df1e7966ae1fcd23a8cda Mon Sep 17 00:00:00 2001 From: Kane610 Date: Wed, 21 Feb 2018 00:28:06 +0100 Subject: [PATCH 138/173] Add support for smoke detector in deconz (#12561) --- homeassistant/components/deconz/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 8435f6ef8a6..20c8ff41e79 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -18,7 +18,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.json import load_json, save_json -REQUIREMENTS = ['pydeconz==28'] +REQUIREMENTS = ['pydeconz==29'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index d90078ad402..072a74b1dd3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -697,7 +697,7 @@ pycsspeechtts==1.0.2 pydaikin==0.4 # homeassistant.components.deconz -pydeconz==28 +pydeconz==29 # homeassistant.components.zwave pydispatcher==2.0.5 From 722926b315812d4e8ce244d32b4f6356700186e3 Mon Sep 17 00:00:00 2001 From: bottomquark Date: Wed, 21 Feb 2018 00:37:34 +0100 Subject: [PATCH 139/173] Fix caldav component handling missing dtend (#12562) --- homeassistant/components/calendar/caldav.py | 18 ++++++++- tests/components/calendar/test_caldav.py | 42 +++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/calendar/caldav.py b/homeassistant/components/calendar/caldav.py index ba798ce7902..d70e7ff8946 100644 --- a/homeassistant/components/calendar/caldav.py +++ b/homeassistant/components/calendar/caldav.py @@ -166,7 +166,7 @@ class WebDavCalendarData(object): self.event = { "summary": vevent.summary.value, "start": self.get_hass_date(vevent.dtstart.value), - "end": self.get_hass_date(vevent.dtend.value), + "end": self.get_hass_date(self.get_end_date(vevent)), "location": self.get_attr_value(vevent, "location"), "description": self.get_attr_value(vevent, "description") } @@ -194,7 +194,7 @@ class WebDavCalendarData(object): @staticmethod def is_over(vevent): """Return if the event is over.""" - return dt.now() > WebDavCalendarData.to_datetime(vevent.dtend.value) + return dt.now() > WebDavCalendarData.get_end_date(vevent) @staticmethod def get_hass_date(obj): @@ -217,3 +217,17 @@ class WebDavCalendarData(object): if hasattr(obj, attribute): return getattr(obj, attribute).value return None + + @staticmethod + def get_end_date(obj): + """Return the end datetime as determined by dtend or duration.""" + if hasattr(obj, "dtend"): + enddate = obj.dtend.value + + elif hasattr(obj, "duration"): + enddate = obj.dtstart.value + obj.duration.value + + else: + enddate = obj.dtstart.value + timedelta(days=1) + + return WebDavCalendarData.to_datetime(enddate) diff --git a/tests/components/calendar/test_caldav.py b/tests/components/calendar/test_caldav.py index 7234d40c410..e44e5cfc1f0 100644 --- a/tests/components/calendar/test_caldav.py +++ b/tests/components/calendar/test_caldav.py @@ -64,7 +64,49 @@ LOCATION:Hamburg DESCRIPTION:What a beautiful day END:VEVENT END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Global Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:4 +DTSTAMP:20171125T000000Z +DTSTART:20171127 +SUMMARY:This is an event without dtend or duration +LOCATION:Hamburg +DESCRIPTION:What an endless day +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Global Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:5 +DTSTAMP:20171125T000000Z +DTSTART:20171127 +DURATION:PT1H +SUMMARY:This is an event with duration +LOCATION:Hamburg +DESCRIPTION:What a day +END:VEVENT +END:VCALENDAR +""", + """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Global Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:6 +DTSTAMP:20171125T000000Z +DTSTART:20171127T100000Z +DURATION:PT1H +SUMMARY:This is an event with duration +LOCATION:Hamburg +DESCRIPTION:What a day +END:VEVENT +END:VCALENDAR """ + ] From 49d410546a8dcf50a951ff6765345c6f7cd2e03f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 20 Feb 2018 17:55:11 -0800 Subject: [PATCH 140/173] Frontend version bump to 20180221.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 6d42452d7e1..c18f266b025 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20180220.0', 'user-agents==1.1.0'] +REQUIREMENTS = ['home-assistant-frontend==20180221.0', 'user-agents==1.1.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 072a74b1dd3..5ec02f96b0d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -356,7 +356,7 @@ hipnotify==1.0.8 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180220.0 +home-assistant-frontend==20180221.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 18605c268c4..48cae775acb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -75,7 +75,7 @@ hbmqtt==0.9.1 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180220.0 +home-assistant-frontend==20180221.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From 5ad0baf1283b06864c978150f02d38d016afcbd4 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Wed, 21 Feb 2018 08:08:45 +0100 Subject: [PATCH 141/173] Add limitlessled night effect (#12567) --- .../components/light/limitlessled.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index 910e3aebcfb..0606d097d49 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -37,6 +37,8 @@ DEFAULT_FADE = False LED_TYPE = ['rgbw', 'rgbww', 'white', 'bridge-led', 'dimmer'] +EFFECT_NIGHT = 'night' + RGB_BOUNDARY = 40 WHITE = [255, 255, 255] @@ -166,12 +168,16 @@ class LimitlessLEDGroup(Light): from limitlessled.group.rgbww import RgbwwGroup if isinstance(group, WhiteGroup): self._supported = SUPPORT_LIMITLESSLED_WHITE + self._effect_list = [EFFECT_NIGHT] elif isinstance(group, DimmerGroup): self._supported = SUPPORT_LIMITLESSLED_DIMMER + self._effect_list = [] elif isinstance(group, RgbwGroup): self._supported = SUPPORT_LIMITLESSLED_RGB + self._effect_list = [EFFECT_COLORLOOP, EFFECT_NIGHT, EFFECT_WHITE] elif isinstance(group, RgbwwGroup): self._supported = SUPPORT_LIMITLESSLED_RGBWW + self._effect_list = [EFFECT_COLORLOOP, EFFECT_NIGHT, EFFECT_WHITE] self.group = group self.config = config @@ -231,6 +237,11 @@ class LimitlessLEDGroup(Light): """Flag supported features.""" return self._supported + @property + def effect_list(self): + """Return the list of supported effects for this light.""" + return self._effect_list + # pylint: disable=arguments-differ @state(False) def turn_off(self, transition_time, pipeline, **kwargs): @@ -243,6 +254,12 @@ class LimitlessLEDGroup(Light): @state(True) def turn_on(self, transition_time, pipeline, **kwargs): """Turn on (or adjust property of) a group.""" + # The night effect does not need a turned on light + if kwargs.get(ATTR_EFFECT) == EFFECT_NIGHT: + if EFFECT_NIGHT in self._effect_list: + pipeline.night_light() + return + pipeline.on() # Set up transition. @@ -282,7 +299,7 @@ class LimitlessLEDGroup(Light): pipeline.flash(duration=duration) # Add effects. - if ATTR_EFFECT in kwargs and self._supported & SUPPORT_EFFECT: + if ATTR_EFFECT in kwargs and self._effect_list: if kwargs[ATTR_EFFECT] == EFFECT_COLORLOOP: from limitlessled.presets import COLORLOOP self.repeating = True From 28fec209e0ed065e552ca22d32e196128a667e63 Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 21 Feb 2018 08:09:14 +0100 Subject: [PATCH 142/173] Basic support of post 2016 AVR-X receivers (#12569) --- homeassistant/components/media_player/denonavr.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/denonavr.py b/homeassistant/components/media_player/denonavr.py index 2276603a910..5bc16d11d64 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_NAME, STATE_ON, CONF_ZONE, CONF_TIMEOUT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['denonavr==0.5.5'] +REQUIREMENTS = ['denonavr==0.6.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 5ec02f96b0d..5ebe822eaae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -219,7 +219,7 @@ defusedxml==0.5.0 deluge-client==1.0.5 # homeassistant.components.media_player.denonavr -denonavr==0.5.5 +denonavr==0.6.0 # homeassistant.components.media_player.directv directpy==0.2 From 8d0b7adf24aae1954b9bff4e6a93489bf1009a39 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Feb 2018 04:16:08 -0800 Subject: [PATCH 143/173] Fix config 404 (#12571) --- homeassistant/components/config/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 16b455684f3..39c35205619 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -159,7 +159,7 @@ class EditKeyBasedConfigView(BaseEditConfigView): def _get_value(self, hass, data, config_key): """Get value.""" - return data.get(config_key, {}) + return data.get(config_key) def _write_value(self, hass, data, config_key, new_value): """Set value.""" From f9ee29a5cd98270c82fca966f0b5ed7119b16815 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Wed, 21 Feb 2018 19:35:55 +0100 Subject: [PATCH 144/173] Logbook speedup (#12566) * Optimize logbook filtering * Avoid State construction during Events filtering * Move tuple creation out of loop * Add benchmark --- homeassistant/components/logbook.py | 48 +++++++++++--------- homeassistant/scripts/benchmark/__init__.py | 50 +++++++++++++++++++++ tests/components/test_logbook.py | 6 ++- 3 files changed, 82 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index b3477099918..e6e447884cb 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -170,6 +170,8 @@ def humanify(events): - if 2+ sensor updates in GROUP_BY_MINUTES, show last - if home assistant stop and start happen in same minute call it restarted """ + domain_prefixes = tuple('{}.'.format(dom) for dom in CONTINUOUS_DOMAINS) + # Group events in batches of GROUP_BY_MINUTES for _, g_events in groupby( events, @@ -189,11 +191,7 @@ def humanify(events): if event.event_type == EVENT_STATE_CHANGED: entity_id = event.data.get('entity_id') - if entity_id is None: - continue - - if entity_id.startswith(tuple('{}.'.format( - domain) for domain in CONTINUOUS_DOMAINS)): + if entity_id.startswith(domain_prefixes): last_sensor_event[entity_id] = event elif event.event_type == EVENT_HOMEASSISTANT_STOP: @@ -214,14 +212,6 @@ def humanify(events): to_state = State.from_dict(event.data.get('new_state')) - # If last_changed != last_updated only attributes have changed - # we do not report on that yet. Also filter auto groups. - if not to_state or \ - to_state.last_changed != to_state.last_updated or \ - to_state.domain == 'group' and \ - to_state.attributes.get('auto', False): - continue - domain = to_state.domain # Skip all but the last sensor state @@ -290,7 +280,7 @@ def _get_events(hass, config, start_day, end_day): def _exclude_events(events, config): - """Get lists of excluded entities and platforms.""" + """Get list of filtered events.""" excluded_entities = [] excluded_domains = [] included_entities = [] @@ -309,23 +299,41 @@ def _exclude_events(events, config): domain, entity_id = None, None if event.event_type == EVENT_STATE_CHANGED: - to_state = State.from_dict(event.data.get('new_state')) + entity_id = event.data.get('entity_id') + + if entity_id is None: + continue + # Do not report on new entities if event.data.get('old_state') is None: continue + new_state = event.data.get('new_state') + # Do not report on entity removal - if not to_state: + if not new_state: + continue + + attributes = new_state.get('attributes', {}) + + # If last_changed != last_updated only attributes have changed + # we do not report on that yet. + last_changed = new_state.get('last_changed') + last_updated = new_state.get('last_updated') + if last_changed != last_updated: + continue + + domain = split_entity_id(entity_id)[0] + + # Also filter auto groups. + if domain == 'group' and attributes.get('auto', False): continue # exclude entities which are customized hidden - hidden = to_state.attributes.get(ATTR_HIDDEN, False) + hidden = attributes.get(ATTR_HIDDEN, False) if hidden: continue - domain = to_state.domain - entity_id = to_state.entity_id - elif event.event_type == EVENT_LOGBOOK_ENTRY: domain = event.data.get(ATTR_DOMAIN) entity_id = event.data.get(ATTR_ENTITY_ID) diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index 470040b8295..834334b8a90 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -144,3 +144,53 @@ def async_million_state_changed_helper(hass): yield from event.wait() return timer() - start + + +@benchmark +@asyncio.coroutine +def logbook_filtering_state(hass): + """Filter state changes.""" + return _logbook_filtering(hass, 1, 1) + + +@benchmark +@asyncio.coroutine +def logbook_filtering_attributes(hass): + """Filter attribute changes.""" + return _logbook_filtering(hass, 1, 2) + + +@benchmark +@asyncio.coroutine +def _logbook_filtering(hass, last_changed, last_updated): + from homeassistant.components import logbook + + entity_id = 'test.entity' + + old_state = { + 'entity_id': entity_id, + 'state': 'off' + } + + new_state = { + 'entity_id': entity_id, + 'state': 'on', + 'last_updated': last_updated, + 'last_changed': last_changed + } + + event = core.Event(EVENT_STATE_CHANGED, { + 'entity_id': entity_id, + 'old_state': old_state, + 'new_state': new_state + }) + + events = [event] * 10**5 + + start = timer() + + # pylint: disable=protected-access + events = logbook._exclude_events(events, {}) + list(logbook.humanify(events)) + + return timer() - start diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index 472590ae13d..bd10416c7a2 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -372,7 +372,8 @@ class TestComponentLogbook(unittest.TestCase): eventB = self.create_state_changed_event(pointA, entity_id2, 20, {'auto': True}) - entries = list(logbook.humanify((eventA, eventB))) + events = logbook._exclude_events((eventA, eventB), {}) + entries = list(logbook.humanify(events)) self.assertEqual(1, len(entries)) self.assert_entry(entries[0], pointA, 'bla', domain='switch', @@ -389,7 +390,8 @@ class TestComponentLogbook(unittest.TestCase): eventB = self.create_state_changed_event( pointA, entity_id2, 20, last_changed=pointA, last_updated=pointB) - entries = list(logbook.humanify((eventA, eventB))) + events = logbook._exclude_events((eventA, eventB), {}) + entries = list(logbook.humanify(events)) self.assertEqual(1, len(entries)) self.assert_entry(entries[0], pointA, 'bla', domain='switch', From ed1a883b52dd3d94cfe773fc1d86c7086116a761 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Wed, 21 Feb 2018 21:11:14 +0100 Subject: [PATCH 145/173] Fix sonos default errorcodes (#12582) --- homeassistant/components/media_player/sonos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 0fbd88ffc54..d9236ae9a54 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -271,7 +271,7 @@ def soco_error(errorcodes=None): try: return funct(*args, **kwargs) except SoCoUPnPException as err: - if err.error_code in errorcodes: + if errorcodes and err.error_code in errorcodes: pass else: _LOGGER.error("Error on %s with %s", funct.__name__, err) From 6ce9be6b3afaf97c7205efe723f056aa87fb7225 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Feb 2018 12:45:10 -0800 Subject: [PATCH 146/173] Update frontend to 20180221.1 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index c18f266b025..2a9a7a8a38a 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20180221.0', 'user-agents==1.1.0'] +REQUIREMENTS = ['home-assistant-frontend==20180221.1', 'user-agents==1.1.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/requirements_all.txt b/requirements_all.txt index 5ebe822eaae..697b29836be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -356,7 +356,7 @@ hipnotify==1.0.8 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180221.0 +home-assistant-frontend==20180221.1 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 48cae775acb..1e443e3ad00 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -75,7 +75,7 @@ hbmqtt==0.9.1 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180221.0 +home-assistant-frontend==20180221.1 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From 03d6071a4585accfe74ed4792dec30c71c3a9f29 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Feb 2018 12:49:56 -0800 Subject: [PATCH 147/173] Update pychromecast to 2.0.0 (#12587) --- homeassistant/components/media_player/cast.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 928062cb2dc..f011e86ecf9 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -20,7 +20,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['pychromecast==1.0.3'] +REQUIREMENTS = ['pychromecast==2.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 697b29836be..8c5955179ff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -678,7 +678,7 @@ pybbox==0.0.5-alpha # pybluez==0.22 # homeassistant.components.media_player.cast -pychromecast==1.0.3 +pychromecast==2.0.0 # homeassistant.components.media_player.cmus pycmus==0.1.0 From 2d36d4d9f3e07bede9a6a657b10f2138a79124e3 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Wed, 21 Feb 2018 21:51:20 +0100 Subject: [PATCH 148/173] Set event_id foreign key in recorded states (#12580) --- homeassistant/components/recorder/__init__.py | 1 + tests/components/recorder/test_init.py | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 01d3f76bb77..53be6f33837 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -319,6 +319,7 @@ class Recorder(threading.Thread): with session_scope(session=self.get_session()) as session: dbevent = Events.from_event(event) session.add(dbevent) + session.flush() if event.event_type == EVENT_STATE_CHANGED: dbstate = States.from_event(event) diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 58b8dc1f839..191c0d6e733 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -42,6 +42,7 @@ class TestRecorder(unittest.TestCase): with session_scope(hass=self.hass) as session: db_states = list(session.query(States)) assert len(db_states) == 1 + assert db_states[0].event_id > 0 state = db_states[0].to_native() assert state == self.hass.states.get(entity_id) From b8df2d40429e6c73e5f5eaeb1cf38b62faa4679b Mon Sep 17 00:00:00 2001 From: Kane610 Date: Wed, 21 Feb 2018 21:52:05 +0100 Subject: [PATCH 149/173] Deconz support water sensor (#12581) * Add support for smoke detector in deconz * Support for water sensors in deconz --- homeassistant/components/deconz/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 20c8ff41e79..693f3e4470a 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -18,7 +18,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.json import load_json, save_json -REQUIREMENTS = ['pydeconz==29'] +REQUIREMENTS = ['pydeconz==30'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 8c5955179ff..9d498ef2749 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -697,7 +697,7 @@ pycsspeechtts==1.0.2 pydaikin==0.4 # homeassistant.components.deconz -pydeconz==29 +pydeconz==30 # homeassistant.components.zwave pydispatcher==2.0.5 From 51c06e35cfab15d936d29f5cc3bedfd8215862ff Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Feb 2018 12:55:33 -0800 Subject: [PATCH 150/173] Cloud reconnect tweaks (#12586) --- homeassistant/components/cloud/__init__.py | 2 +- homeassistant/components/cloud/iot.py | 106 +++++++++++---------- tests/components/cloud/test_iot.py | 12 +-- 3 files changed, 63 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 7de4f5b57f8..3657b64b989 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -219,7 +219,7 @@ class Cloud: # Fetching keyset can fail if internet is not up yet. if not success: - self.hass.helpers.async_call_later(5, self.async_start) + self.hass.helpers.event.async_call_later(5, self.async_start) return def load_config(): diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index 5f61263824b..3220fc372f7 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -44,20 +44,13 @@ class CloudIoT: @asyncio.coroutine def connect(self): """Connect to the IoT broker.""" + if self.state != STATE_DISCONNECTED: + raise RuntimeError('Connect called while not disconnected') + hass = self.cloud.hass - if self.cloud.subscription_expired: - # Try refreshing the token to see if it is still expired. - yield from hass.async_add_job(auth_api.check_token, self.cloud) - - if self.cloud.subscription_expired: - hass.components.persistent_notification.async_create( - MESSAGE_EXPIRATION, 'Subscription expired', - 'cloud_subscription_expired') - self.state = STATE_DISCONNECTED - return - - if self.state == STATE_CONNECTED: - raise RuntimeError('Already connected') + self.close_requested = False + self.state = STATE_CONNECTING + self.tries = 0 @asyncio.coroutine def _handle_hass_stop(event): @@ -66,17 +59,60 @@ class CloudIoT: remove_hass_stop_listener = None yield from self.disconnect() - self.state = STATE_CONNECTING - self.close_requested = False remove_hass_stop_listener = hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, _handle_hass_stop) + + while True: + try: + yield from self._handle_connection() + except Exception: # pylint: disable=broad-except + # Safety net. This should never hit. + # Still adding it here to make sure we can always reconnect + _LOGGER.exception("Unexpected error") + + if self.close_requested: + break + + self.state = STATE_CONNECTING + self.tries += 1 + + try: + # Sleep 0, 5, 10, 15 ... 30 seconds between retries + self.retry_task = hass.async_add_job(asyncio.sleep( + min(30, (self.tries - 1) * 5), loop=hass.loop)) + yield from self.retry_task + self.retry_task = None + except asyncio.CancelledError: + # Happens if disconnect called + break + + self.state = STATE_DISCONNECTED + if remove_hass_stop_listener is not None: + remove_hass_stop_listener() + + @asyncio.coroutine + def _handle_connection(self): + """Connect to the IoT broker.""" + hass = self.cloud.hass + + try: + yield from hass.async_add_job(auth_api.check_token, self.cloud) + except auth_api.CloudError as err: + _LOGGER.warning("Unable to connect: %s", err) + return + + if self.cloud.subscription_expired: + hass.components.persistent_notification.async_create( + MESSAGE_EXPIRATION, 'Subscription expired', + 'cloud_subscription_expired') + self.close_requested = True + return + session = async_get_clientsession(self.cloud.hass) client = None disconnect_warn = None try: - yield from hass.async_add_job(auth_api.check_token, self.cloud) - self.client = client = yield from session.ws_connect( self.cloud.relayer, heartbeat=55, headers={ hdrs.AUTHORIZATION: @@ -93,6 +129,10 @@ class CloudIoT: if msg.type in (WSMsgType.CLOSED, WSMsgType.CLOSING): break + elif msg.type == WSMsgType.ERROR: + disconnect_warn = 'Connection error' + break + elif msg.type != WSMsgType.TEXT: disconnect_warn = 'Received non-Text message: {}'.format( msg.type) @@ -129,9 +169,6 @@ class CloudIoT: _LOGGER.debug("Publishing message: %s", response) yield from client.send_json(response) - except auth_api.CloudError as err: - _LOGGER.warning("Unable to connect: %s", err) - except client_exceptions.WSServerHandshakeError as err: if err.code == 401: disconnect_warn = 'Invalid auth.' @@ -143,41 +180,12 @@ class CloudIoT: except client_exceptions.ClientError as err: _LOGGER.warning("Unable to connect: %s", err) - except Exception: # pylint: disable=broad-except - if not self.close_requested: - _LOGGER.exception("Unexpected error") - finally: if disconnect_warn is None: _LOGGER.info("Connection closed") else: _LOGGER.warning("Connection closed: %s", disconnect_warn) - if remove_hass_stop_listener is not None: - remove_hass_stop_listener() - - if client is not None: - self.client = None - yield from client.close() - - if self.close_requested: - self.state = STATE_DISCONNECTED - - else: - self.state = STATE_CONNECTING - self.tries += 1 - - try: - # Sleep 0, 5, 10, 15 ... up to 30 seconds between retries - self.retry_task = hass.async_add_job(asyncio.sleep( - min(30, (self.tries - 1) * 5), loop=hass.loop)) - yield from self.retry_task - self.retry_task = None - hass.async_add_job(self.connect()) - except asyncio.CancelledError: - # Happens if disconnect called - pass - @asyncio.coroutine def disconnect(self): """Disconnect the client.""" diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index ff382b697cf..3eec350b2cb 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -17,7 +17,8 @@ def mock_client(): client = MagicMock() type(client).closed = PropertyMock(side_effect=[False, True]) - with patch('asyncio.sleep'), \ + # Trigger cancelled error to avoid reconnect. + with patch('asyncio.sleep', side_effect=asyncio.CancelledError), \ patch('homeassistant.components.cloud.iot' '.async_get_clientsession') as session: session().ws_connect.return_value = mock_coro(client) @@ -160,10 +161,10 @@ def test_cloud_getting_disconnected_by_server(mock_client, caplog, mock_cloud): type=WSMsgType.CLOSING, )) - yield from conn.connect() + with patch('asyncio.sleep', side_effect=[None, asyncio.CancelledError]): + yield from conn.connect() assert 'Connection closed' in caplog.text - assert 'connect' in str(mock_cloud.hass.async_add_job.mock_calls[-1][1][0]) @asyncio.coroutine @@ -177,7 +178,6 @@ def test_cloud_receiving_bytes(mock_client, caplog, mock_cloud): yield from conn.connect() assert 'Connection closed: Received non-Text message' in caplog.text - assert 'connect' in str(mock_cloud.hass.async_add_job.mock_calls[-1][1][0]) @asyncio.coroutine @@ -192,19 +192,17 @@ def test_cloud_sending_invalid_json(mock_client, caplog, mock_cloud): yield from conn.connect() assert 'Connection closed: Received invalid JSON.' in caplog.text - assert 'connect' in str(mock_cloud.hass.async_add_job.mock_calls[-1][1][0]) @asyncio.coroutine def test_cloud_check_token_raising(mock_client, caplog, mock_cloud): """Test cloud unable to check token.""" conn = iot.CloudIoT(mock_cloud) - mock_client.receive.side_effect = auth_api.CloudError("BLA") + mock_cloud.hass.async_add_job.side_effect = auth_api.CloudError("BLA") yield from conn.connect() assert 'Unable to connect: BLA' in caplog.text - assert 'connect' in str(mock_cloud.hass.async_add_job.mock_calls[-1][1][0]) @asyncio.coroutine From b228695907efa589519a99a506d86f002c71ca89 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 21 Feb 2018 22:42:55 +0100 Subject: [PATCH 151/173] Hassio cleanup part2 (#12588) * Update handler.py * Update handler.py * Update __init__.py * Update handler.py * Update handler.py * Update __init__.py * Update tests --- homeassistant/components/hassio/__init__.py | 11 +- homeassistant/components/hassio/handler.py | 37 ++++ tests/components/hassio/__init__.py | 1 + tests/components/hassio/conftest.py | 12 +- tests/components/hassio/test_handler.py | 183 +++++++------------- tests/components/hassio/test_init.py | 145 ++++++++++++++++ 6 files changed, 260 insertions(+), 129 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 9b229bd7a85..540659273b3 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -126,8 +126,8 @@ def is_hassio(hass): @asyncio.coroutine def async_check_config(hass): """Check configuration over Hass.io API.""" - result = yield from hass.data[DOMAIN].send_command( - '/homeassistant/check', timeout=300) + hassio = hass.data[DOMAIN] + result = yield from hassio.check_homeassistant_config() if not result: return "Hass.io config check API error" @@ -197,8 +197,7 @@ def async_setup(hass, config): """Update last available Home Assistant version.""" data = yield from hassio.get_homeassistant_info() if data: - hass.data[DATA_HOMEASSISTANT_VERSION] = \ - data['data']['last_version'] + hass.data[DATA_HOMEASSISTANT_VERSION] = data['last_version'] hass.helpers.event.async_track_point_in_utc_time( update_homeassistant_version, utcnow() + HASSIO_UPDATE_INTERVAL) @@ -210,7 +209,7 @@ def async_setup(hass, config): def async_handle_core_service(call): """Service handler for handling core services.""" if call.service == SERVICE_HOMEASSISTANT_STOP: - yield from hassio.send_command('/homeassistant/stop') + yield from hassio.stop_homeassistant() return error = yield from async_check_config(hass) @@ -222,7 +221,7 @@ def async_setup(hass, config): return if call.service == SERVICE_HOMEASSISTANT_RESTART: - yield from hassio.send_command('/homeassistant/restart') + yield from hassio.restart_homeassistant() # Mock core services for service in (SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART, diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 125622f063c..a954aaccbd4 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -32,6 +32,19 @@ def _api_bool(funct): return _wrapper +def _api_data(funct): + """Return a api data.""" + @asyncio.coroutine + def _wrapper(*argv, **kwargs): + """Wrap function.""" + data = yield from funct(*argv, **kwargs) + if data and data['result'] == "ok": + return data['data'] + return None + + return _wrapper + + class HassIO(object): """Small API wrapper for Hass.io.""" @@ -49,6 +62,7 @@ class HassIO(object): """ return self.send_command("/supervisor/ping", method="get") + @_api_data def get_homeassistant_info(self): """Return data for Home Assistant. @@ -56,6 +70,29 @@ class HassIO(object): """ return self.send_command("/homeassistant/info", method="get") + @_api_bool + def restart_homeassistant(self): + """Restart Home-Assistant container. + + This method return a coroutine. + """ + return self.send_command("/homeassistant/restart") + + @_api_bool + def stop_homeassistant(self): + """Stop Home-Assistant container. + + This method return a coroutine. + """ + return self.send_command("/homeassistant/stop") + + def check_homeassistant_config(self): + """Check Home-Assistant config with Hass.io API. + + This method return a coroutine. + """ + return self.send_command("/homeassistant/check", timeout=300) + @_api_bool def update_hass_api(self, http_config): """Update Home Assistant API data on Hass.io. diff --git a/tests/components/hassio/__init__.py b/tests/components/hassio/__init__.py index 34fd4ad23e5..6fcd9d2229f 100644 --- a/tests/components/hassio/__init__.py +++ b/tests/components/hassio/__init__.py @@ -1,3 +1,4 @@ """Tests for Hassio component.""" API_PASSWORD = 'pass1234' +HASSIO_TOKEN = '123456' diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 852ec1aaa15..56d6cbe666e 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -5,9 +5,10 @@ from unittest.mock import patch, Mock import pytest from homeassistant.setup import async_setup_component +from homeassistant.components.hassio.handler import HassIO from tests.common import mock_coro -from . import API_PASSWORD +from . import API_PASSWORD, HASSIO_TOKEN @pytest.fixture @@ -38,3 +39,12 @@ def hassio_client(hassio_env, hass, test_client): } })) yield hass.loop.run_until_complete(test_client(hass.http.app)) + + +@pytest.fixture +def hassio_handler(hass, aioclient_mock): + """Create mock hassio handler.""" + websession = hass.helpers.aiohttp_client.async_get_clientsession() + + with patch.dict(os.environ, {'HASSIO_TOKEN': HASSIO_TOKEN}): + yield HassIO(hass.loop, websession, "127.0.0.1") diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index 39cfa689c59..78745489a78 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -1,151 +1,90 @@ """The tests for the hassio component.""" import asyncio -import os -from unittest.mock import patch -from homeassistant.setup import async_setup_component +import aiohttp @asyncio.coroutine -def test_setup_api_ping(hass, aioclient_mock): +def test_api_ping(hassio_handler, aioclient_mock): """Test setup with API ping.""" aioclient_mock.get( "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) - aioclient_mock.get( - "http://127.0.0.1/homeassistant/info", json={ - 'result': 'ok', 'data': {'last_version': '10.0'}}) - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): - result = yield from async_setup_component(hass, 'hassio', {}) - assert result - - assert aioclient_mock.call_count == 2 - assert hass.components.hassio.get_homeassistant_version() == "10.0" - assert hass.components.hassio.is_hassio() + assert (yield from hassio_handler.is_connected()) + assert aioclient_mock.call_count == 1 @asyncio.coroutine -def test_setup_api_push_api_data(hass, aioclient_mock): - """Test setup with API push.""" +def test_api_ping_error(hassio_handler, aioclient_mock): + """Test setup with API ping error.""" aioclient_mock.get( - "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + "http://127.0.0.1/supervisor/ping", json={'result': 'error'}) + + assert not (yield from hassio_handler.is_connected()) + assert aioclient_mock.call_count == 1 + + +@asyncio.coroutine +def test_api_ping_exeption(hassio_handler, aioclient_mock): + """Test setup with API ping exception.""" + aioclient_mock.get( + "http://127.0.0.1/supervisor/ping", exc=aiohttp.ClientError()) + + assert not (yield from hassio_handler.is_connected()) + assert aioclient_mock.call_count == 1 + + +@asyncio.coroutine +def test_api_homeassistant_info(hassio_handler, aioclient_mock): + """Test setup with API homeassistant info.""" aioclient_mock.get( "http://127.0.0.1/homeassistant/info", json={ 'result': 'ok', 'data': {'last_version': '10.0'}}) + + data = yield from hassio_handler.get_homeassistant_info() + assert aioclient_mock.call_count == 1 + assert data['last_version'] == "10.0" + + +@asyncio.coroutine +def test_api_homeassistant_info_error(hassio_handler, aioclient_mock): + """Test setup with API homeassistant info error.""" + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={ + 'result': 'error', 'message': None}) + + data = yield from hassio_handler.get_homeassistant_info() + assert aioclient_mock.call_count == 1 + assert data is None + + +@asyncio.coroutine +def test_api_homeassistant_stop(hassio_handler, aioclient_mock): + """Test setup with API HomeAssistant stop.""" aioclient_mock.post( - "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) + "http://127.0.0.1/homeassistant/stop", json={'result': 'ok'}) - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): - result = yield from async_setup_component(hass, 'hassio', { - 'http': { - 'api_password': "123456", - 'server_port': 9999 - }, - 'hassio': {} - }) - assert result - - assert aioclient_mock.call_count == 3 - assert not aioclient_mock.mock_calls[1][2]['ssl'] - assert aioclient_mock.mock_calls[1][2]['password'] == "123456" - assert aioclient_mock.mock_calls[1][2]['port'] == 9999 - assert aioclient_mock.mock_calls[1][2]['watchdog'] + assert (yield from hassio_handler.stop_homeassistant()) + assert aioclient_mock.call_count == 1 @asyncio.coroutine -def test_setup_api_push_api_data_server_host(hass, aioclient_mock): - """Test setup with API push with active server host.""" - aioclient_mock.get( - "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) - aioclient_mock.get( - "http://127.0.0.1/homeassistant/info", json={ - 'result': 'ok', 'data': {'last_version': '10.0'}}) +def test_api_homeassistant_restart(hassio_handler, aioclient_mock): + """Test setup with API HomeAssistant restart.""" aioclient_mock.post( - "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) + "http://127.0.0.1/homeassistant/restart", json={'result': 'ok'}) - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): - result = yield from async_setup_component(hass, 'hassio', { - 'http': { - 'api_password': "123456", - 'server_port': 9999, - 'server_host': "127.0.0.1" - }, - 'hassio': {} - }) - assert result - - assert aioclient_mock.call_count == 3 - assert not aioclient_mock.mock_calls[1][2]['ssl'] - assert aioclient_mock.mock_calls[1][2]['password'] == "123456" - assert aioclient_mock.mock_calls[1][2]['port'] == 9999 - assert not aioclient_mock.mock_calls[1][2]['watchdog'] + assert (yield from hassio_handler.restart_homeassistant()) + assert aioclient_mock.call_count == 1 @asyncio.coroutine -def test_setup_api_push_api_data_default(hass, aioclient_mock): - """Test setup with API push default data.""" - aioclient_mock.get( - "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) - aioclient_mock.get( - "http://127.0.0.1/homeassistant/info", json={ - 'result': 'ok', 'data': {'last_version': '10.0'}}) +def test_api_homeassistant_config(hassio_handler, aioclient_mock): + """Test setup with API HomeAssistant restart.""" aioclient_mock.post( - "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) + "http://127.0.0.1/homeassistant/check", json={ + 'result': 'ok', 'data': {'test': 'bla'}}) - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): - result = yield from async_setup_component(hass, 'hassio', { - 'http': {}, - 'hassio': {} - }) - assert result - - assert aioclient_mock.call_count == 3 - assert not aioclient_mock.mock_calls[1][2]['ssl'] - assert aioclient_mock.mock_calls[1][2]['password'] is None - assert aioclient_mock.mock_calls[1][2]['port'] == 8123 - - -@asyncio.coroutine -def test_setup_core_push_timezone(hass, aioclient_mock): - """Test setup with API push default data.""" - aioclient_mock.get( - "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) - aioclient_mock.get( - "http://127.0.0.1/homeassistant/info", json={ - 'result': 'ok', 'data': {'last_version': '10.0'}}) - aioclient_mock.post( - "http://127.0.0.1/supervisor/options", json={'result': 'ok'}) - - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): - result = yield from async_setup_component(hass, 'hassio', { - 'hassio': {}, - 'homeassistant': { - 'time_zone': 'testzone', - }, - }) - assert result - - assert aioclient_mock.call_count == 3 - assert aioclient_mock.mock_calls[1][2]['timezone'] == "testzone" - - -@asyncio.coroutine -def test_setup_hassio_no_additional_data(hass, aioclient_mock): - """Test setup with API push default data.""" - aioclient_mock.get( - "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) - aioclient_mock.get( - "http://127.0.0.1/homeassistant/info", json={ - 'result': 'ok', 'data': {'last_version': '10.0'}}) - aioclient_mock.get( - "http://127.0.0.1/homeassistant/info", json={'result': 'ok'}) - - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}), \ - patch.dict(os.environ, {'HASSIO_TOKEN': "123456"}): - result = yield from async_setup_component(hass, 'hassio', { - 'hassio': {}, - }) - assert result - - assert aioclient_mock.call_count == 2 - assert aioclient_mock.mock_calls[-1][3]['X-HASSIO-KEY'] == "123456" + data = yield from hassio_handler.check_homeassistant_config() + assert data['data']['test'] == 'bla' + assert aioclient_mock.call_count == 1 diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 313623bc40d..e17419e7fd5 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -9,6 +9,151 @@ from homeassistant.components.hassio import async_check_config from tests.common import mock_coro +@asyncio.coroutine +def test_setup_api_ping(hass, aioclient_mock): + """Test setup with API ping.""" + aioclient_mock.get( + "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={ + 'result': 'ok', 'data': {'last_version': '10.0'}}) + + with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + result = yield from async_setup_component(hass, 'hassio', {}) + assert result + + assert aioclient_mock.call_count == 2 + assert hass.components.hassio.get_homeassistant_version() == "10.0" + assert hass.components.hassio.is_hassio() + + +@asyncio.coroutine +def test_setup_api_push_api_data(hass, aioclient_mock): + """Test setup with API push.""" + aioclient_mock.get( + "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={ + 'result': 'ok', 'data': {'last_version': '10.0'}}) + aioclient_mock.post( + "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) + + with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + result = yield from async_setup_component(hass, 'hassio', { + 'http': { + 'api_password': "123456", + 'server_port': 9999 + }, + 'hassio': {} + }) + assert result + + assert aioclient_mock.call_count == 3 + assert not aioclient_mock.mock_calls[1][2]['ssl'] + assert aioclient_mock.mock_calls[1][2]['password'] == "123456" + assert aioclient_mock.mock_calls[1][2]['port'] == 9999 + assert aioclient_mock.mock_calls[1][2]['watchdog'] + + +@asyncio.coroutine +def test_setup_api_push_api_data_server_host(hass, aioclient_mock): + """Test setup with API push with active server host.""" + aioclient_mock.get( + "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={ + 'result': 'ok', 'data': {'last_version': '10.0'}}) + aioclient_mock.post( + "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) + + with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + result = yield from async_setup_component(hass, 'hassio', { + 'http': { + 'api_password': "123456", + 'server_port': 9999, + 'server_host': "127.0.0.1" + }, + 'hassio': {} + }) + assert result + + assert aioclient_mock.call_count == 3 + assert not aioclient_mock.mock_calls[1][2]['ssl'] + assert aioclient_mock.mock_calls[1][2]['password'] == "123456" + assert aioclient_mock.mock_calls[1][2]['port'] == 9999 + assert not aioclient_mock.mock_calls[1][2]['watchdog'] + + +@asyncio.coroutine +def test_setup_api_push_api_data_default(hass, aioclient_mock): + """Test setup with API push default data.""" + aioclient_mock.get( + "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={ + 'result': 'ok', 'data': {'last_version': '10.0'}}) + aioclient_mock.post( + "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) + + with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + result = yield from async_setup_component(hass, 'hassio', { + 'http': {}, + 'hassio': {} + }) + assert result + + assert aioclient_mock.call_count == 3 + assert not aioclient_mock.mock_calls[1][2]['ssl'] + assert aioclient_mock.mock_calls[1][2]['password'] is None + assert aioclient_mock.mock_calls[1][2]['port'] == 8123 + + +@asyncio.coroutine +def test_setup_core_push_timezone(hass, aioclient_mock): + """Test setup with API push default data.""" + aioclient_mock.get( + "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={ + 'result': 'ok', 'data': {'last_version': '10.0'}}) + aioclient_mock.post( + "http://127.0.0.1/supervisor/options", json={'result': 'ok'}) + + with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + result = yield from async_setup_component(hass, 'hassio', { + 'hassio': {}, + 'homeassistant': { + 'time_zone': 'testzone', + }, + }) + assert result + + assert aioclient_mock.call_count == 3 + assert aioclient_mock.mock_calls[1][2]['timezone'] == "testzone" + + +@asyncio.coroutine +def test_setup_hassio_no_additional_data(hass, aioclient_mock): + """Test setup with API push default data.""" + aioclient_mock.get( + "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={ + 'result': 'ok', 'data': {'last_version': '10.0'}}) + aioclient_mock.get( + "http://127.0.0.1/homeassistant/info", json={'result': 'ok'}) + + with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}), \ + patch.dict(os.environ, {'HASSIO_TOKEN': "123456"}): + result = yield from async_setup_component(hass, 'hassio', { + 'hassio': {}, + }) + assert result + + assert aioclient_mock.call_count == 2 + assert aioclient_mock.mock_calls[-1][3]['X-HASSIO-KEY'] == "123456" + + @asyncio.coroutine def test_fail_setup_without_environ_var(hass): """Fail setup if no environ variable set.""" From c6480e46c45cc3ffa2b10bfcc74cc35c9faa162f Mon Sep 17 00:00:00 2001 From: matthewcky2k Date: Wed, 21 Feb 2018 22:03:10 +0000 Subject: [PATCH 152/173] Add Bluetooth and NFC card/tag Alarm types (#12151) * Add Bluetooth and NFC card/tag Alarm types * removed white space --- homeassistant/components/lock/zwave.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lock/zwave.py b/homeassistant/components/lock/zwave.py index c0560722966..8f39d440cae 100644 --- a/homeassistant/components/lock/zwave.py +++ b/homeassistant/components/lock/zwave.py @@ -49,6 +49,7 @@ LOCK_NOTIFICATION = { LOCK_ALARM_TYPE = { '9': 'Deadbolt Jammed', + '16': 'Unlocked by Bluetooth ', '18': 'Locked with Keypad by user ', '19': 'Unlocked with Keypad by user ', '21': 'Manually Locked ', @@ -60,6 +61,7 @@ LOCK_ALARM_TYPE = { '112': 'Master code changed or User added: ', '113': 'Duplicate Pin-code: ', '130': 'RF module, power restored', + '144': 'Unlocked by NFC Tag or Card by user ', '161': 'Tamper Alarm: ', '167': 'Low Battery', '168': 'Critical Battery Level', @@ -98,7 +100,8 @@ ALARM_TYPE_STD = [ '19', '33', '112', - '113' + '113', + '144' ] SET_USERCODE_SCHEMA = vol.Schema({ From 184a54cc58393b1edd5065873684a2799b150b51 Mon Sep 17 00:00:00 2001 From: Greg Laabs Date: Wed, 21 Feb 2018 22:20:40 -0800 Subject: [PATCH 153/173] Fix fix isy994 fan detection (#12595) * Fixed 3 small issues in isy994 component 1. FanLincs have two nodes: one light and one fan motor. In order for each node to get detected as different Hass entity types, I removed the device-type check for FanLinc. The logic will now fall back on the uom checks which should work just fine. (An alternative approach here would be to special case FanLincs and handle them directly - but seeing as the newer 5.x ISY firmware already handles this much better using NodeDefs, I think this quick and dirty approach is fine for the older firmware.) Fixes #12030 2. Some non-dimming switches were appearing as `light`s in Hass due to an duplicate NodeDef being in the light domain filter. Removed! Fixes #12340 3. The `unqiue_id` property was throwing an error for certain entity types that don't have an `_id` property from the ISY. This issue has always been present, but was exposed by the entity registry which seems to be the first thing to actually try reading the `unique_id` property from the isy994 component. * Fix ISY994 fan detection ISY reports "med" in the uom, not "medium" * Add special-case for FanLincs so the light node is detected properly * Re-add insteon-type filter for fans, which dropped in a merge error --- homeassistant/components/isy994.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py index fbdf6e48143..48a9499d1a9 100644 --- a/homeassistant/components/isy994.py +++ b/homeassistant/components/isy994.py @@ -83,9 +83,9 @@ NODE_FILTERS = { }, 'fan': { 'uom': [], - 'states': ['off', 'low', 'medium', 'high'], + 'states': ['off', 'low', 'med', 'high'], 'node_def_id': ['FanLincMotor'], - 'insteon_type': [] + 'insteon_type': ['1.46.'] }, 'cover': { 'uom': ['97'], @@ -173,6 +173,14 @@ def _check_for_insteon_type(hass: HomeAssistant, node, for domain in domains: if any([device_type.startswith(t) for t in set(NODE_FILTERS[domain]['insteon_type'])]): + + # Hacky special-case just for FanLinc, which has a light module + # as one of its nodes. Note that this special-case is not necessary + # on ISY 5.x firmware as it uses the superior NodeDefs method + if domain == 'fan' and int(node.nid[-1]) == 1: + hass.data[ISY994_NODES]['light'].append(node) + return True + hass.data[ISY994_NODES][domain].append(node) return True From 4d7fb2c7de3a6899c75ff7cd753b7dab25885b62 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 22 Feb 2018 07:21:07 +0000 Subject: [PATCH 154/173] Adds folder sensor (#12208) * Adds folder sensor The state of the sensor is the time that the most recently modified file in a folder was modified. * Address lint errors * Edit docstrings Makes the recommended edits to docstrings * Update .coveragerc Add sensor/folder.py * Update folder.py * Address requests Address requests changes * Adds folder * Adds test, tidy up * Tidy * Update test_folder.py * Update folder.py * Fix setup Fix setup with else statement * Update folder.py * Update folder.py * Remove list of files from attributes * Update test_folder.py * Update folder.py * Update test_folder.py * Update folder.py * Update folder.py --- .coveragerc | 1 + homeassistant/components/sensor/folder.py | 108 ++++++++++++++++++++++ tests/components/sensor/test_folder.py | 64 +++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 homeassistant/components/sensor/folder.py create mode 100644 tests/components/sensor/test_folder.py diff --git a/.coveragerc b/.coveragerc index 563ea2b5387..6c34f4d95ed 100644 --- a/.coveragerc +++ b/.coveragerc @@ -564,6 +564,7 @@ omit = homeassistant/components/sensor/filesize.py homeassistant/components/sensor/fitbit.py homeassistant/components/sensor/fixer.py + homeassistant/components/sensor/folder.py homeassistant/components/sensor/fritzbox_callmonitor.py homeassistant/components/sensor/fritzbox_netmonitor.py homeassistant/components/sensor/gearbest.py diff --git a/homeassistant/components/sensor/folder.py b/homeassistant/components/sensor/folder.py new file mode 100644 index 00000000000..bd3957a36ca --- /dev/null +++ b/homeassistant/components/sensor/folder.py @@ -0,0 +1,108 @@ +""" +Sensor for monitoring the contents of a folder. + +For more details about this platform, refer to the documentation at +https://home-assistant.io/components/sensor.folder/ +""" +from datetime import timedelta +import glob +import logging +import os + +import voluptuous as vol + +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA + +_LOGGER = logging.getLogger(__name__) + +CONF_FOLDER_PATHS = 'folder' +CONF_FILTER = 'filter' +DEFAULT_FILTER = '*' + +SCAN_INTERVAL = timedelta(seconds=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_FOLDER_PATHS): cv.isdir, + vol.Optional(CONF_FILTER, default=DEFAULT_FILTER): cv.string, +}) + + +def get_files_list(folder_path, filter_term): + """Return the list of files, applying filter.""" + query = folder_path + filter_term + files_list = glob.glob(query) + return files_list + + +def get_size(files_list): + """Return the sum of the size in bytes of files in the list.""" + size_list = [os.stat(f).st_size for f in files_list] + return sum(size_list) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the folder sensor.""" + path = config.get(CONF_FOLDER_PATHS) + + if not hass.config.is_allowed_path(path): + _LOGGER.error("folder %s is not valid or allowed", path) + else: + folder = Folder(path, config.get(CONF_FILTER)) + add_devices([folder], True) + + +class Folder(Entity): + """Representation of a folder.""" + + ICON = 'mdi:folder' + + def __init__(self, folder_path, filter_term): + """Initialize the data object.""" + folder_path = os.path.join(folder_path, '') # If no trailing / add it + self._folder_path = folder_path # Need to check its a valid path + self._filter_term = filter_term + self._number_of_files = None + self._size = None + self._name = os.path.split(os.path.split(folder_path)[0])[1] + self._unit_of_measurement = 'MB' + + def update(self): + """Update the sensor.""" + files_list = get_files_list(self._folder_path, self._filter_term) + self._number_of_files = len(files_list) + self._size = get_size(files_list) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + decimals = 2 + size_mb = round(self._size/1e6, decimals) + return size_mb + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self.ICON + + @property + def device_state_attributes(self): + """Return other details about the sensor state.""" + attr = { + 'path': self._folder_path, + 'filter': self._filter_term, + 'number_of_files': self._number_of_files, + 'bytes': self._size, + } + return attr + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement diff --git a/tests/components/sensor/test_folder.py b/tests/components/sensor/test_folder.py new file mode 100644 index 00000000000..85ae8a688e7 --- /dev/null +++ b/tests/components/sensor/test_folder.py @@ -0,0 +1,64 @@ +"""The tests for the folder sensor.""" +import unittest +import os + +from homeassistant.components.sensor.folder import CONF_FOLDER_PATHS +from homeassistant.setup import setup_component +from tests.common import get_test_home_assistant + + +CWD = os.path.join(os.path.dirname(__file__)) +TEST_FOLDER = 'test_folder' +TEST_DIR = os.path.join(CWD, TEST_FOLDER) +TEST_TXT = 'mock_test_folder.txt' +TEST_FILE = os.path.join(TEST_DIR, TEST_TXT) + + +def create_file(path): + """Create a test file.""" + with open(path, 'w') as test_file: + test_file.write("test") + + +class TestFolderSensor(unittest.TestCase): + """Test the filesize sensor.""" + + def setup_method(self, method): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + if not os.path.isdir(TEST_DIR): + os.mkdir(TEST_DIR) + self.hass.config.whitelist_external_dirs = set((TEST_DIR)) + + def teardown_method(self, method): + """Stop everything that was started.""" + if os.path.isfile(TEST_FILE): + os.remove(TEST_FILE) + os.rmdir(TEST_DIR) + self.hass.stop() + + def test_invalid_path(self): + """Test that an invalid path is caught.""" + config = { + 'sensor': { + 'platform': 'folder', + CONF_FOLDER_PATHS: 'invalid_path'} + } + self.assertTrue( + setup_component(self.hass, 'sensor', config)) + assert len(self.hass.states.entity_ids()) == 0 + + def test_valid_path(self): + """Test for a valid path.""" + create_file(TEST_FILE) + config = { + 'sensor': { + 'platform': 'folder', + CONF_FOLDER_PATHS: TEST_DIR} + } + self.assertTrue( + setup_component(self.hass, 'sensor', config)) + assert len(self.hass.states.entity_ids()) == 1 + state = self.hass.states.get('sensor.test_folder') + assert state.state == '0.0' + assert state.attributes.get('number_of_files') == 1 From 2a4971dec714e5b6805c84fdde8dfc8184294666 Mon Sep 17 00:00:00 2001 From: Lukas Barth Date: Thu, 22 Feb 2018 08:36:39 +0100 Subject: [PATCH 155/173] Add unique_id to Xiaomi Aqara (#12372) * Add unique_id to Xiaomi Aqara * Slugify the unique ID * Use the domain instead of a separate string * Add underscore * Remove unique ID from attributes * Re-add removed attributes * Remove domain from the unique ID * Use type or data key * Also make sure that the data key is not None * Yes, it does have that member, if that check passes. Thanks pylint. --- homeassistant/components/xiaomi_aqara.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara.py b/homeassistant/components/xiaomi_aqara.py index 9b108b2b47f..e5942f97139 100644 --- a/homeassistant/components/xiaomi_aqara.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -21,6 +21,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow +from homeassistant.util import slugify REQUIREMENTS = ['PyXiaomiGateway==0.8.1'] @@ -203,12 +204,13 @@ def setup(hass, config): class XiaomiDevice(Entity): """Representation a base Xiaomi device.""" - def __init__(self, device, name, xiaomi_hub): + def __init__(self, device, device_type, xiaomi_hub): """Initialize the Xiaomi device.""" self._state = None self._is_available = True self._sid = device['sid'] - self._name = '{}_{}'.format(name, self._sid) + self._name = '{}_{}'.format(device_type, self._sid) + self._type = device_type self._write_to_hub = xiaomi_hub.write_to_hub self._get_from_hub = xiaomi_hub.get_from_hub self._device_state_attributes = {} @@ -217,6 +219,14 @@ class XiaomiDevice(Entity): self.parse_data(device['data'], device['raw_data']) self.parse_voltage(device['data']) + if hasattr(self, '_data_key') \ + and self._data_key: # pylint: disable=no-member + self._unique_id = slugify("{}-{}".format( + self._data_key, # pylint: disable=no-member + self._sid)) + else: + self._unique_id = slugify("{}-{}".format(self._type, self._sid)) + def _add_push_data_job(self, *args): self.hass.add_job(self.push_data, *args) @@ -230,6 +240,11 @@ class XiaomiDevice(Entity): """Return the name of the device.""" return self._name + @property + def unique_id(self) -> str: + """Return an unique ID.""" + return self._unique_id + @property def available(self): """Return True if entity is available.""" From de72eb8fe9642d689c5ecef966fa987999142cbe Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Feb 2018 23:42:23 -0800 Subject: [PATCH 156/173] Make groups entities again (#12574) --- homeassistant/components/alexa/smart_home.py | 23 +++++--------------- tests/components/alexa/test_smart_home.py | 10 +++------ 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index b2f8146bfcf..0d325534266 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -391,6 +391,7 @@ class _AlexaTemperatureSensor(_AlexaInterface): @ENTITY_ADAPTERS.register(alert.DOMAIN) @ENTITY_ADAPTERS.register(automation.DOMAIN) +@ENTITY_ADAPTERS.register(group.DOMAIN) @ENTITY_ADAPTERS.register(input_boolean.DOMAIN) class _GenericCapabilities(_AlexaEntity): """A generic, on/off device. @@ -521,16 +522,6 @@ class _ScriptCapabilities(_AlexaEntity): supports_deactivation=can_cancel)] -@ENTITY_ADAPTERS.register(group.DOMAIN) -class _GroupCapabilities(_AlexaEntity): - def default_display_categories(self): - return [_DisplayCategory.SCENE_TRIGGER] - - def interfaces(self): - return [_AlexaSceneController(self.entity, - supports_deactivation=True)] - - @ENTITY_ADAPTERS.register(sensor.DOMAIN) class _SensorCapabilities(_AlexaEntity): def default_display_categories(self): @@ -773,6 +764,8 @@ def extract_entity(funct): def async_api_turn_on(hass, config, request, entity): """Process a turn on request.""" domain = entity.domain + if entity.domain == group.DOMAIN: + domain = ha.DOMAIN service = SERVICE_TURN_ON if entity.domain == cover.DOMAIN: @@ -928,10 +921,7 @@ def async_api_increase_color_temp(hass, config, request, entity): @asyncio.coroutine def async_api_activate(hass, config, request, entity): """Process an activate request.""" - if entity.domain == group.DOMAIN: - domain = ha.DOMAIN - else: - domain = entity.domain + domain = entity.domain yield from hass.services.async_call(domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: entity.entity_id @@ -955,10 +945,7 @@ def async_api_activate(hass, config, request, entity): @asyncio.coroutine def async_api_deactivate(hass, config, request, entity): """Process a deactivate request.""" - if entity.domain == group.DOMAIN: - domain = ha.DOMAIN - else: - domain = entity.domain + domain = entity.domain yield from hass.services.async_call(domain, SERVICE_TURN_OFF, { ATTR_ENTITY_ID: entity.entity_id diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index ca49950e2a1..8de4d0d9aff 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -569,15 +569,11 @@ def test_group(hass): appliance = yield from discovery_test(device, hass) assert appliance['endpointId'] == 'group#test' - assert appliance['displayCategories'][0] == "SCENE_TRIGGER" + assert appliance['displayCategories'][0] == "OTHER" assert appliance['friendlyName'] == "Test group" + assert_endpoint_capabilities(appliance, 'Alexa.PowerController') - (capability,) = assert_endpoint_capabilities( - appliance, - 'Alexa.SceneController') - assert capability['supportsDeactivation'] - - yield from assert_scene_controller_works( + yield from assert_power_controller_works( 'group#test', 'homeassistant.turn_on', 'homeassistant.turn_off', From 4fdbbc497d59eacf863590fec535ec73f6fc0515 Mon Sep 17 00:00:00 2001 From: JC Connell Date: Thu, 22 Feb 2018 02:47:15 -0500 Subject: [PATCH 157/173] Python spotcrime (#12460) * Added python-spotcrime sensor * Added python-spotcrime sensor * Remove accidental included file * Update indentation to match PEP8 * Remove days from const.py * Changed default days to 1 * Incorporate changes requested by MartinHjelmare * Remove unnecessary import and fix lint error --- .coveragerc | 1 + homeassistant/components/sensor/spotcrime.py | 123 +++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 127 insertions(+) create mode 100644 homeassistant/components/sensor/spotcrime.py diff --git a/.coveragerc b/.coveragerc index 6c34f4d95ed..bd99e3ac2e2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -629,6 +629,7 @@ omit = homeassistant/components/sensor/sochain.py homeassistant/components/sensor/sonarr.py homeassistant/components/sensor/speedtest.py + homeassistant/components/sensor/spotcrime.py homeassistant/components/sensor/steam_online.py homeassistant/components/sensor/supervisord.py homeassistant/components/sensor/swiss_hydrological_data.py diff --git a/homeassistant/components/sensor/spotcrime.py b/homeassistant/components/sensor/spotcrime.py new file mode 100644 index 00000000000..169bcc5f867 --- /dev/null +++ b/homeassistant/components/sensor/spotcrime.py @@ -0,0 +1,123 @@ +""" +Sensor for Spot Crime. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.spotcrime/ +""" + +from datetime import timedelta +from collections import defaultdict +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_INCLUDE, CONF_EXCLUDE, CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, + ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_RADIUS) +from homeassistant.helpers.entity import Entity +from homeassistant.util import slugify +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['spotcrime==1.0.2'] + +_LOGGER = logging.getLogger(__name__) + +CONF_DAYS = 'days' +DEFAULT_DAYS = 1 +NAME = 'spotcrime' + +EVENT_INCIDENT = '{}_incident'.format(NAME) + +SCAN_INTERVAL = timedelta(minutes=30) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_RADIUS): vol.Coerce(float), + vol.Inclusive(CONF_LATITUDE, 'coordinates'): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, 'coordinates'): cv.longitude, + vol.Optional(CONF_DAYS, default=DEFAULT_DAYS): cv.positive_int, + vol.Optional(CONF_INCLUDE): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_EXCLUDE): vol.All(cv.ensure_list, [cv.string]) +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Crime Reports platform.""" + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + name = config[CONF_NAME] + radius = config[CONF_RADIUS] + days = config.get(CONF_DAYS) + include = config.get(CONF_INCLUDE) + exclude = config.get(CONF_EXCLUDE) + + add_devices([SpotCrimeSensor( + name, latitude, longitude, radius, include, + exclude, days)], True) + + +class SpotCrimeSensor(Entity): + """Representation of a Spot Crime Sensor.""" + + def __init__(self, name, latitude, longitude, radius, + include, exclude, days): + """Initialize the Spot Crime sensor.""" + import spotcrime + self._name = name + self._include = include + self._exclude = exclude + self.days = days + self._spotcrime = spotcrime.SpotCrime( + (latitude, longitude), radius, None, None, self.days) + self._attributes = None + self._state = None + self._previous_incidents = set() + self._attributes = { + ATTR_ATTRIBUTION: spotcrime.ATTRIBUTION + } + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + + def _incident_event(self, incident): + data = { + 'type': incident.get('type'), + 'timestamp': incident.get('timestamp'), + 'address': incident.get('location') + } + if incident.get('coordinates'): + data.update({ + ATTR_LATITUDE: incident.get('lat'), + ATTR_LONGITUDE: incident.get('lon') + }) + self.hass.bus.fire(EVENT_INCIDENT, data) + + def update(self): + """Update device state.""" + incident_counts = defaultdict(int) + incidents = self._spotcrime.get_incidents() + if len(incidents) < len(self._previous_incidents): + self._previous_incidents = set() + for incident in incidents: + incident_type = slugify(incident.get('type')) + incident_counts[incident_type] += 1 + if (self._previous_incidents and incident.get('id') + not in self._previous_incidents): + self._incident_event(incident) + self._previous_incidents.add(incident.get('id')) + self._attributes.update(incident_counts) + self._state = len(incidents) diff --git a/requirements_all.txt b/requirements_all.txt index 9d498ef2749..112b77846fa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1138,6 +1138,9 @@ somecomfort==0.5.0 # homeassistant.components.sensor.speedtest speedtest-cli==2.0.0 +# homeassistant.components.sensor.spotcrime +spotcrime==1.0.2 + # homeassistant.components.recorder # homeassistant.scripts.db_migrator # homeassistant.components.sensor.sql From f0a1beac5d9187452b04f7d76a73fba108a78cfa Mon Sep 17 00:00:00 2001 From: Mike Megally Date: Thu, 22 Feb 2018 02:19:18 -0800 Subject: [PATCH 158/173] Allow ignoring call service events in mqtt_eventstream (#12519) * [WIP] Allow ignoring call service events This allows a setting a configuration value (False by default to continue the current behavior) which will ignore call service events. * extra spaces removed them * updates from PR review * removed print * update spacing * updated allowed events to allow for custom events, and included some tests * hound fixes * Remove unused constant * Lint --- homeassistant/components/mqtt_eventstream.py | 7 +++ tests/components/test_mqtt_eventstream.py | 59 +++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt_eventstream.py b/homeassistant/components/mqtt_eventstream.py index 40a752807ed..6f6cb312f2b 100644 --- a/homeassistant/components/mqtt_eventstream.py +++ b/homeassistant/components/mqtt_eventstream.py @@ -26,6 +26,7 @@ DEPENDENCIES = ['mqtt'] CONF_PUBLISH_TOPIC = 'publish_topic' CONF_SUBSCRIBE_TOPIC = 'subscribe_topic' CONF_PUBLISH_EVENTSTREAM_RECEIVED = 'publish_eventstream_received' +CONF_IGNORE_EVENT = 'ignore_event' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -33,6 +34,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_SUBSCRIBE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_PUBLISH_EVENTSTREAM_RECEIVED, default=False): cv.boolean, + vol.Optional(CONF_IGNORE_EVENT, default=[]): cv.ensure_list }), }, extra=vol.ALLOW_EXTRA) @@ -44,6 +46,7 @@ def async_setup(hass, config): conf = config.get(DOMAIN, {}) pub_topic = conf.get(CONF_PUBLISH_TOPIC) sub_topic = conf.get(CONF_SUBSCRIBE_TOPIC) + ignore_event = conf.get(CONF_IGNORE_EVENT) @callback def _event_publisher(event): @@ -53,6 +56,10 @@ def async_setup(hass, config): if event.event_type == EVENT_TIME_CHANGED: return + # User-defined events to ignore + if event.event_type in ignore_event: + return + # Filter out the events that were triggered by publishing # to the MQTT topic, or you will end up in an infinite loop. if event.event_type == EVENT_CALL_SERVICE: diff --git a/tests/components/test_mqtt_eventstream.py b/tests/components/test_mqtt_eventstream.py index 91175024ea6..f4fc3e89ee0 100644 --- a/tests/components/test_mqtt_eventstream.py +++ b/tests/components/test_mqtt_eventstream.py @@ -30,13 +30,16 @@ class TestMqttEventStream(object): """Stop everything that was started.""" self.hass.stop() - def add_eventstream(self, sub_topic=None, pub_topic=None): + def add_eventstream(self, sub_topic=None, pub_topic=None, + ignore_event=None): """Add a mqtt_eventstream component.""" config = {} if sub_topic: config['subscribe_topic'] = sub_topic if pub_topic: config['publish_topic'] = pub_topic + if ignore_event: + config['ignore_event'] = ignore_event return setup_component(self.hass, eventstream.DOMAIN, { eventstream.DOMAIN: config}) @@ -144,3 +147,57 @@ class TestMqttEventStream(object): self.hass.block_till_done() assert 1 == len(calls) + + @patch('homeassistant.components.mqtt.async_publish') + def test_ignored_event_doesnt_send_over_stream(self, mock_pub): + """"Test the ignoring of sending events if defined.""" + assert self.add_eventstream(pub_topic='bar', + ignore_event=['state_changed']) + self.hass.block_till_done() + + e_id = 'entity.test_id' + event = {} + event['event_type'] = EVENT_STATE_CHANGED + new_state = { + "state": "on", + "entity_id": e_id, + "attributes": {}, + } + event['event_data'] = {"new_state": new_state, "entity_id": e_id} + + # Reset the mock because it will have already gotten calls for the + # mqtt_eventstream state change on initialization, etc. + mock_pub.reset_mock() + + # Set a state of an entity + mock_state_change_event(self.hass, State(e_id, 'on')) + self.hass.block_till_done() + + assert not mock_pub.called + + @patch('homeassistant.components.mqtt.async_publish') + def test_wrong_ignored_event_sends_over_stream(self, mock_pub): + """"Test the ignoring of sending events if defined.""" + assert self.add_eventstream(pub_topic='bar', + ignore_event=['statee_changed']) + self.hass.block_till_done() + + e_id = 'entity.test_id' + event = {} + event['event_type'] = EVENT_STATE_CHANGED + new_state = { + "state": "on", + "entity_id": e_id, + "attributes": {}, + } + event['event_data'] = {"new_state": new_state, "entity_id": e_id} + + # Reset the mock because it will have already gotten calls for the + # mqtt_eventstream state change on initialization, etc. + mock_pub.reset_mock() + + # Set a state of an entity + mock_state_change_event(self.hass, State(e_id, 'on')) + self.hass.block_till_done() + + assert mock_pub.called From 1af65f8f235bc9793659cf471f5a76a7b043273c Mon Sep 17 00:00:00 2001 From: Gerard Date: Thu, 22 Feb 2018 22:09:52 +0100 Subject: [PATCH 159/173] Component for Sony Bravia TV with Pre-Shared Key (#12464) --- .coveragerc | 1 + CODEOWNERS | 1 + .../components/media_player/braviatv_psk.py | 363 ++++++++++++++++++ requirements_all.txt | 3 + 4 files changed, 368 insertions(+) mode change 100644 => 100755 CODEOWNERS create mode 100755 homeassistant/components/media_player/braviatv_psk.py diff --git a/.coveragerc b/.coveragerc index bd99e3ac2e2..a1022dcb42e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -432,6 +432,7 @@ omit = homeassistant/components/media_player/aquostv.py homeassistant/components/media_player/bluesound.py homeassistant/components/media_player/braviatv.py + homeassistant/components/media_player/braviatv_psk.py homeassistant/components/media_player/cast.py homeassistant/components/media_player/clementine.py homeassistant/components/media_player/cmus.py diff --git a/CODEOWNERS b/CODEOWNERS old mode 100644 new mode 100755 index a5b5cfcb32c..f3ddfc3b3e6 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -54,6 +54,7 @@ homeassistant/components/device_tracker/tile.py @bachya homeassistant/components/history_graph.py @andrey-git homeassistant/components/light/tplink.py @rytilahti homeassistant/components/light/yeelight.py @rytilahti +homeassistant/components/media_player/braviatv_psk.py @gerard33 homeassistant/components/media_player/kodi.py @armills homeassistant/components/media_player/mediaroom.py @dgomes homeassistant/components/media_player/monoprice.py @etsinko diff --git a/homeassistant/components/media_player/braviatv_psk.py b/homeassistant/components/media_player/braviatv_psk.py new file mode 100755 index 00000000000..c78951e91b7 --- /dev/null +++ b/homeassistant/components/media_player/braviatv_psk.py @@ -0,0 +1,363 @@ +""" +Support for interface with a Sony Bravia TV. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.braviatv_psk/ +""" +import logging +import voluptuous as vol + +from homeassistant.components.media_player import ( + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_ON, + SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, SUPPORT_PLAY, + SUPPORT_VOLUME_SET, SUPPORT_SELECT_SOURCE, MediaPlayerDevice, + PLATFORM_SCHEMA, MEDIA_TYPE_TVSHOW, SUPPORT_STOP) +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_MAC, STATE_OFF, STATE_ON) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pySonyBraviaPSK==0.1.5'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_BRAVIA = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ + SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ + SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_STOP + +DEFAULT_NAME = 'Sony Bravia TV' + +# Config file +CONF_PSK = 'psk' +CONF_AMP = 'amp' +CONF_ANDROID = 'android' +CONF_SOURCE_FILTER = 'sourcefilter' + +# Some additional info to show specific for Sony Bravia TV +TV_WAIT = 'TV started, waiting for program info' +TV_APP_OPENED = 'App opened' +TV_NO_INFO = 'No info: TV resumed after pause' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PSK): cv.string, + vol.Optional(CONF_MAC, default=None): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_AMP, default=False): cv.boolean, + vol.Optional(CONF_ANDROID, default=True): cv.boolean, + vol.Optional(CONF_SOURCE_FILTER, default=[]): vol.All( + cv.ensure_list, [cv.string])}) + +# pylint: disable=unused-argument + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Sony Bravia TV platform.""" + host = config.get(CONF_HOST) + psk = config.get(CONF_PSK) + mac = config.get(CONF_MAC) + name = config.get(CONF_NAME) + amp = config.get(CONF_AMP) + android = config.get(CONF_ANDROID) + source_filter = config.get(CONF_SOURCE_FILTER) + + if host is None or psk is None: + _LOGGER.error( + "No TV IP address or Pre-Shared Key found in configuration file") + return + + add_devices( + [BraviaTVDevice(host, psk, mac, name, amp, android, source_filter)]) + + +class BraviaTVDevice(MediaPlayerDevice): + """Representation of a Sony Bravia TV.""" + + def __init__(self, host, psk, mac, name, amp, android, source_filter): + """Initialize the Sony Bravia device.""" + _LOGGER.info("Setting up Sony Bravia TV") + from braviapsk import sony_bravia_psk + + self._braviarc = sony_bravia_psk.BraviaRC(host, psk, mac) + self._name = name + self._amp = amp + self._android = android + self._source_filter = source_filter + self._state = STATE_OFF + self._muted = False + self._program_name = None + self._channel_name = None + self._channel_number = None + self._source = None + self._source_list = [] + self._original_content_list = [] + self._content_mapping = {} + self._duration = None + self._content_uri = None + self._id = None + self._playing = False + self._start_date_time = None + self._program_media_type = None + self._min_volume = None + self._max_volume = None + self._volume = None + self._start_time = None + self._end_time = None + + _LOGGER.debug( + "Set up Sony Bravia TV with IP: %s, PSK: %s, MAC: %s", host, psk, + mac) + + self.update() + + def update(self): + """Update TV info.""" + try: + power_status = self._braviarc.get_power_status() + if power_status == 'active': + self._state = STATE_ON + self._refresh_volume() + self._refresh_channels() + playing_info = self._braviarc.get_playing_info() + self._reset_playing_info() + if playing_info is None or not playing_info: + self._program_name = TV_NO_INFO + else: + self._program_name = playing_info.get('programTitle') + self._channel_name = playing_info.get('title') + self._program_media_type = playing_info.get( + 'programMediaType') + self._channel_number = playing_info.get('dispNum') + self._source = playing_info.get('source') + self._content_uri = playing_info.get('uri') + self._duration = playing_info.get('durationSec') + self._start_date_time = playing_info.get('startDateTime') + # Get time info from TV program + if self._start_date_time is not None and \ + self._duration is not None: + time_info = self._braviarc.playing_time( + self._start_date_time, self._duration) + self._start_time = time_info.get('start_time') + self._end_time = time_info.get('end_time') + else: + if self._program_name == TV_WAIT: + # TV is starting up, takes some time before it responds + _LOGGER.info("TV is starting, no info available yet") + else: + self._state = STATE_OFF + + except Exception as exception_instance: # pylint: disable=broad-except + _LOGGER.error( + "No data received from TV. Error message: %s", + exception_instance) + self._state = STATE_OFF + + def _reset_playing_info(self): + self._program_name = None + self._channel_name = None + self._program_media_type = None + self._channel_number = None + self._source = None + self._content_uri = None + self._duration = None + self._start_date_time = None + self._start_time = None + self._end_time = None + + def _refresh_volume(self): + """Refresh volume information.""" + volume_info = self._braviarc.get_volume_info() + if volume_info is not None: + self._volume = volume_info.get('volume') + self._min_volume = volume_info.get('minVolume') + self._max_volume = volume_info.get('maxVolume') + self._muted = volume_info.get('mute') + + def _refresh_channels(self): + if not self._source_list: + self._content_mapping = self._braviarc.load_source_list() + self._source_list = [] + if not self._source_filter: # list is empty + for key in self._content_mapping: + self._source_list.append(key) + else: + filtered_dict = {title: uri for (title, uri) in + self._content_mapping.items() + if any(filter_title in title for filter_title + in self._source_filter)} + for key in filtered_dict: + self._source_list.append(key) + + @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 source(self): + """Return the current input source.""" + return self._source + + @property + def source_list(self): + """List of available input sources.""" + return self._source_list + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + if self._volume is not None: + return self._volume / 100 + return None + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._muted + + @property + def supported_features(self): + """Flag media player features that are supported.""" + supported = SUPPORT_BRAVIA + # Remove volume slider if amplifier is attached to TV + if self._amp: + supported = supported ^ SUPPORT_VOLUME_SET + return supported + + @property + def media_content_type(self): + """Content type of current playing media. + + Used for program information below the channel in the state card. + """ + return MEDIA_TYPE_TVSHOW + + @property + def media_title(self): + """Title of current playing media. + + Used to show TV channel info. + """ + return_value = None + if self._channel_name is not None: + if self._channel_number is not None: + return_value = '{0!s}: {1}'.format( + self._channel_number.lstrip('0'), self._channel_name) + else: + return_value = self._channel_name + return return_value + + @property + def media_series_title(self): + """Title of series of current playing media, TV show only. + + Used to show TV program info. + """ + return_value = None + if self._program_name is not None: + if self._start_time is not None and self._end_time is not None: + return_value = '{0} [{1} - {2}]'.format( + self._program_name, self._start_time, self._end_time) + else: + return_value = self._program_name + else: + if not self._channel_name: # This is empty when app is opened + return_value = TV_APP_OPENED + return return_value + + @property + def media_content_id(self): + """Content ID of current playing media.""" + return self._channel_name + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + self._braviarc.set_volume_level(volume) + + def turn_on(self): + """Turn the media player on. + + Use a different command for Android as WOL is not working. + """ + if self._android: + self._braviarc.turn_on_command() + else: + self._braviarc.turn_on() + + # Show that TV is starting while it takes time + # before program info is available + self._reset_playing_info() + self._state = STATE_ON + self._program_name = TV_WAIT + + def turn_off(self): + """Turn off media player.""" + self._state = STATE_OFF + self._braviarc.turn_off() + + def volume_up(self): + """Volume up the media player.""" + self._braviarc.volume_up() + + def volume_down(self): + """Volume down media player.""" + self._braviarc.volume_down() + + def mute_volume(self, mute): + """Send mute command.""" + self._braviarc.mute_volume() + + def select_source(self, source): + """Set the input source.""" + if source in self._content_mapping: + uri = self._content_mapping[source] + self._braviarc.play_content(uri) + + 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._braviarc.media_play() + + def media_pause(self): + """Send media pause command to media player. + + Will pause TV when TV tuner is on. + """ + self._playing = False + if self._program_media_type == 'tv' or self._program_name is not None: + self._braviarc.media_tvpause() + else: + self._braviarc.media_pause() + + def media_next_track(self): + """Send next track command. + + Will switch to next channel when TV tuner is on. + """ + if self._program_media_type == 'tv' or self._program_name is not None: + self._braviarc.send_command('ChannelUp') + else: + self._braviarc.media_next_track() + + def media_previous_track(self): + """Send the previous track command. + + Will switch to previous channel when TV tuner is on. + """ + if self._program_media_type == 'tv' or self._program_name is not None: + self._braviarc.send_command('ChannelDown') + else: + self._braviarc.media_previous_track() diff --git a/requirements_all.txt b/requirements_all.txt index 112b77846fa..6742e842657 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -643,6 +643,9 @@ pyHS100==0.3.0 # homeassistant.components.rfxtrx pyRFXtrx==0.21.1 +# homeassistant.components.media_player.braviatv_psk +pySonyBraviaPSK==0.1.5 + # homeassistant.components.sensor.tibber pyTibber==0.2.1 From 87c69452f9bb4c17b2ac2ff2f32a65361de122b4 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Fri, 23 Feb 2018 00:21:36 +0100 Subject: [PATCH 160/173] Set speed service fixed. (#12602) --- homeassistant/components/fan/xiaomi_miio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index 2538e8fcd1f..264962b9d56 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -302,7 +302,7 @@ class XiaomiAirPurifier(FanEntity): yield from self._try_command( "Setting operation mode of the air purifier failed.", - self._air_purifier.set_mode, OperationMode[speed]) + self._air_purifier.set_mode, OperationMode(speed)) @asyncio.coroutine def async_set_buzzer_on(self): From ffd3889271398c95aaa54e0f4d7857858f74a887 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Fri, 23 Feb 2018 00:22:59 +0100 Subject: [PATCH 161/173] Updated script/lint (#12600) * Compare to common ancestor * Check if no file was changed --- script/lint | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/script/lint b/script/lint index 102dd84a407..b16b92a45b4 100755 --- a/script/lint +++ b/script/lint @@ -4,10 +4,14 @@ cd "$(dirname "$0")/.." if [ "$1" = "--changed" ]; then - export files="`git diff upstream/dev --name-only | grep -e '\.py$'`" + export files="`git diff upstream/dev... --name-only | grep -e '\.py$'`" echo "=================================================" - echo "FILES CHANGED (git diff upstream/dev --name-only)" + echo "FILES CHANGED (git diff upstream/dev... --name-only)" echo "=================================================" + if $files >/dev/null; then + echo "No python file changed" + exit + fi printf "%s\n" $files echo "================" echo "LINT with flake8" From f899ce8fbf769f8bd211120fa2aa1f000ca3ec1c Mon Sep 17 00:00:00 2001 From: Jeremy Klein Date: Thu, 22 Feb 2018 15:24:41 -0800 Subject: [PATCH 162/173] Adding RoomHinting to GoogleAssistant to allow for room annotations. (#12598) --- homeassistant/components/google_assistant/__init__.py | 5 +++-- homeassistant/components/google_assistant/const.py | 1 + homeassistant/components/google_assistant/smart_home.py | 8 +++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index aac258b4e93..20dee082a08 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -27,7 +27,7 @@ from .const import ( CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS, CONF_AGENT_USER_ID, CONF_API_KEY, SERVICE_REQUEST_SYNC, REQUEST_SYNC_BASE_URL, CONF_ENTITY_CONFIG, - CONF_EXPOSE, CONF_ALIASES + CONF_EXPOSE, CONF_ALIASES, CONF_ROOM_HINT ) from .auth import GoogleAssistantAuthView from .http import async_register_http @@ -43,7 +43,8 @@ ENTITY_SCHEMA = vol.Schema({ vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_TYPE): vol.In(MAPPING_COMPONENT), vol.Optional(CONF_EXPOSE): cv.boolean, - vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string]) + vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_ROOM_HINT): cv.string }) CONFIG_SCHEMA = vol.Schema( diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 0483f424ca3..1f1ae4682b4 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -13,6 +13,7 @@ CONF_CLIENT_ID = 'client_id' CONF_ALIASES = 'aliases' CONF_AGENT_USER_ID = 'agent_user_id' CONF_API_KEY = 'api_key' +CONF_ROOM_HINT = 'room' DEFAULT_EXPOSE_BY_DEFAULT = True DEFAULT_EXPOSED_DOMAINS = [ diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index a2444e46ec1..f638b847bcb 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -33,7 +33,8 @@ from .const import ( TRAIT_ONOFF, TRAIT_BRIGHTNESS, TRAIT_COLOR_TEMP, TRAIT_RGB_COLOR, TRAIT_SCENE, TRAIT_TEMPERATURE_SETTING, TYPE_LIGHT, TYPE_SCENE, TYPE_SWITCH, TYPE_THERMOSTAT, - CONF_ALIASES, CLIMATE_SUPPORTED_MODES, CLIMATE_MODE_HEATCOOL + CONF_ALIASES, CONF_ROOM_HINT, CLIMATE_SUPPORTED_MODES, + CLIMATE_MODE_HEATCOOL ) HANDLERS = Registry() @@ -124,6 +125,11 @@ def entity_to_device(entity: Entity, config: Config, units: UnitSystem): if aliases: device['name']['nicknames'] = aliases + # add room hint if annotated + room = entity_config.get(CONF_ROOM_HINT) + if room: + device['roomHint'] = room + # add trait if entity supports feature if class_data[2]: supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) From 7f5ca314ecf6e94cc5fb81d154237eb3f137191b Mon Sep 17 00:00:00 2001 From: kennedyshead Date: Fri, 23 Feb 2018 23:33:12 +0100 Subject: [PATCH 163/173] Fix mclimate accounts with not only melissa components (#12427) * Fixes for mclimate accounts with not only melissa components * Fixes melissa sensor to only use HVAC * Bumping version to 1.0.3 and remove OP_MODE that is not supported * Removes STATE_AUTO from translation and tests --- homeassistant/components/climate/melissa.py | 15 ++++++--------- homeassistant/components/melissa.py | 2 +- homeassistant/components/sensor/melissa.py | 5 +++-- requirements_all.txt | 2 +- tests/components/climate/test_melissa.py | 4 +--- 5 files changed, 12 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/climate/melissa.py b/homeassistant/components/climate/melissa.py index 96bd66d05a5..9c005b62dcc 100644 --- a/homeassistant/components/climate/melissa.py +++ b/homeassistant/components/climate/melissa.py @@ -26,7 +26,7 @@ SUPPORT_FLAGS = (SUPPORT_FAN_MODE | SUPPORT_OPERATION_MODE | SUPPORT_ON_OFF | SUPPORT_TARGET_TEMPERATURE) OP_MODES = [ - STATE_AUTO, STATE_COOL, STATE_DRY, STATE_FAN_ONLY, STATE_HEAT + STATE_COOL, STATE_DRY, STATE_FAN_ONLY, STATE_HEAT ] FAN_MODES = [ @@ -42,8 +42,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): all_devices = [] for device in devices: - all_devices.append(MelissaClimate( - api, device['serial_number'], device)) + if device['type'] == 'melissa': + all_devices.append(MelissaClimate( + api, device['serial_number'], device)) add_devices(all_devices) @@ -199,9 +200,7 @@ class MelissaClimate(ClimateDevice): def melissa_op_to_hass(self, mode): """Translate Melissa modes to hass states.""" - if mode == self._api.MODE_AUTO: - return STATE_AUTO - elif mode == self._api.MODE_HEAT: + if mode == self._api.MODE_HEAT: return STATE_HEAT elif mode == self._api.MODE_COOL: return STATE_COOL @@ -228,9 +227,7 @@ class MelissaClimate(ClimateDevice): def hass_mode_to_melissa(self, mode): """Translate hass states to melissa modes.""" - if mode == STATE_AUTO: - return self._api.MODE_AUTO - elif mode == STATE_HEAT: + if mode == STATE_HEAT: return self._api.MODE_HEAT elif mode == STATE_COOL: return self._api.MODE_COOL diff --git a/homeassistant/components/melissa.py b/homeassistant/components/melissa.py index ae82b96222e..f5a757dbcf3 100644 --- a/homeassistant/components/melissa.py +++ b/homeassistant/components/melissa.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_USERNAME, CONF_PASSWORD from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform -REQUIREMENTS = ["py-melissa-climate==1.0.1"] +REQUIREMENTS = ["py-melissa-climate==1.0.6"] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/melissa.py b/homeassistant/components/sensor/melissa.py index 58313428861..f67722b0198 100644 --- a/homeassistant/components/sensor/melissa.py +++ b/homeassistant/components/sensor/melissa.py @@ -22,8 +22,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = api.fetch_devices().values() for device in devices: - sensors.append(MelissaTemperatureSensor(device, api)) - sensors.append(MelissaHumiditySensor(device, api)) + if device['type'] == 'melissa': + sensors.append(MelissaTemperatureSensor(device, api)) + sensors.append(MelissaHumiditySensor(device, api)) add_devices(sensors) diff --git a/requirements_all.txt b/requirements_all.txt index 6742e842657..898df747067 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -628,7 +628,7 @@ py-canary==0.4.0 py-cpuinfo==3.3.0 # homeassistant.components.melissa -py-melissa-climate==1.0.1 +py-melissa-climate==1.0.6 # homeassistant.components.camera.synology py-synology==0.1.5 diff --git a/tests/components/climate/test_melissa.py b/tests/components/climate/test_melissa.py index 446eec9aba1..5022c556b7d 100644 --- a/tests/components/climate/test_melissa.py +++ b/tests/components/climate/test_melissa.py @@ -122,7 +122,7 @@ class TestMelissa(unittest.TestCase): def test_operation_list(self): """Test the operation list.""" self.assertEqual( - [STATE_AUTO, STATE_COOL, STATE_DRY, STATE_FAN_ONLY, STATE_HEAT], + [STATE_COOL, STATE_DRY, STATE_FAN_ONLY, STATE_HEAT], self.thermostat.operation_list ) @@ -226,7 +226,6 @@ class TestMelissa(unittest.TestCase): def test_melissa_op_to_hass(self): """Test for translate melissa operations to hass.""" - self.assertEqual(STATE_AUTO, self.thermostat.melissa_op_to_hass(0)) self.assertEqual(STATE_FAN_ONLY, self.thermostat.melissa_op_to_hass(1)) self.assertEqual(STATE_HEAT, self.thermostat.melissa_op_to_hass(2)) self.assertEqual(STATE_COOL, self.thermostat.melissa_op_to_hass(3)) @@ -245,7 +244,6 @@ class TestMelissa(unittest.TestCase): @mock.patch('homeassistant.components.climate.melissa._LOGGER.warning') def test_hass_mode_to_melissa(self, mocked_warning): """Test for hass operations to melssa.""" - self.assertEqual(0, self.thermostat.hass_mode_to_melissa(STATE_AUTO)) self.assertEqual( 1, self.thermostat.hass_mode_to_melissa(STATE_FAN_ONLY)) self.assertEqual(2, self.thermostat.hass_mode_to_melissa(STATE_HEAT)) From 2261ce30e3c010ce710c34fecc76eadf079e7b1b Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Fri, 23 Feb 2018 18:31:22 +0100 Subject: [PATCH 164/173] Cast unique_id and async discovery (#12474) * Cast unique_id and async discovery * Lazily load chromecasts * Lint * Fixes & Improvements * Fixes * Improve disconnects cast.disconnect with blocking=False does **not** do I/O; it simply sets an event for the socket client looper * Add tests * Remove unnecessary calls * Lint * Fix use of hass object --- homeassistant/components/media_player/cast.py | 240 +++++++++++---- tests/components/media_player/test_cast.py | 279 +++++++++++++----- 2 files changed, 391 insertions(+), 128 deletions(-) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index f011e86ecf9..a07ff74ccae 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -5,10 +5,16 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.cast/ """ # pylint: disable=import-error +import asyncio import logging +import threading import voluptuous as vol +from homeassistant.helpers.typing import HomeAssistantType, ConfigType +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import (dispatcher_send, + async_dispatcher_connect) from homeassistant.components.media_player import ( MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, @@ -16,7 +22,7 @@ from homeassistant.components.media_player import ( SUPPORT_STOP, SUPPORT_PLAY, MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.const import ( CONF_HOST, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, - STATE_UNKNOWN) + STATE_UNKNOWN, EVENT_HOMEASSISTANT_STOP) import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util @@ -33,7 +39,13 @@ SUPPORT_CAST = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ SUPPORT_NEXT_TRACK | SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_PLAY -KNOWN_HOSTS_KEY = 'cast_known_hosts' +INTERNAL_DISCOVERY_RUNNING_KEY = 'cast_discovery_running' +# UUID -> CastDevice mapping; cast devices without UUID are not stored +ADDED_CAST_DEVICES_KEY = 'cast_added_cast_devices' +# Stores every discovered (host, port, uuid) +KNOWN_CHROMECASTS_KEY = 'cast_all_chromecasts' + +SIGNAL_CAST_DISCOVERED = 'cast_discovered' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST): cv.string, @@ -41,67 +53,144 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -# pylint: disable=unused-argument -def setup_platform(hass, config, add_devices, discovery_info=None): +def _setup_internal_discovery(hass: HomeAssistantType) -> None: + """Set up the pychromecast internal discovery.""" + hass.data.setdefault(INTERNAL_DISCOVERY_RUNNING_KEY, threading.Lock()) + if not hass.data[INTERNAL_DISCOVERY_RUNNING_KEY].acquire(blocking=False): + # Internal discovery is already running + return + + import pychromecast + + def internal_callback(name): + """Called when zeroconf has discovered a new chromecast.""" + mdns = listener.services[name] + ip_address, port, uuid, _, _ = mdns + key = (ip_address, port, uuid) + + if key in hass.data[KNOWN_CHROMECASTS_KEY]: + _LOGGER.debug("Discovered previous chromecast %s", mdns) + return + + _LOGGER.debug("Discovered new chromecast %s", mdns) + try: + # pylint: disable=protected-access + chromecast = pychromecast._get_chromecast_from_host( + mdns, blocking=True) + except pychromecast.ChromecastConnectionError: + _LOGGER.debug("Can't set up cast with mDNS info %s. " + "Assuming it's not a Chromecast", mdns) + return + hass.data[KNOWN_CHROMECASTS_KEY][key] = chromecast + dispatcher_send(hass, SIGNAL_CAST_DISCOVERED, chromecast) + + _LOGGER.debug("Starting internal pychromecast discovery.") + listener, browser = pychromecast.start_discovery(internal_callback) + + def stop_discovery(event): + """Stop discovery of new chromecasts.""" + pychromecast.stop_discovery(browser) + hass.data[INTERNAL_DISCOVERY_RUNNING_KEY].release() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_discovery) + + +@callback +def _async_create_cast_device(hass, chromecast): + """Create a CastDevice Entity from the chromecast object. + + Returns None if the cast device has already been added. Additionally, + automatically updates existing chromecast entities. + """ + if chromecast.uuid is None: + # Found a cast without UUID, we don't store it because we won't be able + # to update it anyway. + return CastDevice(chromecast) + + # Found a cast with UUID + added_casts = hass.data[ADDED_CAST_DEVICES_KEY] + old_cast_device = added_casts.get(chromecast.uuid) + if old_cast_device is None: + # -> New cast device + cast_device = CastDevice(chromecast) + added_casts[chromecast.uuid] = cast_device + return cast_device + + old_key = (old_cast_device.cast.host, + old_cast_device.cast.port, + old_cast_device.cast.uuid) + new_key = (chromecast.host, chromecast.port, chromecast.uuid) + + if old_key == new_key: + # Re-discovered with same data, ignore + return None + + # -> Cast device changed host + # Remove old pychromecast.Chromecast from global list, because it isn't + # valid anymore + old_cast_device.async_set_chromecast(chromecast) + return None + + +@asyncio.coroutine +def async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_devices, discovery_info=None): """Set up the cast platform.""" import pychromecast # Import CEC IGNORE attributes pychromecast.IGNORE_CEC += config.get(CONF_IGNORE_CEC, []) + hass.data.setdefault(ADDED_CAST_DEVICES_KEY, {}) + hass.data.setdefault(KNOWN_CHROMECASTS_KEY, {}) - known_hosts = hass.data.get(KNOWN_HOSTS_KEY) - if known_hosts is None: - known_hosts = hass.data[KNOWN_HOSTS_KEY] = [] - + # None -> use discovery; (host, port) -> manually specify chromecast. + want_host = None if discovery_info: - host = (discovery_info.get('host'), discovery_info.get('port')) - - if host in known_hosts: - return - - hosts = [host] - + want_host = (discovery_info.get('host'), discovery_info.get('port')) elif CONF_HOST in config: - host = (config.get(CONF_HOST), DEFAULT_PORT) + want_host = (config.get(CONF_HOST), DEFAULT_PORT) - if host in known_hosts: - return + enable_discovery = False + if want_host is None: + # We were explicitly told to enable pychromecast discovery. + enable_discovery = True + elif want_host[1] != DEFAULT_PORT: + # We're trying to add a group, so we have to use pychromecast's + # discovery to get the correct friendly name. + enable_discovery = True - hosts = [host] + if enable_discovery: + @callback + def async_cast_discovered(chromecast): + """Callback for when a new chromecast is discovered.""" + if want_host is not None and \ + (chromecast.host, chromecast.port) != want_host: + return # for groups, only add requested device + cast_device = _async_create_cast_device(hass, chromecast) + if cast_device is not None: + async_add_devices([cast_device]) + + async_dispatcher_connect(hass, SIGNAL_CAST_DISCOVERED, + async_cast_discovered) + # Re-play the callback for all past chromecasts, store the objects in + # a list to avoid concurrent modification resulting in exception. + for chromecast in list(hass.data[KNOWN_CHROMECASTS_KEY].values()): + async_cast_discovered(chromecast) + + hass.async_add_job(_setup_internal_discovery, hass) else: - hosts = [tuple(dev[:2]) for dev in pychromecast.discover_chromecasts() - if tuple(dev[:2]) not in known_hosts] - - casts = [] - - # get_chromecasts() returns Chromecast objects with the correct friendly - # name for grouped devices - all_chromecasts = pychromecast.get_chromecasts() - - for host in hosts: - (_, port) = host - found = [device for device in all_chromecasts - if (device.host, device.port) == host] - if found: - try: - casts.append(CastDevice(found[0])) - known_hosts.append(host) - except pychromecast.ChromecastConnectionError: - pass - - # do not add groups using pychromecast.Chromecast as it leads to names - # collision since pychromecast.Chromecast will get device name instead - # of group name - elif port == DEFAULT_PORT: - try: - # add the device anyway, get_chromecasts couldn't find it - casts.append(CastDevice(pychromecast.Chromecast(*host))) - known_hosts.append(host) - except pychromecast.ChromecastConnectionError: - pass - - add_devices(casts) + # Manually add a "normal" Chromecast, we can do that without discovery. + try: + chromecast = pychromecast.Chromecast(*want_host) + except pychromecast.ChromecastConnectionError: + _LOGGER.warning("Can't set up chromecast on %s", want_host[0]) + raise + key = (chromecast.host, chromecast.port, chromecast.uuid) + cast_device = _async_create_cast_device(hass, chromecast) + if cast_device is not None: + hass.data[KNOWN_CHROMECASTS_KEY][key] = chromecast + async_add_devices([cast_device]) class CastDevice(MediaPlayerDevice): @@ -109,16 +198,13 @@ class CastDevice(MediaPlayerDevice): def __init__(self, chromecast): """Initialize the Cast device.""" - self.cast = chromecast - - self.cast.socket_client.receiver_controller.register_status_listener( - self) - self.cast.socket_client.media_controller.register_status_listener(self) - - self.cast_status = self.cast.status - self.media_status = self.cast.media_controller.status + self.cast = None # type: pychromecast.Chromecast + self.cast_status = None + self.media_status = None self.media_status_received = None + self.async_set_chromecast(chromecast) + @property def should_poll(self): """No polling needed.""" @@ -325,3 +411,39 @@ class CastDevice(MediaPlayerDevice): self.media_status = status self.media_status_received = dt_util.utcnow() self.schedule_update_ha_state() + + @property + def unique_id(self) -> str: + """Return an unique ID.""" + if self.cast.uuid is not None: + return str(self.cast.uuid) + return None + + @callback + def async_set_chromecast(self, chromecast): + """Set the internal Chromecast object and disconnect the previous.""" + self._async_disconnect() + + self.cast = chromecast + + self.cast.socket_client.receiver_controller.register_status_listener( + self) + self.cast.socket_client.media_controller.register_status_listener(self) + + self.cast_status = self.cast.status + self.media_status = self.cast.media_controller.status + + @asyncio.coroutine + def async_will_remove_from_hass(self): + """Disconnect Chromecast object when removed.""" + self._async_disconnect() + + @callback + def _async_disconnect(self): + """Disconnect Chromecast object if it is set.""" + if self.cast is None: + return + _LOGGER.debug("Disconnecting existing chromecast object") + old_key = (self.cast.host, self.cast.port, self.cast.uuid) + self.hass.data[KNOWN_CHROMECASTS_KEY].pop(old_key) + self.cast.disconnect(blocking=False) diff --git a/tests/components/media_player/test_cast.py b/tests/components/media_player/test_cast.py index 0bcfc9b9a1a..6eeb9136b07 100644 --- a/tests/components/media_player/test_cast.py +++ b/tests/components/media_player/test_cast.py @@ -1,12 +1,15 @@ """The tests for the Cast Media player platform.""" # pylint: disable=protected-access -import unittest -from unittest.mock import patch, MagicMock +import asyncio +from typing import Optional +from unittest.mock import patch, MagicMock, Mock +from uuid import UUID import pytest +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.components.media_player import cast -from tests.common import get_test_home_assistant @pytest.fixture(autouse=True) @@ -18,83 +21,221 @@ def cast_mock(): yield -class FakeChromeCast(object): - """A fake Chrome Cast.""" - - def __init__(self, host, port): - """Initialize the fake Chrome Cast.""" - self.host = host - self.port = port +# pylint: disable=invalid-name +FakeUUID = UUID('57355bce-9364-4aa6-ac1e-eb849dccf9e2') -class TestCastMediaPlayer(unittest.TestCase): - """Test the media_player module.""" +def get_fake_chromecast(host='192.168.178.42', port=8009, + uuid: Optional[UUID] = FakeUUID): + """Generate a Fake Chromecast object with the specified arguments.""" + return MagicMock(host=host, port=port, uuid=uuid) - def setUp(self): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() +@asyncio.coroutine +def async_setup_cast(hass, config=None, discovery_info=None): + """Helper to setup the cast platform.""" + if config is None: + config = {} + add_devices = Mock() - @patch('homeassistant.components.media_player.cast.CastDevice') - @patch('pychromecast.get_chromecasts') - def test_filter_duplicates(self, mock_get_chromecasts, mock_device): - """Test filtering of duplicates.""" - mock_get_chromecasts.return_value = [ - FakeChromeCast('some_host', cast.DEFAULT_PORT) - ] + yield from cast.async_setup_platform(hass, config, add_devices, + discovery_info=discovery_info) + yield from hass.async_block_till_done() - # Test chromecasts as if they were hardcoded in configuration.yaml - cast.setup_platform(self.hass, { - 'host': 'some_host' - }, lambda _: _) + return add_devices - assert mock_device.called - mock_device.reset_mock() - assert not mock_device.called +@asyncio.coroutine +def async_setup_cast_internal_discovery(hass, config=None, + discovery_info=None, + no_from_host_patch=False): + """Setup the cast platform and the discovery.""" + listener = MagicMock(services={}) - # Test chromecasts as if they were automatically discovered - cast.setup_platform(self.hass, {}, lambda _: _, { - 'host': 'some_host', - 'port': cast.DEFAULT_PORT, - }) - assert not mock_device.called + with patch('pychromecast.start_discovery', + return_value=(listener, None)) as start_discovery: + add_devices = yield from async_setup_cast(hass, config, discovery_info) + yield from hass.async_block_till_done() + yield from hass.async_block_till_done() - @patch('homeassistant.components.media_player.cast.CastDevice') - @patch('pychromecast.get_chromecasts') - @patch('pychromecast.Chromecast') - def test_fallback_cast(self, mock_chromecast, mock_get_chromecasts, - mock_device): - """Test falling back to creating Chromecast when not discovered.""" - mock_get_chromecasts.return_value = [ - FakeChromeCast('some_host', cast.DEFAULT_PORT) - ] + assert start_discovery.call_count == 1 - # Test chromecasts as if they were hardcoded in configuration.yaml - cast.setup_platform(self.hass, { - 'host': 'some_other_host' - }, lambda _: _) + discovery_callback = start_discovery.call_args[0][0] - assert mock_chromecast.called - assert mock_device.called + def discover_chromecast(service_name, chromecast): + """Discover a chromecast device.""" + listener.services[service_name] = ( + chromecast.host, chromecast.port, chromecast.uuid, None, None) + if no_from_host_patch: + discovery_callback(service_name) + else: + with patch('pychromecast._get_chromecast_from_host', + return_value=chromecast): + discovery_callback(service_name) - @patch('homeassistant.components.media_player.cast.CastDevice') - @patch('pychromecast.get_chromecasts') - @patch('pychromecast.Chromecast') - def test_fallback_cast_group(self, mock_chromecast, mock_get_chromecasts, - mock_device): - """Test not creating Cast Group when not discovered.""" - mock_get_chromecasts.return_value = [ - FakeChromeCast('some_host', cast.DEFAULT_PORT) - ] + return discover_chromecast, add_devices - # Test chromecasts as if they were automatically discovered - cast.setup_platform(self.hass, {}, lambda _: _, { - 'host': 'some_other_host', - 'port': 43546, - }) - assert not mock_chromecast.called - assert not mock_device.called + +@asyncio.coroutine +def test_start_discovery_called_once(hass): + """Test pychromecast.start_discovery called exactly once.""" + with patch('pychromecast.start_discovery', + return_value=(None, None)) as start_discovery: + yield from async_setup_cast(hass) + + assert start_discovery.call_count == 1 + + yield from async_setup_cast(hass) + assert start_discovery.call_count == 1 + + +@asyncio.coroutine +def test_stop_discovery_called_on_stop(hass): + """Test pychromecast.stop_discovery called on shutdown.""" + with patch('pychromecast.start_discovery', + return_value=(None, 'the-browser')) as start_discovery: + yield from async_setup_cast(hass) + + assert start_discovery.call_count == 1 + + with patch('pychromecast.stop_discovery') as stop_discovery: + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + yield from hass.async_block_till_done() + + stop_discovery.assert_called_once_with('the-browser') + + with patch('pychromecast.start_discovery', + return_value=(None, 'the-browser')) as start_discovery: + yield from async_setup_cast(hass) + + assert start_discovery.call_count == 1 + + +@asyncio.coroutine +def test_internal_discovery_callback_only_generates_once(hass): + """Test _get_chromecast_from_host only called once per device.""" + discover_cast, _ = yield from async_setup_cast_internal_discovery( + hass, no_from_host_patch=True) + chromecast = get_fake_chromecast() + + with patch('pychromecast._get_chromecast_from_host', + return_value=chromecast) as gen_chromecast: + discover_cast('the-service', chromecast) + mdns = (chromecast.host, chromecast.port, chromecast.uuid, None, None) + gen_chromecast.assert_called_once_with(mdns, blocking=True) + + discover_cast('the-service', chromecast) + gen_chromecast.reset_mock() + assert gen_chromecast.call_count == 0 + + +@asyncio.coroutine +def test_internal_discovery_callback_calls_dispatcher(hass): + """Test internal discovery calls dispatcher.""" + discover_cast, _ = yield from async_setup_cast_internal_discovery(hass) + chromecast = get_fake_chromecast() + + with patch('pychromecast._get_chromecast_from_host', + return_value=chromecast): + signal = MagicMock() + + async_dispatcher_connect(hass, 'cast_discovered', signal) + discover_cast('the-service', chromecast) + yield from hass.async_block_till_done() + + signal.assert_called_once_with(chromecast) + + +@asyncio.coroutine +def test_internal_discovery_callback_with_connection_error(hass): + """Test internal discovery not calling dispatcher on ConnectionError.""" + import pychromecast # imports mock pychromecast + + pychromecast.ChromecastConnectionError = IOError + + discover_cast, _ = yield from async_setup_cast_internal_discovery( + hass, no_from_host_patch=True) + chromecast = get_fake_chromecast() + + with patch('pychromecast._get_chromecast_from_host', + side_effect=pychromecast.ChromecastConnectionError): + signal = MagicMock() + + async_dispatcher_connect(hass, 'cast_discovered', signal) + discover_cast('the-service', chromecast) + yield from hass.async_block_till_done() + + assert signal.call_count == 0 + + +def test_create_cast_device_without_uuid(hass): + """Test create a cast device without a UUID.""" + chromecast = get_fake_chromecast(uuid=None) + cast_device = cast._async_create_cast_device(hass, chromecast) + assert cast_device is not None + + +def test_create_cast_device_with_uuid(hass): + """Test create cast devices with UUID.""" + added_casts = hass.data[cast.ADDED_CAST_DEVICES_KEY] = {} + chromecast = get_fake_chromecast() + cast_device = cast._async_create_cast_device(hass, chromecast) + assert cast_device is not None + assert chromecast.uuid in added_casts + + with patch.object(cast_device, 'async_set_chromecast') as mock_set: + assert cast._async_create_cast_device(hass, chromecast) is None + assert mock_set.call_count == 0 + + chromecast = get_fake_chromecast(host='192.168.178.1') + assert cast._async_create_cast_device(hass, chromecast) is None + assert mock_set.call_count == 1 + mock_set.assert_called_once_with(chromecast) + + +@asyncio.coroutine +def test_normal_chromecast_not_starting_discovery(hass): + """Test cast platform not starting discovery when not required.""" + chromecast = get_fake_chromecast() + + with patch('pychromecast.Chromecast', return_value=chromecast): + add_devices = yield from async_setup_cast(hass, {'host': 'host1'}) + assert add_devices.call_count == 1 + + # Same entity twice + add_devices = yield from async_setup_cast(hass, {'host': 'host1'}) + assert add_devices.call_count == 0 + + hass.data[cast.ADDED_CAST_DEVICES_KEY] = {} + add_devices = yield from async_setup_cast( + hass, discovery_info={'host': 'host1', 'port': 8009}) + assert add_devices.call_count == 1 + + hass.data[cast.ADDED_CAST_DEVICES_KEY] = {} + add_devices = yield from async_setup_cast( + hass, discovery_info={'host': 'host1', 'port': 42}) + assert add_devices.call_count == 0 + + +@asyncio.coroutine +def test_replay_past_chromecasts(hass): + """Test cast platform re-playing past chromecasts when adding new one.""" + cast_group1 = get_fake_chromecast(host='host1', port=42) + cast_group2 = get_fake_chromecast(host='host2', port=42, uuid=UUID( + '9462202c-e747-4af5-a66b-7dce0e1ebc09')) + + discover_cast, add_dev1 = yield from async_setup_cast_internal_discovery( + hass, discovery_info={'host': 'host1', 'port': 42}) + discover_cast('service2', cast_group2) + yield from hass.async_block_till_done() + assert add_dev1.call_count == 0 + + discover_cast('service1', cast_group1) + yield from hass.async_block_till_done() + yield from hass.async_block_till_done() # having jobs that add jobs + assert add_dev1.call_count == 1 + + add_dev2 = yield from async_setup_cast( + hass, discovery_info={'host': 'host2', 'port': 42}) + yield from hass.async_block_till_done() + assert add_dev2.call_count == 1 From 6aa89166541d4cc5d65716869634fda565a5ed7e Mon Sep 17 00:00:00 2001 From: Thijs de Jong Date: Fri, 23 Feb 2018 13:03:00 +0100 Subject: [PATCH 165/173] Add Tahoma scenes (#12498) * add scenes to platform * add scene.tahoma * requires tahoma-api 0.0.12 * update requirements_all.txt * hound * fix pylint error --- homeassistant/components/scene/tahoma.py | 48 ++++++++++++++++++++++++ homeassistant/components/tahoma.py | 11 ++++-- requirements_all.txt | 2 +- 3 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/scene/tahoma.py diff --git a/homeassistant/components/scene/tahoma.py b/homeassistant/components/scene/tahoma.py new file mode 100644 index 00000000000..39206623901 --- /dev/null +++ b/homeassistant/components/scene/tahoma.py @@ -0,0 +1,48 @@ +""" +Support for Tahoma scenes. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/scene.tahoma/ +""" +import logging + +from homeassistant.components.scene import Scene +from homeassistant.components.tahoma import ( + DOMAIN as TAHOMA_DOMAIN) + +DEPENDENCIES = ['tahoma'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tahoma scenes.""" + controller = hass.data[TAHOMA_DOMAIN]['controller'] + scenes = [] + for scene in hass.data[TAHOMA_DOMAIN]['scenes']: + scenes.append(TahomaScene(scene, controller)) + add_devices(scenes, True) + + +class TahomaScene(Scene): + """Representation of a Tahoma scene entity.""" + + def __init__(self, tahoma_scene, controller): + """Initialize the scene.""" + self.tahoma_scene = tahoma_scene + self.controller = controller + self._name = self.tahoma_scene.name + + def activate(self): + """Activate the scene.""" + self.controller.launch_action_group(self.tahoma_scene.oid) + + @property + def name(self): + """Return the name of the scene.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes of the scene.""" + return {'tahoma_scene_oid': self.tahoma_scene.oid} diff --git a/homeassistant/components/tahoma.py b/homeassistant/components/tahoma.py index 00ebc78a40b..b288a704d74 100644 --- a/homeassistant/components/tahoma.py +++ b/homeassistant/components/tahoma.py @@ -14,7 +14,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['tahoma-api==0.0.11'] +REQUIREMENTS = ['tahoma-api==0.0.12'] _LOGGER = logging.getLogger(__name__) @@ -32,7 +32,7 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) TAHOMA_COMPONENTS = [ - 'sensor', 'cover' + 'scene', 'sensor', 'cover' ] TAHOMA_TYPES = { @@ -63,13 +63,15 @@ def setup(hass, config): try: api.get_setup() devices = api.get_devices() + scenes = api.get_action_groups() except RequestException: _LOGGER.exception("Error when getting devices from the Tahoma API") return False hass.data[DOMAIN] = { 'controller': api, - 'devices': defaultdict(list) + 'devices': defaultdict(list), + 'scenes': [] } for device in devices: @@ -82,6 +84,9 @@ def setup(hass, config): continue hass.data[DOMAIN]['devices'][device_type].append(_device) + for scene in scenes: + hass.data[DOMAIN]['scenes'].append(scene) + for component in TAHOMA_COMPONENTS: discovery.load_platform(hass, component, DOMAIN, {}, config) diff --git a/requirements_all.txt b/requirements_all.txt index 898df747067..7de7131b562 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1159,7 +1159,7 @@ steamodd==4.21 suds-py3==1.3.3.0 # homeassistant.components.tahoma -tahoma-api==0.0.11 +tahoma-api==0.0.12 # homeassistant.components.sensor.tank_utility tank_utility==1.4.0 From 9ca67c36cded80f86208da5b4ddff282cc0756d9 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Fri, 23 Feb 2018 00:40:58 +0100 Subject: [PATCH 166/173] Optimize logbook SQL query (#12608) --- homeassistant/components/logbook.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index e6e447884cb..1fc6d1587fc 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -47,6 +47,11 @@ CONFIG_SCHEMA = vol.Schema({ }), }, extra=vol.ALLOW_EXTRA) +ALL_EVENT_TYPES = [ + EVENT_STATE_CHANGED, EVENT_LOGBOOK_ENTRY, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP +] + GROUP_BY_MINUTES = 15 CONTINUOUS_DOMAINS = ['proximity', 'sensor'] @@ -266,15 +271,18 @@ def humanify(events): def _get_events(hass, config, start_day, end_day): """Get events for a period of time.""" - from homeassistant.components.recorder.models import Events + from homeassistant.components.recorder.models import Events, States from homeassistant.components.recorder.util import ( execute, session_scope) with session_scope(hass=hass) as session: - query = session.query(Events).order_by( - Events.time_fired).filter( - (Events.time_fired > start_day) & - (Events.time_fired < end_day)) + query = session.query(Events).order_by(Events.time_fired) \ + .outerjoin(States, (Events.event_id == States.event_id)) \ + .filter(Events.event_type.in_(ALL_EVENT_TYPES)) \ + .filter((Events.time_fired > start_day) + & (Events.time_fired < end_day)) \ + .filter((States.last_updated == States.last_changed) + | (States.last_updated.is_(None))) events = execute(query) return humanify(_exclude_events(events, config)) From 7c80ef714e7ca8cbf674f595e3981e6d3bbba34e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 22 Feb 2018 23:29:42 -0800 Subject: [PATCH 167/173] Fix voluptuous breaking change things (#12611) * Fix voluptuous breaking change things * Change xiaomi aqara back --- homeassistant/components/binary_sensor/knx.py | 2 +- .../components/media_player/braviatv_psk.py | 2 +- tests/helpers/test_config_validation.py | 14 +++++--------- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py index d63a5a5b400..2b33d6850d6 100644 --- a/homeassistant/components/binary_sensor/knx.py +++ b/homeassistant/components/binary_sensor/knx.py @@ -35,7 +35,7 @@ DEPENDENCIES = ['knx'] AUTOMATION_SCHEMA = vol.Schema({ vol.Optional(CONF_HOOK, default=CONF_DEFAULT_HOOK): cv.string, vol.Optional(CONF_COUNTER, default=CONF_DEFAULT_COUNTER): cv.port, - vol.Required(CONF_ACTION, default=None): cv.SCRIPT_SCHEMA + vol.Required(CONF_ACTION): cv.SCRIPT_SCHEMA }) AUTOMATIONS_SCHEMA = vol.All( diff --git a/homeassistant/components/media_player/braviatv_psk.py b/homeassistant/components/media_player/braviatv_psk.py index c78951e91b7..122eb3b9739 100755 --- a/homeassistant/components/media_player/braviatv_psk.py +++ b/homeassistant/components/media_player/braviatv_psk.py @@ -42,7 +42,7 @@ TV_NO_INFO = 'No info: TV resumed after pause' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PSK): cv.string, - vol.Optional(CONF_MAC, default=None): cv.string, + vol.Optional(CONF_MAC): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_AMP, default=False): cv.boolean, vol.Optional(CONF_ANDROID, default=True): cv.boolean, diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 26262f50ac4..66f0597fc93 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -524,18 +524,14 @@ def test_enum(): def test_socket_timeout(): # pylint: disable=invalid-name """Test socket timeout validator.""" - TEST_CONF_TIMEOUT = 'timeout' # pylint: disable=invalid-name - - schema = vol.Schema( - {vol.Required(TEST_CONF_TIMEOUT, default=None): cv.socket_timeout}) + schema = vol.Schema(cv.socket_timeout) with pytest.raises(vol.Invalid): - schema({TEST_CONF_TIMEOUT: 0.0}) + schema(0.0) with pytest.raises(vol.Invalid): - schema({TEST_CONF_TIMEOUT: -1}) + schema(-1) - assert _GLOBAL_DEFAULT_TIMEOUT == schema({TEST_CONF_TIMEOUT: - None})[TEST_CONF_TIMEOUT] + assert _GLOBAL_DEFAULT_TIMEOUT == schema(None) - assert schema({TEST_CONF_TIMEOUT: 1})[TEST_CONF_TIMEOUT] == 1.0 + assert schema(1) == 1.0 From 19d34daef078e9f45a0750ecce5feecc4cc764d2 Mon Sep 17 00:00:00 2001 From: Scott Bradshaw Date: Fri, 23 Feb 2018 00:53:08 -0500 Subject: [PATCH 168/173] OpenGarage - correctly handle offline status (#12612) (#12613) --- homeassistant/components/cover/opengarage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/cover/opengarage.py b/homeassistant/components/cover/opengarage.py index 38fbaf0acdb..d68021d7db3 100644 --- a/homeassistant/components/cover/opengarage.py +++ b/homeassistant/components/cover/opengarage.py @@ -115,7 +115,7 @@ class OpenGarageCover(CoverDevice): @property def is_closed(self): """Return if the cover is closed.""" - if self._state == STATE_UNKNOWN: + if self._state in [STATE_UNKNOWN, STATE_OFFLINE]: return None return self._state in [STATE_CLOSED, STATE_OPENING] From 43ad3ae2d43b7544e3847c0c19359f227d9c930d Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Fri, 23 Feb 2018 10:12:40 +0100 Subject: [PATCH 169/173] Move recorder query out of event loop (#12615) --- homeassistant/components/recorder/__init__.py | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 53be6f33837..bffe29ec59b 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -256,28 +256,6 @@ class Recorder(threading.Thread): self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, notify_hass_started) - if self.keep_days and self.purge_interval: - @callback - def async_purge(now): - """Trigger the purge and schedule the next run.""" - self.queue.put( - PurgeTask(self.keep_days, repack=not self.did_vacuum)) - self.hass.helpers.event.async_track_point_in_time( - async_purge, now + timedelta(days=self.purge_interval)) - - earliest = dt_util.utcnow() + timedelta(minutes=30) - run = latest = dt_util.utcnow() + \ - timedelta(days=self.purge_interval) - with session_scope(session=self.get_session()) as session: - event = session.query(Events).first() - if event is not None: - session.expunge(event) - run = dt_util.as_utc(event.time_fired) + \ - timedelta(days=self.keep_days+self.purge_interval) - run = min(latest, max(run, earliest)) - self.hass.helpers.event.async_track_point_in_time( - async_purge, run) - self.hass.add_job(register) result = hass_started.result() @@ -285,6 +263,29 @@ class Recorder(threading.Thread): if result is shutdown_task: return + # Start periodic purge + if self.keep_days and self.purge_interval: + @callback + def async_purge(now): + """Trigger the purge and schedule the next run.""" + self.queue.put( + PurgeTask(self.keep_days, repack=not self.did_vacuum)) + self.hass.helpers.event.async_track_point_in_time( + async_purge, now + timedelta(days=self.purge_interval)) + + earliest = dt_util.utcnow() + timedelta(minutes=30) + run = latest = dt_util.utcnow() + \ + timedelta(days=self.purge_interval) + with session_scope(session=self.get_session()) as session: + event = session.query(Events).first() + if event is not None: + session.expunge(event) + run = dt_util.as_utc(event.time_fired) + timedelta( + days=self.keep_days+self.purge_interval) + run = min(latest, max(run, earliest)) + + self.hass.helpers.event.track_point_in_time(async_purge, run) + while True: event = self.queue.get() From 286baed9ad0763e8c0acd9022726697f6242af2a Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 23 Feb 2018 19:13:04 +0100 Subject: [PATCH 170/173] Hassio update timeout filter list (#12617) * Update timeout filter list * Update http.py --- homeassistant/components/hassio/http.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index d94826653e8..9dd6427ec38 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -31,6 +31,8 @@ NO_TIMEOUT = { re.compile(r'^addons/[^/]*/rebuild$'), re.compile(r'^snapshots/.*/full$'), re.compile(r'^snapshots/.*/partial$'), + re.compile(r'^snapshots/[^/]*/upload$'), + re.compile(r'^snapshots/[^/]*/download$'), } NO_AUTH = { From 781b7687a4b12820590c5b2d3e7fcf590eba03af Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Fri, 23 Feb 2018 19:11:54 +0100 Subject: [PATCH 171/173] The name of the enum must be used here because of the speed_list. (#12625) The fan.set_speed example value is lower-case and led to confusion. Both spellings are possible now: Idle & idle --- homeassistant/components/fan/xiaomi_miio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index 264962b9d56..b9bc54b5c79 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -302,7 +302,7 @@ class XiaomiAirPurifier(FanEntity): yield from self._try_command( "Setting operation mode of the air purifier failed.", - self._air_purifier.set_mode, OperationMode(speed)) + self._air_purifier.set_mode, OperationMode[speed.title()]) @asyncio.coroutine def async_set_buzzer_on(self): From 8d0d676ff2ecef614ac3c07538a864c7372beea5 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Sat, 24 Feb 2018 00:13:48 +0100 Subject: [PATCH 172/173] Fix cast doing I/O in event loop (#12632) --- homeassistant/components/media_player/cast.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index a07ff74ccae..40e09ea328c 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -182,7 +182,8 @@ def async_setup_platform(hass: HomeAssistantType, config: ConfigType, else: # Manually add a "normal" Chromecast, we can do that without discovery. try: - chromecast = pychromecast.Chromecast(*want_host) + chromecast = yield from hass.async_add_job( + pychromecast.Chromecast, *want_host) except pychromecast.ChromecastConnectionError: _LOGGER.warning("Can't set up chromecast on %s", want_host[0]) raise From 6c614df96e46e7b461dfff9c7c16fa33b5157280 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 25 Feb 2018 11:50:06 -0800 Subject: [PATCH 173/173] Remove braviatv_psk (#12669) --- .coveragerc | 1 - CODEOWNERS | 1 - .../components/media_player/braviatv_psk.py | 363 ------------------ requirements_all.txt | 3 - 4 files changed, 368 deletions(-) delete mode 100755 homeassistant/components/media_player/braviatv_psk.py diff --git a/.coveragerc b/.coveragerc index a1022dcb42e..bd99e3ac2e2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -432,7 +432,6 @@ omit = homeassistant/components/media_player/aquostv.py homeassistant/components/media_player/bluesound.py homeassistant/components/media_player/braviatv.py - homeassistant/components/media_player/braviatv_psk.py homeassistant/components/media_player/cast.py homeassistant/components/media_player/clementine.py homeassistant/components/media_player/cmus.py diff --git a/CODEOWNERS b/CODEOWNERS index f3ddfc3b3e6..a5b5cfcb32c 100755 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -54,7 +54,6 @@ homeassistant/components/device_tracker/tile.py @bachya homeassistant/components/history_graph.py @andrey-git homeassistant/components/light/tplink.py @rytilahti homeassistant/components/light/yeelight.py @rytilahti -homeassistant/components/media_player/braviatv_psk.py @gerard33 homeassistant/components/media_player/kodi.py @armills homeassistant/components/media_player/mediaroom.py @dgomes homeassistant/components/media_player/monoprice.py @etsinko diff --git a/homeassistant/components/media_player/braviatv_psk.py b/homeassistant/components/media_player/braviatv_psk.py deleted file mode 100755 index 122eb3b9739..00000000000 --- a/homeassistant/components/media_player/braviatv_psk.py +++ /dev/null @@ -1,363 +0,0 @@ -""" -Support for interface with a Sony Bravia TV. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/media_player.braviatv_psk/ -""" -import logging -import voluptuous as vol - -from homeassistant.components.media_player import ( - SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_ON, - SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, SUPPORT_PLAY, - SUPPORT_VOLUME_SET, SUPPORT_SELECT_SOURCE, MediaPlayerDevice, - PLATFORM_SCHEMA, MEDIA_TYPE_TVSHOW, SUPPORT_STOP) -from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_MAC, STATE_OFF, STATE_ON) -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['pySonyBraviaPSK==0.1.5'] - -_LOGGER = logging.getLogger(__name__) - -SUPPORT_BRAVIA = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ - SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | \ - SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ - SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ - SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_STOP - -DEFAULT_NAME = 'Sony Bravia TV' - -# Config file -CONF_PSK = 'psk' -CONF_AMP = 'amp' -CONF_ANDROID = 'android' -CONF_SOURCE_FILTER = 'sourcefilter' - -# Some additional info to show specific for Sony Bravia TV -TV_WAIT = 'TV started, waiting for program info' -TV_APP_OPENED = 'App opened' -TV_NO_INFO = 'No info: TV resumed after pause' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PSK): cv.string, - vol.Optional(CONF_MAC): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_AMP, default=False): cv.boolean, - vol.Optional(CONF_ANDROID, default=True): cv.boolean, - vol.Optional(CONF_SOURCE_FILTER, default=[]): vol.All( - cv.ensure_list, [cv.string])}) - -# pylint: disable=unused-argument - - -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Sony Bravia TV platform.""" - host = config.get(CONF_HOST) - psk = config.get(CONF_PSK) - mac = config.get(CONF_MAC) - name = config.get(CONF_NAME) - amp = config.get(CONF_AMP) - android = config.get(CONF_ANDROID) - source_filter = config.get(CONF_SOURCE_FILTER) - - if host is None or psk is None: - _LOGGER.error( - "No TV IP address or Pre-Shared Key found in configuration file") - return - - add_devices( - [BraviaTVDevice(host, psk, mac, name, amp, android, source_filter)]) - - -class BraviaTVDevice(MediaPlayerDevice): - """Representation of a Sony Bravia TV.""" - - def __init__(self, host, psk, mac, name, amp, android, source_filter): - """Initialize the Sony Bravia device.""" - _LOGGER.info("Setting up Sony Bravia TV") - from braviapsk import sony_bravia_psk - - self._braviarc = sony_bravia_psk.BraviaRC(host, psk, mac) - self._name = name - self._amp = amp - self._android = android - self._source_filter = source_filter - self._state = STATE_OFF - self._muted = False - self._program_name = None - self._channel_name = None - self._channel_number = None - self._source = None - self._source_list = [] - self._original_content_list = [] - self._content_mapping = {} - self._duration = None - self._content_uri = None - self._id = None - self._playing = False - self._start_date_time = None - self._program_media_type = None - self._min_volume = None - self._max_volume = None - self._volume = None - self._start_time = None - self._end_time = None - - _LOGGER.debug( - "Set up Sony Bravia TV with IP: %s, PSK: %s, MAC: %s", host, psk, - mac) - - self.update() - - def update(self): - """Update TV info.""" - try: - power_status = self._braviarc.get_power_status() - if power_status == 'active': - self._state = STATE_ON - self._refresh_volume() - self._refresh_channels() - playing_info = self._braviarc.get_playing_info() - self._reset_playing_info() - if playing_info is None or not playing_info: - self._program_name = TV_NO_INFO - else: - self._program_name = playing_info.get('programTitle') - self._channel_name = playing_info.get('title') - self._program_media_type = playing_info.get( - 'programMediaType') - self._channel_number = playing_info.get('dispNum') - self._source = playing_info.get('source') - self._content_uri = playing_info.get('uri') - self._duration = playing_info.get('durationSec') - self._start_date_time = playing_info.get('startDateTime') - # Get time info from TV program - if self._start_date_time is not None and \ - self._duration is not None: - time_info = self._braviarc.playing_time( - self._start_date_time, self._duration) - self._start_time = time_info.get('start_time') - self._end_time = time_info.get('end_time') - else: - if self._program_name == TV_WAIT: - # TV is starting up, takes some time before it responds - _LOGGER.info("TV is starting, no info available yet") - else: - self._state = STATE_OFF - - except Exception as exception_instance: # pylint: disable=broad-except - _LOGGER.error( - "No data received from TV. Error message: %s", - exception_instance) - self._state = STATE_OFF - - def _reset_playing_info(self): - self._program_name = None - self._channel_name = None - self._program_media_type = None - self._channel_number = None - self._source = None - self._content_uri = None - self._duration = None - self._start_date_time = None - self._start_time = None - self._end_time = None - - def _refresh_volume(self): - """Refresh volume information.""" - volume_info = self._braviarc.get_volume_info() - if volume_info is not None: - self._volume = volume_info.get('volume') - self._min_volume = volume_info.get('minVolume') - self._max_volume = volume_info.get('maxVolume') - self._muted = volume_info.get('mute') - - def _refresh_channels(self): - if not self._source_list: - self._content_mapping = self._braviarc.load_source_list() - self._source_list = [] - if not self._source_filter: # list is empty - for key in self._content_mapping: - self._source_list.append(key) - else: - filtered_dict = {title: uri for (title, uri) in - self._content_mapping.items() - if any(filter_title in title for filter_title - in self._source_filter)} - for key in filtered_dict: - self._source_list.append(key) - - @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 source(self): - """Return the current input source.""" - return self._source - - @property - def source_list(self): - """List of available input sources.""" - return self._source_list - - @property - def volume_level(self): - """Volume level of the media player (0..1).""" - if self._volume is not None: - return self._volume / 100 - return None - - @property - def is_volume_muted(self): - """Boolean if volume is currently muted.""" - return self._muted - - @property - def supported_features(self): - """Flag media player features that are supported.""" - supported = SUPPORT_BRAVIA - # Remove volume slider if amplifier is attached to TV - if self._amp: - supported = supported ^ SUPPORT_VOLUME_SET - return supported - - @property - def media_content_type(self): - """Content type of current playing media. - - Used for program information below the channel in the state card. - """ - return MEDIA_TYPE_TVSHOW - - @property - def media_title(self): - """Title of current playing media. - - Used to show TV channel info. - """ - return_value = None - if self._channel_name is not None: - if self._channel_number is not None: - return_value = '{0!s}: {1}'.format( - self._channel_number.lstrip('0'), self._channel_name) - else: - return_value = self._channel_name - return return_value - - @property - def media_series_title(self): - """Title of series of current playing media, TV show only. - - Used to show TV program info. - """ - return_value = None - if self._program_name is not None: - if self._start_time is not None and self._end_time is not None: - return_value = '{0} [{1} - {2}]'.format( - self._program_name, self._start_time, self._end_time) - else: - return_value = self._program_name - else: - if not self._channel_name: # This is empty when app is opened - return_value = TV_APP_OPENED - return return_value - - @property - def media_content_id(self): - """Content ID of current playing media.""" - return self._channel_name - - def set_volume_level(self, volume): - """Set volume level, range 0..1.""" - self._braviarc.set_volume_level(volume) - - def turn_on(self): - """Turn the media player on. - - Use a different command for Android as WOL is not working. - """ - if self._android: - self._braviarc.turn_on_command() - else: - self._braviarc.turn_on() - - # Show that TV is starting while it takes time - # before program info is available - self._reset_playing_info() - self._state = STATE_ON - self._program_name = TV_WAIT - - def turn_off(self): - """Turn off media player.""" - self._state = STATE_OFF - self._braviarc.turn_off() - - def volume_up(self): - """Volume up the media player.""" - self._braviarc.volume_up() - - def volume_down(self): - """Volume down media player.""" - self._braviarc.volume_down() - - def mute_volume(self, mute): - """Send mute command.""" - self._braviarc.mute_volume() - - def select_source(self, source): - """Set the input source.""" - if source in self._content_mapping: - uri = self._content_mapping[source] - self._braviarc.play_content(uri) - - 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._braviarc.media_play() - - def media_pause(self): - """Send media pause command to media player. - - Will pause TV when TV tuner is on. - """ - self._playing = False - if self._program_media_type == 'tv' or self._program_name is not None: - self._braviarc.media_tvpause() - else: - self._braviarc.media_pause() - - def media_next_track(self): - """Send next track command. - - Will switch to next channel when TV tuner is on. - """ - if self._program_media_type == 'tv' or self._program_name is not None: - self._braviarc.send_command('ChannelUp') - else: - self._braviarc.media_next_track() - - def media_previous_track(self): - """Send the previous track command. - - Will switch to previous channel when TV tuner is on. - """ - if self._program_media_type == 'tv' or self._program_name is not None: - self._braviarc.send_command('ChannelDown') - else: - self._braviarc.media_previous_track() diff --git a/requirements_all.txt b/requirements_all.txt index 7de7131b562..7deac085466 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -643,9 +643,6 @@ pyHS100==0.3.0 # homeassistant.components.rfxtrx pyRFXtrx==0.21.1 -# homeassistant.components.media_player.braviatv_psk -pySonyBraviaPSK==0.1.5 - # homeassistant.components.sensor.tibber pyTibber==0.2.1