From 874275092635d71033e96f02e5d0b08aa8ace89e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Tue, 20 Nov 2018 20:00:13 +0100 Subject: [PATCH 001/325] Improve available for Mill heater (#18597) * improve available for Mill heater * typo --- homeassistant/components/climate/mill.py | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/climate/mill.py b/homeassistant/components/climate/mill.py index 3b26b4560678f..6be4fe183b7f8 100644 --- a/homeassistant/components/climate/mill.py +++ b/homeassistant/components/climate/mill.py @@ -19,7 +19,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['millheater==0.2.7'] +REQUIREMENTS = ['millheater==0.2.8'] _LOGGER = logging.getLogger(__name__) @@ -98,7 +98,7 @@ def supported_features(self): @property def available(self): """Return True if entity is available.""" - return self._heater.device_status == 0 # weird api choice + return self._heater.available @property def unique_id(self): diff --git a/requirements_all.txt b/requirements_all.txt index d0753bb5af1e6..13dd968804acd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -634,7 +634,7 @@ mficlient==0.3.0 miflora==0.4.0 # homeassistant.components.climate.mill -millheater==0.2.7 +millheater==0.2.8 # homeassistant.components.sensor.mitemp_bt mitemp_bt==0.0.1 From b7742999cfa0e1a69657fa5a4758d13e77a728f7 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Tue, 20 Nov 2018 14:58:03 -0500 Subject: [PATCH 002/325] Update Z-Wave Tests asyncio/yield from -> async/await (#18599) * Update lock tests * Update binary sensor * Update zwave component tests --- tests/components/binary_sensor/test_zwave.py | 8 +- tests/components/lock/test_zwave.py | 48 +++---- tests/components/zwave/test_init.py | 143 ++++++++----------- tests/components/zwave/test_node_entity.py | 25 ++-- 4 files changed, 97 insertions(+), 127 deletions(-) diff --git a/tests/components/binary_sensor/test_zwave.py b/tests/components/binary_sensor/test_zwave.py index a5dabf6953a66..f33e8a83e1ef9 100644 --- a/tests/components/binary_sensor/test_zwave.py +++ b/tests/components/binary_sensor/test_zwave.py @@ -1,5 +1,4 @@ """Test Z-Wave binary sensors.""" -import asyncio import datetime from unittest.mock import patch @@ -71,8 +70,7 @@ def test_binary_sensor_value_changed(mock_openzwave): assert device.is_on -@asyncio.coroutine -def test_trigger_sensor_value_changed(hass, mock_openzwave): +async def test_trigger_sensor_value_changed(hass, mock_openzwave): """Test value changed for trigger sensor.""" node = MockNode( manufacturer_id='013c', product_type='0002', product_id='0002') @@ -84,13 +82,13 @@ def test_trigger_sensor_value_changed(hass, mock_openzwave): assert not device.is_on value.data = True - yield from hass.async_add_job(value_changed, value) + await hass.async_add_job(value_changed, value) assert device.invalidate_after is None device.hass = hass value.data = True - yield from hass.async_add_job(value_changed, value) + await hass.async_add_job(value_changed, value) assert device.is_on test_time = device.invalidate_after - datetime.timedelta(seconds=1) diff --git a/tests/components/lock/test_zwave.py b/tests/components/lock/test_zwave.py index e9ca5fb2b1f0d..3955538273b5e 100644 --- a/tests/components/lock/test_zwave.py +++ b/tests/components/lock/test_zwave.py @@ -1,6 +1,4 @@ """Test Z-Wave locks.""" -import asyncio - from unittest.mock import patch, MagicMock from homeassistant import config_entries @@ -185,21 +183,19 @@ def test_lock_alarm_level(mock_openzwave): 'Tamper Alarm: Too many keypresses' -@asyncio.coroutine -def setup_ozw(hass, mock_openzwave): +async def setup_ozw(hass, mock_openzwave): """Set up the mock ZWave config entry.""" hass.config.components.add('zwave') config_entry = config_entries.ConfigEntry(1, 'zwave', 'Mock Title', { 'usb_path': 'mock-path', 'network_key': 'mock-key' }, 'test', config_entries.CONN_CLASS_LOCAL_PUSH) - yield from hass.config_entries.async_forward_entry_setup(config_entry, - 'lock') - yield from hass.async_block_till_done() + await hass.config_entries.async_forward_entry_setup(config_entry, + 'lock') + await hass.async_block_till_done() -@asyncio.coroutine -def test_lock_set_usercode_service(hass, mock_openzwave): +async def test_lock_set_usercode_service(hass, mock_openzwave): """Test the zwave lock set_usercode service.""" mock_network = hass.data[zwave.zwave.DATA_NETWORK] = MagicMock() @@ -216,35 +212,34 @@ def test_lock_set_usercode_service(hass, mock_openzwave): node.node_id: node } - yield from setup_ozw(hass, mock_openzwave) - yield from hass.async_block_till_done() + await setup_ozw(hass, mock_openzwave) + await hass.async_block_till_done() - yield from hass.services.async_call( + await hass.services.async_call( zwave.DOMAIN, zwave.SERVICE_SET_USERCODE, { const.ATTR_NODE_ID: node.node_id, zwave.ATTR_USERCODE: '1234', zwave.ATTR_CODE_SLOT: 1, }) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert value1.data == '1234' mock_network.nodes = { node.node_id: node } - yield from hass.services.async_call( + await hass.services.async_call( zwave.DOMAIN, zwave.SERVICE_SET_USERCODE, { const.ATTR_NODE_ID: node.node_id, zwave.ATTR_USERCODE: '123', zwave.ATTR_CODE_SLOT: 1, }) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert value1.data == '1234' -@asyncio.coroutine -def test_lock_get_usercode_service(hass, mock_openzwave): +async def test_lock_get_usercode_service(hass, mock_openzwave): """Test the zwave lock get_usercode service.""" mock_network = hass.data[zwave.zwave.DATA_NETWORK] = MagicMock() node = MockNode(node_id=12) @@ -256,25 +251,24 @@ def test_lock_get_usercode_service(hass, mock_openzwave): value1.value_id: value1, } - yield from setup_ozw(hass, mock_openzwave) - yield from hass.async_block_till_done() + await setup_ozw(hass, mock_openzwave) + await hass.async_block_till_done() with patch.object(zwave, '_LOGGER') as mock_logger: mock_network.nodes = {node.node_id: node} - yield from hass.services.async_call( + await hass.services.async_call( zwave.DOMAIN, zwave.SERVICE_GET_USERCODE, { const.ATTR_NODE_ID: node.node_id, zwave.ATTR_CODE_SLOT: 1, }) - yield from hass.async_block_till_done() + await hass.async_block_till_done() # This service only seems to write to the log assert mock_logger.info.called assert len(mock_logger.info.mock_calls) == 1 assert mock_logger.info.mock_calls[0][1][2] == '1234' -@asyncio.coroutine -def test_lock_clear_usercode_service(hass, mock_openzwave): +async def test_lock_clear_usercode_service(hass, mock_openzwave): """Test the zwave lock clear_usercode service.""" mock_network = hass.data[zwave.zwave.DATA_NETWORK] = MagicMock() node = MockNode(node_id=12) @@ -290,14 +284,14 @@ def test_lock_clear_usercode_service(hass, mock_openzwave): node.node_id: node } - yield from setup_ozw(hass, mock_openzwave) - yield from hass.async_block_till_done() + await setup_ozw(hass, mock_openzwave) + await hass.async_block_till_done() - yield from hass.services.async_call( + await hass.services.async_call( zwave.DOMAIN, zwave.SERVICE_CLEAR_USERCODE, { const.ATTR_NODE_ID: node.node_id, zwave.ATTR_CODE_SLOT: 1 }) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert value1.data == '\0\0\0' diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index c2634b2d62166..d4077345649d5 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -23,36 +23,34 @@ from tests.mock.zwave import MockNetwork, MockNode, MockValue, MockEntityValues -@asyncio.coroutine -def test_valid_device_config(hass, mock_openzwave): +async def test_valid_device_config(hass, mock_openzwave): """Test valid device config.""" device_config = { 'light.kitchen': { 'ignored': 'true' } } - result = yield from async_setup_component(hass, 'zwave', { + result = await async_setup_component(hass, 'zwave', { 'zwave': { 'device_config': device_config }}) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert result -@asyncio.coroutine -def test_invalid_device_config(hass, mock_openzwave): +async def test_invalid_device_config(hass, mock_openzwave): """Test invalid device config.""" device_config = { 'light.kitchen': { 'some_ignored': 'true' } } - result = yield from async_setup_component(hass, 'zwave', { + result = await async_setup_component(hass, 'zwave', { 'zwave': { 'device_config': device_config }}) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert not result @@ -69,15 +67,14 @@ def side_effect(): assert result is None -@asyncio.coroutine -def test_network_options(hass, mock_openzwave): +async def test_network_options(hass, mock_openzwave): """Test network options.""" - result = yield from async_setup_component(hass, 'zwave', { + result = await async_setup_component(hass, 'zwave', { 'zwave': { 'usb_path': 'mock_usb_path', 'config_path': 'mock_config_path', }}) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert result @@ -86,62 +83,59 @@ def test_network_options(hass, mock_openzwave): assert network.options.config_path == 'mock_config_path' -@asyncio.coroutine -def test_auto_heal_midnight(hass, mock_openzwave): +async def test_auto_heal_midnight(hass, mock_openzwave): """Test network auto-heal at midnight.""" - yield from async_setup_component(hass, 'zwave', { + await async_setup_component(hass, 'zwave', { 'zwave': { 'autoheal': True, }}) - yield from hass.async_block_till_done() + await hass.async_block_till_done() network = hass.data[zwave.DATA_NETWORK] assert not network.heal.called time = utc.localize(datetime(2017, 5, 6, 0, 0, 0)) async_fire_time_changed(hass, time) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert network.heal.called assert len(network.heal.mock_calls) == 1 -@asyncio.coroutine -def test_auto_heal_disabled(hass, mock_openzwave): +async def test_auto_heal_disabled(hass, mock_openzwave): """Test network auto-heal disabled.""" - yield from async_setup_component(hass, 'zwave', { + await async_setup_component(hass, 'zwave', { 'zwave': { 'autoheal': False, }}) - yield from hass.async_block_till_done() + await hass.async_block_till_done() network = hass.data[zwave.DATA_NETWORK] assert not network.heal.called time = utc.localize(datetime(2017, 5, 6, 0, 0, 0)) async_fire_time_changed(hass, time) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert not network.heal.called -@asyncio.coroutine -def test_setup_platform(hass, mock_openzwave): +async def test_setup_platform(hass, mock_openzwave): """Test invalid device config.""" mock_device = MagicMock() hass.data[DATA_NETWORK] = MagicMock() hass.data[zwave.DATA_DEVICES] = {456: mock_device} async_add_entities = MagicMock() - result = yield from zwave.async_setup_platform( + result = await zwave.async_setup_platform( hass, None, async_add_entities, None) assert not result assert not async_add_entities.called - result = yield from zwave.async_setup_platform( + result = await zwave.async_setup_platform( hass, None, async_add_entities, {const.DISCOVERY_DEVICE: 123}) assert not result assert not async_add_entities.called - result = yield from zwave.async_setup_platform( + result = await zwave.async_setup_platform( hass, None, async_add_entities, {const.DISCOVERY_DEVICE: 456}) assert result assert async_add_entities.called @@ -149,12 +143,11 @@ def test_setup_platform(hass, mock_openzwave): assert async_add_entities.mock_calls[0][1][0] == [mock_device] -@asyncio.coroutine -def test_zwave_ready_wait(hass, mock_openzwave): +async def test_zwave_ready_wait(hass, mock_openzwave): """Test that zwave continues after waiting for network ready.""" # Initialize zwave - yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - yield from hass.async_block_till_done() + await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() sleeps = [] @@ -163,18 +156,17 @@ def utcnow(): asyncio_sleep = asyncio.sleep - @asyncio.coroutine - def sleep(duration, loop=None): + async def sleep(duration, loop=None): if duration > 0: sleeps.append(duration) - yield from asyncio_sleep(0) + await asyncio_sleep(0) with patch('homeassistant.components.zwave.dt_util.utcnow', new=utcnow): with patch('asyncio.sleep', new=sleep): with patch.object(zwave, '_LOGGER') as mock_logger: hass.data[DATA_NETWORK].state = MockNetwork.STATE_STARTED hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(sleeps) == const.NETWORK_READY_WAIT_SECS assert mock_logger.warning.called @@ -183,8 +175,7 @@ def sleep(duration, loop=None): const.NETWORK_READY_WAIT_SECS -@asyncio.coroutine -def test_device_entity(hass, mock_openzwave): +async def test_device_entity(hass, mock_openzwave): """Test device entity base class.""" node = MockNode(node_id='10', name='Mock Node') value = MockValue(data=False, node=node, instance=2, object_id='11', @@ -197,7 +188,7 @@ def test_device_entity(hass, mock_openzwave): device.hass = hass device.value_added() device.update_properties() - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert not device.should_poll assert device.unique_id == "10-11" @@ -205,8 +196,7 @@ def test_device_entity(hass, mock_openzwave): assert device.device_state_attributes[zwave.ATTR_POWER] == 50.123 -@asyncio.coroutine -def test_node_discovery(hass, mock_openzwave): +async def test_node_discovery(hass, mock_openzwave): """Test discovery of a node.""" mock_receivers = [] @@ -215,14 +205,14 @@ def mock_connect(receiver, signal, *args, **kwargs): mock_receivers.append(receiver) with patch('pydispatch.dispatcher.connect', new=mock_connect): - yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - yield from hass.async_block_till_done() + await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() assert len(mock_receivers) == 1 node = MockNode(node_id=14) hass.async_add_job(mock_receivers[0], node) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert hass.states.get('zwave.mock_node').state is 'unknown' @@ -270,8 +260,7 @@ async def sleep(duration, loop=None): assert hass.states.get('zwave.unknown_node_14').state is 'unknown' -@asyncio.coroutine -def test_node_ignored(hass, mock_openzwave): +async def test_node_ignored(hass, mock_openzwave): """Test discovery of a node.""" mock_receivers = [] @@ -280,24 +269,23 @@ def mock_connect(receiver, signal, *args, **kwargs): mock_receivers.append(receiver) with patch('pydispatch.dispatcher.connect', new=mock_connect): - yield from async_setup_component(hass, 'zwave', {'zwave': { + await async_setup_component(hass, 'zwave', {'zwave': { 'device_config': { 'zwave.mock_node': { 'ignored': True, }}}}) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(mock_receivers) == 1 node = MockNode(node_id=14) hass.async_add_job(mock_receivers[0], node) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert hass.states.get('zwave.mock_node') is None -@asyncio.coroutine -def test_value_discovery(hass, mock_openzwave): +async def test_value_discovery(hass, mock_openzwave): """Test discovery of a node.""" mock_receivers = [] @@ -306,8 +294,8 @@ def mock_connect(receiver, signal, *args, **kwargs): mock_receivers.append(receiver) with patch('pydispatch.dispatcher.connect', new=mock_connect): - yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - yield from hass.async_block_till_done() + await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -316,14 +304,13 @@ def mock_connect(receiver, signal, *args, **kwargs): command_class=const.COMMAND_CLASS_SENSOR_BINARY, type=const.TYPE_BOOL, genre=const.GENRE_USER) hass.async_add_job(mock_receivers[0], node, value) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert hass.states.get( 'binary_sensor.mock_node_mock_value').state is 'off' -@asyncio.coroutine -def test_value_discovery_existing_entity(hass, mock_openzwave): +async def test_value_discovery_existing_entity(hass, mock_openzwave): """Test discovery of a node.""" mock_receivers = [] @@ -332,8 +319,8 @@ def mock_connect(receiver, signal, *args, **kwargs): mock_receivers.append(receiver) with patch('pydispatch.dispatcher.connect', new=mock_connect): - yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - yield from hass.async_block_till_done() + await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -343,7 +330,7 @@ def mock_connect(receiver, signal, *args, **kwargs): command_class=const.COMMAND_CLASS_THERMOSTAT_SETPOINT, genre=const.GENRE_USER, units='C') hass.async_add_job(mock_receivers[0], node, setpoint) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert hass.states.get('climate.mock_node_mock_value').attributes[ 'temperature'] == 22.0 @@ -360,7 +347,7 @@ def mock_update(self): command_class=const.COMMAND_CLASS_SENSOR_MULTILEVEL, genre=const.GENRE_USER, units='C') hass.async_add_job(mock_receivers[0], node, temperature) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert hass.states.get('climate.mock_node_mock_value').attributes[ 'temperature'] == 22.0 @@ -368,8 +355,7 @@ def mock_update(self): 'current_temperature'] == 23.5 -@asyncio.coroutine -def test_power_schemes(hass, mock_openzwave): +async def test_power_schemes(hass, mock_openzwave): """Test power attribute.""" mock_receivers = [] @@ -378,8 +364,8 @@ def mock_connect(receiver, signal, *args, **kwargs): mock_receivers.append(receiver) with patch('pydispatch.dispatcher.connect', new=mock_connect): - yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - yield from hass.async_block_till_done() + await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -390,7 +376,7 @@ def mock_connect(receiver, signal, *args, **kwargs): genre=const.GENRE_USER, type=const.TYPE_BOOL) hass.async_add_job(mock_receivers[0], node, switch) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert hass.states.get('switch.mock_node_mock_value').state == 'on' assert 'power_consumption' not in hass.states.get( @@ -405,14 +391,13 @@ def mock_update(self): data=23.5, node=node, index=const.INDEX_SENSOR_MULTILEVEL_POWER, instance=13, command_class=const.COMMAND_CLASS_SENSOR_MULTILEVEL) hass.async_add_job(mock_receivers[0], node, power) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert hass.states.get('switch.mock_node_mock_value').attributes[ 'power_consumption'] == 23.5 -@asyncio.coroutine -def test_network_ready(hass, mock_openzwave): +async def test_network_ready(hass, mock_openzwave): """Test Node network ready event.""" mock_receivers = [] @@ -421,8 +406,8 @@ def mock_connect(receiver, signal, *args, **kwargs): mock_receivers.append(receiver) with patch('pydispatch.dispatcher.connect', new=mock_connect): - yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - yield from hass.async_block_till_done() + await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -434,13 +419,12 @@ def listener(event): hass.bus.async_listen(const.EVENT_NETWORK_COMPLETE, listener) hass.async_add_job(mock_receivers[0]) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 1 -@asyncio.coroutine -def test_network_complete(hass, mock_openzwave): +async def test_network_complete(hass, mock_openzwave): """Test Node network complete event.""" mock_receivers = [] @@ -449,8 +433,8 @@ def mock_connect(receiver, signal, *args, **kwargs): mock_receivers.append(receiver) with patch('pydispatch.dispatcher.connect', new=mock_connect): - yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - yield from hass.async_block_till_done() + await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -462,13 +446,12 @@ def listener(event): hass.bus.async_listen(const.EVENT_NETWORK_READY, listener) hass.async_add_job(mock_receivers[0]) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 1 -@asyncio.coroutine -def test_network_complete_some_dead(hass, mock_openzwave): +async def test_network_complete_some_dead(hass, mock_openzwave): """Test Node network complete some dead event.""" mock_receivers = [] @@ -477,8 +460,8 @@ def mock_connect(receiver, signal, *args, **kwargs): mock_receivers.append(receiver) with patch('pydispatch.dispatcher.connect', new=mock_connect): - yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - yield from hass.async_block_till_done() + await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -490,7 +473,7 @@ def listener(event): hass.bus.async_listen(const.EVENT_NETWORK_COMPLETE_SOME_DEAD, listener) hass.async_add_job(mock_receivers[0]) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 1 diff --git a/tests/components/zwave/test_node_entity.py b/tests/components/zwave/test_node_entity.py index 034360c6b3e1c..b8f88e6f37fa8 100644 --- a/tests/components/zwave/test_node_entity.py +++ b/tests/components/zwave/test_node_entity.py @@ -1,5 +1,4 @@ """Test Z-Wave node entity.""" -import asyncio import unittest from unittest.mock import patch, MagicMock import tests.mock.zwave as mock_zwave @@ -8,8 +7,7 @@ from homeassistant.const import ATTR_ENTITY_ID -@asyncio.coroutine -def test_maybe_schedule_update(hass, mock_openzwave): +async def test_maybe_schedule_update(hass, mock_openzwave): """Test maybe schedule update.""" base_entity = node_entity.ZWaveBaseEntity() base_entity.hass = hass @@ -31,8 +29,7 @@ def test_maybe_schedule_update(hass, mock_openzwave): assert len(mock_call_later.mock_calls) == 2 -@asyncio.coroutine -def test_node_event_activated(hass, mock_openzwave): +async def test_node_event_activated(hass, mock_openzwave): """Test Node event activated event.""" mock_receivers = [] @@ -57,7 +54,7 @@ def listener(event): # Test event before entity added to hass value = 234 hass.async_add_job(mock_receivers[0], node, value) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 0 # Add entity to hass @@ -66,7 +63,7 @@ def listener(event): value = 234 hass.async_add_job(mock_receivers[0], node, value) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 1 assert events[0].data[ATTR_ENTITY_ID] == "zwave.mock_node" @@ -74,8 +71,7 @@ def listener(event): assert events[0].data[const.ATTR_BASIC_LEVEL] == value -@asyncio.coroutine -def test_scene_activated(hass, mock_openzwave): +async def test_scene_activated(hass, mock_openzwave): """Test scene activated event.""" mock_receivers = [] @@ -100,7 +96,7 @@ def listener(event): # Test event before entity added to hass scene_id = 123 hass.async_add_job(mock_receivers[0], node, scene_id) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 0 # Add entity to hass @@ -109,7 +105,7 @@ def listener(event): scene_id = 123 hass.async_add_job(mock_receivers[0], node, scene_id) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 1 assert events[0].data[ATTR_ENTITY_ID] == "zwave.mock_node" @@ -117,8 +113,7 @@ def listener(event): assert events[0].data[const.ATTR_SCENE_ID] == scene_id -@asyncio.coroutine -def test_central_scene_activated(hass, mock_openzwave): +async def test_central_scene_activated(hass, mock_openzwave): """Test central scene activated event.""" mock_receivers = [] @@ -148,7 +143,7 @@ def listener(event): index=scene_id, data=scene_data) hass.async_add_job(mock_receivers[0], node, value) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 0 # Add entity to hass @@ -162,7 +157,7 @@ def listener(event): index=scene_id, data=scene_data) hass.async_add_job(mock_receivers[0], node, value) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 1 assert events[0].data[ATTR_ENTITY_ID] == "zwave.mock_node" From d9c7f777c536936b1dfa2f21a6c6e63ef85db0a0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 20 Nov 2018 23:23:07 +0100 Subject: [PATCH 003/325] Add cloud pref for Google unlock (#18600) --- homeassistant/components/cloud/__init__.py | 49 ++--------------- homeassistant/components/cloud/const.py | 4 ++ homeassistant/components/cloud/http_api.py | 14 ++--- homeassistant/components/cloud/iot.py | 4 +- homeassistant/components/cloud/prefs.py | 63 ++++++++++++++++++++++ tests/components/cloud/__init__.py | 8 +-- tests/components/cloud/test_http_api.py | 18 ++++--- tests/components/cloud/test_iot.py | 9 ++-- 8 files changed, 103 insertions(+), 66 deletions(-) create mode 100644 homeassistant/components/cloud/prefs.py diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index d9ee2a62b846b..b968850668d8b 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -20,17 +20,12 @@ from homeassistant.components.google_assistant import helpers as ga_h from homeassistant.components.google_assistant import const as ga_c -from . import http_api, iot, auth_api +from . import http_api, iot, auth_api, prefs from .const import CONFIG_DIR, DOMAIN, SERVERS REQUIREMENTS = ['warrant==0.6.1'] -STORAGE_KEY = DOMAIN -STORAGE_VERSION = 1 -STORAGE_ENABLE_ALEXA = 'alexa_enabled' -STORAGE_ENABLE_GOOGLE = 'google_enabled' _LOGGER = logging.getLogger(__name__) -_UNDEF = object() CONF_ALEXA = 'alexa' CONF_ALIASES = 'aliases' @@ -70,8 +65,6 @@ GACTIONS_SCHEMA = ASSISTANT_SCHEMA.extend({ vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA}, - vol.Optional(ga_c.CONF_ALLOW_UNLOCK, - default=ga_c.DEFAULT_ALLOW_UNLOCK): cv.boolean }) CONFIG_SCHEMA = vol.Schema({ @@ -127,12 +120,11 @@ def __init__(self, hass, mode, alexa, google_actions, self.alexa_config = alexa self.google_actions_user_conf = google_actions self._gactions_config = None - self._prefs = None + self.prefs = prefs.CloudPreferences(hass) self.id_token = None self.access_token = None self.refresh_token = None self.iot = iot.CloudIoT(self) - self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) if mode == MODE_DEV: self.cognito_client_id = cognito_client_id @@ -196,21 +188,11 @@ def should_expose(entity): should_expose=should_expose, agent_user_id=self.claims['cognito:username'], entity_config=conf.get(CONF_ENTITY_CONFIG), - allow_unlock=conf.get(ga_c.CONF_ALLOW_UNLOCK), + allow_unlock=self.prefs.google_allow_unlock, ) return self._gactions_config - @property - def alexa_enabled(self): - """Return if Alexa is enabled.""" - return self._prefs[STORAGE_ENABLE_ALEXA] - - @property - def google_enabled(self): - """Return if Google is enabled.""" - return self._prefs[STORAGE_ENABLE_GOOGLE] - def path(self, *parts): """Get config path inside cloud dir. @@ -250,20 +232,6 @@ def write_user_info(self): async def async_start(self, _): """Start the cloud component.""" - prefs = await self._store.async_load() - if prefs is None: - prefs = {} - if self.mode not in prefs: - # Default to True if already logged in to make this not a - # breaking change. - enabled = await self.hass.async_add_executor_job( - os.path.isfile, self.user_info_path) - prefs = { - STORAGE_ENABLE_ALEXA: enabled, - STORAGE_ENABLE_GOOGLE: enabled, - } - self._prefs = prefs - def load_config(): """Load config.""" # Ensure config dir exists @@ -280,6 +248,8 @@ def load_config(): info = await self.hass.async_add_job(load_config) + await self.prefs.async_initialize(not info) + if info is None: return @@ -289,15 +259,6 @@ def load_config(): self.hass.add_job(self.iot.connect()) - async def update_preferences(self, *, google_enabled=_UNDEF, - alexa_enabled=_UNDEF): - """Update user preferences.""" - if google_enabled is not _UNDEF: - self._prefs[STORAGE_ENABLE_GOOGLE] = google_enabled - if alexa_enabled is not _UNDEF: - self._prefs[STORAGE_ENABLE_ALEXA] = alexa_enabled - await self._store.async_save(self._prefs) - def _decode_claims(self, token): # pylint: disable=no-self-use """Decode the claims in a token.""" from jose import jwt diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 88fb88474a133..abc72da796cf6 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -3,6 +3,10 @@ CONFIG_DIR = '.cloud' REQUEST_TIMEOUT = 10 +PREF_ENABLE_ALEXA = 'alexa_enabled' +PREF_ENABLE_GOOGLE = 'google_enabled' +PREF_GOOGLE_ALLOW_UNLOCK = 'google_allow_unlock' + SERVERS = { 'production': { 'cognito_client_id': '60i2uvhvbiref2mftj7rgcrt9u', diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index cb62d773dfdf1..7b509f4eae25b 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -15,7 +15,9 @@ from homeassistant.components.google_assistant import smart_home as google_sh from . import auth_api -from .const import DOMAIN, REQUEST_TIMEOUT +from .const import ( + DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, + PREF_GOOGLE_ALLOW_UNLOCK) from .iot import STATE_DISCONNECTED, STATE_CONNECTED _LOGGER = logging.getLogger(__name__) @@ -30,8 +32,9 @@ WS_TYPE_UPDATE_PREFS = 'cloud/update_prefs' SCHEMA_WS_UPDATE_PREFS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_UPDATE_PREFS, - vol.Optional('google_enabled'): bool, - vol.Optional('alexa_enabled'): bool, + vol.Optional(PREF_ENABLE_GOOGLE): bool, + vol.Optional(PREF_ENABLE_ALEXA): bool, + vol.Optional(PREF_GOOGLE_ALLOW_UNLOCK): bool, }) @@ -288,7 +291,7 @@ async def websocket_update_prefs(hass, connection, msg): changes = dict(msg) changes.pop('id') changes.pop('type') - await cloud.update_preferences(**changes) + await cloud.prefs.async_update(**changes) connection.send_message(websocket_api.result_message( msg['id'], {'success': True})) @@ -308,10 +311,9 @@ def _account_data(cloud): 'logged_in': True, 'email': claims['email'], 'cloud': cloud.iot.state, - 'google_enabled': cloud.google_enabled, + 'prefs': cloud.prefs.as_dict(), 'google_entities': cloud.google_actions_user_conf['filter'].config, 'google_domains': list(google_sh.DOMAIN_TO_GOOGLE_TYPES), - 'alexa_enabled': cloud.alexa_enabled, 'alexa_entities': cloud.alexa_config.should_expose.config, 'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS), } diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index b4f228a630d2c..c5657ae97292f 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -229,7 +229,7 @@ def async_handle_alexa(hass, cloud, payload): """Handle an incoming IoT message for Alexa.""" result = yield from alexa.async_handle_message( hass, cloud.alexa_config, payload, - enabled=cloud.alexa_enabled) + enabled=cloud.prefs.alexa_enabled) return result @@ -237,7 +237,7 @@ def async_handle_alexa(hass, cloud, payload): @asyncio.coroutine def async_handle_google_actions(hass, cloud, payload): """Handle an incoming IoT message for Google Actions.""" - if not cloud.google_enabled: + if not cloud.prefs.google_enabled: return ga.turned_off_response(payload) result = yield from ga.async_handle_message( diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py new file mode 100644 index 0000000000000..d29b356cfc0a0 --- /dev/null +++ b/homeassistant/components/cloud/prefs.py @@ -0,0 +1,63 @@ +"""Preference management for cloud.""" +from .const import ( + DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, + PREF_GOOGLE_ALLOW_UNLOCK) + +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 +_UNDEF = object() + + +class CloudPreferences: + """Handle cloud preferences.""" + + def __init__(self, hass): + """Initialize cloud prefs.""" + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + self._prefs = None + + async def async_initialize(self, logged_in): + """Finish initializing the preferences.""" + prefs = await self._store.async_load() + + if prefs is None: + # Backwards compat: we enable alexa/google if already logged in + prefs = { + PREF_ENABLE_ALEXA: logged_in, + PREF_ENABLE_GOOGLE: logged_in, + PREF_GOOGLE_ALLOW_UNLOCK: False, + } + + self._prefs = prefs + + async def async_update(self, *, google_enabled=_UNDEF, + alexa_enabled=_UNDEF, google_allow_unlock=_UNDEF): + """Update user preferences.""" + for key, value in ( + (PREF_ENABLE_GOOGLE, google_enabled), + (PREF_ENABLE_ALEXA, alexa_enabled), + (PREF_GOOGLE_ALLOW_UNLOCK, google_allow_unlock), + ): + if value is not _UNDEF: + self._prefs[key] = value + + await self._store.async_save(self._prefs) + + def as_dict(self): + """Return dictionary version.""" + return self._prefs + + @property + def alexa_enabled(self): + """Return if Alexa is enabled.""" + return self._prefs[PREF_ENABLE_ALEXA] + + @property + def google_enabled(self): + """Return if Google is enabled.""" + return self._prefs[PREF_ENABLE_GOOGLE] + + @property + def google_allow_unlock(self): + """Return if Google is allowed to unlock locks.""" + return self._prefs.get(PREF_GOOGLE_ALLOW_UNLOCK, False) diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index 108e5c45137c0..ba63e43d09152 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -2,6 +2,7 @@ from unittest.mock import patch from homeassistant.setup import async_setup_component from homeassistant.components import cloud +from homeassistant.components.cloud import const from jose import jwt @@ -24,9 +25,10 @@ def mock_cloud(hass, config={}): def mock_cloud_prefs(hass, prefs={}): """Fixture for cloud component.""" prefs_to_set = { - cloud.STORAGE_ENABLE_ALEXA: True, - cloud.STORAGE_ENABLE_GOOGLE: True, + const.PREF_ENABLE_ALEXA: True, + const.PREF_ENABLE_GOOGLE: True, + const.PREF_GOOGLE_ALLOW_UNLOCK: True, } prefs_to_set.update(prefs) - hass.data[cloud.DOMAIN]._prefs = prefs_to_set + hass.data[cloud.DOMAIN].prefs._prefs = prefs_to_set return prefs_to_set diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index a8128c8d3e02d..4abf5b8501d9a 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -6,7 +6,9 @@ from jose import jwt from homeassistant.components.cloud import ( - DOMAIN, auth_api, iot, STORAGE_ENABLE_GOOGLE, STORAGE_ENABLE_ALEXA) + DOMAIN, auth_api, iot) +from homeassistant.components.cloud.const import ( + PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_ALLOW_UNLOCK) from homeassistant.util import dt as dt_util from tests.common import mock_coro @@ -350,7 +352,7 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture): 'logged_in': True, 'email': 'hello@home-assistant.io', 'cloud': 'connected', - 'alexa_enabled': True, + 'prefs': mock_cloud_fixture, 'alexa_entities': { 'include_domains': [], 'include_entities': ['light.kitchen', 'switch.ac'], @@ -358,7 +360,6 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture): 'exclude_entities': [], }, 'alexa_domains': ['switch'], - 'google_enabled': True, 'google_entities': { 'include_domains': ['light'], 'include_entities': [], @@ -505,8 +506,9 @@ async def test_websocket_subscription_not_logged_in(hass, hass_ws_client): async def test_websocket_update_preferences(hass, hass_ws_client, aioclient_mock, setup_api): """Test updating preference.""" - assert setup_api[STORAGE_ENABLE_GOOGLE] - assert setup_api[STORAGE_ENABLE_ALEXA] + assert setup_api[PREF_ENABLE_GOOGLE] + assert setup_api[PREF_ENABLE_ALEXA] + assert setup_api[PREF_GOOGLE_ALLOW_UNLOCK] hass.data[DOMAIN].id_token = jwt.encode({ 'email': 'hello@home-assistant.io', 'custom:sub-exp': '2018-01-03' @@ -517,9 +519,11 @@ async def test_websocket_update_preferences(hass, hass_ws_client, 'type': 'cloud/update_prefs', 'alexa_enabled': False, 'google_enabled': False, + 'google_allow_unlock': False, }) response = await client.receive_json() assert response['success'] - assert not setup_api[STORAGE_ENABLE_GOOGLE] - assert not setup_api[STORAGE_ENABLE_ALEXA] + assert not setup_api[PREF_ENABLE_GOOGLE] + assert not setup_api[PREF_ENABLE_ALEXA] + assert not setup_api[PREF_GOOGLE_ALLOW_UNLOCK] diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index d0b145c1b6714..c900fc3a7a85d 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -7,8 +7,9 @@ from homeassistant.setup import async_setup_component from homeassistant.components.cloud import ( - Cloud, iot, auth_api, MODE_DEV, STORAGE_ENABLE_ALEXA, - STORAGE_ENABLE_GOOGLE) + Cloud, iot, auth_api, MODE_DEV) +from homeassistant.components.cloud.const import ( + PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE) from tests.components.alexa import test_smart_home as test_alexa from tests.common import mock_coro @@ -308,7 +309,7 @@ def test_handler_alexa(hass): @asyncio.coroutine def test_handler_alexa_disabled(hass, mock_cloud_fixture): """Test handler Alexa when user has disabled it.""" - mock_cloud_fixture[STORAGE_ENABLE_ALEXA] = False + mock_cloud_fixture[PREF_ENABLE_ALEXA] = False resp = yield from iot.async_handle_alexa( hass, hass.data['cloud'], @@ -377,7 +378,7 @@ def test_handler_google_actions(hass): async def test_handler_google_actions_disabled(hass, mock_cloud_fixture): """Test handler Google Actions when user has disabled it.""" - mock_cloud_fixture[STORAGE_ENABLE_GOOGLE] = False + mock_cloud_fixture[PREF_ENABLE_GOOGLE] = False with patch('homeassistant.components.cloud.Cloud.async_start', return_value=mock_coro()): From 377730a37cf430c5fa93a8baad2b0c36f0865530 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Tue, 20 Nov 2018 16:05:25 -0700 Subject: [PATCH 004/325] Change channel with play_media instead of select_source (#18474) * Use service play_media instead of select_source Use service play_media instead of select_source to change the channel as play_media is the right service for that. * Log error on invalid media type Log an error instead of raising a NotImplementedError if an invalid media type is provided. * Changed so that success is not in else statement Updated so that if media_type is channel that it is not in the else of an if. * Update directv.py Removed SELECT_SOURCE as supported feature. * Rebased Re-based with dev --- .../components/media_player/directv.py | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/media_player/directv.py b/homeassistant/components/media_player/directv.py index 51f5cbc5bb0ba..7a1e240d82e1d 100644 --- a/homeassistant/components/media_player/directv.py +++ b/homeassistant/components/media_player/directv.py @@ -9,9 +9,9 @@ import voluptuous as vol from homeassistant.components.media_player import ( - MEDIA_TYPE_MOVIE, MEDIA_TYPE_TVSHOW, PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, - SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, - SUPPORT_SELECT_SOURCE, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + MEDIA_TYPE_CHANNEL, MEDIA_TYPE_MOVIE, MEDIA_TYPE_TVSHOW, PLATFORM_SCHEMA, + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, MediaPlayerDevice) from homeassistant.const import ( CONF_DEVICE, CONF_HOST, CONF_NAME, CONF_PORT, STATE_OFF, STATE_PAUSED, @@ -33,12 +33,12 @@ DEFAULT_PORT = 8080 SUPPORT_DTV = SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ - SUPPORT_PLAY_MEDIA | SUPPORT_SELECT_SOURCE | SUPPORT_STOP | \ - SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY + SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_NEXT_TRACK | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY SUPPORT_DTV_CLIENT = SUPPORT_PAUSE | \ - SUPPORT_PLAY_MEDIA | SUPPORT_SELECT_SOURCE | SUPPORT_STOP | \ - SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY + SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_NEXT_TRACK | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY DATA_DIRECTV = 'data_directv' @@ -375,7 +375,12 @@ def media_next_track(self): _LOGGER.debug("Fast forward on %s", self._name) self.dtv.key_press('ffwd') - def select_source(self, source): + def play_media(self, media_type, media_id, **kwargs): """Select input source.""" - _LOGGER.debug("Changing channel on %s to %s", self._name, source) - self.dtv.tune_channel(source) + if media_type != MEDIA_TYPE_CHANNEL: + _LOGGER.error("Invalid media type %s. Only %s is supported", + media_type, MEDIA_TYPE_CHANNEL) + return + + _LOGGER.debug("Changing channel on %s to %s", self._name, media_id) + self.dtv.tune_channel(media_id) From 3b53003795f5b11cb3a3eb263125f46c92f0145c Mon Sep 17 00:00:00 2001 From: pbalogh77 Date: Wed, 21 Nov 2018 06:15:54 +0100 Subject: [PATCH 005/325] Fibaro components (#18487) * Added Fibaro omcponents Added cover, light, sensor and switch components * Improvements based on code review Improvements based on code review * Fixes based on code review Fixes based on code review * Changes to light behavior based on code review Changes to light behavior based on code review * Internal changes Changed how brightness is represented internally. It should have no impact on functionality. --- homeassistant/components/cover/fibaro.py | 92 ++++++++++++ homeassistant/components/fibaro.py | 29 ++-- homeassistant/components/light/fibaro.py | 165 ++++++++++++++++++++++ homeassistant/components/sensor/fibaro.py | 99 +++++++++++++ homeassistant/components/switch/fibaro.py | 68 +++++++++ 5 files changed, 445 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/cover/fibaro.py create mode 100644 homeassistant/components/light/fibaro.py create mode 100644 homeassistant/components/sensor/fibaro.py create mode 100644 homeassistant/components/switch/fibaro.py diff --git a/homeassistant/components/cover/fibaro.py b/homeassistant/components/cover/fibaro.py new file mode 100644 index 0000000000000..dc82087f8029f --- /dev/null +++ b/homeassistant/components/cover/fibaro.py @@ -0,0 +1,92 @@ +""" +Support for Fibaro cover - curtains, rollershutters etc. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.fibaro/ +""" +import logging + +from homeassistant.components.cover import ( + CoverDevice, ENTITY_ID_FORMAT, ATTR_POSITION, ATTR_TILT_POSITION) +from homeassistant.components.fibaro import ( + FIBARO_CONTROLLER, FIBARO_DEVICES, FibaroDevice) + +DEPENDENCIES = ['fibaro'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Fibaro covers.""" + if discovery_info is None: + return + + add_entities( + [FibaroCover(device, hass.data[FIBARO_CONTROLLER]) for + device in hass.data[FIBARO_DEVICES]['cover']], True) + + +class FibaroCover(FibaroDevice, CoverDevice): + """Representation a Fibaro Cover.""" + + def __init__(self, fibaro_device, controller): + """Initialize the Vera device.""" + super().__init__(fibaro_device, controller) + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + + @staticmethod + def bound(position): + """Normalize the position.""" + if position is None: + return None + position = int(position) + if position <= 5: + return 0 + if position >= 95: + return 100 + return position + + @property + def current_cover_position(self): + """Return current position of cover. 0 is closed, 100 is open.""" + return self.bound(self.level) + + @property + def current_cover_tilt_position(self): + """Return the current tilt position for venetian blinds.""" + return self.bound(self.level2) + + def set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + self.set_level(kwargs.get(ATTR_POSITION)) + + def set_cover_tilt_position(self, **kwargs): + """Move the cover to a specific position.""" + self.set_level2(kwargs.get(ATTR_TILT_POSITION)) + + @property + def is_closed(self): + """Return if the cover is closed.""" + if self.current_cover_position is None: + return None + return self.current_cover_position == 0 + + def open_cover(self, **kwargs): + """Open the cover.""" + self.action("open") + + def close_cover(self, **kwargs): + """Close the cover.""" + self.action("close") + + def open_cover_tilt(self, **kwargs): + """Open the cover tilt.""" + self.set_level2(100) + + def close_cover_tilt(self, **kwargs): + """Close the cover.""" + self.set_level2(0) + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self.action("stop") diff --git a/homeassistant/components/fibaro.py b/homeassistant/components/fibaro.py index 9a9e5b1285194..c9dd19b4bc870 100644 --- a/homeassistant/components/fibaro.py +++ b/homeassistant/components/fibaro.py @@ -23,14 +23,11 @@ DOMAIN = 'fibaro' FIBARO_DEVICES = 'fibaro_devices' FIBARO_CONTROLLER = 'fibaro_controller' -FIBARO_ID_FORMAT = '{}_{}_{}' ATTR_CURRENT_POWER_W = "current_power_w" ATTR_CURRENT_ENERGY_KWH = "current_energy_kwh" CONF_PLUGINS = "plugins" -FIBARO_COMPONENTS = [ - 'binary_sensor', -] +FIBARO_COMPONENTS = ['binary_sensor', 'cover', 'light', 'sensor', 'switch'] FIBARO_TYPEMAP = { 'com.fibaro.multilevelSensor': "sensor", @@ -174,7 +171,7 @@ def _read_devices(self): else: room_name = self._room_map[device.roomID].name device.friendly_name = room_name + ' ' + device.name - device.ha_id = FIBARO_ID_FORMAT.format( + device.ha_id = '{}_{}_{}'.format( slugify(room_name), slugify(device.name), device.id) self._device_map[device.id] = device self.fibaro_devices = defaultdict(list) @@ -232,13 +229,15 @@ def _update_callback(self): """Update the state.""" self.schedule_update_ha_state(True) - def get_level(self): + @property + def level(self): """Get the level of Fibaro device.""" if 'value' in self.fibaro_device.properties: return self.fibaro_device.properties.value return None - def get_level2(self): + @property + def level2(self): """Get the tilt level of Fibaro device.""" if 'value2' in self.fibaro_device.properties: return self.fibaro_device.properties.value2 @@ -258,7 +257,21 @@ def set_level(self, level): if 'brightness' in self.fibaro_device.properties: self.fibaro_device.properties.brightness = level - def set_color(self, red, green, blue, white): + def set_level2(self, level): + """Set the level2 of Fibaro device.""" + self.action("setValue2", level) + if 'value2' in self.fibaro_device.properties: + self.fibaro_device.properties.value2 = level + + def call_turn_on(self): + """Turn on the Fibaro device.""" + self.action("turnOn") + + def call_turn_off(self): + """Turn off the Fibaro device.""" + self.action("turnOff") + + def call_set_color(self, red, green, blue, white): """Set the color of Fibaro device.""" color_str = "{},{},{},{}".format(int(red), int(green), int(blue), int(white)) diff --git a/homeassistant/components/light/fibaro.py b/homeassistant/components/light/fibaro.py new file mode 100644 index 0000000000000..cfc28e12218a9 --- /dev/null +++ b/homeassistant/components/light/fibaro.py @@ -0,0 +1,165 @@ +""" +Support for Fibaro lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.fibaro/ +""" + +import logging +import threading + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_WHITE_VALUE, ENTITY_ID_FORMAT, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_WHITE_VALUE, Light) +import homeassistant.util.color as color_util +from homeassistant.components.fibaro import ( + FIBARO_CONTROLLER, FIBARO_DEVICES, FibaroDevice) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['fibaro'] + + +def scaleto255(value): + """Scale the input value from 0-100 to 0-255.""" + # Fibaro has a funny way of storing brightness either 0-100 or 0-99 + # depending on device type (e.g. dimmer vs led) + if value > 98: + value = 100 + return max(0, min(255, ((value * 256.0) / 100.0))) + + +def scaleto100(value): + """Scale the input value from 0-255 to 0-100.""" + # Make sure a low but non-zero value is not rounded down to zero + if 0 < value < 3: + return 1 + return max(0, min(100, ((value * 100.4) / 255.0))) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Perform the setup for Fibaro controller devices.""" + if discovery_info is None: + return + + add_entities( + [FibaroLight(device, hass.data[FIBARO_CONTROLLER]) + for device in hass.data[FIBARO_DEVICES]['light']], True) + + +class FibaroLight(FibaroDevice, Light): + """Representation of a Fibaro Light, including dimmable.""" + + def __init__(self, fibaro_device, controller): + """Initialize the light.""" + self._supported_flags = 0 + self._last_brightness = 0 + self._color = (0, 0) + self._brightness = None + self._white = 0 + + self._update_lock = threading.RLock() + if 'levelChange' in fibaro_device.interfaces: + self._supported_flags |= SUPPORT_BRIGHTNESS + if 'color' in fibaro_device.properties: + self._supported_flags |= SUPPORT_COLOR + if 'setW' in fibaro_device.actions: + self._supported_flags |= SUPPORT_WHITE_VALUE + super().__init__(fibaro_device, controller) + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + + @property + def brightness(self): + """Return the brightness of the light.""" + return scaleto255(self._brightness) + + @property + def hs_color(self): + """Return the color of the light.""" + return self._color + + @property + def white_value(self): + """Return the white value of this light between 0..255.""" + return self._white + + @property + def supported_features(self): + """Flag supported features.""" + return self._supported_flags + + def turn_on(self, **kwargs): + """Turn the light on.""" + with self._update_lock: + if self._supported_flags & SUPPORT_BRIGHTNESS: + target_brightness = kwargs.get(ATTR_BRIGHTNESS) + + # No brightness specified, so we either restore it to + # last brightness or switch it on at maximum level + if target_brightness is None: + if self._brightness == 0: + if self._last_brightness: + self._brightness = self._last_brightness + else: + self._brightness = 100 + else: + # We set it to the target brightness and turn it on + self._brightness = scaleto100(target_brightness) + + if self._supported_flags & SUPPORT_COLOR: + # Update based on parameters + self._white = kwargs.get(ATTR_WHITE_VALUE, self._white) + self._color = kwargs.get(ATTR_HS_COLOR, self._color) + rgb = color_util.color_hs_to_RGB(*self._color) + self.call_set_color( + int(rgb[0] * self._brightness / 99.0 + 0.5), + int(rgb[1] * self._brightness / 99.0 + 0.5), + int(rgb[2] * self._brightness / 99.0 + 0.5), + int(self._white * self._brightness / 99.0 + + 0.5)) + if self.state == 'off': + self.set_level(int(self._brightness)) + return + + if self._supported_flags & SUPPORT_BRIGHTNESS: + self.set_level(int(self._brightness)) + return + + # The simplest case is left for last. No dimming, just switch on + self.call_turn_on() + + def turn_off(self, **kwargs): + """Turn the light off.""" + # Let's save the last brightness level before we switch it off + with self._update_lock: + if (self._supported_flags & SUPPORT_BRIGHTNESS) and \ + self._brightness and self._brightness > 0: + self._last_brightness = self._brightness + self._brightness = 0 + self.call_turn_off() + + @property + def is_on(self): + """Return true if device is on.""" + return self.current_binary_state + + def update(self): + """Call to update state.""" + # Brightness handling + with self._update_lock: + if self._supported_flags & SUPPORT_BRIGHTNESS: + self._brightness = float(self.fibaro_device.properties.value) + # Color handling + if self._supported_flags & SUPPORT_COLOR: + # Fibaro communicates the color as an 'R, G, B, W' string + rgbw_s = self.fibaro_device.properties.color + if rgbw_s == '0,0,0,0' and\ + 'lastColorSet' in self.fibaro_device.properties: + rgbw_s = self.fibaro_device.properties.lastColorSet + rgbw_list = [int(i) for i in rgbw_s.split(",")][:4] + if rgbw_list[0] or rgbw_list[1] or rgbw_list[2]: + self._color = color_util.color_RGB_to_hs(*rgbw_list[:3]) + if (self._supported_flags & SUPPORT_WHITE_VALUE) and \ + self.brightness != 0: + self._white = min(255, max(0, rgbw_list[3]*100.0 / + self._brightness)) diff --git a/homeassistant/components/sensor/fibaro.py b/homeassistant/components/sensor/fibaro.py new file mode 100644 index 0000000000000..e5ed5638c5b39 --- /dev/null +++ b/homeassistant/components/sensor/fibaro.py @@ -0,0 +1,99 @@ +""" +Support for Fibaro sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.fibaro/ +""" +import logging + +from homeassistant.const import ( + DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, TEMP_CELSIUS, TEMP_FAHRENHEIT) +from homeassistant.helpers.entity import Entity +from homeassistant.components.sensor import ENTITY_ID_FORMAT +from homeassistant.components.fibaro import ( + FIBARO_CONTROLLER, FIBARO_DEVICES, FibaroDevice) + +SENSOR_TYPES = { + 'com.fibaro.temperatureSensor': + ['Temperature', None, None, DEVICE_CLASS_TEMPERATURE], + 'com.fibaro.smokeSensor': + ['Smoke', 'ppm', 'mdi:fire', None], + 'CO2': + ['CO2', 'ppm', 'mdi:cloud', None], + 'com.fibaro.humiditySensor': + ['Humidity', '%', None, DEVICE_CLASS_HUMIDITY], + 'com.fibaro.lightSensor': + ['Light', 'lx', None, DEVICE_CLASS_ILLUMINANCE] +} + +DEPENDENCIES = ['fibaro'] +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Fibaro controller devices.""" + if discovery_info is None: + return + + add_entities( + [FibaroSensor(device, hass.data[FIBARO_CONTROLLER]) + for device in hass.data[FIBARO_DEVICES]['sensor']], True) + + +class FibaroSensor(FibaroDevice, Entity): + """Representation of a Fibaro Sensor.""" + + def __init__(self, fibaro_device, controller): + """Initialize the sensor.""" + self.current_value = None + self.last_changed_time = None + super().__init__(fibaro_device, controller) + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + if fibaro_device.type in SENSOR_TYPES: + self._unit = SENSOR_TYPES[fibaro_device.type][1] + self._icon = SENSOR_TYPES[fibaro_device.type][2] + self._device_class = SENSOR_TYPES[fibaro_device.type][3] + else: + self._unit = None + self._icon = None + self._device_class = None + try: + if not self._unit: + if self.fibaro_device.properties.unit == 'lux': + self._unit = 'lx' + elif self.fibaro_device.properties.unit == 'C': + self._unit = TEMP_CELSIUS + elif self.fibaro_device.properties.unit == 'F': + self._unit = TEMP_FAHRENHEIT + else: + self._unit = self.fibaro_device.properties.unit + except (KeyError, ValueError): + pass + + @property + def state(self): + """Return the state of the sensor.""" + return self.current_value + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def device_class(self): + """Return the device class of the sensor.""" + return self._device_class + + def update(self): + """Update the state.""" + try: + self.current_value = float(self.fibaro_device.properties.value) + except (KeyError, ValueError): + pass diff --git a/homeassistant/components/switch/fibaro.py b/homeassistant/components/switch/fibaro.py new file mode 100644 index 0000000000000..d3e96646a4580 --- /dev/null +++ b/homeassistant/components/switch/fibaro.py @@ -0,0 +1,68 @@ +""" +Support for Fibaro switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.fibaro/ +""" +import logging + +from homeassistant.util import convert +from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchDevice +from homeassistant.components.fibaro import ( + FIBARO_CONTROLLER, FIBARO_DEVICES, FibaroDevice) + +DEPENDENCIES = ['fibaro'] +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Fibaro switches.""" + if discovery_info is None: + return + + add_entities( + [FibaroSwitch(device, hass.data[FIBARO_CONTROLLER]) for + device in hass.data[FIBARO_DEVICES]['switch']], True) + + +class FibaroSwitch(FibaroDevice, SwitchDevice): + """Representation of a Fibaro Switch.""" + + def __init__(self, fibaro_device, controller): + """Initialize the Fibaro device.""" + self._state = False + super().__init__(fibaro_device, controller) + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + + def turn_on(self, **kwargs): + """Turn device on.""" + self.call_turn_on() + self._state = True + + def turn_off(self, **kwargs): + """Turn device off.""" + self.call_turn_off() + self._state = False + + @property + def current_power_w(self): + """Return the current power usage in W.""" + if 'power' in self.fibaro_device.interfaces: + return convert(self.fibaro_device.properties.power, float, 0.0) + return None + + @property + def today_energy_kwh(self): + """Return the today total energy usage in kWh.""" + if 'energy' in self.fibaro_device.interfaces: + return convert(self.fibaro_device.properties.energy, float, 0.0) + return None + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + def update(self): + """Update device state.""" + self._state = self.current_binary_state From 8aa2cefd7575ca3158bbafd1cdfd27a55c5103ba Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Wed, 21 Nov 2018 02:57:59 -0500 Subject: [PATCH 006/325] Upgrade blinkpy to 0.10.3 (Fixes #18341) (#18603) --- homeassistant/components/blink/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 66cfe3990a305..62e73a52cc8bb 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -15,7 +15,7 @@ CONF_BINARY_SENSORS, CONF_SENSORS, CONF_FILENAME, CONF_MONITORED_CONDITIONS, TEMP_FAHRENHEIT) -REQUIREMENTS = ['blinkpy==0.10.1'] +REQUIREMENTS = ['blinkpy==0.10.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 13dd968804acd..b6e21143e302a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -186,7 +186,7 @@ bellows==0.7.0 bimmer_connected==0.5.3 # homeassistant.components.blink -blinkpy==0.10.1 +blinkpy==0.10.3 # homeassistant.components.light.blinksticklight blinkstick==1.1.8 From 36c31a629356616c08af27f9d37ebdbf0cc19d3b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Nov 2018 12:26:08 +0100 Subject: [PATCH 007/325] Add permissions check in service helper (#18596) * Add permissions check in service helper * Lint * Fix tests * Lint * Typing * Fix unused impoert --- homeassistant/auth/__init__.py | 4 + homeassistant/auth/auth_store.py | 8 ++ homeassistant/exceptions.py | 35 ++++-- homeassistant/helpers/service.py | 69 ++++++++++-- tests/components/conftest.py | 17 +++ tests/components/counter/test_init.py | 6 +- tests/components/light/test_init.py | 6 +- tests/components/switch/test_init.py | 6 +- tests/components/test_input_boolean.py | 6 +- tests/components/test_input_datetime.py | 6 +- tests/components/test_input_number.py | 6 +- tests/components/test_input_select.py | 6 +- tests/components/test_input_text.py | 6 +- tests/helpers/test_service.py | 135 +++++++++++++++++++++++- 14 files changed, 269 insertions(+), 47 deletions(-) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 0011c98ce730f..e69dec37df28d 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -118,6 +118,10 @@ async def async_get_user(self, user_id: str) -> Optional[models.User]: """Retrieve a user.""" return await self._store.async_get_user(user_id) + async def async_get_group(self, group_id: str) -> Optional[models.Group]: + """Retrieve all groups.""" + return await self._store.async_get_group(group_id) + async def async_get_user_by_credentials( self, credentials: models.Credentials) -> Optional[models.User]: """Get a user by credential, return None if not found.""" diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index ab233489db0e1..867d5357a583a 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -45,6 +45,14 @@ async def async_get_groups(self) -> List[models.Group]: return list(self._groups.values()) + async def async_get_group(self, group_id: str) -> Optional[models.Group]: + """Retrieve all users.""" + if self._groups is None: + await self._async_load() + assert self._groups is not None + + return self._groups.get(group_id) + async def async_get_users(self) -> List[models.User]: """Retrieve all users.""" if self._users is None: diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 11aa1848529c8..0613b7cb10c56 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -1,24 +1,24 @@ """The exceptions used by Home Assistant.""" +from typing import Optional, Tuple, TYPE_CHECKING import jinja2 +# pylint: disable=using-constant-test +if TYPE_CHECKING: + # pylint: disable=unused-import + from .core import Context # noqa + class HomeAssistantError(Exception): """General Home Assistant exception occurred.""" - pass - class InvalidEntityFormatError(HomeAssistantError): """When an invalid formatted entity is encountered.""" - pass - class NoEntitySpecifiedError(HomeAssistantError): """When no entity is specified.""" - pass - class TemplateError(HomeAssistantError): """Error during template rendering.""" @@ -32,16 +32,29 @@ def __init__(self, exception: jinja2.TemplateError) -> None: class PlatformNotReady(HomeAssistantError): """Error to indicate that platform is not ready.""" - pass - class ConfigEntryNotReady(HomeAssistantError): """Error to indicate that config entry is not ready.""" - pass - class InvalidStateError(HomeAssistantError): """When an invalid state is encountered.""" - pass + +class Unauthorized(HomeAssistantError): + """When an action is unauthorized.""" + + def __init__(self, context: Optional['Context'] = None, + user_id: Optional[str] = None, + entity_id: Optional[str] = None, + permission: Optional[Tuple[str]] = None) -> None: + """Unauthorized error.""" + super().__init__(self.__class__.__name__) + self.context = context + self.user_id = user_id + self.entity_id = entity_id + self.permission = permission + + +class UnknownUser(Unauthorized): + """When call is made with user ID that doesn't exist.""" diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 0f394a6f153f8..5e0d9c7e88afc 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -5,9 +5,10 @@ import voluptuous as vol +from homeassistant.auth.permissions.const import POLICY_CONTROL from homeassistant.const import ATTR_ENTITY_ID import homeassistant.core as ha -from homeassistant.exceptions import TemplateError +from homeassistant.exceptions import TemplateError, Unauthorized, UnknownUser from homeassistant.helpers import template from homeassistant.loader import get_component, bind_hass from homeassistant.util.yaml import load_yaml @@ -187,23 +188,75 @@ async def entity_service_call(hass, platforms, func, call): Calls all platforms simultaneously. """ - tasks = [] - all_entities = ATTR_ENTITY_ID not in call.data - if not all_entities: + if call.context.user_id: + user = await hass.auth.async_get_user(call.context.user_id) + if user is None: + raise UnknownUser(context=call.context) + perms = user.permissions + else: + perms = None + + # Are we trying to target all entities + target_all_entities = ATTR_ENTITY_ID not in call.data + + if not target_all_entities: + # A set of entities we're trying to target. entity_ids = set( extract_entity_ids(hass, call, True)) + # If the service function is a string, we'll pass it the service call data if isinstance(func, str): data = {key: val for key, val in call.data.items() if key != ATTR_ENTITY_ID} + # If the service function is not a string, we pass the service call else: data = call + # Check the permissions + + # A list with for each platform in platforms a list of entities to call + # the service on. + platforms_entities = [] + + if perms is None: + for platform in platforms: + if target_all_entities: + platforms_entities.append(list(platform.entities.values())) + else: + platforms_entities.append([ + entity for entity in platform.entities.values() + if entity.entity_id in entity_ids + ]) + + elif target_all_entities: + # If we target all entities, we will select all entities the user + # is allowed to control. + for platform in platforms: + platforms_entities.append([ + entity for entity in platform.entities.values() + if perms.check_entity(entity.entity_id, POLICY_CONTROL)]) + + else: + for platform in platforms: + platform_entities = [] + for entity in platform.entities.values(): + if entity.entity_id not in entity_ids: + continue + + if not perms.check_entity(entity.entity_id, POLICY_CONTROL): + raise Unauthorized( + context=call.context, + entity_id=entity.entity_id, + permission=POLICY_CONTROL + ) + + platform_entities.append(entity) + + platforms_entities.append(platform_entities) + tasks = [ - _handle_service_platform_call(func, data, [ - entity for entity in platform.entities.values() - if all_entities or entity.entity_id in entity_ids - ], call.context) for platform in platforms + _handle_service_platform_call(func, data, entities, call.context) + for platform, entities in zip(platforms, platforms_entities) ] if tasks: diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 252d0b1d872fe..2568a1092448e 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -3,6 +3,7 @@ import pytest +from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY from homeassistant.setup import async_setup_component from homeassistant.components.websocket_api.http import URL from homeassistant.components.websocket_api.auth import ( @@ -77,3 +78,19 @@ def hass_access_token(hass): refresh_token = hass.loop.run_until_complete( hass.auth.async_create_refresh_token(user, CLIENT_ID)) yield hass.auth.async_create_access_token(refresh_token) + + +@pytest.fixture +def hass_admin_user(hass): + """Return a Home Assistant admin user.""" + admin_group = hass.loop.run_until_complete(hass.auth.async_get_group( + GROUP_ID_ADMIN)) + return MockUser(groups=[admin_group]).add_to_hass(hass) + + +@pytest.fixture +def hass_read_only_user(hass): + """Return a Home Assistant read only user.""" + read_only_group = hass.loop.run_until_complete(hass.auth.async_get_group( + GROUP_ID_READ_ONLY)) + return MockUser(groups=[read_only_group]).add_to_hass(hass) diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py index 78ca72dd1e4ae..c8411bf2fdecc 100644 --- a/tests/components/counter/test_init.py +++ b/tests/components/counter/test_init.py @@ -234,7 +234,7 @@ def test_no_initial_state_and_no_restore_state(hass): assert int(state.state) == 0 -async def test_counter_context(hass): +async def test_counter_context(hass, hass_admin_user): """Test that counter context works.""" assert await async_setup_component(hass, 'counter', { 'counter': { @@ -247,9 +247,9 @@ async def test_counter_context(hass): await hass.services.async_call('counter', 'increment', { 'entity_id': state.entity_id, - }, True, Context(user_id='abcd')) + }, True, Context(user_id=hass_admin_user.id)) state2 = hass.states.get('counter.test') assert state2 is not None assert state.state != state2.state - assert state2.context.user_id == 'abcd' + assert state2.context.user_id == hass_admin_user.id diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index a04fb853996bc..09474a5ad064b 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -476,7 +476,7 @@ async def test_intent_set_color_and_brightness(hass): assert call.data.get(light.ATTR_BRIGHTNESS_PCT) == 20 -async def test_light_context(hass): +async def test_light_context(hass, hass_admin_user): """Test that light context works.""" assert await async_setup_component(hass, 'light', { 'light': { @@ -489,9 +489,9 @@ async def test_light_context(hass): await hass.services.async_call('light', 'toggle', { 'entity_id': state.entity_id, - }, True, core.Context(user_id='abcd')) + }, True, core.Context(user_id=hass_admin_user.id)) state2 = hass.states.get('light.ceiling') assert state2 is not None assert state.state != state2.state - assert state2.context.user_id == 'abcd' + assert state2.context.user_id == hass_admin_user.id diff --git a/tests/components/switch/test_init.py b/tests/components/switch/test_init.py index 1a51457df9621..d39c5a24ddc53 100644 --- a/tests/components/switch/test_init.py +++ b/tests/components/switch/test_init.py @@ -91,7 +91,7 @@ def test_setup_two_platforms(self): ) -async def test_switch_context(hass): +async def test_switch_context(hass, hass_admin_user): """Test that switch context works.""" assert await async_setup_component(hass, 'switch', { 'switch': { @@ -104,9 +104,9 @@ async def test_switch_context(hass): await hass.services.async_call('switch', 'toggle', { 'entity_id': state.entity_id, - }, True, core.Context(user_id='abcd')) + }, True, core.Context(user_id=hass_admin_user.id)) state2 = hass.states.get('switch.ac') assert state2 is not None assert state.state != state2.state - assert state2.context.user_id == 'abcd' + assert state2.context.user_id == hass_admin_user.id diff --git a/tests/components/test_input_boolean.py b/tests/components/test_input_boolean.py index a77e0a8c01023..019318c2693f5 100644 --- a/tests/components/test_input_boolean.py +++ b/tests/components/test_input_boolean.py @@ -147,7 +147,7 @@ def test_initial_state_overrules_restore_state(hass): assert state.state == 'on' -async def test_input_boolean_context(hass): +async def test_input_boolean_context(hass, hass_admin_user): """Test that input_boolean context works.""" assert await async_setup_component(hass, 'input_boolean', { 'input_boolean': { @@ -160,9 +160,9 @@ async def test_input_boolean_context(hass): await hass.services.async_call('input_boolean', 'turn_off', { 'entity_id': state.entity_id, - }, True, Context(user_id='abcd')) + }, True, Context(user_id=hass_admin_user.id)) state2 = hass.states.get('input_boolean.ac') assert state2 is not None assert state.state != state2.state - assert state2.context.user_id == 'abcd' + assert state2.context.user_id == hass_admin_user.id diff --git a/tests/components/test_input_datetime.py b/tests/components/test_input_datetime.py index 9649531a8a1d4..a61cefe34f2f6 100644 --- a/tests/components/test_input_datetime.py +++ b/tests/components/test_input_datetime.py @@ -195,7 +195,7 @@ def test_restore_state(hass): assert state_bogus.state == str(initial) -async def test_input_datetime_context(hass): +async def test_input_datetime_context(hass, hass_admin_user): """Test that input_datetime context works.""" assert await async_setup_component(hass, 'input_datetime', { 'input_datetime': { @@ -211,9 +211,9 @@ async def test_input_datetime_context(hass): await hass.services.async_call('input_datetime', 'set_datetime', { 'entity_id': state.entity_id, 'date': '2018-01-02' - }, True, Context(user_id='abcd')) + }, True, Context(user_id=hass_admin_user.id)) state2 = hass.states.get('input_datetime.only_date') assert state2 is not None assert state.state != state2.state - assert state2.context.user_id == 'abcd' + assert state2.context.user_id == hass_admin_user.id diff --git a/tests/components/test_input_number.py b/tests/components/test_input_number.py index 354c67b4d1b6e..70dfeec2e7fb8 100644 --- a/tests/components/test_input_number.py +++ b/tests/components/test_input_number.py @@ -266,7 +266,7 @@ def test_no_initial_state_and_no_restore_state(hass): assert float(state.state) == 0 -async def test_input_number_context(hass): +async def test_input_number_context(hass, hass_admin_user): """Test that input_number context works.""" assert await async_setup_component(hass, 'input_number', { 'input_number': { @@ -282,9 +282,9 @@ async def test_input_number_context(hass): await hass.services.async_call('input_number', 'increment', { 'entity_id': state.entity_id, - }, True, Context(user_id='abcd')) + }, True, Context(user_id=hass_admin_user.id)) state2 = hass.states.get('input_number.b1') assert state2 is not None assert state.state != state2.state - assert state2.context.user_id == 'abcd' + assert state2.context.user_id == hass_admin_user.id diff --git a/tests/components/test_input_select.py b/tests/components/test_input_select.py index f37566ffd7379..528560edc0495 100644 --- a/tests/components/test_input_select.py +++ b/tests/components/test_input_select.py @@ -302,7 +302,7 @@ def test_initial_state_overrules_restore_state(hass): assert state.state == 'middle option' -async def test_input_select_context(hass): +async def test_input_select_context(hass, hass_admin_user): """Test that input_select context works.""" assert await async_setup_component(hass, 'input_select', { 'input_select': { @@ -321,9 +321,9 @@ async def test_input_select_context(hass): await hass.services.async_call('input_select', 'select_next', { 'entity_id': state.entity_id, - }, True, Context(user_id='abcd')) + }, True, Context(user_id=hass_admin_user.id)) state2 = hass.states.get('input_select.s1') assert state2 is not None assert state.state != state2.state - assert state2.context.user_id == 'abcd' + assert state2.context.user_id == hass_admin_user.id diff --git a/tests/components/test_input_text.py b/tests/components/test_input_text.py index 7e8cec6ff8032..f0dec42ccea1f 100644 --- a/tests/components/test_input_text.py +++ b/tests/components/test_input_text.py @@ -184,7 +184,7 @@ def test_no_initial_state_and_no_restore_state(hass): assert str(state.state) == 'unknown' -async def test_input_text_context(hass): +async def test_input_text_context(hass, hass_admin_user): """Test that input_text context works.""" assert await async_setup_component(hass, 'input_text', { 'input_text': { @@ -200,9 +200,9 @@ async def test_input_text_context(hass): await hass.services.async_call('input_text', 'set_value', { 'entity_id': state.entity_id, 'value': 'new_value', - }, True, Context(user_id='abcd')) + }, True, Context(user_id=hass_admin_user.id)) state2 = hass.states.get('input_text.t1') assert state2 is not None assert state.state != state2.state - assert state2.context.user_id == 'abcd' + assert state2.context.user_id == hass_admin_user.id diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 71775574c2807..a4e9a5719434f 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1,18 +1,49 @@ """Test service helpers.""" import asyncio +from collections import OrderedDict from copy import deepcopy import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch + +import pytest # To prevent circular import when running just this file import homeassistant.components # noqa -from homeassistant import core as ha, loader +from homeassistant import core as ha, loader, exceptions from homeassistant.const import STATE_ON, STATE_OFF, ATTR_ENTITY_ID from homeassistant.helpers import service, template from homeassistant.setup import async_setup_component import homeassistant.helpers.config_validation as cv - -from tests.common import get_test_home_assistant, mock_service +from homeassistant.auth.permissions import PolicyPermissions + +from tests.common import get_test_home_assistant, mock_service, mock_coro + + +@pytest.fixture +def mock_service_platform_call(): + """Mock service platform call.""" + with patch('homeassistant.helpers.service._handle_service_platform_call', + side_effect=lambda *args: mock_coro()) as mock_call: + yield mock_call + + +@pytest.fixture +def mock_entities(): + """Return mock entities in an ordered dict.""" + kitchen = Mock( + entity_id='light.kitchen', + available=True, + should_poll=False, + ) + living_room = Mock( + entity_id='light.living_room', + available=True, + should_poll=False, + ) + entities = OrderedDict() + entities[kitchen.entity_id] = kitchen + entities[living_room.entity_id] = living_room + return entities class TestServiceHelpers(unittest.TestCase): @@ -179,3 +210,99 @@ def test_async_get_all_descriptions(hass): assert 'description' in descriptions[logger.DOMAIN]['set_level'] assert 'fields' in descriptions[logger.DOMAIN]['set_level'] + + +async def test_call_context_user_not_exist(hass): + """Check we don't allow deleted users to do things.""" + with pytest.raises(exceptions.UnknownUser) as err: + await service.entity_service_call(hass, [], Mock(), ha.ServiceCall( + 'test_domain', 'test_service', context=ha.Context( + user_id='non-existing'))) + + assert err.value.context.user_id == 'non-existing' + + +async def test_call_context_target_all(hass, mock_service_platform_call, + mock_entities): + """Check we only target allowed entities if targetting all.""" + with patch('homeassistant.auth.AuthManager.async_get_user', + return_value=mock_coro(Mock(permissions=PolicyPermissions({ + 'entities': { + 'entity_ids': { + 'light.kitchen': True + } + } + })))): + await service.entity_service_call(hass, [ + Mock(entities=mock_entities) + ], Mock(), ha.ServiceCall('test_domain', 'test_service', + context=ha.Context(user_id='mock-id'))) + + assert len(mock_service_platform_call.mock_calls) == 1 + entities = mock_service_platform_call.mock_calls[0][1][2] + assert entities == [mock_entities['light.kitchen']] + + +async def test_call_context_target_specific(hass, mock_service_platform_call, + mock_entities): + """Check targeting specific entities.""" + with patch('homeassistant.auth.AuthManager.async_get_user', + return_value=mock_coro(Mock(permissions=PolicyPermissions({ + 'entities': { + 'entity_ids': { + 'light.kitchen': True + } + } + })))): + await service.entity_service_call(hass, [ + Mock(entities=mock_entities) + ], Mock(), ha.ServiceCall('test_domain', 'test_service', { + 'entity_id': 'light.kitchen' + }, context=ha.Context(user_id='mock-id'))) + + assert len(mock_service_platform_call.mock_calls) == 1 + entities = mock_service_platform_call.mock_calls[0][1][2] + assert entities == [mock_entities['light.kitchen']] + + +async def test_call_context_target_specific_no_auth( + hass, mock_service_platform_call, mock_entities): + """Check targeting specific entities without auth.""" + with pytest.raises(exceptions.Unauthorized) as err: + with patch('homeassistant.auth.AuthManager.async_get_user', + return_value=mock_coro(Mock( + permissions=PolicyPermissions({})))): + await service.entity_service_call(hass, [ + Mock(entities=mock_entities) + ], Mock(), ha.ServiceCall('test_domain', 'test_service', { + 'entity_id': 'light.kitchen' + }, context=ha.Context(user_id='mock-id'))) + + assert err.value.context.user_id == 'mock-id' + assert err.value.entity_id == 'light.kitchen' + + +async def test_call_no_context_target_all(hass, mock_service_platform_call, + mock_entities): + """Check we target all if no user context given.""" + await service.entity_service_call(hass, [ + Mock(entities=mock_entities) + ], Mock(), ha.ServiceCall('test_domain', 'test_service')) + + assert len(mock_service_platform_call.mock_calls) == 1 + entities = mock_service_platform_call.mock_calls[0][1][2] + assert entities == list(mock_entities.values()) + + +async def test_call_no_context_target_specific( + hass, mock_service_platform_call, mock_entities): + """Check we can target specified entities.""" + await service.entity_service_call(hass, [ + Mock(entities=mock_entities) + ], Mock(), ha.ServiceCall('test_domain', 'test_service', { + 'entity_id': ['light.kitchen', 'light.non-existing'] + })) + + assert len(mock_service_platform_call.mock_calls) == 1 + entities = mock_service_platform_call.mock_calls[0][1][2] + assert entities == [mock_entities['light.kitchen']] From 3cde8dc3a95094a8f9e4fe17bda57ddf05143e63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 21 Nov 2018 12:38:42 +0100 Subject: [PATCH 008/325] Add support for HTTPS and basic HTTP authentication for Glances (#18608) * Add support for SSL and basic HTTP auth * Remove blank line at the end of the file --- homeassistant/components/sensor/glances.py | 32 ++++++++++++++-------- requirements_all.txt | 2 +- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/sensor/glances.py b/homeassistant/components/sensor/glances.py index c2127827ebdc8..1dfb7a206c6ee 100644 --- a/homeassistant/components/sensor/glances.py +++ b/homeassistant/components/sensor/glances.py @@ -11,14 +11,15 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PORT, CONF_RESOURCES, TEMP_CELSIUS) + CONF_HOST, CONF_NAME, CONF_PORT, CONF_USERNAME, CONF_PASSWORD, CONF_SSL, + CONF_VERIFY_SSL, CONF_RESOURCES, TEMP_CELSIUS) from homeassistant.exceptions import PlatformNotReady 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 = ['glances_api==0.1.0'] +REQUIREMENTS = ['glances_api==0.2.0'] _LOGGER = logging.getLogger(__name__) @@ -54,8 +55,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, vol.Optional(CONF_RESOURCES, default=['disk_use']): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): vol.In([2, 3]), @@ -67,15 +72,20 @@ async def async_setup_platform( """Set up the Glances sensors.""" from glances_api import Glances - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - version = config.get(CONF_VERSION) - var_conf = config.get(CONF_RESOURCES) - - session = async_get_clientsession(hass) + name = config[CONF_NAME] + host = config[CONF_HOST] + port = config[CONF_PORT] + version = config[CONF_VERSION] + var_conf = config[CONF_RESOURCES] + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + ssl = config[CONF_SSL] + verify_ssl = config[CONF_VERIFY_SSL] + + session = async_get_clientsession(hass, verify_ssl) glances = GlancesData( - Glances(hass.loop, session, host=host, port=port, version=version)) + Glances(hass.loop, session, host=host, port=port, version=version, + username=username, password=password, ssl=ssl)) await glances.async_update() diff --git a/requirements_all.txt b/requirements_all.txt index b6e21143e302a..2df07efc4fdcd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -428,7 +428,7 @@ ghlocalapi==0.1.0 gitterpy==0.1.7 # homeassistant.components.sensor.glances -glances_api==0.1.0 +glances_api==0.2.0 # homeassistant.components.notify.gntp gntp==1.0.3 From 1e3930a447201c9198f289377a7121147d059612 Mon Sep 17 00:00:00 2001 From: Jonathan McDowell Date: Wed, 21 Nov 2018 13:22:24 +0000 Subject: [PATCH 009/325] Add support for Panasonic Blu-Ray players (#18541) * Add support for Panasonic Blu-Ray players * Update panasonic_bluray.py * Update panasonic_bluray.py --- .coveragerc | 1 + .../media_player/panasonic_bluray.py | 154 ++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 158 insertions(+) create mode 100644 homeassistant/components/media_player/panasonic_bluray.py diff --git a/.coveragerc b/.coveragerc index a4fd6ea1c2eff..2a6446092e566 100644 --- a/.coveragerc +++ b/.coveragerc @@ -600,6 +600,7 @@ omit = homeassistant/components/media_player/nadtcp.py homeassistant/components/media_player/onkyo.py homeassistant/components/media_player/openhome.py + homeassistant/components/media_player/panasonic_bluray.py homeassistant/components/media_player/panasonic_viera.py homeassistant/components/media_player/pandora.py homeassistant/components/media_player/philips_js.py diff --git a/homeassistant/components/media_player/panasonic_bluray.py b/homeassistant/components/media_player/panasonic_bluray.py new file mode 100644 index 0000000000000..bcd34f162c7a6 --- /dev/null +++ b/homeassistant/components/media_player/panasonic_bluray.py @@ -0,0 +1,154 @@ +""" +Support for Panasonic Blu-Ray players. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/panasonic_bluray/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.media_player import ( + PLATFORM_SCHEMA, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_STOP, + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, MediaPlayerDevice) +from homeassistant.const import ( + CONF_HOST, CONF_NAME, STATE_IDLE, STATE_OFF, STATE_PLAYING, STATE_UNKNOWN) +import homeassistant.helpers.config_validation as cv +from homeassistant.util.dt import utcnow + +REQUIREMENTS = ['panacotta==0.1'] + +DEFAULT_NAME = "Panasonic Blu-Ray" +SCAN_INTERVAL = timedelta(seconds=30) + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_PANASONIC_BD = (SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY | + SUPPORT_STOP | SUPPORT_PAUSE) + +# No host is needed for configuration, however it can be set. +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Panasonic Blu-Ray platform.""" + conf = discovery_info if discovery_info else config + + # Register configured device with Home Assistant. + add_entities([PanasonicBluRay(conf[CONF_HOST], conf[CONF_NAME])]) + + +class PanasonicBluRay(MediaPlayerDevice): + """Represent Panasonic Blu-Ray devices for Home Assistant.""" + + def __init__(self, ip, name): + """Receive IP address and name to construct class.""" + # Import panacotta library. + import panacotta + + # Initialize the Panasonic device. + self._device = panacotta.PanasonicBD(ip) + # Default name value, only to be overridden by user. + self._name = name + # Assume we're off to start with + self._state = STATE_OFF + self._position = 0 + self._duration = 0 + self._position_valid = 0 + + @property + def icon(self): + """Return a disc player icon for the device.""" + return 'mdi:disc-player' + + @property + def name(self): + """Return the display name of this device.""" + return self._name + + @property + def state(self): + """Return _state variable, containing the appropriate constant.""" + return self._state + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_PANASONIC_BD + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self._duration + + @property + def media_position(self): + """Position of current playing media in seconds.""" + return self._position + + @property + def media_position_updated_at(self): + """When was the position of the current playing media valid.""" + return self._position_valid + + def update(self): + """Update the internal state by querying the device.""" + # This can take 5+ seconds to complete + state = self._device.get_play_status() + + if state[0] == 'error': + self._state = STATE_UNKNOWN + elif state[0] in ['off', 'standby']: + # We map both of these to off. If it's really off we can't + # turn it on, but from standby we can go to idle by pressing + # POWER. + self._state = STATE_OFF + elif state[0] in ['paused', 'stopped']: + self._state = STATE_IDLE + elif state[0] == 'playing': + self._state = STATE_PLAYING + + # Update our current media position + length + if state[1] >= 0: + self._position = state[1] + else: + self._position = 0 + self._position_valid = utcnow() + self._duration = state[2] + + def turn_off(self): + """ + Instruct the device to turn standby. + + Sending the "POWER" button will turn the device to standby - there + is no way to turn it completely off remotely. However this works in + our favour as it means the device is still accepting commands and we + can thus turn it back on when desired. + """ + if self._state != STATE_OFF: + self._device.send_key('POWER') + + self._state = STATE_OFF + + def turn_on(self): + """Wake the device back up from standby.""" + if self._state == STATE_OFF: + self._device.send_key('POWER') + + self._state = STATE_IDLE + + def media_play(self): + """Send play command.""" + self._device.send_key('PLAYBACK') + + def media_pause(self): + """Send pause command.""" + self._device.send_key('PAUSE') + + def media_stop(self): + """Send stop command.""" + self._device.send_key('STOP') diff --git a/requirements_all.txt b/requirements_all.txt index 2df07efc4fdcd..d6c5bc82b92f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -716,6 +716,9 @@ orvibo==1.1.1 # homeassistant.components.shiftr paho-mqtt==1.4.0 +# homeassistant.components.media_player.panasonic_bluray +panacotta==0.1 + # homeassistant.components.media_player.panasonic_viera panasonic_viera==0.3.1 From 81cac33801fced429b20f9743c8cc45f481f6432 Mon Sep 17 00:00:00 2001 From: Martin Gross Date: Wed, 21 Nov 2018 15:13:20 +0100 Subject: [PATCH 010/325] Update locationsharinglib requirement to 3.0.8 (#18612) --- homeassistant/components/device_tracker/google_maps.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/google_maps.py b/homeassistant/components/device_tracker/google_maps.py index 94a2033e7c01c..1995179ff5abe 100644 --- a/homeassistant/components/device_tracker/google_maps.py +++ b/homeassistant/components/device_tracker/google_maps.py @@ -19,7 +19,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify, dt as dt_util -REQUIREMENTS = ['locationsharinglib==3.0.7'] +REQUIREMENTS = ['locationsharinglib==3.0.8'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index d6c5bc82b92f3..0672b553eed9b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -594,7 +594,7 @@ liveboxplaytv==2.0.2 lmnotify==0.0.4 # homeassistant.components.device_tracker.google_maps -locationsharinglib==3.0.7 +locationsharinglib==3.0.8 # homeassistant.components.logi_circle logi_circle==0.1.7 From 92c0f9e4aa85f9cf475e882a010f738edef6098f Mon Sep 17 00:00:00 2001 From: Pawel Date: Wed, 21 Nov 2018 15:48:44 +0100 Subject: [PATCH 011/325] Fix mqtt cover inverted (#18456) * Fixed state and position retrieval in inverted mode 100-0 * Always calculating find_percentage_in_range * Added usage of max/min functions. --- homeassistant/components/cover/mqtt.py | 25 ++++++---- tests/components/cover/test_mqtt.py | 69 ++++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index 235b28b5be2ac..f51cca8a276de 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -279,21 +279,19 @@ def position_message_received(topic, payload, qos): if self._template is not None: payload = self._template.async_render_with_possible_json_value( payload) + if payload.isnumeric(): - if 0 <= int(payload) <= 100: - percentage_payload = int(payload) - else: - percentage_payload = self.find_percentage_in_range( - float(payload), COVER_PAYLOAD) - if 0 <= percentage_payload <= 100: - self._position = percentage_payload - self._state = self._position == self._position_closed + percentage_payload = self.find_percentage_in_range( + float(payload), COVER_PAYLOAD) + self._position = percentage_payload + self._state = percentage_payload == DEFAULT_POSITION_CLOSED else: _LOGGER.warning( "Payload is not integer within range: %s", payload) return self.async_schedule_update_ha_state() + if self._get_position_topic: await mqtt.async_subscribe( self.hass, self._get_position_topic, @@ -374,7 +372,8 @@ async def async_open_cover(self, **kwargs): # Optimistically assume that cover has changed state. self._state = False if self._get_position_topic: - self._position = self._position_open + self._position = self.find_percentage_in_range( + self._position_open, COVER_PAYLOAD) self.async_schedule_update_ha_state() async def async_close_cover(self, **kwargs): @@ -389,7 +388,8 @@ async def async_close_cover(self, **kwargs): # Optimistically assume that cover has changed state. self._state = True if self._get_position_topic: - self._position = self._position_closed + self._position = self.find_percentage_in_range( + self._position_closed, COVER_PAYLOAD) self.async_schedule_update_ha_state() async def async_stop_cover(self, **kwargs): @@ -469,6 +469,11 @@ def find_percentage_in_range(self, position, range_type=TILT_PAYLOAD): offset_position = position - min_range position_percentage = round( float(offset_position) / current_range * 100.0) + + max_percent = 100 + min_percent = 0 + position_percentage = min(max(position_percentage, min_percent), + max_percent) if range_type == TILT_PAYLOAD and self._tilt_invert: return 100 - position_percentage return position_percentage diff --git a/tests/components/cover/test_mqtt.py b/tests/components/cover/test_mqtt.py index 81c0848c4c5f5..26204ce6ebdf7 100644 --- a/tests/components/cover/test_mqtt.py +++ b/tests/components/cover/test_mqtt.py @@ -308,17 +308,80 @@ def test_current_cover_position(self): 'cover.test').attributes['current_position'] assert 50 == current_cover_position - fire_mqtt_message(self.hass, 'get-position-topic', '101') + fire_mqtt_message(self.hass, 'get-position-topic', 'non-numeric') self.hass.block_till_done() current_cover_position = self.hass.states.get( 'cover.test').attributes['current_position'] assert 50 == current_cover_position - fire_mqtt_message(self.hass, 'get-position-topic', 'non-numeric') + fire_mqtt_message(self.hass, 'get-position-topic', '101') self.hass.block_till_done() current_cover_position = self.hass.states.get( 'cover.test').attributes['current_position'] - assert 50 == current_cover_position + assert 100 == current_cover_position + + def test_current_cover_position_inverted(self): + """Test the current cover position.""" + assert setup_component(self.hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'position_topic': 'get-position-topic', + 'command_topic': 'command-topic', + 'position_open': 0, + 'position_closed': 100, + 'payload_open': 'OPEN', + 'payload_close': 'CLOSE', + 'payload_stop': 'STOP' + } + }) + + state_attributes_dict = self.hass.states.get( + 'cover.test').attributes + assert not ('current_position' in state_attributes_dict) + assert not ('current_tilt_position' in state_attributes_dict) + assert not (4 & self.hass.states.get( + 'cover.test').attributes['supported_features'] == 4) + + fire_mqtt_message(self.hass, 'get-position-topic', '100') + self.hass.block_till_done() + current_percentage_cover_position = self.hass.states.get( + 'cover.test').attributes['current_position'] + assert 0 == current_percentage_cover_position + assert STATE_CLOSED == self.hass.states.get( + 'cover.test').state + + fire_mqtt_message(self.hass, 'get-position-topic', '0') + self.hass.block_till_done() + current_percentage_cover_position = self.hass.states.get( + 'cover.test').attributes['current_position'] + assert 100 == current_percentage_cover_position + assert STATE_OPEN == self.hass.states.get( + 'cover.test').state + + fire_mqtt_message(self.hass, 'get-position-topic', '50') + self.hass.block_till_done() + current_percentage_cover_position = self.hass.states.get( + 'cover.test').attributes['current_position'] + assert 50 == current_percentage_cover_position + assert STATE_OPEN == self.hass.states.get( + 'cover.test').state + + fire_mqtt_message(self.hass, 'get-position-topic', 'non-numeric') + self.hass.block_till_done() + current_percentage_cover_position = self.hass.states.get( + 'cover.test').attributes['current_position'] + assert 50 == current_percentage_cover_position + assert STATE_OPEN == self.hass.states.get( + 'cover.test').state + + fire_mqtt_message(self.hass, 'get-position-topic', '101') + self.hass.block_till_done() + current_percentage_cover_position = self.hass.states.get( + 'cover.test').attributes['current_position'] + assert 0 == current_percentage_cover_position + assert STATE_CLOSED == self.hass.states.get( + 'cover.test').state def test_set_cover_position(self): """Test setting cover position.""" From 708ababd78dcc68925f3f583eabbc17d48563960 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 21 Nov 2018 19:58:56 +0100 Subject: [PATCH 012/325] Upgrade requests to 2.20.1 (#18615) --- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7e225cceaee15..11f9659170549 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,7 +10,7 @@ cryptography==2.3.1 pip>=8.0.3 pytz>=2018.04 pyyaml>=3.13,<4 -requests==2.20.0 +requests==2.20.1 ruamel.yaml==0.15.78 voluptuous==0.11.5 voluptuous-serialize==2.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0672b553eed9b..e337222d405f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -11,7 +11,7 @@ cryptography==2.3.1 pip>=8.0.3 pytz>=2018.04 pyyaml>=3.13,<4 -requests==2.20.0 +requests==2.20.1 ruamel.yaml==0.15.78 voluptuous==0.11.5 voluptuous-serialize==2.0.0 diff --git a/setup.py b/setup.py index 9e24362fe8a2b..49147afdd705f 100755 --- a/setup.py +++ b/setup.py @@ -45,7 +45,7 @@ 'pip>=8.0.3', 'pytz>=2018.04', 'pyyaml>=3.13,<4', - 'requests==2.20.0', + 'requests==2.20.1', 'ruamel.yaml==0.15.78', 'voluptuous==0.11.5', 'voluptuous-serialize==2.0.0', From 49121f2347e0d83c6d0e9d4f59678140208d841a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Nov 2018 20:18:56 +0100 Subject: [PATCH 013/325] Update translations --- .../dialogflow/.translations/de.json | 10 +++ .../dialogflow/.translations/nl.json | 15 +++++ .../homematicip_cloud/.translations/ko.json | 2 +- .../components/hue/.translations/it.json | 2 +- .../luftdaten/.translations/de.json | 17 ++++++ .../luftdaten/.translations/nl.json | 19 ++++++ .../luftdaten/.translations/zh-Hans.json | 19 ++++++ .../components/point/.translations/ca.json | 32 ++++++++++ .../components/point/.translations/en.json | 61 +++++++++---------- .../components/point/.translations/it.json | 12 ++++ .../components/point/.translations/ko.json | 32 ++++++++++ .../components/point/.translations/lb.json | 32 ++++++++++ .../components/point/.translations/ru.json | 28 +++++++++ .../point/.translations/zh-Hans.json | 11 ++++ .../rainmachine/.translations/ca.json | 19 ++++++ .../rainmachine/.translations/de.json | 19 ++++++ .../rainmachine/.translations/nl.json | 19 ++++++ .../rainmachine/.translations/no.json | 19 ++++++ .../rainmachine/.translations/zh-Hans.json | 16 +++++ .../rainmachine/.translations/zh-Hant.json | 19 ++++++ .../components/twilio/.translations/de.json | 5 ++ .../components/twilio/.translations/nl.json | 1 + .../components/unifi/.translations/de.json | 16 +++++ .../components/upnp/.translations/nl.json | 1 + 24 files changed, 393 insertions(+), 33 deletions(-) create mode 100644 homeassistant/components/dialogflow/.translations/de.json create mode 100644 homeassistant/components/dialogflow/.translations/nl.json create mode 100644 homeassistant/components/luftdaten/.translations/de.json create mode 100644 homeassistant/components/luftdaten/.translations/nl.json create mode 100644 homeassistant/components/luftdaten/.translations/zh-Hans.json create mode 100644 homeassistant/components/point/.translations/ca.json create mode 100644 homeassistant/components/point/.translations/it.json create mode 100644 homeassistant/components/point/.translations/ko.json create mode 100644 homeassistant/components/point/.translations/lb.json create mode 100644 homeassistant/components/point/.translations/ru.json create mode 100644 homeassistant/components/point/.translations/zh-Hans.json create mode 100644 homeassistant/components/rainmachine/.translations/ca.json create mode 100644 homeassistant/components/rainmachine/.translations/de.json create mode 100644 homeassistant/components/rainmachine/.translations/nl.json create mode 100644 homeassistant/components/rainmachine/.translations/no.json create mode 100644 homeassistant/components/rainmachine/.translations/zh-Hans.json create mode 100644 homeassistant/components/rainmachine/.translations/zh-Hant.json create mode 100644 homeassistant/components/twilio/.translations/de.json create mode 100644 homeassistant/components/unifi/.translations/de.json diff --git a/homeassistant/components/dialogflow/.translations/de.json b/homeassistant/components/dialogflow/.translations/de.json new file mode 100644 index 0000000000000..e10d890b50154 --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/de.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "user": { + "title": "Dialogflow Webhook einrichten" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/nl.json b/homeassistant/components/dialogflow/.translations/nl.json new file mode 100644 index 0000000000000..5a28d6be9ac2d --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Uw Home Assistant instantie moet toegankelijk zijn vanaf het internet om Dialogflow-berichten te ontvangen.", + "one_instance_allowed": "Slechts \u00e9\u00e9n instantie is nodig." + }, + "step": { + "user": { + "description": "Weet u zeker dat u Dialogflow wilt instellen?", + "title": "Stel de Twilio Dialogflow in" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/ko.json b/homeassistant/components/homematicip_cloud/.translations/ko.json index 46ef55c9eca31..b60da944f648c 100644 --- a/homeassistant/components/homematicip_cloud/.translations/ko.json +++ b/homeassistant/components/homematicip_cloud/.translations/ko.json @@ -21,7 +21,7 @@ "title": "HomematicIP \uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8 \uc120\ud0dd" }, "link": { - "description": "Home Assistant \uc5d0 HomematicIP \ub97c \ub4f1\ub85d\ud558\ub824\uba74 \uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8\uc758 \ud30c\ub780\uc0c9 \ubc84\ud2bc\uacfc \uc11c\ubc0b \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694.\n\n![\ube0c\ub9bf\uc9c0\uc758 \ubc84\ud2bc \uc704\uce58 \ubcf4\uae30](/static/images/config_flows/config_homematicip_cloud.png)", + "description": "Home Assistant \uc5d0 HomematicIP \ub97c \ub4f1\ub85d\ud558\ub824\uba74 \uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8\uc758 \ud30c\ub780\uc0c9 \ubc84\ud2bc\uacfc Submit \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694.\n\n![\ube0c\ub9bf\uc9c0\uc758 \ubc84\ud2bc \uc704\uce58 \ubcf4\uae30](/static/images/config_flows/config_homematicip_cloud.png)", "title": "\uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8\uc5d0 \uc5f0\uacb0" } }, diff --git a/homeassistant/components/hue/.translations/it.json b/homeassistant/components/hue/.translations/it.json index a9f2a732127a2..72b2fd6445bf3 100644 --- a/homeassistant/components/hue/.translations/it.json +++ b/homeassistant/components/hue/.translations/it.json @@ -17,7 +17,7 @@ "data": { "host": "Host" }, - "title": "Selezione il bridge Hue" + "title": "Seleziona il bridge Hue" }, "link": { "description": "Premi il pulsante sul bridge per registrare Philips Hue con Home Assistant\n\n![Posizione del pulsante sul bridge](/static/images/config_philips_hue.jpg)", diff --git a/homeassistant/components/luftdaten/.translations/de.json b/homeassistant/components/luftdaten/.translations/de.json new file mode 100644 index 0000000000000..136b907df8184 --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/de.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "communication_error": "Keine Kommunikation mit Lufdaten API m\u00f6glich", + "invalid_sensor": "Sensor nicht verf\u00fcgbar oder ung\u00fcltig", + "sensor_exists": "Sensor bereits registriert" + }, + "step": { + "user": { + "data": { + "show_on_map": "Auf Karte anzeigen" + } + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/nl.json b/homeassistant/components/luftdaten/.translations/nl.json new file mode 100644 index 0000000000000..3284b581f5fe3 --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/nl.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "Kan niet communiceren met de Luftdaten API", + "invalid_sensor": "Sensor niet beschikbaar of ongeldig", + "sensor_exists": "Sensor bestaat al" + }, + "step": { + "user": { + "data": { + "show_on_map": "Toon op kaart", + "station_id": "Luftdaten Sensor ID" + }, + "title": "Definieer Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/zh-Hans.json b/homeassistant/components/luftdaten/.translations/zh-Hans.json new file mode 100644 index 0000000000000..375a08d8a4571 --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/zh-Hans.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "\u65e0\u6cd5\u4e0e Luftdaten API \u901a\u4fe1", + "invalid_sensor": "\u4f20\u611f\u5668\u4e0d\u53ef\u7528\u6216\u65e0\u6548", + "sensor_exists": "\u4f20\u611f\u5668\u5df2\u6ce8\u518c" + }, + "step": { + "user": { + "data": { + "show_on_map": "\u5728\u5730\u56fe\u4e0a\u663e\u793a", + "station_id": "Luftdaten \u4f20\u611f\u5668 ID" + }, + "title": "\u5b9a\u4e49 Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/ca.json b/homeassistant/components/point/.translations/ca.json new file mode 100644 index 0000000000000..6298b29f2689c --- /dev/null +++ b/homeassistant/components/point/.translations/ca.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_setup": "Nom\u00e9s podeu configurar un compte de Point.", + "authorize_url_fail": "S'ha produ\u00eft un error desconegut al generar l'URL d'autoritzaci\u00f3.", + "authorize_url_timeout": "S'ha acabat el temps d'espera mentre \u00e9s generava l'URL d'autoritzaci\u00f3.", + "external_setup": "Point s'ha configurat correctament des d'un altre lloc.", + "no_flows": "Necessiteu configurar Point abans de poder autenticar-vos-hi. [Llegiu les instruccions](https://www.home-assistant.io/components/point/)." + }, + "create_entry": { + "default": "Autenticaci\u00f3 exitosa amb Minut per als vostres dispositiu/s Point." + }, + "error": { + "follow_link": "Si us plau seguiu l'enlla\u00e7 i autentiqueu-vos abans de pr\u00e9mer Enviar", + "no_token": "No s'ha autenticat amb Minut" + }, + "step": { + "auth": { + "description": "Aneu a l'enlla\u00e7 seg\u00fcent i Accepta l'acc\u00e9s al vostre compte de Minut, despr\u00e9s torneu i premeu Enviar (a sota). \n\n[Enlla\u00e7]({authorization_url})", + "title": "Autenticar Point" + }, + "user": { + "data": { + "flow_impl": "Prove\u00efdor" + }, + "description": "Trieu a trav\u00e9s de quin prove\u00efdor d'autenticaci\u00f3 us voleu autenticar amb Point.", + "title": "Prove\u00efdor d'autenticaci\u00f3" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/en.json b/homeassistant/components/point/.translations/en.json index fed892113c304..705ac59b98d01 100644 --- a/homeassistant/components/point/.translations/en.json +++ b/homeassistant/components/point/.translations/en.json @@ -1,33 +1,32 @@ { - "config": { - "title": "Minut Point", - "step": { - "user": { - "title": "Authentication Provider", - "description": "Pick via which authentication provider you want to authenticate with Point.", - "data": { - "flow_impl": "Provider" - } - }, - "auth": { - "title": "Authenticate Point", - "description": "Please follow the link below and Accept access to your Minut account, then come back and press Submit below.\n\n[Link]({authorization_url})" - } - }, - "create_entry": { - "default": "Successfully authenticated with Minut for your Point device(s)" - }, - "error": { - "no_token": "Not authenticated with Minut", - "follow_link": "Please follow the link and authenticate before pressing Submit" - }, - "abort": { - "already_setup": "You can only configure a Point account.", - "external_setup": "Point successfully configured from another flow.", - "no_flows": "You need to configure Point before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/point/).", - "authorize_url_timeout": "Timeout generating authorize url.", - "authorize_url_fail": "Unknown error generating an authorize url." + "config": { + "abort": { + "already_setup": "You can only configure a Point account.", + "authorize_url_fail": "Unknown error generating an authorize url.", + "authorize_url_timeout": "Timeout generating authorize url.", + "external_setup": "Point successfully configured from another flow.", + "no_flows": "You need to configure Point before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/point/)." + }, + "create_entry": { + "default": "Successfully authenticated with Minut for your Point device(s)" + }, + "error": { + "follow_link": "Please follow the link and authenticate before pressing Submit", + "no_token": "Not authenticated with Minut" + }, + "step": { + "auth": { + "description": "Please follow the link below and Accept access to your Minut account, then come back and press Submit below.\n\n[Link]({authorization_url})", + "title": "Authenticate Point" + }, + "user": { + "data": { + "flow_impl": "Provider" + }, + "description": "Pick via which authentication provider you want to authenticate with Point.", + "title": "Authentication Provider" + } + }, + "title": "Minut Point" } - } -} - \ No newline at end of file +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/it.json b/homeassistant/components/point/.translations/it.json new file mode 100644 index 0000000000000..00e2cb02358f7 --- /dev/null +++ b/homeassistant/components/point/.translations/it.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "flow_impl": "Provider" + }, + "title": "Provider di autenticazione" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/ko.json b/homeassistant/components/point/.translations/ko.json new file mode 100644 index 0000000000000..fcc9a92bd5eed --- /dev/null +++ b/homeassistant/components/point/.translations/ko.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_setup": "Point \uacc4\uc815 \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", + "authorize_url_fail": "\uc778\uc99d url \uc0dd\uc131\uc5d0 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", + "authorize_url_timeout": "\uc778\uc99d url \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "external_setup": "Point \uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4.", + "no_flows": "Point \ub97c \uc778\uc99d\ud558\uae30 \uc804\uc5d0 Point \ub97c \uad6c\uc131\ud574\uc57c \ud569\ub2c8\ub2e4. [\uc548\ub0b4](https://www.home-assistant.io/components/point/) \ub97c \uc77d\uc5b4\ubcf4\uc138\uc694." + }, + "create_entry": { + "default": "Point \uc7a5\uce58\ub294 Minut \ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "follow_link": "Submit \ubc84\ud2bc\uc744 \ub204\ub974\uae30 \uc804\uc5d0 \ub9c1\ud06c\ub97c \ub530\ub77c \uc778\uc99d\uc744 \ubc1b\uc544\uc8fc\uc138\uc694", + "no_token": "Minut \ub85c \uc778\uc99d\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4" + }, + "step": { + "auth": { + "description": "\uc544\ub798 \ub9c1\ud06c\ub97c \ud074\ub9ad\ud558\uc5ec Minut \uacc4\uc815\uc5d0 \ub300\ud55c \ub3d9\uc758 \ud55c \ub2e4\uc74c, \ub2e4\uc2dc \ub3cc\uc544\uc640\uc11c \ud558\ub2e8\uc758 Submit \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694. \n\n [\ub9c1\ud06c] ( {authorization_url} )", + "title": "Point \uc778\uc99d" + }, + "user": { + "data": { + "flow_impl": "\uacf5\uae09\uc790" + }, + "description": "Point\ub85c \uc778\uc99d\ud558\ub824\ub294 \uc778\uc99d \uacf5\uae09\uc790\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694.", + "title": "\uc778\uc99d \uacf5\uae09\uc790" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/lb.json b/homeassistant/components/point/.translations/lb.json new file mode 100644 index 0000000000000..571f461721578 --- /dev/null +++ b/homeassistant/components/point/.translations/lb.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_setup": "Dir k\u00ebnnt n\u00ebmmen een eenzegen Point Kont konfigur\u00e9ieren.", + "authorize_url_fail": "Onbekannte Feeler beim gener\u00e9ieren vun der Autorisatiouns URL.", + "authorize_url_timeout": "Z\u00e4it Iwwerschreidung beim gener\u00e9ieren vun der Autorisatiouns URL.", + "external_setup": "Point gouf vun engem anere Floss erfollegr\u00e4ich konfigur\u00e9iert.", + "no_flows": "Dir musst Point konfigur\u00e9ieren, ier Dir d\u00ebs Authentifiz\u00e9ierung k\u00ebnnt benotzen.[Liest w.e.g. d'Instruktioune](https://www.home-assistant.io/components/point/)." + }, + "create_entry": { + "default": "Erfollegr\u00e4ich mat Minut authentifiz\u00e9iert fir \u00e4r Point Apparater" + }, + "error": { + "follow_link": "Follegt w.e.g dem Link an authentifiz\u00e9iert iech ier de op Ofsch\u00e9cken dr\u00e9ckt", + "no_token": "Net mat Minut authentifiz\u00e9iert" + }, + "step": { + "auth": { + "description": "Follegt dem Link \u00ebnnendr\u00ebnner an accept\u00e9iert den Acc\u00e8s zu \u00e4rem Minut Kont , dann kommt zer\u00e9ck heihin an dr\u00e9ck op ofsch\u00e9cken hei \u00ebnnen.\n\n[Link]({authorization_url})", + "title": "Point authentifiz\u00e9ieren" + }, + "user": { + "data": { + "flow_impl": "Ubidder" + }, + "description": "Wielt den Authentifikatioun Ubidder deen sech mat Point verbanne soll.", + "title": "Authentifikatioun Ubidder" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/ru.json b/homeassistant/components/point/.translations/ru.json new file mode 100644 index 0000000000000..1257e1a7f016b --- /dev/null +++ b/homeassistant/components/point/.translations/ru.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_setup": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "authorize_url_fail": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u0438 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.", + "authorize_url_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.", + "no_flows": "\u0412\u0430\u043c \u043d\u0443\u0436\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Point \u043f\u0435\u0440\u0435\u0434 \u0442\u0435\u043c, \u043a\u0430\u043a \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e. [\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/components/point/)." + }, + "error": { + "follow_link": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e \u0441\u0441\u044b\u043b\u043a\u0435 \u0438 \u043f\u0440\u043e\u0439\u0434\u0438\u0442\u0435 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443 \u043f\u043e\u0434\u043b\u0438\u043d\u043d\u043e\u0441\u0442\u0438, \u043f\u0440\u0435\u0436\u0434\u0435 \u0447\u0435\u043c \u043d\u0430\u0436\u0430\u0442\u044c \u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c", + "no_token": "\u041d\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d \u0432\u0445\u043e\u0434 \u0432 Minut" + }, + "step": { + "auth": { + "description": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e [\u0441\u0441\u044b\u043b\u043a\u0435]({authorization_url}) \u0438 \u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u0435 \u0434\u043e\u0441\u0442\u0443\u043f \u043a \u0412\u0430\u0448\u0435\u0439 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Minut, \u0437\u0430\u0442\u0435\u043c \u0432\u0435\u0440\u043d\u0438\u0442\u0435\u0441\u044c \u0441\u044e\u0434\u0430 \u0438 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c.", + "title": "\u0412\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0432\u0445\u043e\u0434 \u0432 Point" + }, + "user": { + "data": { + "flow_impl": "\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435, \u0447\u0435\u0440\u0435\u0437 \u043a\u0430\u043a\u043e\u0433\u043e \u043f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 \u0432\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0432\u0445\u043e\u0434 \u0432 Point.", + "title": "\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/zh-Hans.json b/homeassistant/components/point/.translations/zh-Hans.json new file mode 100644 index 0000000000000..7d88bfeec42ae --- /dev/null +++ b/homeassistant/components/point/.translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "description": "\u9009\u62e9\u60a8\u60f3\u901a\u8fc7\u54ea\u4e2a\u6388\u6743\u63d0\u4f9b\u8005\u4e0e Point \u8fdb\u884c\u6388\u6743\u3002", + "title": "\u6388\u6743\u63d0\u4f9b\u8005" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/ca.json b/homeassistant/components/rainmachine/.translations/ca.json new file mode 100644 index 0000000000000..7a1459cff6b1d --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/ca.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Aquest compte ja est\u00e0 registrat", + "invalid_credentials": "Credencials inv\u00e0lides" + }, + "step": { + "user": { + "data": { + "ip_address": "Nom de l'amfitri\u00f3 o adre\u00e7a IP", + "password": "Contrasenya", + "port": "Port" + }, + "title": "Introdu\u00efu la vostra informaci\u00f3" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/de.json b/homeassistant/components/rainmachine/.translations/de.json new file mode 100644 index 0000000000000..c262fa5a6521d --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/de.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Konto bereits registriert", + "invalid_credentials": "Ung\u00fcltige Anmeldeinformationen" + }, + "step": { + "user": { + "data": { + "ip_address": "Hostname oder IP-Adresse", + "password": "Passwort", + "port": "Port" + }, + "title": "Informationen eingeben" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/nl.json b/homeassistant/components/rainmachine/.translations/nl.json new file mode 100644 index 0000000000000..2e1e62c683c01 --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/nl.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Account bestaat al", + "invalid_credentials": "Ongeldige gebruikersgegevens" + }, + "step": { + "user": { + "data": { + "ip_address": "Hostnaam of IP-adres", + "password": "Wachtwoord", + "port": "Poort" + }, + "title": "Vul uw gegevens in" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/no.json b/homeassistant/components/rainmachine/.translations/no.json new file mode 100644 index 0000000000000..5ec4e5fdc3458 --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/no.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Konto er allerede registrert", + "invalid_credentials": "Ugyldig legitimasjon" + }, + "step": { + "user": { + "data": { + "ip_address": "Vertsnavn eller IP-adresse", + "password": "Passord", + "port": "Port" + }, + "title": "Fyll ut informasjonen din" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/zh-Hans.json b/homeassistant/components/rainmachine/.translations/zh-Hans.json new file mode 100644 index 0000000000000..7c6f07a7edd3c --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/zh-Hans.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "identifier_exists": "\u5e10\u6237\u5df2\u6ce8\u518c" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801", + "port": "\u7aef\u53e3" + }, + "title": "\u586b\u5199\u60a8\u7684\u4fe1\u606f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/zh-Hant.json b/homeassistant/components/rainmachine/.translations/zh-Hant.json new file mode 100644 index 0000000000000..518cc54192f8b --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/zh-Hant.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "\u5e33\u865f\u5df2\u8a3b\u518a", + "invalid_credentials": "\u6191\u8b49\u7121\u6548" + }, + "step": { + "user": { + "data": { + "ip_address": "\u4e3b\u6a5f\u540d\u6216 IP \u4f4d\u5740", + "password": "\u5bc6\u78bc", + "port": "\u901a\u8a0a\u57e0" + }, + "title": "\u586b\u5beb\u8cc7\u8a0a" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/twilio/.translations/de.json b/homeassistant/components/twilio/.translations/de.json new file mode 100644 index 0000000000000..86e5d9051b339 --- /dev/null +++ b/homeassistant/components/twilio/.translations/de.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Twilio" + } +} \ No newline at end of file diff --git a/homeassistant/components/twilio/.translations/nl.json b/homeassistant/components/twilio/.translations/nl.json index a053bf372a54c..fc8b5c0826123 100644 --- a/homeassistant/components/twilio/.translations/nl.json +++ b/homeassistant/components/twilio/.translations/nl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "not_internet_accessible": "Uw Home Assistant instantie moet toegankelijk zijn vanaf het internet om Twillo-berichten te ontvangen.", "one_instance_allowed": "Slechts \u00e9\u00e9n exemplaar is nodig." }, "step": { diff --git a/homeassistant/components/unifi/.translations/de.json b/homeassistant/components/unifi/.translations/de.json new file mode 100644 index 0000000000000..346c193735595 --- /dev/null +++ b/homeassistant/components/unifi/.translations/de.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "user_privilege": "Der Benutzer muss Administrator sein" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "port": "Port", + "username": "Benutzername" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/upnp/.translations/nl.json b/homeassistant/components/upnp/.translations/nl.json index 647eb647f24d7..c6939f9a0a7f4 100644 --- a/homeassistant/components/upnp/.translations/nl.json +++ b/homeassistant/components/upnp/.translations/nl.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "UPnP / IGD is al geconfigureerd", + "incomplete_device": "Onvolledig UPnP-apparaat negeren", "no_devices_discovered": "Geen UPnP / IGD's ontdekt", "no_sensors_or_port_mapping": "Schakel ten minste sensoren of poorttoewijzing in" }, From 4e58eb8baeb15b91ff80725c4d0a793a2d8ae655 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Nov 2018 20:35:46 +0100 Subject: [PATCH 014/325] Updated frontend to 20181121.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 2f723af2b1367..3768a59788e02 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20181112.0'] +REQUIREMENTS = ['home-assistant-frontend==20181121.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index e337222d405f1..4ddc81686b4ae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -485,7 +485,7 @@ hole==0.3.0 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181112.0 +home-assistant-frontend==20181121.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4ed7510cae0b0..6ebc180908e76 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -97,7 +97,7 @@ hdate==0.7.5 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181112.0 +home-assistant-frontend==20181121.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From fd7fff2ce8d759db9eaa74c4823622471691a0a6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Nov 2018 20:50:11 +0100 Subject: [PATCH 015/325] Version bump to 0.83.0b0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 72fc2165d2884..29e01faaa4812 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 83 -PATCH_VERSION = '0.dev0' +PATCH_VERSION = '0b0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 5b3e9399a92c8678a6f9af96a5f225fec16e676e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Nov 2018 20:53:44 +0100 Subject: [PATCH 016/325] Bump to 0.84.0.dev0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 72fc2165d2884..651a395b4680c 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 = 83 +MINOR_VERSION = 84 PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) From 1341ecd2eb8b03022336f11b1e574eaabc93e678 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Nov 2018 20:55:21 +0100 Subject: [PATCH 017/325] Use proper signals (#18613) * Emulated Hue not use deprecated handler * Remove no longer needed workaround * Add middleware directly * Dont always load the ban config file * Update homeassistant/components/http/ban.py Co-Authored-By: balloob * Update __init__.py --- .../components/emulated_hue/__init__.py | 27 +++++++++---------- homeassistant/components/http/__init__.py | 7 +---- homeassistant/components/http/auth.py | 6 +---- homeassistant/components/http/ban.py | 17 ++++++------ homeassistant/components/http/real_ip.py | 6 +---- tests/components/conftest.py | 10 ++++++- tests/components/http/test_ban.py | 15 ++++++----- 7 files changed, 43 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 5f1d61dd602b6..9c0df0f9f0342 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -97,8 +97,8 @@ async def async_setup(hass, yaml_config): app._on_startup.freeze() await app.startup() - handler = None - server = None + runner = None + site = None DescriptionXmlView(config).register(app, app.router) HueUsernameView().register(app, app.router) @@ -115,25 +115,24 @@ async def async_setup(hass, yaml_config): async def stop_emulated_hue_bridge(event): """Stop the emulated hue bridge.""" upnp_listener.stop() - if server: - server.close() - await server.wait_closed() - await app.shutdown() - if handler: - await handler.shutdown(10) - await app.cleanup() + if site: + await site.stop() + if runner: + await runner.cleanup() async def start_emulated_hue_bridge(event): """Start the emulated hue bridge.""" upnp_listener.start() - nonlocal handler - nonlocal server + nonlocal site + nonlocal runner - handler = app.make_handler(loop=hass.loop) + runner = web.AppRunner(app) + await runner.setup() + + site = web.TCPSite(runner, config.host_ip_addr, config.listen_port) try: - server = await hass.loop.create_server( - handler, config.host_ip_addr, config.listen_port) + await site.start() except OSError as error: _LOGGER.error("Failed to create HTTP server at port %d: %s", config.listen_port, error) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 1b22f8e62d431..7180002430aad 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -302,12 +302,6 @@ async def serve_file(request): async def start(self): """Start the aiohttp server.""" - # We misunderstood the startup signal. You're not allowed to change - # anything during startup. Temp workaround. - # pylint: disable=protected-access - self.app._on_startup.freeze() - await self.app.startup() - if self.ssl_certificate: try: if self.ssl_profile == SSL_INTERMEDIATE: @@ -335,6 +329,7 @@ async def start(self): # However in Home Assistant components can be discovered after boot. # This will now raise a RunTimeError. # To work around this we now prevent the router from getting frozen + # pylint: disable=protected-access self.app._router.freeze = lambda: None self.runner = web.AppRunner(self.app) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 64ee7fb8a3fb1..1f89dc5e4ca25 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -96,11 +96,7 @@ async def auth_middleware(request, handler): request[KEY_AUTHENTICATED] = authenticated return await handler(request) - async def auth_startup(app): - """Initialize auth middleware when app starts up.""" - app.middlewares.append(auth_middleware) - - app.on_startup.append(auth_startup) + app.middlewares.append(auth_middleware) def _is_trusted_ip(request, trusted_networks): diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 2a25de96edc1d..d6d7168ce6d75 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -9,7 +9,7 @@ from aiohttp.web_exceptions import HTTPForbidden, HTTPUnauthorized import voluptuous as vol -from homeassistant.core import callback +from homeassistant.core import callback, HomeAssistant from homeassistant.config import load_yaml_config_file from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv @@ -36,13 +36,14 @@ @callback def setup_bans(hass, app, login_threshold): """Create IP Ban middleware for the app.""" + app.middlewares.append(ban_middleware) + app[KEY_FAILED_LOGIN_ATTEMPTS] = defaultdict(int) + app[KEY_LOGIN_THRESHOLD] = login_threshold + async def ban_startup(app): """Initialize bans when app starts up.""" - app.middlewares.append(ban_middleware) - app[KEY_BANNED_IPS] = await 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[KEY_BANNED_IPS] = await async_load_ip_bans_config( + hass, hass.config.path(IP_BANS_FILE)) app.on_startup.append(ban_startup) @@ -149,7 +150,7 @@ def __init__(self, ip_ban: str, banned_at: datetime = None) -> None: self.banned_at = banned_at or datetime.utcnow() -def load_ip_bans_config(path: str): +async def async_load_ip_bans_config(hass: HomeAssistant, path: str): """Load list of banned IPs from config file.""" ip_list = [] @@ -157,7 +158,7 @@ def load_ip_bans_config(path: str): return ip_list try: - list_ = load_yaml_config_file(path) + list_ = await hass.async_add_executor_job(load_yaml_config_file, path) except HomeAssistantError as err: _LOGGER.error('Unable to load %s: %s', path, str(err)) return ip_list diff --git a/homeassistant/components/http/real_ip.py b/homeassistant/components/http/real_ip.py index f8adc815fdef2..27a8550ab8cac 100644 --- a/homeassistant/components/http/real_ip.py +++ b/homeassistant/components/http/real_ip.py @@ -33,8 +33,4 @@ async def real_ip_middleware(request, handler): return await handler(request) - async def app_startup(app): - """Initialize bans when app starts up.""" - app.middlewares.append(real_ip_middleware) - - app.on_startup.append(app_startup) + app.middlewares.append(real_ip_middleware) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 2568a1092448e..b519b8e936d6b 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -9,7 +9,15 @@ from homeassistant.components.websocket_api.auth import ( TYPE_AUTH, TYPE_AUTH_OK, TYPE_AUTH_REQUIRED) -from tests.common import MockUser, CLIENT_ID +from tests.common import MockUser, CLIENT_ID, mock_coro + + +@pytest.fixture(autouse=True) +def prevent_io(): + """Fixture to prevent certain I/O from happening.""" + with patch('homeassistant.components.http.ban.async_load_ip_bans_config', + side_effect=lambda *args: mock_coro([])): + yield @pytest.fixture diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index a6a07928113b3..6624937da8dfb 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -16,6 +16,9 @@ from . import mock_real_ip +from tests.common import mock_coro + + BANNED_IPS = ['200.201.202.203', '100.64.0.2'] @@ -25,9 +28,9 @@ async def test_access_from_banned_ip(hass, aiohttp_client): 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]): + with patch('homeassistant.components.http.ban.async_load_ip_bans_config', + return_value=mock_coro([IpBan(banned_ip) for banned_ip + in BANNED_IPS])): client = await aiohttp_client(app) for remote_addr in BANNED_IPS: @@ -71,9 +74,9 @@ async def unauth_handler(request): 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]): + with patch('homeassistant.components.http.ban.async_load_ip_bans_config', + return_value=mock_coro([IpBan(banned_ip) for banned_ip + in BANNED_IPS])): client = await aiohttp_client(app) m = mock_open() From 3d178708fc8968be49b30732f8493729f88354e8 Mon Sep 17 00:00:00 2001 From: Josh Anderson Date: Wed, 21 Nov 2018 19:56:38 +0000 Subject: [PATCH 018/325] Add /sbin to launchd PATH (#18601) * Add /sbin to launchd PATH * Put /sbin at the end to allow overrides Co-Authored-By: andersonshatch --- homeassistant/scripts/macos/launchd.plist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/scripts/macos/launchd.plist b/homeassistant/scripts/macos/launchd.plist index 920f45a0c0e9e..19b182a4cd56b 100644 --- a/homeassistant/scripts/macos/launchd.plist +++ b/homeassistant/scripts/macos/launchd.plist @@ -8,7 +8,7 @@ EnvironmentVariables PATH - /usr/local/bin/:/usr/bin:/usr/sbin:$PATH + /usr/local/bin/:/usr/bin:/usr/sbin:/sbin:$PATH LC_CTYPE UTF-8 From 1ad3c3b1e29ced7048ab16c45100ee836f08a6d7 Mon Sep 17 00:00:00 2001 From: nragon Date: Wed, 21 Nov 2018 22:12:16 +0000 Subject: [PATCH 019/325] Minor change to still image on mjpeg (#18602) * Update mjpeg.py * Lint --- homeassistant/components/camera/mjpeg.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/camera/mjpeg.py b/homeassistant/components/camera/mjpeg.py index 9db7c1381824e..5c6d7e18075ea 100644 --- a/homeassistant/components/camera/mjpeg.py +++ b/homeassistant/components/camera/mjpeg.py @@ -59,14 +59,15 @@ async def async_setup_platform(hass, config, async_add_entities, def extract_image_from_mjpeg(stream): """Take in a MJPEG stream object, return the jpg from it.""" - data = b'' + data = bytes() + data_start = b"\xff\xd8" + data_end = b"\xff\xd9" for chunk in stream: + end_idx = chunk.find(data_end) + if end_idx != -1: + return data[data.find(data_start):] + chunk[:end_idx + 2] + data += chunk - jpg_start = data.find(b'\xff\xd8') - jpg_end = data.find(b'\xff\xd9') - if jpg_start != -1 and jpg_end != -1: - jpg = data[jpg_start:jpg_end + 2] - return jpg class MjpegCamera(Camera): From 22ab83acae11780c62741cd8be2557b9bc5001f7 Mon Sep 17 00:00:00 2001 From: Nick Whyte Date: Thu, 22 Nov 2018 12:41:53 +1100 Subject: [PATCH 020/325] Cleanup BOM dependencies + add basic test + IDEA autoformat (#18462) * Cleanup BOM dependencies + add basic test --- homeassistant/components/sensor/bom.py | 5 ++--- homeassistant/components/weather/bom.py | 2 +- tests/components/sensor/test_bom.py | 14 ++++++++++++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/sensor/bom.py b/homeassistant/components/sensor/bom.py index 6f7bc56cca92b..df8b539135992 100644 --- a/homeassistant/components/sensor/bom.py +++ b/homeassistant/components/sensor/bom.py @@ -119,7 +119,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.error("Could not get BOM weather station from lat/lon") return - bom_data = BOMCurrentData(hass, station) + bom_data = BOMCurrentData(station) try: bom_data.update() @@ -181,9 +181,8 @@ def update(self): class BOMCurrentData: """Get data from BOM.""" - def __init__(self, hass, station_id): + def __init__(self, station_id): """Initialize the data object.""" - self._hass = hass self._zone_id, self._wmo_id = station_id.split('.') self._data = None self.last_updated = None diff --git a/homeassistant/components/weather/bom.py b/homeassistant/components/weather/bom.py index 4c517824bca01..1ed54496c6f53 100644 --- a/homeassistant/components/weather/bom.py +++ b/homeassistant/components/weather/bom.py @@ -33,7 +33,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if station is None: _LOGGER.error("Could not get BOM weather station from lat/lon") return False - bom_data = BOMCurrentData(hass, station) + bom_data = BOMCurrentData(station) try: bom_data.update() except ValueError as err: diff --git a/tests/components/sensor/test_bom.py b/tests/components/sensor/test_bom.py index 50669f5a77d02..fc2722f9742b5 100644 --- a/tests/components/sensor/test_bom.py +++ b/tests/components/sensor/test_bom.py @@ -6,11 +6,12 @@ from urllib.parse import urlparse import requests -from tests.common import ( - assert_setup_component, get_test_home_assistant, load_fixture) from homeassistant.components import sensor +from homeassistant.components.sensor.bom import BOMCurrentData from homeassistant.setup import setup_component +from tests.common import ( + assert_setup_component, get_test_home_assistant, load_fixture) VALID_CONFIG = { 'platform': 'bom', @@ -97,3 +98,12 @@ def test_sensor_values(self, mock_get): feels_like = self.hass.states.get('sensor.bom_fake_feels_like_c').state assert '25.0' == feels_like + + +class TestBOMCurrentData(unittest.TestCase): + """Test the BOM data container.""" + + def test_should_update_initial(self): + """Test that the first update always occurs.""" + bom_data = BOMCurrentData('IDN60901.94767') + assert bom_data.should_update() is True From 9f36cebe59dd702e023f3bdbdb6f5c9ce90694d6 Mon Sep 17 00:00:00 2001 From: Daniel Shokouhi Date: Wed, 21 Nov 2018 22:47:30 -0800 Subject: [PATCH 021/325] Add additional neato error messages to status attribute --- homeassistant/components/neato.py | 53 ++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/neato.py b/homeassistant/components/neato.py index 6c5fac074ba41..1a6dbf9756700 100644 --- a/homeassistant/components/neato.py +++ b/homeassistant/components/neato.py @@ -56,25 +56,68 @@ } ERRORS = { + 'ui_error_battery_battundervoltlithiumsafety': 'Replace battery', + 'ui_error_battery_critical': 'Replace battery', + 'ui_error_battery_invalidsensor': 'Replace battery', + 'ui_error_battery_lithiumadapterfailure': 'Replace battery', + 'ui_error_battery_mismatch': 'Replace battery', + 'ui_error_battery_nothermistor': 'Replace battery', + 'ui_error_battery_overtemp': 'Replace battery', + 'ui_error_battery_undercurrent': 'Replace battery', + 'ui_error_battery_undertemp': 'Replace battery', + 'ui_error_battery_undervolt': 'Replace battery', + 'ui_error_battery_unplugged': 'Replace battery', 'ui_error_brush_stuck': 'Brush stuck', 'ui_error_brush_overloaded': 'Brush overloaded', 'ui_error_bumper_stuck': 'Bumper stuck', + 'ui_error_check_battery_switch': 'Check battery', + 'ui_error_corrupt_scb': 'Call customer service corrupt scb', + 'ui_error_deck_debris': 'Deck debris', 'ui_error_dust_bin_missing': 'Dust bin missing', 'ui_error_dust_bin_full': 'Dust bin full', 'ui_error_dust_bin_emptied': 'Dust bin emptied', + 'ui_error_hardware_failure': 'Hardware failure', + 'ui_error_ldrop_stuck': 'Clear my path', + 'ui_error_lwheel_stuck': 'Clear my path', + 'ui_error_navigation_backdrop_frontbump': 'Clear my path', 'ui_error_navigation_backdrop_leftbump': 'Clear my path', + 'ui_error_navigation_backdrop_wheelextended': 'Clear my path', 'ui_error_navigation_noprogress': 'Clear my path', 'ui_error_navigation_origin_unclean': 'Clear my path', 'ui_error_navigation_pathproblems_returninghome': 'Cannot return to base', 'ui_error_navigation_falling': 'Clear my path', + 'ui_error_navigation_noexitstogo': 'Clear my path', + 'ui_error_navigation_nomotioncommands': 'Clear my path', + 'ui_error_navigation_rightdrop_leftbump': 'Clear my path', + 'ui_error_navigation_undockingfailed': 'Clear my path', 'ui_error_picked_up': 'Picked up', + 'ui_error_rdrop_stuck': 'Clear my path', + 'ui_error_rwheel_stuck': 'Clear my path', 'ui_error_stuck': 'Stuck!', + 'ui_error_unable_to_see': 'Clean vacuum sensors', + 'ui_error_vacuum_slip': 'Clear my path', + 'ui_error_vacuum_stuck': 'Clear my path', + 'ui_error_warning': 'Error check app', + 'batt_base_connect_fail': 'Battery failed to connect to base', + 'batt_base_no_power': 'Battery base has no power', + 'batt_low': 'Battery low', + 'batt_on_base': 'Battery on base', + 'clean_tilt_on_start': 'Clean the tilt on start', 'dustbin_full': 'Dust bin full', 'dustbin_missing': 'Dust bin missing', + 'gen_picked_up': 'Picked up', + 'hw_fail': 'Hardware failure', + 'hw_tof_sensor_sensor': 'Hardware sensor disconnected', + 'lds_bad_packets': 'Bad packets', + 'lds_deck_debris': 'Debris on deck', + 'lds_disconnected': 'Disconnected', + 'lds_jammed': 'Jammed', 'maint_brush_stuck': 'Brush stuck', 'maint_brush_overload': 'Brush overloaded', 'maint_bumper_stuck': 'Bumper stuck', + 'maint_customer_support_qa': 'Contact customer support', 'maint_vacuum_stuck': 'Vacuum is stuck', + 'maint_vacuum_slip': 'Vacuum is stuck', 'maint_left_drop_stuck': 'Vacuum is stuck', 'maint_left_wheel_stuck': 'Vacuum is stuck', 'maint_right_drop_stuck': 'Vacuum is stuck', @@ -82,7 +125,15 @@ 'not_on_charge_base': 'Not on the charge base', 'nav_robot_falling': 'Clear my path', 'nav_no_path': 'Clear my path', - 'nav_path_problem': 'Clear my path' + 'nav_path_problem': 'Clear my path', + 'nav_backdrop_frontbump': 'Clear my path', + 'nav_backdrop_leftbump': 'Clear my path', + 'nav_backdrop_wheelextended': 'Clear my path', + 'nav_mag_sensor': 'Clear my path', + 'nav_no_exit': 'Clear my path', + 'nav_no_movement': 'Clear my path', + 'nav_rightdrop_leftbump': 'Clear my path', + 'nav_undocking_failed': 'Clear my path' } ALERTS = { From 7daf2caef26065297183c4327f88203de7b03b6a Mon Sep 17 00:00:00 2001 From: Daniel Shokouhi Date: Wed, 21 Nov 2018 23:31:08 -0800 Subject: [PATCH 022/325] Correct error message --- homeassistant/components/neato.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/neato.py b/homeassistant/components/neato.py index 1a6dbf9756700..c6ab06d688456 100644 --- a/homeassistant/components/neato.py +++ b/homeassistant/components/neato.py @@ -71,7 +71,7 @@ 'ui_error_brush_overloaded': 'Brush overloaded', 'ui_error_bumper_stuck': 'Bumper stuck', 'ui_error_check_battery_switch': 'Check battery', - 'ui_error_corrupt_scb': 'Call customer service corrupt scb', + 'ui_error_corrupt_scb': 'Call customer service corrupt board', 'ui_error_deck_debris': 'Deck debris', 'ui_error_dust_bin_missing': 'Dust bin missing', 'ui_error_dust_bin_full': 'Dust bin full', From 01ee03a9a181494bbf078132add1b9d5c363f106 Mon Sep 17 00:00:00 2001 From: mopolus <43782170+mopolus@users.noreply.github.com> Date: Thu, 22 Nov 2018 09:45:40 +0100 Subject: [PATCH 023/325] Add support for multiple IHC controllers (#18058) * Added support for secondary IHC controller Most IHC systems only have one controller but the system can be setup with a linked secondary controller. I have updated the code to have it support both primary and secondary controller. Existing configuration is not impacted and secondary controller can be setup the same way, with similar settings nested under 'secondary' in the configuration * Update __init__.py * Update __init__.py * Update __init__.py * Update ihc.py * Update ihc.py * Update ihc.py * Update __init__.py * Update ihc.py * Update ihc.py * Update ihc.py * Update __init__.py * Update ihc.py * Update ihc.py * Update __init__.py * Update ihc.py * Update __init__.py * Update const.py * Update ihc.py * Update ihc.py * Update ihc.py * Update ihc.py * Update ihc.py * Update ihc.py * Update ihc.py * Update ihc.py * Update ihc.py * Update ihc.py * Update ihc.py * Update ihc.py * Update __init__.py * Update __init__.py * Update __init__.py * Update const.py * Update __init__.py * Update ihc.py * Update ihc.py * Update ihc.py * Update ihc.py * Update __init__.py * Update __init__.py * Update ihc.py * Update ihc.py * Update ihc.py * Update __init__.py * Update ihc.py * Update ihc.py * Update __init__.py * Update ihc.py * Update ihc.py * Update __init__.py * Update ihc.py * Update ihc.py * Update ihc.py * Update ihc.py * Update ihc.py * Update ihc.py * Update ihc.py * Update ihc.py * Update __init__.py * Update __init__.py * Update ihc.py * Update __init__.py * Update __init__.py * Update __init__.py * Update __init__.py * Update __init__.py * Update __init__.py indentation was incorrect for "load_platform" in "get_manual_configuration". Load_platform was not called with the correct component name --- homeassistant/components/binary_sensor/ihc.py | 62 +++---- homeassistant/components/ihc/__init__.py | 170 ++++++++++++++---- homeassistant/components/ihc/const.py | 3 + homeassistant/components/light/ihc.py | 56 +++--- homeassistant/components/sensor/ihc.py | 56 ++---- homeassistant/components/switch/ihc.py | 47 ++--- 6 files changed, 214 insertions(+), 180 deletions(-) diff --git a/homeassistant/components/binary_sensor/ihc.py b/homeassistant/components/binary_sensor/ihc.py index 20937af6bfcbf..fb5b4c0bfc227 100644 --- a/homeassistant/components/binary_sensor/ihc.py +++ b/homeassistant/components/binary_sensor/ihc.py @@ -3,59 +3,39 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.ihc/ """ -import voluptuous as vol - from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA) + BinarySensorDevice) from homeassistant.components.ihc import ( - validate_name, IHC_DATA, IHC_CONTROLLER, IHC_INFO) -from homeassistant.components.ihc.const import CONF_INVERTING + IHC_DATA, IHC_CONTROLLER, IHC_INFO) +from homeassistant.components.ihc.const import ( + CONF_INVERTING) from homeassistant.components.ihc.ihcdevice import IHCDevice from homeassistant.const import ( - CONF_NAME, CONF_TYPE, CONF_ID, CONF_BINARY_SENSORS) -import homeassistant.helpers.config_validation as cv + CONF_TYPE) DEPENDENCIES = ['ihc'] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_BINARY_SENSORS, default=[]): - vol.All(cv.ensure_list, [ - vol.All({ - vol.Required(CONF_ID): cv.positive_int, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_TYPE): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_INVERTING, default=False): cv.boolean, - }, validate_name) - ]) -}) - def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the IHC binary sensor platform.""" - ihc_controller = hass.data[IHC_DATA][IHC_CONTROLLER] - info = hass.data[IHC_DATA][IHC_INFO] + if discovery_info is None: + return devices = [] - if discovery_info: - for name, device in discovery_info.items(): - ihc_id = device['ihc_id'] - product_cfg = device['product_cfg'] - product = device['product'] - sensor = IHCBinarySensor(ihc_controller, name, ihc_id, info, - product_cfg.get(CONF_TYPE), - product_cfg[CONF_INVERTING], - product) - devices.append(sensor) - else: - binary_sensors = config[CONF_BINARY_SENSORS] - for sensor_cfg in binary_sensors: - ihc_id = sensor_cfg[CONF_ID] - name = sensor_cfg[CONF_NAME] - sensor_type = sensor_cfg.get(CONF_TYPE) - inverting = sensor_cfg[CONF_INVERTING] - sensor = IHCBinarySensor(ihc_controller, name, ihc_id, info, - sensor_type, inverting) - devices.append(sensor) + for name, device in discovery_info.items(): + ihc_id = device['ihc_id'] + product_cfg = device['product_cfg'] + product = device['product'] + # Find controller that corresponds with device id + ctrl_id = device['ctrl_id'] + ihc_key = IHC_DATA.format(ctrl_id) + info = hass.data[ihc_key][IHC_INFO] + ihc_controller = hass.data[ihc_key][IHC_CONTROLLER] + sensor = IHCBinarySensor(ihc_controller, name, ihc_id, info, + product_cfg.get(CONF_TYPE), + product_cfg[CONF_INVERTING], + product) + devices.append(sensor) add_entities(devices) diff --git a/homeassistant/components/ihc/__init__.py b/homeassistant/components/ihc/__init__.py index 9b00f3bd7896d..052921ad37a86 100644 --- a/homeassistant/components/ihc/__init__.py +++ b/homeassistant/components/ihc/__init__.py @@ -9,16 +9,18 @@ import xml.etree.ElementTree import voluptuous as vol - +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA) from homeassistant.components.ihc.const import ( ATTR_IHC_ID, ATTR_VALUE, CONF_AUTOSETUP, CONF_BINARY_SENSOR, CONF_DIMMABLE, - CONF_INFO, CONF_INVERTING, CONF_LIGHT, CONF_NODE, CONF_SENSOR, CONF_SWITCH, - CONF_XPATH, SERVICE_SET_RUNTIME_VALUE_BOOL, + CONF_INFO, CONF_INVERTING, CONF_LIGHT, CONF_NODE, CONF_NOTE, CONF_POSITION, + CONF_SENSOR, CONF_SWITCH, CONF_XPATH, SERVICE_SET_RUNTIME_VALUE_BOOL, SERVICE_SET_RUNTIME_VALUE_FLOAT, SERVICE_SET_RUNTIME_VALUE_INT) from homeassistant.config import load_yaml_config_file from homeassistant.const import ( - CONF_ID, CONF_NAME, CONF_PASSWORD, CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, - CONF_URL, CONF_USERNAME, TEMP_CELSIUS) + CONF_BINARY_SENSORS, CONF_ID, CONF_LIGHTS, CONF_NAME, CONF_PASSWORD, + CONF_SENSORS, CONF_SWITCHES, CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, CONF_URL, + CONF_USERNAME, TEMP_CELSIUS) from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import HomeAssistantType @@ -26,21 +28,87 @@ REQUIREMENTS = ['ihcsdk==2.2.0'] DOMAIN = 'ihc' -IHC_DATA = 'ihc' +IHC_DATA = 'ihc{}' IHC_CONTROLLER = 'controller' IHC_INFO = 'info' AUTO_SETUP_YAML = 'ihc_auto_setup.yaml' + +def validate_name(config): + """Validate device name.""" + if CONF_NAME in config: + return config + ihcid = config[CONF_ID] + name = 'ihc_{}'.format(ihcid) + config[CONF_NAME] = name + return config + + +DEVICE_SCHEMA = vol.Schema({ + vol.Required(CONF_ID): cv.positive_int, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_POSITION): cv.string, + vol.Optional(CONF_NOTE): cv.string +}, extra=vol.ALLOW_EXTRA) + + +SWITCH_SCHEMA = DEVICE_SCHEMA.extend({ +}) + +BINARY_SENSOR_SCHEMA = DEVICE_SCHEMA.extend({ + vol.Optional(CONF_TYPE): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_INVERTING, default=False): cv.boolean, +}) + +LIGHT_SCHEMA = DEVICE_SCHEMA.extend({ + vol.Optional(CONF_DIMMABLE, default=False): cv.boolean, +}) + +SENSOR_SCHEMA = DEVICE_SCHEMA.extend({ + vol.Optional(CONF_UNIT_OF_MEASUREMENT, + default=TEMP_CELSIUS): cv.string, +}) + +IHC_SCHEMA = vol.Schema({ + vol.Required(CONF_URL): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_AUTOSETUP, default=True): cv.boolean, + vol.Optional(CONF_INFO, default=True): cv.boolean, + vol.Optional(CONF_BINARY_SENSORS, default=[]): + vol.All(cv.ensure_list, [ + vol.All( + BINARY_SENSOR_SCHEMA, + validate_name) + ]), + vol.Optional(CONF_LIGHTS, default=[]): + vol.All(cv.ensure_list, [ + vol.All( + LIGHT_SCHEMA, + validate_name) + ]), + vol.Optional(CONF_SENSORS, default=[]): + vol.All(cv.ensure_list, [ + vol.All( + SENSOR_SCHEMA, + validate_name) + ]), + vol.Optional(CONF_SWITCHES, default=[]): + vol.All(cv.ensure_list, [ + vol.All( + SWITCH_SCHEMA, + validate_name) + ]), +}, extra=vol.ALLOW_EXTRA) + CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_URL): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_AUTOSETUP, default=True): cv.boolean, - vol.Optional(CONF_INFO, default=True): cv.boolean, - }), + DOMAIN: vol.Schema(vol.All( + cv.ensure_list, + [IHC_SCHEMA] + )), }, extra=vol.ALLOW_EXTRA) + AUTO_SETUP_SCHEMA = vol.Schema({ vol.Optional(CONF_BINARY_SENSOR, default=[]): vol.All(cv.ensure_list, [ @@ -98,35 +166,79 @@ def setup(hass, config): + """Set up the IHC platform.""" + conf = config.get(DOMAIN) + for index, controller_conf in enumerate(conf): + if not ihc_setup(hass, config, controller_conf, index): + return False + + return True + + +def ihc_setup(hass, config, conf, controller_id): """Set up the IHC component.""" from ihcsdk.ihccontroller import IHCController - conf = config[DOMAIN] + url = conf[CONF_URL] username = conf[CONF_USERNAME] password = conf[CONF_PASSWORD] - ihc_controller = IHCController(url, username, password) + ihc_controller = IHCController(url, username, password) if not ihc_controller.authenticate(): _LOGGER.error("Unable to authenticate on IHC controller") return False if (conf[CONF_AUTOSETUP] and - not autosetup_ihc_products(hass, config, ihc_controller)): + not autosetup_ihc_products(hass, config, ihc_controller, + controller_id)): return False - - hass.data[IHC_DATA] = { + # Manual configuration + get_manual_configuration(hass, config, conf, ihc_controller, + controller_id) + # Store controler configuration + ihc_key = IHC_DATA.format(controller_id) + hass.data[ihc_key] = { IHC_CONTROLLER: ihc_controller, IHC_INFO: conf[CONF_INFO]} - setup_service_functions(hass, ihc_controller) return True -def autosetup_ihc_products(hass: HomeAssistantType, config, ihc_controller): +def get_manual_configuration(hass, config, conf, ihc_controller, + controller_id): + """Get manual configuration for IHC devices.""" + for component in IHC_PLATFORMS: + discovery_info = {} + if component in conf: + component_setup = conf.get(component) + for sensor_cfg in component_setup: + name = sensor_cfg[CONF_NAME] + device = { + 'ihc_id': sensor_cfg[CONF_ID], + 'ctrl_id': controller_id, + 'product': { + 'name': name, + 'note': sensor_cfg.get(CONF_NOTE) or '', + 'position': sensor_cfg.get(CONF_POSITION) or ''}, + 'product_cfg': { + 'type': sensor_cfg.get(CONF_TYPE), + 'inverting': sensor_cfg.get(CONF_INVERTING), + 'dimmable': sensor_cfg.get(CONF_DIMMABLE), + 'unit': sensor_cfg.get(CONF_UNIT_OF_MEASUREMENT) + } + } + discovery_info[name] = device + if discovery_info: + discovery.load_platform(hass, component, DOMAIN, + discovery_info, config) + + +def autosetup_ihc_products(hass: HomeAssistantType, config, ihc_controller, + controller_id): """Auto setup of IHC products from the IHC project file.""" project_xml = ihc_controller.get_project() if not project_xml: - _LOGGER.error("Unable to read project from ICH controller") + _LOGGER.error("Unable to read project from IHC controller") return False project = xml.etree.ElementTree.fromstring(project_xml) @@ -143,14 +255,15 @@ def autosetup_ihc_products(hass: HomeAssistantType, config, ihc_controller): groups = project.findall('.//group') for component in IHC_PLATFORMS: component_setup = auto_setup_conf[component] - discovery_info = get_discovery_info(component_setup, groups) + discovery_info = get_discovery_info(component_setup, groups, + controller_id) if discovery_info: discovery.load_platform(hass, component, DOMAIN, discovery_info, config) return True -def get_discovery_info(component_setup, groups): +def get_discovery_info(component_setup, groups, controller_id): """Get discovery info for specified IHC component.""" discovery_data = {} for group in groups: @@ -167,6 +280,7 @@ def get_discovery_info(component_setup, groups): name = '{}_{}'.format(groupname, ihc_id) device = { 'ihc_id': ihc_id, + 'ctrl_id': controller_id, 'product': { 'name': product.attrib['name'], 'note': product.attrib['note'], @@ -205,13 +319,3 @@ def set_runtime_value_float(call): hass.services.register(DOMAIN, SERVICE_SET_RUNTIME_VALUE_FLOAT, set_runtime_value_float, schema=SET_RUNTIME_VALUE_FLOAT_SCHEMA) - - -def validate_name(config): - """Validate device name.""" - if CONF_NAME in config: - return config - ihcid = config[CONF_ID] - name = 'ihc_{}'.format(ihcid) - config[CONF_NAME] = name - return config diff --git a/homeassistant/components/ihc/const.py b/homeassistant/components/ihc/const.py index b06746c8e7aef..d6e4d0e0d4d93 100644 --- a/homeassistant/components/ihc/const.py +++ b/homeassistant/components/ihc/const.py @@ -10,6 +10,9 @@ CONF_LIGHT = 'light' CONF_SENSOR = 'sensor' CONF_SWITCH = 'switch' +CONF_NAME = 'name' +CONF_POSITION = 'position' +CONF_NOTE = 'note' ATTR_IHC_ID = 'ihc_id' ATTR_VALUE = 'value' diff --git a/homeassistant/components/light/ihc.py b/homeassistant/components/light/ihc.py index da90a53c84804..f80c9b2fd6fd1 100644 --- a/homeassistant/components/light/ihc.py +++ b/homeassistant/components/light/ihc.py @@ -3,53 +3,39 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.ihc/ """ -import voluptuous as vol +import logging from homeassistant.components.ihc import ( - validate_name, IHC_DATA, IHC_CONTROLLER, IHC_INFO) -from homeassistant.components.ihc.const import CONF_DIMMABLE + IHC_DATA, IHC_CONTROLLER, IHC_INFO) +from homeassistant.components.ihc.const import ( + CONF_DIMMABLE) from homeassistant.components.ihc.ihcdevice import IHCDevice from homeassistant.components.light import ( - ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, PLATFORM_SCHEMA, Light) -from homeassistant.const import CONF_ID, CONF_NAME, CONF_LIGHTS -import homeassistant.helpers.config_validation as cv + ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) DEPENDENCIES = ['ihc'] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_LIGHTS, default=[]): - vol.All(cv.ensure_list, [ - vol.All({ - vol.Required(CONF_ID): cv.positive_int, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_DIMMABLE, default=False): cv.boolean, - }, validate_name) - ]) -}) +_LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the IHC lights platform.""" - ihc_controller = hass.data[IHC_DATA][IHC_CONTROLLER] - info = hass.data[IHC_DATA][IHC_INFO] + if discovery_info is None: + return devices = [] - if discovery_info: - for name, device in discovery_info.items(): - ihc_id = device['ihc_id'] - product_cfg = device['product_cfg'] - product = device['product'] - light = IhcLight(ihc_controller, name, ihc_id, info, - product_cfg[CONF_DIMMABLE], product) - devices.append(light) - else: - lights = config[CONF_LIGHTS] - for light in lights: - ihc_id = light[CONF_ID] - name = light[CONF_NAME] - dimmable = light[CONF_DIMMABLE] - device = IhcLight(ihc_controller, name, ihc_id, info, dimmable) - devices.append(device) - + for name, device in discovery_info.items(): + ihc_id = device['ihc_id'] + product_cfg = device['product_cfg'] + product = device['product'] + # Find controller that corresponds with device id + ctrl_id = device['ctrl_id'] + ihc_key = IHC_DATA.format(ctrl_id) + info = hass.data[ihc_key][IHC_INFO] + ihc_controller = hass.data[ihc_key][IHC_CONTROLLER] + dimmable = product_cfg[CONF_DIMMABLE] + light = IhcLight(ihc_controller, name, ihc_id, info, + dimmable, product) + devices.append(light) add_entities(devices) diff --git a/homeassistant/components/sensor/ihc.py b/homeassistant/components/sensor/ihc.py index f5140838a7a30..f5a45599bb75a 100644 --- a/homeassistant/components/sensor/ihc.py +++ b/homeassistant/components/sensor/ihc.py @@ -3,56 +3,34 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.ihc/ """ -import voluptuous as vol - from homeassistant.components.ihc import ( - validate_name, IHC_DATA, IHC_CONTROLLER, IHC_INFO) + IHC_DATA, IHC_CONTROLLER, IHC_INFO) from homeassistant.components.ihc.ihcdevice import IHCDevice -from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_ID, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_SENSORS, - TEMP_CELSIUS) -import homeassistant.helpers.config_validation as cv + CONF_UNIT_OF_MEASUREMENT) from homeassistant.helpers.entity import Entity DEPENDENCIES = ['ihc'] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_SENSORS, default=[]): - vol.All(cv.ensure_list, [ - vol.All({ - vol.Required(CONF_ID): cv.positive_int, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT, - default=TEMP_CELSIUS): cv.string - }, validate_name) - ]) -}) - def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the IHC sensor platform.""" - ihc_controller = hass.data[IHC_DATA][IHC_CONTROLLER] - info = hass.data[IHC_DATA][IHC_INFO] + if discovery_info is None: + return devices = [] - if discovery_info: - for name, device in discovery_info.items(): - ihc_id = device['ihc_id'] - product_cfg = device['product_cfg'] - product = device['product'] - sensor = IHCSensor(ihc_controller, name, ihc_id, info, - product_cfg[CONF_UNIT_OF_MEASUREMENT], - product) - devices.append(sensor) - else: - sensors = config[CONF_SENSORS] - for sensor_cfg in sensors: - ihc_id = sensor_cfg[CONF_ID] - name = sensor_cfg[CONF_NAME] - unit = sensor_cfg[CONF_UNIT_OF_MEASUREMENT] - sensor = IHCSensor(ihc_controller, name, ihc_id, info, unit) - devices.append(sensor) - + for name, device in discovery_info.items(): + ihc_id = device['ihc_id'] + product_cfg = device['product_cfg'] + product = device['product'] + # Find controller that corresponds with device id + ctrl_id = device['ctrl_id'] + ihc_key = IHC_DATA.format(ctrl_id) + info = hass.data[ihc_key][IHC_INFO] + ihc_controller = hass.data[ihc_key][IHC_CONTROLLER] + unit = product_cfg[CONF_UNIT_OF_MEASUREMENT] + sensor = IHCSensor(ihc_controller, name, ihc_id, info, + unit, product) + devices.append(sensor) add_entities(devices) diff --git a/homeassistant/components/switch/ihc.py b/homeassistant/components/switch/ihc.py index 4ddafa228a797..e217d109cbc9e 100644 --- a/homeassistant/components/switch/ihc.py +++ b/homeassistant/components/switch/ihc.py @@ -3,47 +3,30 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.ihc/ """ -import voluptuous as vol - from homeassistant.components.ihc import ( - validate_name, IHC_DATA, IHC_CONTROLLER, IHC_INFO) + IHC_DATA, IHC_CONTROLLER, IHC_INFO) from homeassistant.components.ihc.ihcdevice import IHCDevice -from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA -from homeassistant.const import CONF_ID, CONF_NAME, CONF_SWITCHES -import homeassistant.helpers.config_validation as cv +from homeassistant.components.switch import SwitchDevice DEPENDENCIES = ['ihc'] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_SWITCHES, default=[]): - vol.All(cv.ensure_list, [ - vol.All({ - vol.Required(CONF_ID): cv.positive_int, - vol.Optional(CONF_NAME): cv.string, - }, validate_name) - ]) -}) - def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the IHC switch platform.""" - ihc_controller = hass.data[IHC_DATA][IHC_CONTROLLER] - info = hass.data[IHC_DATA][IHC_INFO] + if discovery_info is None: + return devices = [] - if discovery_info: - for name, device in discovery_info.items(): - ihc_id = device['ihc_id'] - product = device['product'] - switch = IHCSwitch(ihc_controller, name, ihc_id, info, product) - devices.append(switch) - else: - switches = config[CONF_SWITCHES] - for switch in switches: - ihc_id = switch[CONF_ID] - name = switch[CONF_NAME] - sensor = IHCSwitch(ihc_controller, name, ihc_id, info) - devices.append(sensor) - + for name, device in discovery_info.items(): + ihc_id = device['ihc_id'] + product = device['product'] + # Find controller that corresponds with device id + ctrl_id = device['ctrl_id'] + ihc_key = IHC_DATA.format(ctrl_id) + info = hass.data[ihc_key][IHC_INFO] + ihc_controller = hass.data[ihc_key][IHC_CONTROLLER] + + switch = IHCSwitch(ihc_controller, name, ihc_id, info, product) + devices.append(switch) add_entities(devices) From e5d290015166f3ebf0cc0a03896f3b69d67f8d44 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 22 Nov 2018 12:48:50 +0100 Subject: [PATCH 024/325] Fix vol Dict -> dict (#18637) --- homeassistant/components/lovelace/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 39644bd047b3f..5234dbaf29d4a 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -63,7 +63,7 @@ SCHEMA_UPDATE_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_UPDATE_CARD, vol.Required('card_id'): str, - vol.Required('card_config'): vol.Any(str, Dict), + vol.Required('card_config'): vol.Any(str, dict), vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, FORMAT_YAML), }) @@ -71,7 +71,7 @@ SCHEMA_ADD_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_ADD_CARD, vol.Required('view_id'): str, - vol.Required('card_config'): vol.Any(str, Dict), + vol.Required('card_config'): vol.Any(str, dict), vol.Optional('position'): int, vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, FORMAT_YAML), @@ -99,14 +99,14 @@ SCHEMA_UPDATE_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_UPDATE_VIEW, vol.Required('view_id'): str, - vol.Required('view_config'): vol.Any(str, Dict), + vol.Required('view_config'): vol.Any(str, dict), vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, FORMAT_YAML), }) SCHEMA_ADD_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_ADD_VIEW, - vol.Required('view_config'): vol.Any(str, Dict), + vol.Required('view_config'): vol.Any(str, dict), vol.Optional('position'): int, vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, FORMAT_YAML), From b246fc977e85ad472467d1d3df94dbd08dd7700f Mon Sep 17 00:00:00 2001 From: Giuseppe Date: Thu, 22 Nov 2018 13:14:28 +0100 Subject: [PATCH 025/325] Add support for cropping pictures in proxy camera (#18431) * Added support for cropping pictures in proxy camera This includes extending the configuration to introduce a mode (either 'resize', default, or 'crop') and further coordinates for the crop operation. * Also fixed async job type, following code review --- homeassistant/components/camera/proxy.py | 117 +++++++++++++++++++---- 1 file changed, 98 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/camera/proxy.py b/homeassistant/components/camera/proxy.py index 83d873116460e..48d324fcd3ab5 100644 --- a/homeassistant/components/camera/proxy.py +++ b/homeassistant/components/camera/proxy.py @@ -10,12 +10,13 @@ import voluptuous as vol from homeassistant.components.camera import PLATFORM_SCHEMA, Camera -from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, HTTP_HEADER_HA_AUTH +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_MODE, \ + HTTP_HEADER_HA_AUTH from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.util.async_ import run_coroutine_threadsafe import homeassistant.util.dt as dt_util -from . import async_get_still_stream +from homeassistant.components.camera import async_get_still_stream REQUIREMENTS = ['pillow==5.2.0'] @@ -26,21 +27,34 @@ CONF_IMAGE_QUALITY = 'image_quality' CONF_IMAGE_REFRESH_RATE = 'image_refresh_rate' CONF_MAX_IMAGE_WIDTH = 'max_image_width' +CONF_MAX_IMAGE_HEIGHT = 'max_image_height' CONF_MAX_STREAM_WIDTH = 'max_stream_width' +CONF_MAX_STREAM_HEIGHT = 'max_stream_height' +CONF_IMAGE_TOP = 'image_top' +CONF_IMAGE_LEFT = 'image_left' CONF_STREAM_QUALITY = 'stream_quality' +MODE_RESIZE = 'resize' +MODE_CROP = 'crop' + DEFAULT_BASENAME = "Camera Proxy" DEFAULT_QUALITY = 75 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_CACHE_IMAGES, False): cv.boolean, vol.Optional(CONF_FORCE_RESIZE, False): cv.boolean, + vol.Optional(CONF_MODE, default=MODE_RESIZE): + vol.In([MODE_RESIZE, MODE_CROP]), vol.Optional(CONF_IMAGE_QUALITY): int, vol.Optional(CONF_IMAGE_REFRESH_RATE): float, vol.Optional(CONF_MAX_IMAGE_WIDTH): int, + vol.Optional(CONF_MAX_IMAGE_HEIGHT): int, vol.Optional(CONF_MAX_STREAM_WIDTH): int, - vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_MAX_STREAM_HEIGHT): int, + vol.Optional(CONF_IMAGE_LEFT): int, + vol.Optional(CONF_IMAGE_TOP): int, vol.Optional(CONF_STREAM_QUALITY): int, }) @@ -51,26 +65,37 @@ async def async_setup_platform( async_add_entities([ProxyCamera(hass, config)]) -def _resize_image(image, opts): - """Resize image.""" +def _precheck_image(image, opts): + """Perform some pre-checks on the given image.""" from PIL import Image import io if not opts: - return image - - quality = opts.quality or DEFAULT_QUALITY - new_width = opts.max_width - + raise ValueError try: img = Image.open(io.BytesIO(image)) except IOError: - return image + _LOGGER.warning("Failed to open image") + raise ValueError imgfmt = str(img.format) if imgfmt not in ('PNG', 'JPEG'): - _LOGGER.debug("Image is of unsupported type: %s", imgfmt) + _LOGGER.warning("Image is of unsupported type: %s", imgfmt) + raise ValueError + return img + + +def _resize_image(image, opts): + """Resize image.""" + from PIL import Image + import io + + try: + img = _precheck_image(image, opts) + except ValueError: return image + quality = opts.quality or DEFAULT_QUALITY + new_width = opts.max_width (old_width, old_height) = img.size old_size = len(image) if old_width <= new_width: @@ -87,7 +112,7 @@ def _resize_image(image, opts): img.save(imgbuf, 'JPEG', optimize=True, quality=quality) newimage = imgbuf.getvalue() if not opts.force_resize and len(newimage) >= old_size: - _LOGGER.debug("Using original image(%d bytes) " + _LOGGER.debug("Using original image (%d bytes) " "because resized image (%d bytes) is not smaller", old_size, len(newimage)) return image @@ -98,12 +123,50 @@ def _resize_image(image, opts): return newimage +def _crop_image(image, opts): + """Crop image.""" + import io + + try: + img = _precheck_image(image, opts) + except ValueError: + return image + + quality = opts.quality or DEFAULT_QUALITY + (old_width, old_height) = img.size + old_size = len(image) + if opts.top is None: + opts.top = 0 + if opts.left is None: + opts.left = 0 + if opts.max_width is None or opts.max_width > old_width - opts.left: + opts.max_width = old_width - opts.left + if opts.max_height is None or opts.max_height > old_height - opts.top: + opts.max_height = old_height - opts.top + + img = img.crop((opts.left, opts.top, + opts.left+opts.max_width, opts.top+opts.max_height)) + imgbuf = io.BytesIO() + img.save(imgbuf, 'JPEG', optimize=True, quality=quality) + newimage = imgbuf.getvalue() + + _LOGGER.debug( + "Cropped image from (%dx%d - %d bytes) to (%dx%d - %d bytes)", + old_width, old_height, old_size, opts.max_width, opts.max_height, + len(newimage)) + return newimage + + class ImageOpts(): """The representation of image options.""" - def __init__(self, max_width, quality, force_resize): + def __init__(self, max_width, max_height, left, top, + quality, force_resize): """Initialize image options.""" self.max_width = max_width + self.max_height = max_height + self.left = left + self.top = top self.quality = quality self.force_resize = force_resize @@ -125,11 +188,18 @@ def __init__(self, hass, config): "{} - {}".format(DEFAULT_BASENAME, self._proxied_camera)) self._image_opts = ImageOpts( config.get(CONF_MAX_IMAGE_WIDTH), + config.get(CONF_MAX_IMAGE_HEIGHT), + config.get(CONF_IMAGE_LEFT), + config.get(CONF_IMAGE_TOP), config.get(CONF_IMAGE_QUALITY), config.get(CONF_FORCE_RESIZE)) self._stream_opts = ImageOpts( - config.get(CONF_MAX_STREAM_WIDTH), config.get(CONF_STREAM_QUALITY), + config.get(CONF_MAX_STREAM_WIDTH), + config.get(CONF_MAX_STREAM_HEIGHT), + config.get(CONF_IMAGE_LEFT), + config.get(CONF_IMAGE_TOP), + config.get(CONF_STREAM_QUALITY), True) self._image_refresh_rate = config.get(CONF_IMAGE_REFRESH_RATE) @@ -141,6 +211,7 @@ def __init__(self, hass, config): self._headers = ( {HTTP_HEADER_HA_AUTH: self.hass.config.api.api_password} if self.hass.config.api.api_password is not None else None) + self._mode = config.get(CONF_MODE) def camera_image(self): """Return camera image.""" @@ -162,8 +233,12 @@ async def async_camera_image(self): _LOGGER.error("Error getting original camera image") return self._last_image - image = await self.hass.async_add_job( - _resize_image, image.content, self._image_opts) + if self._mode == MODE_RESIZE: + job = _resize_image + else: + job = _crop_image + image = await self.hass.async_add_executor_job( + job, image.content, self._image_opts) if self._cache_images: self._last_image = image @@ -194,5 +269,9 @@ async def _async_stream_image(self): except HomeAssistantError: raise asyncio.CancelledError - return await self.hass.async_add_job( - _resize_image, image.content, self._stream_opts) + if self._mode == MODE_RESIZE: + job = _resize_image + else: + job = _crop_image + return await self.hass.async_add_executor_job( + job, image.content, self._stream_opts) From 13144af65e8d1bab87c339cc9cdf50051c86f4eb Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 22 Nov 2018 15:06:31 +0100 Subject: [PATCH 026/325] Fix raising objects on proxy camera component --- homeassistant/components/camera/proxy.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/camera/proxy.py b/homeassistant/components/camera/proxy.py index 48d324fcd3ab5..6e7ab9385bdf5 100644 --- a/homeassistant/components/camera/proxy.py +++ b/homeassistant/components/camera/proxy.py @@ -71,16 +71,16 @@ def _precheck_image(image, opts): import io if not opts: - raise ValueError + raise ValueError() try: img = Image.open(io.BytesIO(image)) except IOError: _LOGGER.warning("Failed to open image") - raise ValueError + raise ValueError() imgfmt = str(img.format) if imgfmt not in ('PNG', 'JPEG'): _LOGGER.warning("Image is of unsupported type: %s", imgfmt) - raise ValueError + raise ValueError() return img @@ -267,7 +267,7 @@ async def _async_stream_image(self): if not image: return None except HomeAssistantError: - raise asyncio.CancelledError + raise asyncio.CancelledError() if self._mode == MODE_RESIZE: job = _resize_image From cccc41c23e8a05660b0f6f3c3fdebb1370fe0015 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Thu, 22 Nov 2018 16:43:10 +0100 Subject: [PATCH 027/325] Updated webhook_register, version bump pypoint (#18635) * Updated webhook_register, version bump pypoint * A binary_sensor should be a BinarySensorDevice --- homeassistant/components/binary_sensor/point.py | 3 ++- homeassistant/components/point/__init__.py | 6 +++--- requirements_all.txt | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/binary_sensor/point.py b/homeassistant/components/binary_sensor/point.py index a2ed9eabebf43..90a8b0b581348 100644 --- a/homeassistant/components/binary_sensor/point.py +++ b/homeassistant/components/binary_sensor/point.py @@ -7,6 +7,7 @@ import logging +from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.point import MinutPointEntity from homeassistant.components.point.const import ( DOMAIN as POINT_DOMAIN, NEW_DEVICE, SIGNAL_WEBHOOK) @@ -45,7 +46,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for device_class in EVENTS), True) -class MinutPointBinarySensor(MinutPointEntity): +class MinutPointBinarySensor(MinutPointEntity, BinarySensorDevice): """The platform class required by Home Assistant.""" def __init__(self, point_client, device_id, device_class): diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index fcbd5ddb06450..36215da78935d 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -25,7 +25,7 @@ CONF_WEBHOOK_URL, DOMAIN, EVENT_RECEIVED, NEW_DEVICE, SCAN_INTERVAL, SIGNAL_UPDATE_ENTITY, SIGNAL_WEBHOOK) -REQUIREMENTS = ['pypoint==1.0.5'] +REQUIREMENTS = ['pypoint==1.0.6'] DEPENDENCIES = ['webhook'] _LOGGER = logging.getLogger(__name__) @@ -113,8 +113,8 @@ async def async_setup_webhook(hass: HomeAssistantType, entry: ConfigEntry, session.update_webhook(entry.data[CONF_WEBHOOK_URL], entry.data[CONF_WEBHOOK_ID]) - hass.components.webhook.async_register(entry.data[CONF_WEBHOOK_ID], - handle_webhook) + hass.components.webhook.async_register( + DOMAIN, 'Point', entry.data[CONF_WEBHOOK_ID], handle_webhook) async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): diff --git a/requirements_all.txt b/requirements_all.txt index 4ddc81686b4ae..4581967bbf61b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1099,7 +1099,7 @@ pyowm==2.9.0 pypjlink2==1.2.0 # homeassistant.components.point -pypoint==1.0.5 +pypoint==1.0.6 # homeassistant.components.sensor.pollen pypollencom==2.2.2 From 67aa76d295fcdd64b764244f4331c37247a74c86 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Thu, 22 Nov 2018 13:00:46 -0500 Subject: [PATCH 028/325] Refactor ZHA (#18629) * refactor ZHA * lint * review request * Exclude more zha modules from coverage --- .coveragerc | 2 + homeassistant/components/binary_sensor/zha.py | 23 +- homeassistant/components/fan/zha.py | 13 +- homeassistant/components/light/zha.py | 48 ++-- homeassistant/components/sensor/zha.py | 13 +- homeassistant/components/switch/zha.py | 17 +- homeassistant/components/zha/__init__.py | 233 +----------------- homeassistant/components/zha/const.py | 14 +- .../components/zha/entities/__init__.py | 10 + .../components/zha/entities/device_entity.py | 81 ++++++ .../components/zha/entities/entity.py | 89 +++++++ homeassistant/components/zha/helpers.py | 84 +++++++ 12 files changed, 343 insertions(+), 284 deletions(-) create mode 100644 homeassistant/components/zha/entities/__init__.py create mode 100644 homeassistant/components/zha/entities/device_entity.py create mode 100644 homeassistant/components/zha/entities/entity.py create mode 100644 homeassistant/components/zha/helpers.py diff --git a/.coveragerc b/.coveragerc index 2a6446092e566..7fa418f0b4603 100644 --- a/.coveragerc +++ b/.coveragerc @@ -400,6 +400,8 @@ omit = homeassistant/components/zha/__init__.py homeassistant/components/zha/const.py + homeassistant/components/zha/entities/* + homeassistant/components/zha/helpers.py homeassistant/components/*/zha.py homeassistant/components/zigbee.py diff --git a/homeassistant/components/binary_sensor/zha.py b/homeassistant/components/binary_sensor/zha.py index 9365ba42cc1ed..c1ced3766c99e 100644 --- a/homeassistant/components/binary_sensor/zha.py +++ b/homeassistant/components/binary_sensor/zha.py @@ -7,7 +7,8 @@ import logging from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice -from homeassistant.components import zha +from homeassistant.components.zha.entities import ZhaEntity +from homeassistant.components.zha import helpers _LOGGER = logging.getLogger(__name__) @@ -27,7 +28,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Zigbee Home Automation binary sensors.""" - discovery_info = zha.get_discovery_info(hass, discovery_info) + discovery_info = helpers.get_discovery_info(hass, discovery_info) if discovery_info is None: return @@ -72,13 +73,13 @@ async def _async_setup_remote(hass, config, async_add_entities, out_clusters = discovery_info['out_clusters'] if OnOff.cluster_id in out_clusters: cluster = out_clusters[OnOff.cluster_id] - await zha.configure_reporting( + await helpers.configure_reporting( remote.entity_id, cluster, 0, min_report=0, max_report=600, reportable_change=1 ) if LevelControl.cluster_id in out_clusters: cluster = out_clusters[LevelControl.cluster_id] - await zha.configure_reporting( + await helpers.configure_reporting( remote.entity_id, cluster, 0, min_report=1, max_report=600, reportable_change=1 ) @@ -86,7 +87,7 @@ async def _async_setup_remote(hass, config, async_add_entities, async_add_entities([remote], update_before_add=True) -class BinarySensor(zha.Entity, BinarySensorDevice): +class BinarySensor(ZhaEntity, BinarySensorDevice): """The ZHA Binary Sensor.""" _domain = DOMAIN @@ -130,16 +131,16 @@ async def async_update(self): """Retrieve latest state.""" from zigpy.types.basic import uint16_t - result = await zha.safe_read(self._endpoint.ias_zone, - ['zone_status'], - allow_cache=False, - only_cache=(not self._initialized)) + result = await helpers.safe_read(self._endpoint.ias_zone, + ['zone_status'], + allow_cache=False, + only_cache=(not self._initialized)) state = result.get('zone_status', self._state) if isinstance(state, (int, uint16_t)): self._state = result.get('zone_status', self._state) & 3 -class Remote(zha.Entity, BinarySensorDevice): +class Remote(ZhaEntity, BinarySensorDevice): """ZHA switch/remote controller/button.""" _domain = DOMAIN @@ -252,7 +253,7 @@ def set_state(self, state): async def async_update(self): """Retrieve latest state.""" from zigpy.zcl.clusters.general import OnOff - result = await zha.safe_read( + result = await helpers.safe_read( self._endpoint.out_clusters[OnOff.cluster_id], ['on_off'], allow_cache=False, diff --git a/homeassistant/components/fan/zha.py b/homeassistant/components/fan/zha.py index b5615f18d730e..d948ba2ff5b7d 100644 --- a/homeassistant/components/fan/zha.py +++ b/homeassistant/components/fan/zha.py @@ -5,7 +5,8 @@ at https://home-assistant.io/components/fan.zha/ """ import logging -from homeassistant.components import zha +from homeassistant.components.zha.entities import ZhaEntity +from homeassistant.components.zha import helpers from homeassistant.components.fan import ( DOMAIN, FanEntity, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, SUPPORT_SET_SPEED) @@ -40,14 +41,14 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Zigbee Home Automation fans.""" - discovery_info = zha.get_discovery_info(hass, discovery_info) + discovery_info = helpers.get_discovery_info(hass, discovery_info) if discovery_info is None: return async_add_entities([ZhaFan(**discovery_info)], update_before_add=True) -class ZhaFan(zha.Entity, FanEntity): +class ZhaFan(ZhaEntity, FanEntity): """Representation of a ZHA fan.""" _domain = DOMAIN @@ -101,9 +102,9 @@ async def async_set_speed(self, speed: str) -> None: async def async_update(self): """Retrieve latest state.""" - result = await zha.safe_read(self._endpoint.fan, ['fan_mode'], - allow_cache=False, - only_cache=(not self._initialized)) + result = await helpers.safe_read(self._endpoint.fan, ['fan_mode'], + allow_cache=False, + only_cache=(not self._initialized)) new_value = result.get('fan_mode', None) self._state = VALUE_TO_SPEED.get(new_value, None) diff --git a/homeassistant/components/light/zha.py b/homeassistant/components/light/zha.py index 56a1e9e5169bb..20c9faf2514c5 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -5,7 +5,9 @@ at https://home-assistant.io/components/light.zha/ """ import logging -from homeassistant.components import light, zha +from homeassistant.components import light +from homeassistant.components.zha.entities import ZhaEntity +from homeassistant.components.zha import helpers import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) @@ -23,13 +25,13 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Zigbee Home Automation lights.""" - discovery_info = zha.get_discovery_info(hass, discovery_info) + discovery_info = helpers.get_discovery_info(hass, discovery_info) if discovery_info is None: return endpoint = discovery_info['endpoint'] if hasattr(endpoint, 'light_color'): - caps = await zha.safe_read( + caps = await helpers.safe_read( endpoint.light_color, ['color_capabilities']) discovery_info['color_capabilities'] = caps.get('color_capabilities') if discovery_info['color_capabilities'] is None: @@ -37,7 +39,7 @@ async def async_setup_platform(hass, config, async_add_entities, # attribute. In this version XY support is mandatory, but we need # to probe to determine if the device supports color temperature. discovery_info['color_capabilities'] = CAPABILITIES_COLOR_XY - result = await zha.safe_read( + result = await helpers.safe_read( endpoint.light_color, ['color_temperature']) if result.get('color_temperature') is not UNSUPPORTED_ATTRIBUTE: discovery_info['color_capabilities'] |= CAPABILITIES_COLOR_TEMP @@ -45,7 +47,7 @@ async def async_setup_platform(hass, config, async_add_entities, async_add_entities([Light(**discovery_info)], update_before_add=True) -class Light(zha.Entity, light.Light): +class Light(ZhaEntity, light.Light): """Representation of a ZHA or ZLL light.""" _domain = light.DOMAIN @@ -181,31 +183,37 @@ def supported_features(self): async def async_update(self): """Retrieve latest state.""" - result = await zha.safe_read(self._endpoint.on_off, ['on_off'], - allow_cache=False, - only_cache=(not self._initialized)) + result = await helpers.safe_read(self._endpoint.on_off, ['on_off'], + allow_cache=False, + only_cache=(not self._initialized)) self._state = result.get('on_off', self._state) if self._supported_features & light.SUPPORT_BRIGHTNESS: - result = await zha.safe_read(self._endpoint.level, - ['current_level'], - allow_cache=False, - only_cache=(not self._initialized)) + result = await helpers.safe_read(self._endpoint.level, + ['current_level'], + allow_cache=False, + only_cache=( + not self._initialized + )) self._brightness = result.get('current_level', self._brightness) if self._supported_features & light.SUPPORT_COLOR_TEMP: - result = await zha.safe_read(self._endpoint.light_color, - ['color_temperature'], - allow_cache=False, - only_cache=(not self._initialized)) + result = await helpers.safe_read(self._endpoint.light_color, + ['color_temperature'], + allow_cache=False, + only_cache=( + not self._initialized + )) self._color_temp = result.get('color_temperature', self._color_temp) if self._supported_features & light.SUPPORT_COLOR: - result = await zha.safe_read(self._endpoint.light_color, - ['current_x', 'current_y'], - allow_cache=False, - only_cache=(not self._initialized)) + result = await helpers.safe_read(self._endpoint.light_color, + ['current_x', 'current_y'], + allow_cache=False, + only_cache=( + not self._initialized + )) if 'current_x' in result and 'current_y' in result: xy_color = (round(result['current_x']/65535, 3), round(result['current_y']/65535, 3)) diff --git a/homeassistant/components/sensor/zha.py b/homeassistant/components/sensor/zha.py index 9a9de0d6cf2ab..993b247a4394c 100644 --- a/homeassistant/components/sensor/zha.py +++ b/homeassistant/components/sensor/zha.py @@ -7,7 +7,8 @@ import logging from homeassistant.components.sensor import DOMAIN -from homeassistant.components import zha +from homeassistant.components.zha.entities import ZhaEntity +from homeassistant.components.zha import helpers from homeassistant.const import TEMP_CELSIUS from homeassistant.util.temperature import convert as convert_temperature @@ -19,7 +20,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up Zigbee Home Automation sensors.""" - discovery_info = zha.get_discovery_info(hass, discovery_info) + discovery_info = helpers.get_discovery_info(hass, discovery_info) if discovery_info is None: return @@ -56,7 +57,7 @@ async def make_sensor(discovery_info): if discovery_info['new_join']: cluster = list(in_clusters.values())[0] - await zha.configure_reporting( + await helpers.configure_reporting( sensor.entity_id, cluster, sensor.value_attribute, reportable_change=sensor.min_reportable_change ) @@ -64,7 +65,7 @@ async def make_sensor(discovery_info): return sensor -class Sensor(zha.Entity): +class Sensor(ZhaEntity): """Base ZHA sensor.""" _domain = DOMAIN @@ -92,7 +93,7 @@ def attribute_updated(self, attribute, value): async def async_update(self): """Retrieve latest state.""" - result = await zha.safe_read( + result = await helpers.safe_read( list(self._in_clusters.values())[0], [self.value_attribute], allow_cache=False, @@ -224,7 +225,7 @@ async def async_update(self): """Retrieve latest state.""" _LOGGER.debug("%s async_update", self.entity_id) - result = await zha.safe_read( + result = await helpers.safe_read( self._endpoint.electrical_measurement, ['active_power'], allow_cache=False, only_cache=(not self._initialized)) self._state = result.get('active_power', self._state) diff --git a/homeassistant/components/switch/zha.py b/homeassistant/components/switch/zha.py index 68a94cc1ca514..b184d7baa5ccb 100644 --- a/homeassistant/components/switch/zha.py +++ b/homeassistant/components/switch/zha.py @@ -7,7 +7,8 @@ import logging from homeassistant.components.switch import DOMAIN, SwitchDevice -from homeassistant.components import zha +from homeassistant.components.zha.entities import ZhaEntity +from homeassistant.components.zha import helpers _LOGGER = logging.getLogger(__name__) @@ -19,7 +20,7 @@ async def async_setup_platform(hass, config, async_add_entities, """Set up the Zigbee Home Automation switches.""" from zigpy.zcl.clusters.general import OnOff - discovery_info = zha.get_discovery_info(hass, discovery_info) + discovery_info = helpers.get_discovery_info(hass, discovery_info) if discovery_info is None: return @@ -28,7 +29,7 @@ async def async_setup_platform(hass, config, async_add_entities, if discovery_info['new_join']: in_clusters = discovery_info['in_clusters'] cluster = in_clusters[OnOff.cluster_id] - await zha.configure_reporting( + await helpers.configure_reporting( switch.entity_id, cluster, switch.value_attribute, min_report=0, max_report=600, reportable_change=1 ) @@ -36,7 +37,7 @@ async def async_setup_platform(hass, config, async_add_entities, async_add_entities([switch], update_before_add=True) -class Switch(zha.Entity, SwitchDevice): +class Switch(ZhaEntity, SwitchDevice): """ZHA switch.""" _domain = DOMAIN @@ -94,8 +95,8 @@ async def async_turn_off(self, **kwargs): async def async_update(self): """Retrieve latest state.""" - result = await zha.safe_read(self._endpoint.on_off, - ['on_off'], - allow_cache=False, - only_cache=(not self._initialized)) + result = await helpers.safe_read(self._endpoint.on_off, + ['on_off'], + allow_cache=False, + only_cache=(not self._initialized)) self._state = result.get('on_off', self._state) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 228e589ab01e8..e54b7f7f65793 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -7,15 +7,15 @@ import collections import enum import logging -import time import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant import const as ha_const -from homeassistant.helpers import discovery, entity -from homeassistant.util import slugify +from homeassistant.helpers import discovery from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.components.zha.entities import ZhaDeviceEntity +from . import const as zha_const REQUIREMENTS = [ 'bellows==0.7.0', @@ -145,6 +145,7 @@ def __init__(self, hass, config): self._component = EntityComponent(_LOGGER, DOMAIN, hass) self._device_registry = collections.defaultdict(list) hass.data[DISCOVERY_KEY] = hass.data.get(DISCOVERY_KEY, {}) + zha_const.populate_data() def device_joined(self, device): """Handle device joined. @@ -177,8 +178,6 @@ def device_removed(self, device): async def async_device_initialized(self, device, join): """Handle device joined and basic information discovered (async).""" import zigpy.profiles - import homeassistant.components.zha.const as zha_const - zha_const.populate_data() device_manufacturer = device_model = None @@ -276,7 +275,6 @@ async def _attempt_single_cluster_device(self, endpoint, cluster, device_classes, discovery_attr, is_new_join): """Try to set up an entity from a "bare" cluster.""" - import homeassistant.components.zha.const as zha_const if cluster.cluster_id in profile_clusters: return @@ -320,226 +318,3 @@ async def _attempt_single_cluster_device(self, endpoint, cluster, {'discovery_key': cluster_key}, self._config, ) - - -class Entity(entity.Entity): - """A base class for ZHA entities.""" - - _domain = None # Must be overridden by subclasses - - def __init__(self, endpoint, in_clusters, out_clusters, manufacturer, - 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 = "{}.{}_{}_{}_{}{}".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._domain, - ieeetail, - endpoint.endpoint_id, - kwargs.get('entity_suffix', ''), - ) - - self._endpoint = endpoint - self._in_clusters = in_clusters - self._out_clusters = out_clusters - self._state = None - self._unique_id = unique_id - - # Normally the entity itself is the listener. Sub-classes may set this - # to a dict of cluster ID -> listener to receive messages for specific - # clusters separately - self._in_listeners = {} - self._out_listeners = {} - - self._initialized = False - application_listener.register_entity(ieee, self) - - async def async_added_to_hass(self): - """Handle entity addition to hass. - - It is now safe to update the entity state - """ - for cluster_id, cluster in self._in_clusters.items(): - cluster.add_listener(self._in_listeners.get(cluster_id, self)) - for cluster_id, cluster in self._out_clusters.items(): - cluster.add_listener(self._out_listeners.get(cluster_id, self)) - - self._initialized = True - - @property - def unique_id(self) -> str: - """Return a 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 - - def zdo_command(self, tsn, command_id, args): - """Handle a ZDO command received on this cluster.""" - pass - - -class ZhaDeviceEntity(entity.Entity): - """A base class for ZHA devices.""" - - def __init__(self, device, manufacturer, model, application_listener, - keepalive_interval=7200, **kwargs): - """Init ZHA endpoint entity.""" - self._device_state_attributes = { - 'nwk': '0x{0:04x}'.format(device.nwk), - 'ieee': str(device.ieee), - 'lqi': device.lqi, - 'rssi': device.rssi, - } - - ieee = device.ieee - ieeetail = ''.join(['%02x' % (o, ) for o in ieee[-4:]]) - if manufacturer is not None and model is not None: - self._unique_id = "{}_{}_{}".format( - slugify(manufacturer), - slugify(model), - ieeetail, - ) - self._device_state_attributes['friendly_name'] = "{} {}".format( - manufacturer, - model, - ) - else: - self._unique_id = str(ieeetail) - - self._device = device - self._state = 'offline' - self._keepalive_interval = keepalive_interval - - application_listener.register_entity(ieee, self) - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def state(self) -> str: - """Return the state of the entity.""" - return self._state - - @property - def device_state_attributes(self): - """Return device specific state attributes.""" - update_time = None - if self._device.last_seen is not None and self._state == 'offline': - time_struct = time.localtime(self._device.last_seen) - update_time = time.strftime("%Y-%m-%dT%H:%M:%S", time_struct) - self._device_state_attributes['last_seen'] = update_time - if ('last_seen' in self._device_state_attributes and - self._state != 'offline'): - del self._device_state_attributes['last_seen'] - self._device_state_attributes['lqi'] = self._device.lqi - self._device_state_attributes['rssi'] = self._device.rssi - return self._device_state_attributes - - async def async_update(self): - """Handle polling.""" - if self._device.last_seen is None: - self._state = 'offline' - else: - difference = time.time() - self._device.last_seen - if difference > self._keepalive_interval: - self._state = 'offline' - else: - self._state = 'online' - - -def get_discovery_info(hass, discovery_info): - """Get the full discovery info for a device. - - Some of the info that needs to be passed to platforms is not JSON - serializable, so it cannot be put in the discovery_info dictionary. This - component places that info we need to pass to the platform in hass.data, - and this function is a helper for platforms to retrieve the complete - discovery info. - """ - if discovery_info is None: - return - - discovery_key = discovery_info.get('discovery_key', None) - all_discovery_info = hass.data.get(DISCOVERY_KEY, {}) - return all_discovery_info.get(discovery_key, None) - - -async def safe_read(cluster, attributes, allow_cache=True, only_cache=False): - """Swallow all exceptions from network read. - - If we throw during initialization, setup fails. Rather have an entity that - exists, but is in a maybe wrong state, than no entity. This method should - probably only be used during initialization. - """ - try: - result, _ = await cluster.read_attributes( - attributes, - allow_cache=allow_cache, - only_cache=only_cache - ) - return result - except Exception: # pylint: disable=broad-except - return {} - - -async def configure_reporting(entity_id, cluster, attr, skip_bind=False, - min_report=300, max_report=900, - reportable_change=1): - """Configure attribute reporting for a cluster. - - while swallowing the DeliverError exceptions in case of unreachable - devices. - """ - from zigpy.exceptions import DeliveryError - - attr_name = cluster.attributes.get(attr, [attr])[0] - cluster_name = cluster.ep_attribute - if not skip_bind: - try: - res = await cluster.bind() - _LOGGER.debug( - "%s: bound '%s' cluster: %s", entity_id, cluster_name, res[0] - ) - except DeliveryError as ex: - _LOGGER.debug( - "%s: Failed to bind '%s' cluster: %s", - entity_id, cluster_name, str(ex) - ) - - try: - res = await cluster.configure_reporting(attr, min_report, - max_report, reportable_change) - _LOGGER.debug( - "%s: reporting '%s' attr on '%s' cluster: %d/%d/%d: Result: '%s'", - entity_id, attr_name, cluster_name, min_report, max_report, - reportable_change, res - ) - except DeliveryError as ex: - _LOGGER.debug( - "%s: failed to set reporting for '%s' attr on '%s' cluster: %s", - entity_id, attr_name, cluster_name, str(ex) - ) diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py index 88dee57aa7032..2a7e35ff517df 100644 --- a/homeassistant/components/zha/const.py +++ b/homeassistant/components/zha/const.py @@ -1,5 +1,6 @@ """All constants related to the ZHA component.""" +DISCOVERY_KEY = 'zha_discovery_info' DEVICE_CLASS = {} SINGLE_INPUT_CLUSTER_DEVICE_CLASS = {} SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS = {} @@ -17,7 +18,12 @@ def populate_data(): from zigpy.profiles import PROFILES, zha, zll from homeassistant.components.sensor import zha as sensor_zha - DEVICE_CLASS[zha.PROFILE_ID] = { + if zha.PROFILE_ID not in DEVICE_CLASS: + DEVICE_CLASS[zha.PROFILE_ID] = {} + if zll.PROFILE_ID not in DEVICE_CLASS: + DEVICE_CLASS[zll.PROFILE_ID] = {} + + DEVICE_CLASS[zha.PROFILE_ID].update({ zha.DeviceType.ON_OFF_SWITCH: 'binary_sensor', zha.DeviceType.LEVEL_CONTROL_SWITCH: 'binary_sensor', zha.DeviceType.REMOTE_CONTROL: 'binary_sensor', @@ -29,8 +35,8 @@ def populate_data(): zha.DeviceType.ON_OFF_LIGHT_SWITCH: 'binary_sensor', zha.DeviceType.DIMMER_SWITCH: 'binary_sensor', zha.DeviceType.COLOR_DIMMER_SWITCH: 'binary_sensor', - } - DEVICE_CLASS[zll.PROFILE_ID] = { + }) + DEVICE_CLASS[zll.PROFILE_ID].update({ zll.DeviceType.ON_OFF_LIGHT: 'light', zll.DeviceType.ON_OFF_PLUGIN_UNIT: 'switch', zll.DeviceType.DIMMABLE_LIGHT: 'light', @@ -43,7 +49,7 @@ def populate_data(): zll.DeviceType.CONTROLLER: 'binary_sensor', zll.DeviceType.SCENE_CONTROLLER: 'binary_sensor', zll.DeviceType.ON_OFF_SENSOR: 'binary_sensor', - } + }) SINGLE_INPUT_CLUSTER_DEVICE_CLASS.update({ zcl.clusters.general.OnOff: 'switch', diff --git a/homeassistant/components/zha/entities/__init__.py b/homeassistant/components/zha/entities/__init__.py new file mode 100644 index 0000000000000..d5e52e9277f4b --- /dev/null +++ b/homeassistant/components/zha/entities/__init__.py @@ -0,0 +1,10 @@ +""" +Entities for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" + +# flake8: noqa +from .entity import ZhaEntity +from .device_entity import ZhaDeviceEntity diff --git a/homeassistant/components/zha/entities/device_entity.py b/homeassistant/components/zha/entities/device_entity.py new file mode 100644 index 0000000000000..1a10f2494897e --- /dev/null +++ b/homeassistant/components/zha/entities/device_entity.py @@ -0,0 +1,81 @@ +""" +Device entity for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" + +import time +from homeassistant.helpers import entity +from homeassistant.util import slugify + + +class ZhaDeviceEntity(entity.Entity): + """A base class for ZHA devices.""" + + def __init__(self, device, manufacturer, model, application_listener, + keepalive_interval=7200, **kwargs): + """Init ZHA endpoint entity.""" + self._device_state_attributes = { + 'nwk': '0x{0:04x}'.format(device.nwk), + 'ieee': str(device.ieee), + 'lqi': device.lqi, + 'rssi': device.rssi, + } + + ieee = device.ieee + ieeetail = ''.join(['%02x' % (o, ) for o in ieee[-4:]]) + if manufacturer is not None and model is not None: + self._unique_id = "{}_{}_{}".format( + slugify(manufacturer), + slugify(model), + ieeetail, + ) + self._device_state_attributes['friendly_name'] = "{} {}".format( + manufacturer, + model, + ) + else: + self._unique_id = str(ieeetail) + + self._device = device + self._state = 'offline' + self._keepalive_interval = keepalive_interval + + application_listener.register_entity(ieee, self) + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._unique_id + + @property + def state(self) -> str: + """Return the state of the entity.""" + return self._state + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + update_time = None + if self._device.last_seen is not None and self._state == 'offline': + time_struct = time.localtime(self._device.last_seen) + update_time = time.strftime("%Y-%m-%dT%H:%M:%S", time_struct) + self._device_state_attributes['last_seen'] = update_time + if ('last_seen' in self._device_state_attributes and + self._state != 'offline'): + del self._device_state_attributes['last_seen'] + self._device_state_attributes['lqi'] = self._device.lqi + self._device_state_attributes['rssi'] = self._device.rssi + return self._device_state_attributes + + async def async_update(self): + """Handle polling.""" + if self._device.last_seen is None: + self._state = 'offline' + else: + difference = time.time() - self._device.last_seen + if difference > self._keepalive_interval: + self._state = 'offline' + else: + self._state = 'online' diff --git a/homeassistant/components/zha/entities/entity.py b/homeassistant/components/zha/entities/entity.py new file mode 100644 index 0000000000000..a16f29f447a48 --- /dev/null +++ b/homeassistant/components/zha/entities/entity.py @@ -0,0 +1,89 @@ +""" +Entity for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" +from homeassistant.helpers import entity +from homeassistant.util import slugify +from homeassistant.core import callback + + +class ZhaEntity(entity.Entity): + """A base class for ZHA entities.""" + + _domain = None # Must be overridden by subclasses + + def __init__(self, endpoint, in_clusters, out_clusters, manufacturer, + 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 = "{}.{}_{}_{}_{}{}".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._domain, + ieeetail, + endpoint.endpoint_id, + kwargs.get('entity_suffix', ''), + ) + + self._endpoint = endpoint + self._in_clusters = in_clusters + self._out_clusters = out_clusters + self._state = None + self._unique_id = unique_id + + # Normally the entity itself is the listener. Sub-classes may set this + # to a dict of cluster ID -> listener to receive messages for specific + # clusters separately + self._in_listeners = {} + self._out_listeners = {} + + self._initialized = False + application_listener.register_entity(ieee, self) + + async def async_added_to_hass(self): + """Handle entity addition to hass. + + It is now safe to update the entity state + """ + for cluster_id, cluster in self._in_clusters.items(): + cluster.add_listener(self._in_listeners.get(cluster_id, self)) + for cluster_id, cluster in self._out_clusters.items(): + cluster.add_listener(self._out_listeners.get(cluster_id, self)) + + self._initialized = True + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._unique_id + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + return self._device_state_attributes + + @callback + def attribute_updated(self, attribute, value): + """Handle an attribute updated on this cluster.""" + pass + + @callback + def zdo_command(self, tsn, command_id, args): + """Handle a ZDO command received on this cluster.""" + pass diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py new file mode 100644 index 0000000000000..9d07f546b7f61 --- /dev/null +++ b/homeassistant/components/zha/helpers.py @@ -0,0 +1,84 @@ +""" +Helpers for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" +import logging + +_LOGGER = logging.getLogger(__name__) + + +def get_discovery_info(hass, discovery_info): + """Get the full discovery info for a device. + + Some of the info that needs to be passed to platforms is not JSON + serializable, so it cannot be put in the discovery_info dictionary. This + component places that info we need to pass to the platform in hass.data, + and this function is a helper for platforms to retrieve the complete + discovery info. + """ + if discovery_info is None: + return + + import homeassistant.components.zha.const as zha_const + discovery_key = discovery_info.get('discovery_key', None) + all_discovery_info = hass.data.get(zha_const.DISCOVERY_KEY, {}) + return all_discovery_info.get(discovery_key, None) + + +async def safe_read(cluster, attributes, allow_cache=True, only_cache=False): + """Swallow all exceptions from network read. + + If we throw during initialization, setup fails. Rather have an entity that + exists, but is in a maybe wrong state, than no entity. This method should + probably only be used during initialization. + """ + try: + result, _ = await cluster.read_attributes( + attributes, + allow_cache=allow_cache, + only_cache=only_cache + ) + return result + except Exception: # pylint: disable=broad-except + return {} + + +async def configure_reporting(entity_id, cluster, attr, skip_bind=False, + min_report=300, max_report=900, + reportable_change=1): + """Configure attribute reporting for a cluster. + + while swallowing the DeliverError exceptions in case of unreachable + devices. + """ + from zigpy.exceptions import DeliveryError + + attr_name = cluster.attributes.get(attr, [attr])[0] + cluster_name = cluster.ep_attribute + if not skip_bind: + try: + res = await cluster.bind() + _LOGGER.debug( + "%s: bound '%s' cluster: %s", entity_id, cluster_name, res[0] + ) + except DeliveryError as ex: + _LOGGER.debug( + "%s: Failed to bind '%s' cluster: %s", + entity_id, cluster_name, str(ex) + ) + + try: + res = await cluster.configure_reporting(attr, min_report, + max_report, reportable_change) + _LOGGER.debug( + "%s: reporting '%s' attr on '%s' cluster: %d/%d/%d: Result: '%s'", + entity_id, attr_name, cluster_name, min_report, max_report, + reportable_change, res + ) + except DeliveryError as ex: + _LOGGER.debug( + "%s: failed to set reporting for '%s' attr on '%s' cluster: %s", + entity_id, attr_name, cluster_name, str(ex) + ) From af0f3fcbdb323429f395b70be431e31ce8129a93 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Fri, 23 Nov 2018 00:09:45 +0000 Subject: [PATCH 029/325] IPMA Weather Service - version bump (#18626) * version bump * gen * gen --- homeassistant/components/weather/ipma.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/weather/ipma.py b/homeassistant/components/weather/ipma.py index 7fecfbcd0744b..55a1527db8c5c 100644 --- a/homeassistant/components/weather/ipma.py +++ b/homeassistant/components/weather/ipma.py @@ -20,7 +20,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.util import Throttle -REQUIREMENTS = ['pyipma==1.1.4'] +REQUIREMENTS = ['pyipma==1.1.6'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 4581967bbf61b..1fa86a9daf5a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -980,7 +980,7 @@ pyialarm==0.3 pyicloud==0.9.1 # homeassistant.components.weather.ipma -pyipma==1.1.4 +pyipma==1.1.6 # homeassistant.components.sensor.irish_rail_transport pyirishrail==0.0.2 From bb37151987d11b41400e1c1e8c29de770f4d82d0 Mon Sep 17 00:00:00 2001 From: Eliseo Martelli Date: Fri, 23 Nov 2018 01:46:22 +0100 Subject: [PATCH 030/325] fixed wording that may confuse user (#18628) --- homeassistant/components/recorder/migration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index a6a6ed461742d..825f402aef21d 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -29,7 +29,7 @@ def migrate_schema(instance): with open(progress_path, 'w'): pass - _LOGGER.warning("Database requires upgrade. Schema version: %s", + _LOGGER.warning("Database is about to upgrade. Schema version: %s", current_version) if current_version is None: From 98f159a039eeb7fc0a3cd9228aede22b8b33062f Mon Sep 17 00:00:00 2001 From: cdheiser <10488026+cdheiser@users.noreply.github.com> Date: Thu, 22 Nov 2018 23:54:28 -0800 Subject: [PATCH 031/325] [Breaking Change] Cleanup Lutron light component (#18650) Remove the return value from setup_platform Convert LutronLight.__init__ to use super() when referencing the parent class. Change device_state_attributes() to use lowercase snakecase (Rename 'Lutron Integration ID' to 'lutron_integration_id') --- homeassistant/components/light/lutron.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/light/lutron.py b/homeassistant/components/light/lutron.py index 6c4047e231404..ee08e532ce73e 100644 --- a/homeassistant/components/light/lutron.py +++ b/homeassistant/components/light/lutron.py @@ -24,7 +24,6 @@ def setup_platform(hass, config, add_entities, discovery_info=None): devs.append(dev) add_entities(devs, True) - return True def to_lutron_level(level): @@ -43,7 +42,7 @@ class LutronLight(LutronDevice, Light): def __init__(self, area_name, lutron_device, controller): """Initialize the light.""" self._prev_brightness = None - LutronDevice.__init__(self, area_name, lutron_device, controller) + super().__init__(self, area_name, lutron_device, controller) @property def supported_features(self): @@ -77,7 +76,7 @@ def turn_off(self, **kwargs): def device_state_attributes(self): """Return the state attributes.""" attr = {} - attr['Lutron Integration ID'] = self._lutron_device.id + attr['lutron_integration_id'] = self._lutron_device.id return attr @property From c99204149c5007ca8ad73ded4ed4b821fb4d47f5 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Fri, 23 Nov 2018 02:55:25 -0500 Subject: [PATCH 032/325] Convert device tracker init tests to async (#18640) --- tests/components/device_tracker/common.py | 31 + tests/components/device_tracker/test_init.py | 910 ++++++++++--------- 2 files changed, 491 insertions(+), 450 deletions(-) create mode 100644 tests/components/device_tracker/common.py diff --git a/tests/components/device_tracker/common.py b/tests/components/device_tracker/common.py new file mode 100644 index 0000000000000..b76eb9a833267 --- /dev/null +++ b/tests/components/device_tracker/common.py @@ -0,0 +1,31 @@ +"""Collection of helper methods. + +All containing methods are legacy helpers that should not be used by new +components. Instead call the service directly. +""" +from homeassistant.components.device_tracker import ( + DOMAIN, ATTR_ATTRIBUTES, ATTR_BATTERY, ATTR_GPS, ATTR_GPS_ACCURACY, + ATTR_LOCATION_NAME, ATTR_MAC, ATTR_DEV_ID, ATTR_HOST_NAME, SERVICE_SEE) +from homeassistant.core import callback +from homeassistant.helpers.typing import GPSType, HomeAssistantType +from homeassistant.loader import bind_hass + + +@callback +@bind_hass +def async_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: int = None, attributes: dict = None): + """Call service to notify you see device.""" + data = {key: value for key, value in + ((ATTR_MAC, mac), + (ATTR_DEV_ID, dev_id), + (ATTR_HOST_NAME, host_name), + (ATTR_LOCATION_NAME, location_name), + (ATTR_GPS, gps), + (ATTR_GPS_ACCURACY, gps_accuracy), + (ATTR_BATTERY, battery)) if value is not None} + if attributes: + data[ATTR_ATTRIBUTES] = attributes + hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_SEE, data)) diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 93de359610f4d..6f0d881d25706 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -3,504 +3,514 @@ import asyncio import json import logging -import unittest -from unittest.mock import call, patch +from unittest.mock import call from datetime import datetime, timedelta import os +from asynctest import patch +import pytest from homeassistant.components import zone from homeassistant.core import callback, State -from homeassistant.setup import setup_component, async_setup_component +from homeassistant.setup import async_setup_component from homeassistant.helpers import discovery from homeassistant.loader import get_component -from homeassistant.util.async_ import run_coroutine_threadsafe import homeassistant.util.dt as dt_util from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, ATTR_HIDDEN, STATE_HOME, STATE_NOT_HOME, CONF_PLATFORM, ATTR_ICON) import homeassistant.components.device_tracker as device_tracker +from tests.components.device_tracker import common from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.json import JSONEncoder from tests.common import ( - get_test_home_assistant, fire_time_changed, - patch_yaml_files, assert_setup_component, mock_restore_cache) -import pytest + async_fire_time_changed, patch_yaml_files, assert_setup_component, + mock_restore_cache) TEST_PLATFORM = {device_tracker.DOMAIN: {CONF_PLATFORM: 'test'}} _LOGGER = logging.getLogger(__name__) -class TestComponentsDeviceTracker(unittest.TestCase): - """Test the Device tracker.""" - - hass = None # HomeAssistant - yaml_devices = None # type: str - - # pylint: disable=invalid-name - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.yaml_devices = self.hass.config.path(device_tracker.YAML_DEVICES) - - # pylint: disable=invalid-name - def tearDown(self): - """Stop everything that was started.""" - if os.path.isfile(self.yaml_devices): - os.remove(self.yaml_devices) - - self.hass.stop() - - def test_is_on(self): - """Test is_on method.""" - entity_id = device_tracker.ENTITY_ID_FORMAT.format('test') - - self.hass.states.set(entity_id, STATE_HOME) - - assert device_tracker.is_on(self.hass, entity_id) - - self.hass.states.set(entity_id, STATE_NOT_HOME) - - assert not device_tracker.is_on(self.hass, entity_id) - - # pylint: disable=no-self-use - def test_reading_broken_yaml_config(self): - """Test when known devices contains invalid data.""" - files = {'empty.yaml': '', - 'nodict.yaml': '100', - 'badkey.yaml': '@:\n name: Device', - 'noname.yaml': 'my_device:\n', - 'allok.yaml': 'My Device:\n name: Device', - 'oneok.yaml': ('My Device!:\n name: Device\n' - 'bad_device:\n nme: Device')} - args = {'hass': self.hass, 'consider_home': timedelta(seconds=60)} - with patch_yaml_files(files): - assert device_tracker.load_config('empty.yaml', **args) == [] - assert device_tracker.load_config('nodict.yaml', **args) == [] - assert device_tracker.load_config('noname.yaml', **args) == [] - assert device_tracker.load_config('badkey.yaml', **args) == [] - - res = device_tracker.load_config('allok.yaml', **args) - assert len(res) == 1 - assert res[0].name == 'Device' - assert res[0].dev_id == 'my_device' - - res = device_tracker.load_config('oneok.yaml', **args) - assert len(res) == 1 - assert res[0].name == 'Device' - assert res[0].dev_id == 'my_device' - - def test_reading_yaml_config(self): - """Test the rendering of the YAML configuration.""" - dev_id = 'test' - device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, dev_id, - 'AB:CD:EF:GH:IJ', 'Test name', picture='http://test.picture', - hide_if_away=True, icon='mdi:kettle') - device_tracker.update_config(self.yaml_devices, dev_id, device) - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) - config = device_tracker.load_config(self.yaml_devices, self.hass, - device.consider_home)[0] - assert device.dev_id == config.dev_id - assert device.track == config.track - assert device.mac == config.mac - assert device.config_picture == config.config_picture - assert device.away_hide == config.away_hide - assert device.consider_home == config.consider_home - assert device.icon == config.icon - - # pylint: disable=invalid-name - @patch('homeassistant.components.device_tracker._LOGGER.warning') - def test_track_with_duplicate_mac_dev_id(self, mock_warning): - """Test adding duplicate MACs or device IDs to DeviceTracker.""" - devices = [ - device_tracker.Device(self.hass, True, True, 'my_device', 'AB:01', - 'My device', None, None, False), - device_tracker.Device(self.hass, True, True, 'your_device', - 'AB:01', 'Your device', None, None, False)] - device_tracker.DeviceTracker(self.hass, False, True, {}, devices) - _LOGGER.debug(mock_warning.call_args_list) - assert mock_warning.call_count == 1, \ - "The only warning call should be duplicates (check DEBUG)" - args, _ = mock_warning.call_args - assert 'Duplicate device MAC' in args[0], \ - 'Duplicate MAC warning expected' - - mock_warning.reset_mock() - devices = [ - device_tracker.Device(self.hass, True, True, 'my_device', - 'AB:01', 'My device', None, None, False), - device_tracker.Device(self.hass, True, True, 'my_device', - None, 'Your device', None, None, False)] - device_tracker.DeviceTracker(self.hass, False, True, {}, devices) - - _LOGGER.debug(mock_warning.call_args_list) - assert mock_warning.call_count == 1, \ - "The only warning call should be duplicates (check DEBUG)" - args, _ = mock_warning.call_args - assert 'Duplicate device IDs' in args[0], \ - 'Duplicate device IDs warning expected' - - def test_setup_without_yaml_file(self): - """Test with no YAML file.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) - - def test_gravatar(self): - """Test the Gravatar generation.""" - dev_id = 'test' - device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, dev_id, - 'AB:CD:EF:GH:IJ', 'Test name', gravatar='test@example.com') - gravatar_url = ("https://www.gravatar.com/avatar/" - "55502f40dc8b7c769880b10874abc9d0.jpg?s=80&d=wavatar") - assert device.config_picture == gravatar_url - - def test_gravatar_and_picture(self): - """Test that Gravatar overrides picture.""" - dev_id = 'test' - device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, dev_id, - 'AB:CD:EF:GH:IJ', 'Test name', picture='http://test.picture', - gravatar='test@example.com') - gravatar_url = ("https://www.gravatar.com/avatar/" - "55502f40dc8b7c769880b10874abc9d0.jpg?s=80&d=wavatar") - assert device.config_picture == gravatar_url - - @patch( - 'homeassistant.components.device_tracker.DeviceTracker.see') - @patch( - 'homeassistant.components.device_tracker.demo.setup_scanner', - autospec=True) - def test_discover_platform(self, mock_demo_setup_scanner, mock_see): - """Test discovery of device_tracker demo platform.""" - assert device_tracker.DOMAIN not in self.hass.config.components - discovery.load_platform( - self.hass, device_tracker.DOMAIN, 'demo', {'test_key': 'test_val'}, - {'demo': {}}) - self.hass.block_till_done() - assert device_tracker.DOMAIN in self.hass.config.components - assert mock_demo_setup_scanner.called - assert mock_demo_setup_scanner.call_args[0] == ( - self.hass, {}, mock_see, {'test_key': 'test_val'}) - - def test_update_stale(self): - """Test stalled update.""" - scanner = get_component(self.hass, 'device_tracker.test').SCANNER - scanner.reset() - scanner.come_home('DEV1') - - register_time = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC) - scan_time = datetime(2015, 9, 15, 23, 1, tzinfo=dt_util.UTC) - - with patch('homeassistant.components.device_tracker.dt_util.utcnow', - return_value=register_time): - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'test', - device_tracker.CONF_CONSIDER_HOME: 59, - }}) - self.hass.block_till_done() - - assert STATE_HOME == \ - self.hass.states.get('device_tracker.dev1').state - - scanner.leave_home('DEV1') - - with patch('homeassistant.components.device_tracker.dt_util.utcnow', - return_value=scan_time): - fire_time_changed(self.hass, scan_time) - self.hass.block_till_done() - - assert STATE_NOT_HOME == \ - self.hass.states.get('device_tracker.dev1').state - - def test_entity_attributes(self): - """Test the entity attributes.""" - dev_id = 'test_entity' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) - friendly_name = 'Paulus' - picture = 'http://placehold.it/200x200' - icon = 'mdi:kettle' - - device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, dev_id, None, - friendly_name, picture, hide_if_away=True, icon=icon) - device_tracker.update_config(self.yaml_devices, dev_id, device) +@pytest.fixture +def yaml_devices(hass): + """Get a path for storing yaml devices.""" + yaml_devices = hass.config.path(device_tracker.YAML_DEVICES) + if os.path.isfile(yaml_devices): + os.remove(yaml_devices) + yield yaml_devices + if os.path.isfile(yaml_devices): + os.remove(yaml_devices) - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) - attrs = self.hass.states.get(entity_id).attributes +async def test_is_on(hass): + """Test is_on method.""" + entity_id = device_tracker.ENTITY_ID_FORMAT.format('test') - assert friendly_name == attrs.get(ATTR_FRIENDLY_NAME) - assert icon == attrs.get(ATTR_ICON) - assert picture == attrs.get(ATTR_ENTITY_PICTURE) + hass.states.async_set(entity_id, STATE_HOME) - def test_device_hidden(self): - """Test hidden devices.""" - dev_id = 'test_entity' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) - device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, dev_id, None, - hide_if_away=True) - device_tracker.update_config(self.yaml_devices, dev_id, device) + assert device_tracker.is_on(hass, entity_id) - scanner = get_component(self.hass, 'device_tracker.test').SCANNER - scanner.reset() + hass.states.async_set(entity_id, STATE_NOT_HOME) - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) + assert not device_tracker.is_on(hass, entity_id) - assert self.hass.states.get(entity_id) \ - .attributes.get(ATTR_HIDDEN) - def test_group_all_devices(self): - """Test grouping of devices.""" - dev_id = 'test_entity' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) - device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, dev_id, None, - hide_if_away=True) - device_tracker.update_config(self.yaml_devices, dev_id, device) +async def test_reading_broken_yaml_config(hass): + """Test when known devices contains invalid data.""" + files = {'empty.yaml': '', + 'nodict.yaml': '100', + 'badkey.yaml': '@:\n name: Device', + 'noname.yaml': 'my_device:\n', + 'allok.yaml': 'My Device:\n name: Device', + 'oneok.yaml': ('My Device!:\n name: Device\n' + 'bad_device:\n nme: Device')} + args = {'hass': hass, 'consider_home': timedelta(seconds=60)} + with patch_yaml_files(files): + assert await device_tracker.async_load_config( + 'empty.yaml', **args) == [] + assert await device_tracker.async_load_config( + 'nodict.yaml', **args) == [] + assert await device_tracker.async_load_config( + 'noname.yaml', **args) == [] + assert await device_tracker.async_load_config( + 'badkey.yaml', **args) == [] + + res = await device_tracker.async_load_config('allok.yaml', **args) + assert len(res) == 1 + assert res[0].name == 'Device' + assert res[0].dev_id == 'my_device' + + res = await device_tracker.async_load_config('oneok.yaml', **args) + assert len(res) == 1 + assert res[0].name == 'Device' + assert res[0].dev_id == 'my_device' + + +async def test_reading_yaml_config(hass, yaml_devices): + """Test the rendering of the YAML configuration.""" + dev_id = 'test' + device = device_tracker.Device( + hass, timedelta(seconds=180), True, dev_id, + 'AB:CD:EF:GH:IJ', 'Test name', picture='http://test.picture', + hide_if_away=True, icon='mdi:kettle') + device_tracker.update_config(yaml_devices, dev_id, device) + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) + config = (await device_tracker.async_load_config(yaml_devices, hass, + device.consider_home))[0] + assert device.dev_id == config.dev_id + assert device.track == config.track + assert device.mac == config.mac + assert device.config_picture == config.config_picture + assert device.away_hide == config.away_hide + assert device.consider_home == config.consider_home + assert device.icon == config.icon + + +# pylint: disable=invalid-name +@patch('homeassistant.components.device_tracker._LOGGER.warning') +async def test_track_with_duplicate_mac_dev_id(mock_warning, hass): + """Test adding duplicate MACs or device IDs to DeviceTracker.""" + devices = [ + device_tracker.Device(hass, True, True, 'my_device', 'AB:01', + 'My device', None, None, False), + device_tracker.Device(hass, True, True, 'your_device', + 'AB:01', 'Your device', None, None, False)] + device_tracker.DeviceTracker(hass, False, True, {}, devices) + _LOGGER.debug(mock_warning.call_args_list) + assert mock_warning.call_count == 1, \ + "The only warning call should be duplicates (check DEBUG)" + args, _ = mock_warning.call_args + assert 'Duplicate device MAC' in args[0], \ + 'Duplicate MAC warning expected' + + mock_warning.reset_mock() + devices = [ + device_tracker.Device(hass, True, True, 'my_device', + 'AB:01', 'My device', None, None, False), + device_tracker.Device(hass, True, True, 'my_device', + None, 'Your device', None, None, False)] + device_tracker.DeviceTracker(hass, False, True, {}, devices) + + _LOGGER.debug(mock_warning.call_args_list) + assert mock_warning.call_count == 1, \ + "The only warning call should be duplicates (check DEBUG)" + args, _ = mock_warning.call_args + assert 'Duplicate device IDs' in args[0], \ + 'Duplicate device IDs warning expected' + + +async def test_setup_without_yaml_file(hass): + """Test with no YAML file.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) + + +async def test_gravatar(hass): + """Test the Gravatar generation.""" + dev_id = 'test' + device = device_tracker.Device( + hass, timedelta(seconds=180), True, dev_id, + 'AB:CD:EF:GH:IJ', 'Test name', gravatar='test@example.com') + gravatar_url = ("https://www.gravatar.com/avatar/" + "55502f40dc8b7c769880b10874abc9d0.jpg?s=80&d=wavatar") + assert device.config_picture == gravatar_url + + +async def test_gravatar_and_picture(hass): + """Test that Gravatar overrides picture.""" + dev_id = 'test' + device = device_tracker.Device( + hass, timedelta(seconds=180), True, dev_id, + 'AB:CD:EF:GH:IJ', 'Test name', picture='http://test.picture', + gravatar='test@example.com') + gravatar_url = ("https://www.gravatar.com/avatar/" + "55502f40dc8b7c769880b10874abc9d0.jpg?s=80&d=wavatar") + assert device.config_picture == gravatar_url + + +@patch( + 'homeassistant.components.device_tracker.DeviceTracker.see') +@patch( + 'homeassistant.components.device_tracker.demo.setup_scanner', + autospec=True) +async def test_discover_platform(mock_demo_setup_scanner, mock_see, hass): + """Test discovery of device_tracker demo platform.""" + assert device_tracker.DOMAIN not in hass.config.components + await discovery.async_load_platform( + hass, device_tracker.DOMAIN, 'demo', {'test_key': 'test_val'}, + {'demo': {}}) + await hass.async_block_till_done() + assert device_tracker.DOMAIN in hass.config.components + assert mock_demo_setup_scanner.called + assert mock_demo_setup_scanner.call_args[0] == ( + hass, {}, mock_see, {'test_key': 'test_val'}) - scanner = get_component(self.hass, 'device_tracker.test').SCANNER - scanner.reset() +async def test_update_stale(hass): + """Test stalled update.""" + scanner = get_component(hass, 'device_tracker.test').SCANNER + scanner.reset() + scanner.come_home('DEV1') + + register_time = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC) + scan_time = datetime(2015, 9, 15, 23, 1, tzinfo=dt_util.UTC) + + with patch('homeassistant.components.device_tracker.dt_util.utcnow', + return_value=register_time): with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) - self.hass.block_till_done() - - state = self.hass.states.get(device_tracker.ENTITY_ID_ALL_DEVICES) - assert state is not None - assert STATE_NOT_HOME == state.state - assert (entity_id,) == state.attributes.get(ATTR_ENTITY_ID) - - @patch('homeassistant.components.device_tracker.DeviceTracker.async_see') - def test_see_service(self, mock_see): - """Test the see service with a unicode dev_id and NO MAC.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) - params = { - 'dev_id': 'some_device', - 'host_name': 'example.com', - 'location_name': 'Work', - 'gps': [.3, .8], - 'attributes': { - 'test': 'test' - } - } - device_tracker.see(self.hass, **params) - self.hass.block_till_done() - assert mock_see.call_count == 1 - assert mock_see.call_count == 1 - assert mock_see.call_args == call(**params) - - mock_see.reset_mock() - params['dev_id'] += chr(233) # e' acute accent from icloud - - device_tracker.see(self.hass, **params) - self.hass.block_till_done() - assert mock_see.call_count == 1 - assert mock_see.call_count == 1 - assert mock_see.call_args == call(**params) - - def test_new_device_event_fired(self): - """Test that the device tracker will fire an event.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) - test_events = [] + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'test', + device_tracker.CONF_CONSIDER_HOME: 59, + }}) + await hass.async_block_till_done() + + assert STATE_HOME == \ + hass.states.get('device_tracker.dev1').state - @callback - def listener(event): - """Record that our event got called.""" - test_events.append(event) + scanner.leave_home('DEV1') - self.hass.bus.listen("device_tracker_new_device", listener) + with patch('homeassistant.components.device_tracker.dt_util.utcnow', + return_value=scan_time): + async_fire_time_changed(hass, scan_time) + await hass.async_block_till_done() - device_tracker.see(self.hass, 'mac_1', host_name='hello') - device_tracker.see(self.hass, 'mac_1', host_name='hello') + assert STATE_NOT_HOME == \ + hass.states.get('device_tracker.dev1').state - self.hass.block_till_done() - assert len(test_events) == 1 +async def test_entity_attributes(hass, yaml_devices): + """Test the entity attributes.""" + dev_id = 'test_entity' + entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + friendly_name = 'Paulus' + picture = 'http://placehold.it/200x200' + icon = 'mdi:kettle' - # Assert we can serialize the event - json.dumps(test_events[0].as_dict(), cls=JSONEncoder) + device = device_tracker.Device( + hass, timedelta(seconds=180), True, dev_id, None, + friendly_name, picture, hide_if_away=True, icon=icon) + device_tracker.update_config(yaml_devices, dev_id, device) - assert test_events[0].data == { - 'entity_id': 'device_tracker.hello', - 'host_name': 'hello', - 'mac': 'MAC_1', + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) + + attrs = hass.states.get(entity_id).attributes + + assert friendly_name == attrs.get(ATTR_FRIENDLY_NAME) + assert icon == attrs.get(ATTR_ICON) + assert picture == attrs.get(ATTR_ENTITY_PICTURE) + + +async def test_device_hidden(hass, yaml_devices): + """Test hidden devices.""" + dev_id = 'test_entity' + entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + device = device_tracker.Device( + hass, timedelta(seconds=180), True, dev_id, None, + hide_if_away=True) + device_tracker.update_config(yaml_devices, dev_id, device) + + scanner = get_component(hass, 'device_tracker.test').SCANNER + scanner.reset() + + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) + + assert hass.states.get(entity_id).attributes.get(ATTR_HIDDEN) + + +async def test_group_all_devices(hass, yaml_devices): + """Test grouping of devices.""" + dev_id = 'test_entity' + entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + device = device_tracker.Device( + hass, timedelta(seconds=180), True, dev_id, None, + hide_if_away=True) + device_tracker.update_config(yaml_devices, dev_id, device) + + scanner = get_component(hass, 'device_tracker.test').SCANNER + scanner.reset() + + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) + await hass.async_block_till_done() + + state = hass.states.get(device_tracker.ENTITY_ID_ALL_DEVICES) + assert state is not None + assert STATE_NOT_HOME == state.state + assert (entity_id,) == state.attributes.get(ATTR_ENTITY_ID) + + +@patch('homeassistant.components.device_tracker.DeviceTracker.async_see') +async def test_see_service(mock_see, hass): + """Test the see service with a unicode dev_id and NO MAC.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) + params = { + 'dev_id': 'some_device', + 'host_name': 'example.com', + 'location_name': 'Work', + 'gps': [.3, .8], + 'attributes': { + 'test': 'test' } + } + common.async_see(hass, **params) + await hass.async_block_till_done() + assert mock_see.call_count == 1 + assert mock_see.call_count == 1 + assert mock_see.call_args == call(**params) - # pylint: disable=invalid-name - def test_not_write_duplicate_yaml_keys(self): - """Test that the device tracker will not generate invalid YAML.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) + mock_see.reset_mock() + params['dev_id'] += chr(233) # e' acute accent from icloud - device_tracker.see(self.hass, 'mac_1', host_name='hello') - device_tracker.see(self.hass, 'mac_2', host_name='hello') + common.async_see(hass, **params) + await hass.async_block_till_done() + assert mock_see.call_count == 1 + assert mock_see.call_count == 1 + assert mock_see.call_args == call(**params) - self.hass.block_till_done() - config = device_tracker.load_config(self.yaml_devices, self.hass, - timedelta(seconds=0)) - assert len(config) == 2 +async def test_new_device_event_fired(hass): + """Test that the device tracker will fire an event.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) + test_events = [] - # pylint: disable=invalid-name - def test_not_allow_invalid_dev_id(self): - """Test that the device tracker will not allow invalid dev ids.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) - - device_tracker.see(self.hass, dev_id='hello-world') - - config = device_tracker.load_config(self.yaml_devices, self.hass, - timedelta(seconds=0)) - assert len(config) == 0 - - def test_see_state(self): - """Test device tracker see records state correctly.""" - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) - - params = { - 'mac': 'AA:BB:CC:DD:EE:FF', - 'dev_id': 'some_device', - 'host_name': 'example.com', - 'location_name': 'Work', - 'gps': [.3, .8], - 'gps_accuracy': 1, - 'battery': 100, - 'attributes': { - 'test': 'test', - 'number': 1, - }, + @callback + def listener(event): + """Record that our event got called.""" + test_events.append(event) + + hass.bus.async_listen("device_tracker_new_device", listener) + + common.async_see(hass, 'mac_1', host_name='hello') + common.async_see(hass, 'mac_1', host_name='hello') + + await hass.async_block_till_done() + + assert len(test_events) == 1 + + # Assert we can serialize the event + json.dumps(test_events[0].as_dict(), cls=JSONEncoder) + + assert test_events[0].data == { + 'entity_id': 'device_tracker.hello', + 'host_name': 'hello', + 'mac': 'MAC_1', + } + + +# pylint: disable=invalid-name +async def test_not_write_duplicate_yaml_keys(hass, yaml_devices): + """Test that the device tracker will not generate invalid YAML.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) + + common.async_see(hass, 'mac_1', host_name='hello') + common.async_see(hass, 'mac_2', host_name='hello') + + await hass.async_block_till_done() + + config = await device_tracker.async_load_config(yaml_devices, hass, + timedelta(seconds=0)) + assert len(config) == 2 + + +# pylint: disable=invalid-name +async def test_not_allow_invalid_dev_id(hass, yaml_devices): + """Test that the device tracker will not allow invalid dev ids.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) + + common.async_see(hass, dev_id='hello-world') + + config = await device_tracker.async_load_config(yaml_devices, hass, + timedelta(seconds=0)) + assert len(config) == 0 + + +async def test_see_state(hass, yaml_devices): + """Test device tracker see records state correctly.""" + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) + + params = { + 'mac': 'AA:BB:CC:DD:EE:FF', + 'dev_id': 'some_device', + 'host_name': 'example.com', + 'location_name': 'Work', + 'gps': [.3, .8], + 'gps_accuracy': 1, + 'battery': 100, + 'attributes': { + 'test': 'test', + 'number': 1, + }, + } + + common.async_see(hass, **params) + await hass.async_block_till_done() + + config = await device_tracker.async_load_config(yaml_devices, hass, + timedelta(seconds=0)) + assert len(config) == 1 + + state = hass.states.get('device_tracker.examplecom') + attrs = state.attributes + assert state.state == 'Work' + assert state.object_id == 'examplecom' + assert state.name == 'example.com' + assert attrs['friendly_name'] == 'example.com' + assert attrs['battery'] == 100 + assert attrs['latitude'] == 0.3 + assert attrs['longitude'] == 0.8 + assert attrs['test'] == 'test' + assert attrs['gps_accuracy'] == 1 + assert attrs['source_type'] == 'gps' + assert attrs['number'] == 1 + + +async def test_see_passive_zone_state(hass): + """Test that the device tracker sets gps for passive trackers.""" + register_time = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC) + scan_time = datetime(2015, 9, 15, 23, 1, tzinfo=dt_util.UTC) + + with assert_setup_component(1, zone.DOMAIN): + zone_info = { + 'name': 'Home', + 'latitude': 1, + 'longitude': 2, + 'radius': 250, + 'passive': False } - device_tracker.see(self.hass, **params) - self.hass.block_till_done() - - config = device_tracker.load_config(self.yaml_devices, self.hass, - timedelta(seconds=0)) - assert len(config) == 1 - - state = self.hass.states.get('device_tracker.examplecom') - attrs = state.attributes - assert state.state == 'Work' - assert state.object_id == 'examplecom' - assert state.name == 'example.com' - assert attrs['friendly_name'] == 'example.com' - assert attrs['battery'] == 100 - assert attrs['latitude'] == 0.3 - assert attrs['longitude'] == 0.8 - assert attrs['test'] == 'test' - assert attrs['gps_accuracy'] == 1 - assert attrs['source_type'] == 'gps' - assert attrs['number'] == 1 - - def test_see_passive_zone_state(self): - """Test that the device tracker sets gps for passive trackers.""" - register_time = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC) - scan_time = datetime(2015, 9, 15, 23, 1, tzinfo=dt_util.UTC) - - with assert_setup_component(1, zone.DOMAIN): - zone_info = { - 'name': 'Home', - 'latitude': 1, - 'longitude': 2, - 'radius': 250, - 'passive': False - } - - setup_component(self.hass, zone.DOMAIN, { - 'zone': zone_info - }) - - scanner = get_component(self.hass, 'device_tracker.test').SCANNER - scanner.reset() - scanner.come_home('dev1') - - with patch('homeassistant.components.device_tracker.dt_util.utcnow', - return_value=register_time): - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'test', - device_tracker.CONF_CONSIDER_HOME: 59, - }}) - self.hass.block_till_done() - - state = self.hass.states.get('device_tracker.dev1') - attrs = state.attributes - assert STATE_HOME == state.state - assert state.object_id == 'dev1' - assert state.name == 'dev1' - assert attrs.get('friendly_name') == 'dev1' - assert attrs.get('latitude') == 1 - assert attrs.get('longitude') == 2 - assert attrs.get('gps_accuracy') == 0 - assert attrs.get('source_type') == \ - device_tracker.SOURCE_TYPE_ROUTER - - scanner.leave_home('dev1') - - with patch('homeassistant.components.device_tracker.dt_util.utcnow', - return_value=scan_time): - fire_time_changed(self.hass, scan_time) - self.hass.block_till_done() - - state = self.hass.states.get('device_tracker.dev1') - attrs = state.attributes - assert STATE_NOT_HOME == state.state - assert state.object_id == 'dev1' - assert state.name == 'dev1' - assert attrs.get('friendly_name') == 'dev1' - assert attrs.get('latitude')is None - assert attrs.get('longitude')is None - assert attrs.get('gps_accuracy')is None - assert attrs.get('source_type') == \ - device_tracker.SOURCE_TYPE_ROUTER - - @patch('homeassistant.components.device_tracker._LOGGER.warning') - def test_see_failures(self, mock_warning): - """Test that the device tracker see failures.""" - tracker = device_tracker.DeviceTracker( - self.hass, timedelta(seconds=60), 0, {}, []) - - # MAC is not a string (but added) - tracker.see(mac=567, host_name="Number MAC") - - # No device id or MAC(not added) - with pytest.raises(HomeAssistantError): - run_coroutine_threadsafe( - tracker.async_see(), self.hass.loop).result() - assert mock_warning.call_count == 0 - - # Ignore gps on invalid GPS (both added & warnings) - tracker.see(mac='mac_1_bad_gps', gps=1) - tracker.see(mac='mac_2_bad_gps', gps=[1]) - tracker.see(mac='mac_3_bad_gps', gps='gps') - self.hass.block_till_done() - config = device_tracker.load_config(self.yaml_devices, self.hass, - timedelta(seconds=0)) - assert mock_warning.call_count == 3 - - assert len(config) == 4 + await async_setup_component(hass, zone.DOMAIN, { + 'zone': zone_info + }) + + scanner = get_component(hass, 'device_tracker.test').SCANNER + scanner.reset() + scanner.come_home('dev1') + + with patch('homeassistant.components.device_tracker.dt_util.utcnow', + return_value=register_time): + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'test', + device_tracker.CONF_CONSIDER_HOME: 59, + }}) + await hass.async_block_till_done() + + state = hass.states.get('device_tracker.dev1') + attrs = state.attributes + assert STATE_HOME == state.state + assert state.object_id == 'dev1' + assert state.name == 'dev1' + assert attrs.get('friendly_name') == 'dev1' + assert attrs.get('latitude') == 1 + assert attrs.get('longitude') == 2 + assert attrs.get('gps_accuracy') == 0 + assert attrs.get('source_type') == \ + device_tracker.SOURCE_TYPE_ROUTER + + scanner.leave_home('dev1') + + with patch('homeassistant.components.device_tracker.dt_util.utcnow', + return_value=scan_time): + async_fire_time_changed(hass, scan_time) + await hass.async_block_till_done() + + state = hass.states.get('device_tracker.dev1') + attrs = state.attributes + assert STATE_NOT_HOME == state.state + assert state.object_id == 'dev1' + assert state.name == 'dev1' + assert attrs.get('friendly_name') == 'dev1' + assert attrs.get('latitude')is None + assert attrs.get('longitude')is None + assert attrs.get('gps_accuracy')is None + assert attrs.get('source_type') == \ + device_tracker.SOURCE_TYPE_ROUTER + + +@patch('homeassistant.components.device_tracker._LOGGER.warning') +async def test_see_failures(mock_warning, hass, yaml_devices): + """Test that the device tracker see failures.""" + tracker = device_tracker.DeviceTracker( + hass, timedelta(seconds=60), 0, {}, []) + + # MAC is not a string (but added) + await tracker.async_see(mac=567, host_name="Number MAC") + + # No device id or MAC(not added) + with pytest.raises(HomeAssistantError): + await tracker.async_see() + assert mock_warning.call_count == 0 + + # Ignore gps on invalid GPS (both added & warnings) + await tracker.async_see(mac='mac_1_bad_gps', gps=1) + await tracker.async_see(mac='mac_2_bad_gps', gps=[1]) + await tracker.async_see(mac='mac_3_bad_gps', gps='gps') + await hass.async_block_till_done() + config = await device_tracker.async_load_config(yaml_devices, hass, + timedelta(seconds=0)) + assert mock_warning.call_count == 3 + + assert len(config) == 4 @asyncio.coroutine From 92978b2f267b58ec1225c63c0eef8a163837e313 Mon Sep 17 00:00:00 2001 From: Ian Richardson Date: Fri, 23 Nov 2018 01:56:18 -0600 Subject: [PATCH 033/325] Add websocket call for adding item to shopping-list (#18623) --- homeassistant/components/shopping_list.py | 20 ++++++++++++ tests/components/test_shopping_list.py | 38 +++++++++++++++++++++-- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shopping_list.py b/homeassistant/components/shopping_list.py index 45650ece62169..650d23fe1dfdf 100644 --- a/homeassistant/components/shopping_list.py +++ b/homeassistant/components/shopping_list.py @@ -38,12 +38,19 @@ }) WS_TYPE_SHOPPING_LIST_ITEMS = 'shopping_list/items' +WS_TYPE_SHOPPING_LIST_ADD_ITEM = 'shopping_list/items/add' SCHEMA_WEBSOCKET_ITEMS = \ websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_SHOPPING_LIST_ITEMS }) +SCHEMA_WEBSOCKET_ADD_ITEM = \ + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_SHOPPING_LIST_ADD_ITEM, + vol.Required('name'): str + }) + @asyncio.coroutine def async_setup(hass, config): @@ -103,6 +110,10 @@ def complete_item_service(call): WS_TYPE_SHOPPING_LIST_ITEMS, websocket_handle_items, SCHEMA_WEBSOCKET_ITEMS) + hass.components.websocket_api.async_register_command( + WS_TYPE_SHOPPING_LIST_ADD_ITEM, + websocket_handle_add, + SCHEMA_WEBSOCKET_ADD_ITEM) return True @@ -276,3 +287,12 @@ def websocket_handle_items(hass, connection, msg): """Handle get shopping_list items.""" connection.send_message(websocket_api.result_message( msg['id'], hass.data[DOMAIN].items)) + + +@callback +def websocket_handle_add(hass, connection, msg): + """Handle add item to shopping_list.""" + item = hass.data[DOMAIN].async_add(msg['name']) + hass.bus.async_fire(EVENT) + connection.send_message(websocket_api.result_message( + msg['id'], item)) diff --git a/tests/components/test_shopping_list.py b/tests/components/test_shopping_list.py index e64b9a5ae26c7..44714138eb355 100644 --- a/tests/components/test_shopping_list.py +++ b/tests/components/test_shopping_list.py @@ -228,7 +228,7 @@ def test_api_clear_completed(hass, aiohttp_client): @asyncio.coroutine -def test_api_create(hass, aiohttp_client): +def test_deprecated_api_create(hass, aiohttp_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -249,7 +249,7 @@ def test_api_create(hass, aiohttp_client): @asyncio.coroutine -def test_api_create_fail(hass, aiohttp_client): +def test_deprecated_api_create_fail(hass, aiohttp_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -260,3 +260,37 @@ def test_api_create_fail(hass, aiohttp_client): assert resp.status == 400 assert len(hass.data['shopping_list'].items) == 0 + + +async def test_ws_add_item(hass, hass_ws_client): + """Test adding shopping_list item websocket command.""" + await async_setup_component(hass, 'shopping_list', {}) + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'shopping_list/items/add', + 'name': 'soda', + }) + msg = await client.receive_json() + assert msg['success'] is True + data = msg['result'] + assert data['name'] == 'soda' + assert data['complete'] is False + items = hass.data['shopping_list'].items + assert len(items) == 1 + assert items[0]['name'] == 'soda' + assert items[0]['complete'] is False + + +async def test_ws_add_item_fail(hass, hass_ws_client): + """Test adding shopping_list item failure websocket command.""" + await async_setup_component(hass, 'shopping_list', {}) + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'shopping_list/items/add', + 'name': 123, + }) + msg = await client.receive_json() + assert msg['success'] is False + assert len(hass.data['shopping_list'].items) == 0 From c0cf29aba935fe11f2e537c8301df2c660d118f2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 23 Nov 2018 11:55:45 +0100 Subject: [PATCH 034/325] Remove since last boot from systemmonitor sensor (#18644) * Remove since last boot * Make systemmonitor last_boot be a timestamp --- homeassistant/components/sensor/systemmonitor.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 27a7f083fbe3a..212602aa72cec 100644 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -41,7 +41,6 @@ 'packets_out': ['Packets out', ' ', 'mdi:server-network'], 'process': ['Process', ' ', 'mdi:memory'], 'processor_use': ['Processor use', '%', 'mdi:memory'], - 'since_last_boot': ['Since last boot', '', 'mdi:clock'], 'swap_free': ['Swap free', 'MiB', 'mdi:harddisk'], 'swap_use': ['Swap use', 'MiB', 'mdi:harddisk'], 'swap_use_percent': ['Swap use (percent)', '%', 'mdi:harddisk'], @@ -174,10 +173,7 @@ def update(self): elif self.type == 'last_boot': self._state = dt_util.as_local( dt_util.utc_from_timestamp(psutil.boot_time()) - ).date().isoformat() - elif self.type == 'since_last_boot': - self._state = dt_util.utcnow() - dt_util.utc_from_timestamp( - psutil.boot_time()) + ).isoformat() elif self.type == 'load_1m': self._state = os.getloadavg()[0] elif self.type == 'load_5m': From 1c17b885db6de09019eb482920fdda12c961ac12 Mon Sep 17 00:00:00 2001 From: Eliseo Martelli Date: Fri, 23 Nov 2018 14:51:26 +0100 Subject: [PATCH 035/325] Added deviceclass timestamp constant (#18652) * Added deviceclass timestamp * added device class timestamp to sensor * fixed comment --- homeassistant/components/sensor/__init__.py | 3 ++- homeassistant/const.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index be599cc295a8e..2800b689dc651 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.const import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_PRESSURE) + DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_PRESSURE) _LOGGER = logging.getLogger(__name__) @@ -28,6 +28,7 @@ DEVICE_CLASS_HUMIDITY, # % of humidity in the air DEVICE_CLASS_ILLUMINANCE, # current light level (lx/lm) DEVICE_CLASS_TEMPERATURE, # temperature (C/F) + DEVICE_CLASS_TIMESTAMP, # timestamp (ISO8601) DEVICE_CLASS_PRESSURE, # pressure (hPa/mbar) ] diff --git a/homeassistant/const.py b/homeassistant/const.py index 651a395b4680c..fc97e1bc52d5d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -177,6 +177,7 @@ DEVICE_CLASS_HUMIDITY = 'humidity' DEVICE_CLASS_ILLUMINANCE = 'illuminance' DEVICE_CLASS_TEMPERATURE = 'temperature' +DEVICE_CLASS_TIMESTAMP = 'timestamp' DEVICE_CLASS_PRESSURE = 'pressure' # #### STATES #### From b198bb441a2e6cfc69866e2f9ce1cc897f200152 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 23 Nov 2018 15:32:00 +0100 Subject: [PATCH 036/325] Support updated MQTT QoS when reconfiguring MQTT availability --- homeassistant/components/mqtt/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 66b105326649e..72684c7ec13f6 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -827,12 +827,13 @@ def __init__(self, availability_topic: Optional[str], qos: Optional[int], payload_available: Optional[str], payload_not_available: Optional[str]) -> None: """Initialize the availability mixin.""" + self._availability_sub_state = None + self._availability_topic = availability_topic self._availability_qos = qos - self._available = availability_topic is None # type: bool + self._available = self._availability_topic is None # type: bool self._payload_available = payload_available self._payload_not_available = payload_not_available - self._availability_sub_state = None async def async_added_to_hass(self) -> None: """Subscribe MQTT events. @@ -849,6 +850,8 @@ async def availability_discovery_update(self, config: dict): def _availability_setup_from_config(self, config): """(Re)Setup.""" self._availability_topic = config.get(CONF_AVAILABILITY_TOPIC) + self._availability_qos = config.get(CONF_QOS) + self._available = self._availability_topic is None # type: bool self._payload_available = config.get(CONF_PAYLOAD_AVAILABLE) self._payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) From 37327f6cbd1175aedc915923b7130d19fad59074 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 23 Nov 2018 22:56:58 +0100 Subject: [PATCH 037/325] Add save command to lovelace (#18655) * Add save command to lovelace * Default for save should by json * typing --- homeassistant/components/lovelace/__init__.py | 32 +++++++++++++++++-- homeassistant/util/ruamel_yaml.py | 10 ++++-- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 5234dbaf29d4a..72b19235c3019 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -31,6 +31,7 @@ OLD_WS_TYPE_GET_LOVELACE_UI = 'frontend/lovelace_config' WS_TYPE_GET_LOVELACE_UI = 'lovelace/config' WS_TYPE_MIGRATE_CONFIG = 'lovelace/config/migrate' +WS_TYPE_SAVE_CONFIG = 'lovelace/config/save' WS_TYPE_GET_CARD = 'lovelace/config/card/get' WS_TYPE_UPDATE_CARD = 'lovelace/config/card/update' @@ -53,6 +54,13 @@ vol.Required('type'): WS_TYPE_MIGRATE_CONFIG, }) +SCHEMA_SAVE_CONFIG = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_SAVE_CONFIG, + vol.Required('config'): vol.Any(str, Dict), + vol.Optional('format', default=FORMAT_JSON): + vol.Any(FORMAT_JSON, FORMAT_YAML), +}) + SCHEMA_GET_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_GET_CARD, vol.Required('card_id'): str, @@ -204,6 +212,13 @@ def migrate_config(fname: str) -> None: yaml.save_yaml(fname, config) +def save_config(fname: str, config, data_format: str = FORMAT_JSON) -> None: + """Save config to file.""" + if data_format == FORMAT_YAML: + config = yaml.yaml_to_object(config) + yaml.save_yaml(fname, config) + + def get_card(fname: str, card_id: str, data_format: str = FORMAT_YAML)\ -> JSON_TYPE: """Load a specific card config for id.""" @@ -422,13 +437,17 @@ async def async_setup(hass, config): OLD_WS_TYPE_GET_LOVELACE_UI, websocket_lovelace_config, SCHEMA_GET_LOVELACE_UI) + hass.components.websocket_api.async_register_command( + WS_TYPE_GET_LOVELACE_UI, websocket_lovelace_config, + SCHEMA_GET_LOVELACE_UI) + hass.components.websocket_api.async_register_command( WS_TYPE_MIGRATE_CONFIG, websocket_lovelace_migrate_config, SCHEMA_MIGRATE_CONFIG) hass.components.websocket_api.async_register_command( - WS_TYPE_GET_LOVELACE_UI, websocket_lovelace_config, - SCHEMA_GET_LOVELACE_UI) + WS_TYPE_SAVE_CONFIG, websocket_lovelace_save_config, + SCHEMA_SAVE_CONFIG) hass.components.websocket_api.async_register_command( WS_TYPE_GET_CARD, websocket_lovelace_get_card, SCHEMA_GET_CARD) @@ -516,6 +535,15 @@ async def websocket_lovelace_migrate_config(hass, connection, msg): migrate_config, hass.config.path(LOVELACE_CONFIG_FILE)) +@websocket_api.async_response +@handle_yaml_errors +async def websocket_lovelace_save_config(hass, connection, msg): + """Save Lovelace UI configuration.""" + return await hass.async_add_executor_job( + save_config, hass.config.path(LOVELACE_CONFIG_FILE), msg['config'], + msg.get('format', FORMAT_JSON)) + + @websocket_api.async_response @handle_yaml_errors async def websocket_lovelace_get_card(hass, connection, msg): diff --git a/homeassistant/util/ruamel_yaml.py b/homeassistant/util/ruamel_yaml.py index 8211252a516d2..0659e3d80544d 100644 --- a/homeassistant/util/ruamel_yaml.py +++ b/homeassistant/util/ruamel_yaml.py @@ -1,7 +1,7 @@ """ruamel.yaml utility functions.""" import logging import os -from os import O_CREAT, O_TRUNC, O_WRONLY +from os import O_CREAT, O_TRUNC, O_WRONLY, stat_result from collections import OrderedDict from typing import Union, List, Dict @@ -104,13 +104,17 @@ def save_yaml(fname: str, data: JSON_TYPE) -> None: yaml.indent(sequence=4, offset=2) tmp_fname = fname + "__TEMP__" try: - file_stat = os.stat(fname) + try: + file_stat = os.stat(fname) + except OSError: + file_stat = stat_result( + (0o644, -1, -1, -1, -1, -1, -1, -1, -1, -1)) with open(os.open(tmp_fname, O_WRONLY | O_CREAT | O_TRUNC, file_stat.st_mode), 'w', encoding='utf-8') \ as temp_file: yaml.dump(data, temp_file) os.replace(tmp_fname, fname) - if hasattr(os, 'chown'): + if hasattr(os, 'chown') and file_stat.st_ctime > -1: try: os.chown(fname, file_stat.st_uid, file_stat.st_gid) except OSError: From 8771f9f7dd4368dd3d3db068e3b06fac095d04be Mon Sep 17 00:00:00 2001 From: Kacper Krupa Date: Fri, 23 Nov 2018 23:53:33 +0100 Subject: [PATCH 038/325] converted majority of effects from ifs to dict map, which makes it easier to extend in the future. Also, added LSD effect! (#18656) --- homeassistant/components/light/yeelight.py | 44 +++++++++++----------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/light/yeelight.py b/homeassistant/components/light/yeelight.py index 15f2d24fa8aac..25704eea0cc6b 100644 --- a/homeassistant/components/light/yeelight.py +++ b/homeassistant/components/light/yeelight.py @@ -76,6 +76,7 @@ EFFECT_RGB = "RGB" EFFECT_RANDOM_LOOP = "Random Loop" EFFECT_FAST_RANDOM_LOOP = "Fast Random Loop" +EFFECT_LSD = "LSD" EFFECT_SLOWDOWN = "Slowdown" EFFECT_WHATSAPP = "WhatsApp" EFFECT_FACEBOOK = "Facebook" @@ -94,6 +95,7 @@ EFFECT_RGB, EFFECT_RANDOM_LOOP, EFFECT_FAST_RANDOM_LOOP, + EFFECT_LSD, EFFECT_SLOWDOWN, EFFECT_WHATSAPP, EFFECT_FACEBOOK, @@ -413,34 +415,30 @@ def set_effect(self, effect) -> None: from yeelight.transitions import (disco, temp, strobe, pulse, strobe_color, alarm, police, police2, christmas, rgb, - randomloop, slowdown) + randomloop, lsd, slowdown) if effect == EFFECT_STOP: self._bulb.stop_flow() return - if effect == EFFECT_DISCO: - flow = Flow(count=0, transitions=disco()) - if effect == EFFECT_TEMP: - flow = Flow(count=0, transitions=temp()) - if effect == EFFECT_STROBE: - flow = Flow(count=0, transitions=strobe()) - if effect == EFFECT_STROBE_COLOR: - flow = Flow(count=0, transitions=strobe_color()) - if effect == EFFECT_ALARM: - flow = Flow(count=0, transitions=alarm()) - if effect == EFFECT_POLICE: - flow = Flow(count=0, transitions=police()) - if effect == EFFECT_POLICE2: - flow = Flow(count=0, transitions=police2()) - if effect == EFFECT_CHRISTMAS: - flow = Flow(count=0, transitions=christmas()) - if effect == EFFECT_RGB: - flow = Flow(count=0, transitions=rgb()) - if effect == EFFECT_RANDOM_LOOP: - flow = Flow(count=0, transitions=randomloop()) + + effects_map = { + EFFECT_DISCO: disco, + EFFECT_TEMP: temp, + EFFECT_STROBE: strobe, + EFFECT_STROBE_COLOR: strobe_color, + EFFECT_ALARM: alarm, + EFFECT_POLICE: police, + EFFECT_POLICE2: police2, + EFFECT_CHRISTMAS: christmas, + EFFECT_RGB: rgb, + EFFECT_RANDOM_LOOP: randomloop, + EFFECT_LSD: lsd, + EFFECT_SLOWDOWN: slowdown, + } + + if effect in effects_map: + flow = Flow(count=0, transitions=effects_map[effect]()) if effect == EFFECT_FAST_RANDOM_LOOP: flow = Flow(count=0, transitions=randomloop(duration=250)) - if effect == EFFECT_SLOWDOWN: - flow = Flow(count=0, transitions=slowdown()) if effect == EFFECT_WHATSAPP: flow = Flow(count=2, transitions=pulse(37, 211, 102)) if effect == EFFECT_FACEBOOK: From 986ca239347c24f52ad788d09ec8e0e16291941f Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Sat, 24 Nov 2018 10:02:06 +0100 Subject: [PATCH 039/325] Dict -> dict (#18665) --- homeassistant/components/lovelace/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 72b19235c3019..3e6958f35e271 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -56,7 +56,7 @@ SCHEMA_SAVE_CONFIG = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_SAVE_CONFIG, - vol.Required('config'): vol.Any(str, Dict), + vol.Required('config'): vol.Any(str, dict), vol.Optional('format', default=FORMAT_JSON): vol.Any(FORMAT_JSON, FORMAT_YAML), }) From e41af133fc856767a4d5fb2d1035b39cdab08887 Mon Sep 17 00:00:00 2001 From: emontnemery Date: Sat, 24 Nov 2018 10:40:07 +0100 Subject: [PATCH 040/325] Reconfigure MQTT climate component if discovery info is changed (#18174) --- homeassistant/components/climate/mqtt.py | 295 +++++++++++++---------- tests/components/climate/test_mqtt.py | 31 +++ 2 files changed, 202 insertions(+), 124 deletions(-) diff --git a/homeassistant/components/climate/mqtt.py b/homeassistant/components/climate/mqtt.py index b107710fea547..7436ffc41ea1e 100644 --- a/homeassistant/components/climate/mqtt.py +++ b/homeassistant/components/climate/mqtt.py @@ -22,7 +22,8 @@ from homeassistant.components.mqtt import ( ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_QOS, CONF_RETAIN, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, - MQTT_BASE_PLATFORM_SCHEMA, MqttAvailability, MqttDiscoveryUpdate) + MQTT_BASE_PLATFORM_SCHEMA, MqttAvailability, MqttDiscoveryUpdate, + subscription) from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -77,6 +78,18 @@ CONF_MAX_TEMP = 'max_temp' CONF_TEMP_STEP = 'temp_step' +TEMPLATE_KEYS = ( + CONF_POWER_STATE_TEMPLATE, + CONF_MODE_STATE_TEMPLATE, + CONF_TEMPERATURE_STATE_TEMPLATE, + CONF_FAN_MODE_STATE_TEMPLATE, + CONF_SWING_MODE_STATE_TEMPLATE, + CONF_AWAY_MODE_STATE_TEMPLATE, + CONF_HOLD_STATE_TEMPLATE, + CONF_AUX_STATE_TEMPLATE, + CONF_CURRENT_TEMPERATURE_TEMPLATE +) + SCHEMA_BASE = CLIMATE_PLATFORM_SCHEMA.extend(MQTT_BASE_PLATFORM_SCHEMA.schema) PLATFORM_SCHEMA = SCHEMA_BASE.extend({ vol.Optional(CONF_POWER_COMMAND_TOPIC): mqtt.valid_publish_topic, @@ -153,69 +166,10 @@ async def async_discover(discovery_payload): async def _async_setup_entity(hass, config, async_add_entities, discovery_hash=None): """Set up the MQTT climate devices.""" - template_keys = ( - CONF_POWER_STATE_TEMPLATE, - CONF_MODE_STATE_TEMPLATE, - CONF_TEMPERATURE_STATE_TEMPLATE, - CONF_FAN_MODE_STATE_TEMPLATE, - CONF_SWING_MODE_STATE_TEMPLATE, - CONF_AWAY_MODE_STATE_TEMPLATE, - CONF_HOLD_STATE_TEMPLATE, - CONF_AUX_STATE_TEMPLATE, - CONF_CURRENT_TEMPERATURE_TEMPLATE - ) - value_templates = {} - if CONF_VALUE_TEMPLATE in config: - value_template = config.get(CONF_VALUE_TEMPLATE) - value_template.hass = hass - value_templates = {key: value_template for key in template_keys} - for key in template_keys & config.keys(): - value_templates[key] = config.get(key) - value_templates[key].hass = hass - async_add_entities([ MqttClimate( hass, - config.get(CONF_NAME), - { - key: config.get(key) for key in ( - CONF_POWER_COMMAND_TOPIC, - CONF_MODE_COMMAND_TOPIC, - CONF_TEMPERATURE_COMMAND_TOPIC, - CONF_FAN_MODE_COMMAND_TOPIC, - CONF_SWING_MODE_COMMAND_TOPIC, - CONF_AWAY_MODE_COMMAND_TOPIC, - CONF_HOLD_COMMAND_TOPIC, - CONF_AUX_COMMAND_TOPIC, - CONF_POWER_STATE_TOPIC, - CONF_MODE_STATE_TOPIC, - CONF_TEMPERATURE_STATE_TOPIC, - CONF_FAN_MODE_STATE_TOPIC, - CONF_SWING_MODE_STATE_TOPIC, - CONF_AWAY_MODE_STATE_TOPIC, - CONF_HOLD_STATE_TOPIC, - CONF_AUX_STATE_TOPIC, - CONF_CURRENT_TEMPERATURE_TOPIC - ) - }, - value_templates, - config.get(CONF_QOS), - config.get(CONF_RETAIN), - config.get(CONF_MODE_LIST), - config.get(CONF_FAN_MODE_LIST), - config.get(CONF_SWING_MODE_LIST), - config.get(CONF_INITIAL), - False, None, SPEED_LOW, - STATE_OFF, STATE_OFF, False, - config.get(CONF_SEND_IF_OFF), - config.get(CONF_PAYLOAD_ON), - config.get(CONF_PAYLOAD_OFF), - config.get(CONF_AVAILABILITY_TOPIC), - config.get(CONF_PAYLOAD_AVAILABLE), - config.get(CONF_PAYLOAD_NOT_AVAILABLE), - config.get(CONF_MIN_TEMP), - config.get(CONF_MAX_TEMP), - config.get(CONF_TEMP_STEP), + config, discovery_hash, )]) @@ -223,54 +177,130 @@ async def _async_setup_entity(hass, config, async_add_entities, class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): """Representation of an MQTT climate device.""" - def __init__(self, hass, name, topic, value_templates, qos, retain, - mode_list, fan_mode_list, swing_mode_list, - target_temperature, away, hold, current_fan_mode, - current_swing_mode, current_operation, aux, send_if_off, - payload_on, payload_off, availability_topic, - payload_available, payload_not_available, - min_temp, max_temp, temp_step, discovery_hash): + def __init__(self, hass, config, discovery_hash): """Initialize the climate device.""" - MqttAvailability.__init__(self, availability_topic, qos, - payload_available, payload_not_available) - MqttDiscoveryUpdate.__init__(self, discovery_hash) + self._config = config + self._sub_state = None + self.hass = hass - self._name = name - self._topic = topic - self._value_templates = value_templates - self._qos = qos - self._retain = retain - # set to None in non-optimistic mode - self._target_temperature = self._current_fan_mode = \ - self._current_operation = self._current_swing_mode = None - if self._topic[CONF_TEMPERATURE_STATE_TOPIC] is None: - self._target_temperature = target_temperature + self._name = None + self._topic = None + self._value_templates = None + self._qos = None + self._retain = None + self._target_temperature = None + self._current_fan_mode = None + self._current_operation = None + self._current_swing_mode = None self._unit_of_measurement = hass.config.units.temperature_unit - self._away = away - self._hold = hold + self._away = False + self._hold = None self._current_temperature = None - if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None: - self._current_fan_mode = current_fan_mode - if self._topic[CONF_MODE_STATE_TOPIC] is None: - self._current_operation = current_operation - self._aux = aux - if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None: - self._current_swing_mode = current_swing_mode - self._fan_list = fan_mode_list - self._operation_list = mode_list - self._swing_list = swing_mode_list - self._target_temperature_step = temp_step - self._send_if_off = send_if_off - self._payload_on = payload_on - self._payload_off = payload_off - self._min_temp = min_temp - self._max_temp = max_temp - self._discovery_hash = discovery_hash + self._aux = False + self._fan_list = None + self._operation_list = None + self._swing_list = None + self._target_temperature_step = None + self._send_if_off = None + self._payload_on = None + self._payload_off = None + self._min_temp = None + self._max_temp = None + + # Load config + self._setup_from_config(config) + + availability_topic = config.get(CONF_AVAILABILITY_TOPIC) + payload_available = config.get(CONF_PAYLOAD_AVAILABLE) + payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) + + MqttAvailability.__init__(self, availability_topic, self._qos, + payload_available, payload_not_available) + MqttDiscoveryUpdate.__init__(self, discovery_hash, + self.discovery_update) async def async_added_to_hass(self): """Handle being added to home assistant.""" await MqttAvailability.async_added_to_hass(self) await MqttDiscoveryUpdate.async_added_to_hass(self) + await self._subscribe_topics() + + async def discovery_update(self, discovery_payload): + """Handle updated discovery message.""" + config = PLATFORM_SCHEMA(discovery_payload) + self._setup_from_config(config) + await self.availability_discovery_update(config) + await self._subscribe_topics() + self.async_schedule_update_ha_state() + + def _setup_from_config(self, config): + """(Re)Setup the entity.""" + self._name = config.get(CONF_NAME) + self._topic = { + key: config.get(key) for key in ( + CONF_POWER_COMMAND_TOPIC, + CONF_MODE_COMMAND_TOPIC, + CONF_TEMPERATURE_COMMAND_TOPIC, + CONF_FAN_MODE_COMMAND_TOPIC, + CONF_SWING_MODE_COMMAND_TOPIC, + CONF_AWAY_MODE_COMMAND_TOPIC, + CONF_HOLD_COMMAND_TOPIC, + CONF_AUX_COMMAND_TOPIC, + CONF_POWER_STATE_TOPIC, + CONF_MODE_STATE_TOPIC, + CONF_TEMPERATURE_STATE_TOPIC, + CONF_FAN_MODE_STATE_TOPIC, + CONF_SWING_MODE_STATE_TOPIC, + CONF_AWAY_MODE_STATE_TOPIC, + CONF_HOLD_STATE_TOPIC, + CONF_AUX_STATE_TOPIC, + CONF_CURRENT_TEMPERATURE_TOPIC + ) + } + self._qos = config.get(CONF_QOS) + self._retain = config.get(CONF_RETAIN) + self._operation_list = config.get(CONF_MODE_LIST) + self._fan_list = config.get(CONF_FAN_MODE_LIST) + self._swing_list = config.get(CONF_SWING_MODE_LIST) + + # set to None in non-optimistic mode + self._target_temperature = self._current_fan_mode = \ + self._current_operation = self._current_swing_mode = None + if self._topic[CONF_TEMPERATURE_STATE_TOPIC] is None: + self._target_temperature = config.get(CONF_INITIAL) + if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None: + self._current_fan_mode = SPEED_LOW + if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None: + self._current_swing_mode = STATE_OFF + if self._topic[CONF_MODE_STATE_TOPIC] is None: + self._current_operation = STATE_OFF + self._away = False + self._hold = None + self._aux = False + self._send_if_off = config.get(CONF_SEND_IF_OFF) + self._payload_on = config.get(CONF_PAYLOAD_ON) + self._payload_off = config.get(CONF_PAYLOAD_OFF) + self._min_temp = config.get(CONF_MIN_TEMP) + self._max_temp = config.get(CONF_MAX_TEMP) + self._target_temperature_step = config.get(CONF_TEMP_STEP) + + config.get(CONF_AVAILABILITY_TOPIC) + config.get(CONF_PAYLOAD_AVAILABLE) + config.get(CONF_PAYLOAD_NOT_AVAILABLE) + + value_templates = {} + if CONF_VALUE_TEMPLATE in config: + value_template = config.get(CONF_VALUE_TEMPLATE) + value_template.hass = self.hass + value_templates = {key: value_template for key in TEMPLATE_KEYS} + for key in TEMPLATE_KEYS & config.keys(): + value_templates[key] = config.get(key) + value_templates[key].hass = self.hass + self._value_templates = value_templates + + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" + topics = {} @callback def handle_current_temp_received(topic, payload, qos): @@ -287,9 +317,10 @@ def handle_current_temp_received(topic, payload, qos): _LOGGER.error("Could not parse temperature from %s", payload) if self._topic[CONF_CURRENT_TEMPERATURE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_CURRENT_TEMPERATURE_TOPIC], - handle_current_temp_received, self._qos) + topics[CONF_CURRENT_TEMPERATURE_TOPIC] = { + 'topic': self._topic[CONF_CURRENT_TEMPERATURE_TOPIC], + 'msg_callback': handle_current_temp_received, + 'qos': self._qos} @callback def handle_mode_received(topic, payload, qos): @@ -305,9 +336,10 @@ def handle_mode_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_MODE_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_MODE_STATE_TOPIC], - handle_mode_received, self._qos) + topics[CONF_MODE_STATE_TOPIC] = { + 'topic': self._topic[CONF_MODE_STATE_TOPIC], + 'msg_callback': handle_mode_received, + 'qos': self._qos} @callback def handle_temperature_received(topic, payload, qos): @@ -324,9 +356,10 @@ def handle_temperature_received(topic, payload, qos): _LOGGER.error("Could not parse temperature from %s", payload) if self._topic[CONF_TEMPERATURE_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_TEMPERATURE_STATE_TOPIC], - handle_temperature_received, self._qos) + topics[CONF_TEMPERATURE_STATE_TOPIC] = { + 'topic': self._topic[CONF_TEMPERATURE_STATE_TOPIC], + 'msg_callback': handle_temperature_received, + 'qos': self._qos} @callback def handle_fan_mode_received(topic, payload, qos): @@ -343,9 +376,10 @@ def handle_fan_mode_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_FAN_MODE_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_FAN_MODE_STATE_TOPIC], - handle_fan_mode_received, self._qos) + topics[CONF_FAN_MODE_STATE_TOPIC] = { + 'topic': self._topic[CONF_FAN_MODE_STATE_TOPIC], + 'msg_callback': handle_fan_mode_received, + 'qos': self._qos} @callback def handle_swing_mode_received(topic, payload, qos): @@ -362,9 +396,10 @@ def handle_swing_mode_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_SWING_MODE_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_SWING_MODE_STATE_TOPIC], - handle_swing_mode_received, self._qos) + topics[CONF_SWING_MODE_STATE_TOPIC] = { + 'topic': self._topic[CONF_SWING_MODE_STATE_TOPIC], + 'msg_callback': handle_swing_mode_received, + 'qos': self._qos} @callback def handle_away_mode_received(topic, payload, qos): @@ -388,9 +423,10 @@ def handle_away_mode_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_AWAY_MODE_STATE_TOPIC], - handle_away_mode_received, self._qos) + topics[CONF_AWAY_MODE_STATE_TOPIC] = { + 'topic': self._topic[CONF_AWAY_MODE_STATE_TOPIC], + 'msg_callback': handle_away_mode_received, + 'qos': self._qos} @callback def handle_aux_mode_received(topic, payload, qos): @@ -413,9 +449,10 @@ def handle_aux_mode_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_AUX_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_AUX_STATE_TOPIC], - handle_aux_mode_received, self._qos) + topics[CONF_AUX_STATE_TOPIC] = { + 'topic': self._topic[CONF_AUX_STATE_TOPIC], + 'msg_callback': handle_aux_mode_received, + 'qos': self._qos} @callback def handle_hold_mode_received(topic, payload, qos): @@ -428,9 +465,19 @@ def handle_hold_mode_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_HOLD_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_HOLD_STATE_TOPIC], - handle_hold_mode_received, self._qos) + topics[CONF_HOLD_STATE_TOPIC] = { + 'topic': self._topic[CONF_HOLD_STATE_TOPIC], + 'msg_callback': handle_hold_mode_received, + 'qos': self._qos} + + self._sub_state = await subscription.async_subscribe_topics( + self.hass, self._sub_state, + topics) + + async def async_will_remove_from_hass(self): + """Unsubscribe when removed.""" + await subscription.async_unsubscribe_topics(self.hass, self._sub_state) + await MqttAvailability.async_will_remove_from_hass(self) @property def should_poll(self): diff --git a/tests/components/climate/test_mqtt.py b/tests/components/climate/test_mqtt.py index 61b481ed4db89..894fc290c38cc 100644 --- a/tests/components/climate/test_mqtt.py +++ b/tests/components/climate/test_mqtt.py @@ -684,3 +684,34 @@ async def test_discovery_removal_climate(hass, mqtt_mock, caplog): await hass.async_block_till_done() state = hass.states.get('climate.beer') assert state is None + + +async def test_discovery_update_climate(hass, mqtt_mock, caplog): + """Test removal of discovered climate.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer" }' + ) + data2 = ( + '{ "name": "Milk" }' + ) + async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('climate.beer') + assert state is not None + assert state.name == 'Beer' + + async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('climate.beer') + assert state is not None + assert state.name == 'Milk' + + state = hass.states.get('climate.milk') + assert state is None From 5e18d5230213be6135161c93f6baca5321854da6 Mon Sep 17 00:00:00 2001 From: emontnemery Date: Sat, 24 Nov 2018 10:48:01 +0100 Subject: [PATCH 041/325] Reconfigure MQTT alarm component if discovery info is changed (#18173) --- .../components/alarm_control_panel/mqtt.py | 91 ++++++++++++------- .../alarm_control_panel/test_mqtt.py | 39 ++++++++ 2 files changed, 98 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/mqtt.py b/homeassistant/components/alarm_control_panel/mqtt.py index ad1c0d1e3b85e..1b9bb020eada6 100644 --- a/homeassistant/components/alarm_control_panel/mqtt.py +++ b/homeassistant/components/alarm_control_panel/mqtt.py @@ -19,7 +19,7 @@ from homeassistant.components.mqtt import ( ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, - CONF_QOS, CONF_RETAIN, MqttAvailability, MqttDiscoveryUpdate) + CONF_QOS, CONF_RETAIN, MqttAvailability, MqttDiscoveryUpdate, subscription) from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -71,18 +71,7 @@ async def _async_setup_entity(hass, config, async_add_entities, discovery_hash=None): """Set up the MQTT Alarm Control Panel platform.""" async_add_entities([MqttAlarm( - config.get(CONF_NAME), - config.get(CONF_STATE_TOPIC), - config.get(CONF_COMMAND_TOPIC), - config.get(CONF_QOS), - config.get(CONF_RETAIN), - config.get(CONF_PAYLOAD_DISARM), - config.get(CONF_PAYLOAD_ARM_HOME), - config.get(CONF_PAYLOAD_ARM_AWAY), - config.get(CONF_CODE), - config.get(CONF_AVAILABILITY_TOPIC), - config.get(CONF_PAYLOAD_AVAILABLE), - config.get(CONF_PAYLOAD_NOT_AVAILABLE), + config, discovery_hash,)]) @@ -90,31 +79,61 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, alarm.AlarmControlPanel): """Representation of a MQTT alarm status.""" - def __init__(self, name, state_topic, command_topic, qos, retain, - payload_disarm, payload_arm_home, payload_arm_away, code, - availability_topic, payload_available, payload_not_available, - discovery_hash): + def __init__(self, config, discovery_hash): """Init the MQTT Alarm Control Panel.""" - MqttAvailability.__init__(self, availability_topic, qos, - payload_available, payload_not_available) - MqttDiscoveryUpdate.__init__(self, discovery_hash) self._state = STATE_UNKNOWN - self._name = name - self._state_topic = state_topic - self._command_topic = command_topic - self._qos = qos - self._retain = retain - self._payload_disarm = payload_disarm - self._payload_arm_home = payload_arm_home - self._payload_arm_away = payload_arm_away - self._code = code - self._discovery_hash = discovery_hash + self._config = config + self._sub_state = None + + self._name = None + self._state_topic = None + self._command_topic = None + self._qos = None + self._retain = None + self._payload_disarm = None + self._payload_arm_home = None + self._payload_arm_away = None + self._code = None + + # Load config + self._setup_from_config(config) + + availability_topic = config.get(CONF_AVAILABILITY_TOPIC) + payload_available = config.get(CONF_PAYLOAD_AVAILABLE) + payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) + MqttAvailability.__init__(self, availability_topic, self._qos, + payload_available, payload_not_available) + MqttDiscoveryUpdate.__init__(self, discovery_hash, + self.discovery_update) async def async_added_to_hass(self): """Subscribe mqtt events.""" await MqttAvailability.async_added_to_hass(self) await MqttDiscoveryUpdate.async_added_to_hass(self) + await self._subscribe_topics() + async def discovery_update(self, discovery_payload): + """Handle updated discovery message.""" + config = PLATFORM_SCHEMA(discovery_payload) + self._setup_from_config(config) + await self.availability_discovery_update(config) + await self._subscribe_topics() + self.async_schedule_update_ha_state() + + def _setup_from_config(self, config): + """(Re)Setup the entity.""" + self._name = config.get(CONF_NAME) + self._state_topic = config.get(CONF_STATE_TOPIC) + self._command_topic = config.get(CONF_COMMAND_TOPIC) + self._qos = config.get(CONF_QOS) + self._retain = config.get(CONF_RETAIN) + self._payload_disarm = config.get(CONF_PAYLOAD_DISARM) + self._payload_arm_home = config.get(CONF_PAYLOAD_ARM_HOME) + self._payload_arm_away = config.get(CONF_PAYLOAD_ARM_AWAY) + self._code = config.get(CONF_CODE) + + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" @callback def message_received(topic, payload, qos): """Run when new MQTT message has been received.""" @@ -126,8 +145,16 @@ def message_received(topic, payload, qos): self._state = payload self.async_schedule_update_ha_state() - await mqtt.async_subscribe( - self.hass, self._state_topic, message_received, self._qos) + self._sub_state = await subscription.async_subscribe_topics( + self.hass, self._sub_state, + {'state_topic': {'topic': self._state_topic, + 'msg_callback': message_received, + 'qos': self._qos}}) + + async def async_will_remove_from_hass(self): + """Unsubscribe when removed.""" + await subscription.async_unsubscribe_topics(self.hass, self._sub_state) + await MqttAvailability.async_will_remove_from_hass(self) @property def should_poll(self): diff --git a/tests/components/alarm_control_panel/test_mqtt.py b/tests/components/alarm_control_panel/test_mqtt.py index 6461671812586..24f1b00ee9052 100644 --- a/tests/components/alarm_control_panel/test_mqtt.py +++ b/tests/components/alarm_control_panel/test_mqtt.py @@ -271,3 +271,42 @@ async def test_discovery_removal_alarm(hass, mqtt_mock, caplog): state = hass.states.get('alarm_control_panel.beer') assert state is None + + +async def test_discovery_update_alarm(hass, mqtt_mock, caplog): + """Test removal of discovered alarm_control_panel.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer",' + ' "status_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "status_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + + async_fire_mqtt_message(hass, + 'homeassistant/alarm_control_panel/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('alarm_control_panel.beer') + assert state is not None + assert state.name == 'Beer' + + async_fire_mqtt_message(hass, + 'homeassistant/alarm_control_panel/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('alarm_control_panel.beer') + assert state is not None + assert state.name == 'Milk' + + state = hass.states.get('alarm_control_panel.milk') + assert state is None From d24ea7da9035565448130de4a9ffef3ad20c87e7 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Sat, 24 Nov 2018 13:24:06 -0500 Subject: [PATCH 042/325] Async tests for device tracker mqtt (#18680) --- tests/components/device_tracker/test_mqtt.py | 247 +++++++------ .../device_tracker/test_mqtt_json.py | 332 +++++++++--------- 2 files changed, 287 insertions(+), 292 deletions(-) diff --git a/tests/components/device_tracker/test_mqtt.py b/tests/components/device_tracker/test_mqtt.py index e760db151df6f..abfa32ca06bbd 100644 --- a/tests/components/device_tracker/test_mqtt.py +++ b/tests/components/device_tracker/test_mqtt.py @@ -1,147 +1,144 @@ """The tests for the MQTT device tracker platform.""" -import asyncio -import unittest -from unittest.mock import patch import logging import os +from asynctest import patch +import pytest -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component from homeassistant.components import device_tracker from homeassistant.const import CONF_PLATFORM from tests.common import ( - get_test_home_assistant, mock_mqtt_component, fire_mqtt_message) + async_mock_mqtt_component, async_fire_mqtt_message) _LOGGER = logging.getLogger(__name__) -class TestComponentsDeviceTrackerMQTT(unittest.TestCase): - """Test MQTT device tracker platform.""" - - def setUp(self): # pylint: disable=invalid-name - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - mock_mqtt_component(self.hass) - - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that was started.""" - self.hass.stop() - try: - os.remove(self.hass.config.path(device_tracker.YAML_DEVICES)) - except FileNotFoundError: - pass - - def test_ensure_device_tracker_platform_validation(self): - """Test if platform validation was done.""" - @asyncio.coroutine - def mock_setup_scanner(hass, config, see, discovery_info=None): - """Check that Qos was added by validation.""" - assert 'qos' in config - - with patch('homeassistant.components.device_tracker.mqtt.' - 'async_setup_scanner', autospec=True, - side_effect=mock_setup_scanner) as mock_sp: - - dev_id = 'paulus' - topic = '/location/paulus' - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'mqtt', - 'devices': {dev_id: topic} - } - }) - assert mock_sp.call_count == 1 - - def test_new_message(self): - """Test new message.""" - dev_id = 'paulus' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) - 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: topic} - } - }) - fire_mqtt_message(self.hass, topic, location) - self.hass.block_till_done() - assert 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' +@pytest.fixture(autouse=True) +def setup_comp(hass): + """Initialize components.""" + hass.loop.run_until_complete(async_mock_mqtt_component(hass)) + yaml_devices = hass.config.path(device_tracker.YAML_DEVICES) + yield + if os.path.isfile(yaml_devices): + os.remove(yaml_devices) - 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() - assert 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' +async def test_ensure_device_tracker_platform_validation(hass): + """Test if platform validation was done.""" + async def mock_setup_scanner(hass, config, see, discovery_info=None): + """Check that Qos was added by validation.""" + assert 'qos' in config - 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() - assert location == self.hass.states.get(entity_id).state + with patch('homeassistant.components.device_tracker.mqtt.' + 'async_setup_scanner', autospec=True, + side_effect=mock_setup_scanner) as mock_sp: - 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() - assert self.hass.states.get(entity_id) is None - - 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, { + assert await async_setup_component(hass, device_tracker.DOMAIN, { device_tracker.DOMAIN: { CONF_PLATFORM: 'mqtt', - 'devices': {dev_id: subscription} + 'devices': {dev_id: topic} } }) - fire_mqtt_message(self.hass, topic, location) - self.hass.block_till_done() - assert self.hass.states.get(entity_id) is None + assert mock_sp.call_count == 1 + + +async def test_new_message(hass): + """Test new message.""" + dev_id = 'paulus' + entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + topic = '/location/paulus' + location = 'work' + + hass.config.components = set(['mqtt', 'zone']) + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt', + 'devices': {dev_id: topic} + } + }) + async_fire_mqtt_message(hass, topic, location) + await hass.async_block_till_done() + assert location == hass.states.get(entity_id).state + + +async def test_single_level_wildcard_topic(hass): + """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' + + hass.config.components = set(['mqtt', 'zone']) + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt', + 'devices': {dev_id: subscription} + } + }) + async_fire_mqtt_message(hass, topic, location) + await hass.async_block_till_done() + assert location == hass.states.get(entity_id).state + + +async def test_multi_level_wildcard_topic(hass): + """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' + + hass.config.components = set(['mqtt', 'zone']) + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt', + 'devices': {dev_id: subscription} + } + }) + async_fire_mqtt_message(hass, topic, location) + await hass.async_block_till_done() + assert location == hass.states.get(entity_id).state + + +async def test_single_level_wildcard_topic_not_matching(hass): + """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' + + hass.config.components = set(['mqtt', 'zone']) + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt', + 'devices': {dev_id: subscription} + } + }) + async_fire_mqtt_message(hass, topic, location) + await hass.async_block_till_done() + assert hass.states.get(entity_id) is None + + +async def test_multi_level_wildcard_topic_not_matching(hass): + """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' + + hass.config.components = set(['mqtt', 'zone']) + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt', + 'devices': {dev_id: subscription} + } + }) + async_fire_mqtt_message(hass, topic, location) + await hass.async_block_till_done() + assert hass.states.get(entity_id) is None diff --git a/tests/components/device_tracker/test_mqtt_json.py b/tests/components/device_tracker/test_mqtt_json.py index 44d687a4d4536..252d40338fca9 100644 --- a/tests/components/device_tracker/test_mqtt_json.py +++ b/tests/components/device_tracker/test_mqtt_json.py @@ -1,17 +1,15 @@ """The tests for the JSON MQTT device tracker platform.""" -import asyncio import json -import unittest -from unittest.mock import patch +from asynctest import patch import logging import os +import pytest -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component from homeassistant.components import device_tracker from homeassistant.const import CONF_PLATFORM -from tests.common import ( - get_test_home_assistant, mock_mqtt_component, fire_mqtt_message) +from tests.common import async_mock_mqtt_component, async_fire_mqtt_message _LOGGER = logging.getLogger(__name__) @@ -25,172 +23,172 @@ 'longitude': 2.0} -class TestComponentsDeviceTrackerJSONMQTT(unittest.TestCase): - """Test JSON MQTT device tracker platform.""" - - def setUp(self): # pylint: disable=invalid-name - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - mock_mqtt_component(self.hass) - - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that was started.""" - self.hass.stop() - try: - os.remove(self.hass.config.path(device_tracker.YAML_DEVICES)) - except FileNotFoundError: - pass - - def test_ensure_device_tracker_platform_validation(self): - """Test if platform validation was done.""" - @asyncio.coroutine - def mock_setup_scanner(hass, config, see, discovery_info=None): - """Check that Qos was added by validation.""" - assert 'qos' in config - - with patch('homeassistant.components.device_tracker.mqtt_json.' - 'async_setup_scanner', autospec=True, - side_effect=mock_setup_scanner) as mock_sp: - - dev_id = 'paulus' - topic = 'location/paulus' - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'mqtt_json', - 'devices': {dev_id: topic} - } - }) - assert mock_sp.call_count == 1 - - def test_json_message(self): - """Test json location message.""" - dev_id = '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: topic} - } - }) - fire_mqtt_message(self.hass, topic, location) - self.hass.block_till_done() - state = self.hass.states.get('device_tracker.zanzito') - assert state.attributes.get('latitude') == 2.0 - assert state.attributes.get('longitude') == 1.0 - - def test_non_json_message(self): - """Test receiving a non JSON message.""" - dev_id = 'zanzito' - topic = 'location/zanzito' - location = 'home' - - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'mqtt_json', - 'devices': {dev_id: topic} - } - }) +@pytest.fixture(autouse=True) +def setup_comp(hass): + """Initialize components.""" + hass.loop.run_until_complete(async_mock_mqtt_component(hass)) + yaml_devices = hass.config.path(device_tracker.YAML_DEVICES) + yield + if os.path.isfile(yaml_devices): + os.remove(yaml_devices) - with self.assertLogs(level='ERROR') as test_handle: - fire_mqtt_message(self.hass, topic, location) - self.hass.block_till_done() - assert "ERROR:homeassistant.components.device_tracker.mqtt_json:" \ - "Error parsing JSON payload: home" in \ - test_handle.output[0] - def test_incomplete_message(self): - """Test receiving an incomplete message.""" - dev_id = 'zanzito' - topic = 'location/zanzito' - location = json.dumps(LOCATION_MESSAGE_INCOMPLETE) +async def test_ensure_device_tracker_platform_validation(hass): + """Test if platform validation was done.""" + async def mock_setup_scanner(hass, config, see, discovery_info=None): + """Check that Qos was added by validation.""" + assert 'qos' in config - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'mqtt_json', - 'devices': {dev_id: topic} - } - }) + with patch('homeassistant.components.device_tracker.mqtt_json.' + 'async_setup_scanner', autospec=True, + side_effect=mock_setup_scanner) as mock_sp: - with self.assertLogs(level='ERROR') as test_handle: - fire_mqtt_message(self.hass, topic, location) - self.hass.block_till_done() - assert "ERROR:homeassistant.components.device_tracker.mqtt_json:" \ - "Skipping update for following data because of missing " \ - "or malformatted data: {\"longitude\": 2.0}" in \ - 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') - assert state.attributes.get('latitude') == 2.0 - assert 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') - assert state.attributes.get('latitude') == 2.0 - assert 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, { + dev_id = 'paulus' + topic = 'location/paulus' + assert await async_setup_component(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() - assert self.hass.states.get(entity_id) is None - - 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} + 'devices': {dev_id: topic} } }) - fire_mqtt_message(self.hass, topic, location) - self.hass.block_till_done() - assert self.hass.states.get(entity_id) is None + assert mock_sp.call_count == 1 + + +async def test_json_message(hass): + """Test json location message.""" + dev_id = 'zanzito' + topic = 'location/zanzito' + location = json.dumps(LOCATION_MESSAGE) + + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt_json', + 'devices': {dev_id: topic} + } + }) + async_fire_mqtt_message(hass, topic, location) + await hass.async_block_till_done() + state = hass.states.get('device_tracker.zanzito') + assert state.attributes.get('latitude') == 2.0 + assert state.attributes.get('longitude') == 1.0 + + +async def test_non_json_message(hass, caplog): + """Test receiving a non JSON message.""" + dev_id = 'zanzito' + topic = 'location/zanzito' + location = 'home' + + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt_json', + 'devices': {dev_id: topic} + } + }) + + caplog.set_level(logging.ERROR) + caplog.clear() + async_fire_mqtt_message(hass, topic, location) + await hass.async_block_till_done() + assert "Error parsing JSON payload: home" in \ + caplog.text + + +async def test_incomplete_message(hass, caplog): + """Test receiving an incomplete message.""" + dev_id = 'zanzito' + topic = 'location/zanzito' + location = json.dumps(LOCATION_MESSAGE_INCOMPLETE) + + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt_json', + 'devices': {dev_id: topic} + } + }) + + caplog.set_level(logging.ERROR) + caplog.clear() + async_fire_mqtt_message(hass, topic, location) + await hass.async_block_till_done() + assert "Skipping update for following data because of missing " \ + "or malformatted data: {\"longitude\": 2.0}" in \ + caplog.text + + +async def test_single_level_wildcard_topic(hass): + """Test single level wildcard topic.""" + dev_id = 'zanzito' + subscription = 'location/+/zanzito' + topic = 'location/room/zanzito' + location = json.dumps(LOCATION_MESSAGE) + + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt_json', + 'devices': {dev_id: subscription} + } + }) + async_fire_mqtt_message(hass, topic, location) + await hass.async_block_till_done() + state = hass.states.get('device_tracker.zanzito') + assert state.attributes.get('latitude') == 2.0 + assert state.attributes.get('longitude') == 1.0 + + +async def test_multi_level_wildcard_topic(hass): + """Test multi level wildcard topic.""" + dev_id = 'zanzito' + subscription = 'location/#' + topic = 'location/zanzito' + location = json.dumps(LOCATION_MESSAGE) + + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt_json', + 'devices': {dev_id: subscription} + } + }) + async_fire_mqtt_message(hass, topic, location) + await hass.async_block_till_done() + state = hass.states.get('device_tracker.zanzito') + assert state.attributes.get('latitude') == 2.0 + assert state.attributes.get('longitude') == 1.0 + + +async def test_single_level_wildcard_topic_not_matching(hass): + """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 await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt_json', + 'devices': {dev_id: subscription} + } + }) + async_fire_mqtt_message(hass, topic, location) + await hass.async_block_till_done() + assert hass.states.get(entity_id) is None + + +async def test_multi_level_wildcard_topic_not_matching(hass): + """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 await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt_json', + 'devices': {dev_id: subscription} + } + }) + async_fire_mqtt_message(hass, topic, location) + await hass.async_block_till_done() + assert hass.states.get(entity_id) is None From 6ebdc7dabc6a5e8f03ceba51ef65dd871f525164 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Sat, 24 Nov 2018 14:34:36 -0500 Subject: [PATCH 043/325] Async tests for owntracks device tracker (#18681) --- .../device_tracker/test_owntracks.py | 2280 +++++++++-------- 1 file changed, 1156 insertions(+), 1124 deletions(-) diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index eaf17fb53f4b5..2d7397692f8e7 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -1,17 +1,15 @@ """The tests for the Owntracks device tracker.""" -import asyncio import json -import unittest -from unittest.mock import patch +from asynctest import patch +import pytest from tests.common import ( - assert_setup_component, fire_mqtt_message, mock_coro, mock_component, - get_test_home_assistant, mock_mqtt_component) + assert_setup_component, async_fire_mqtt_message, mock_coro, mock_component, + async_mock_mqtt_component) import homeassistant.components.device_tracker.owntracks as owntracks -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component from homeassistant.components import device_tracker from homeassistant.const import CONF_PLATFORM, STATE_NOT_HOME -from homeassistant.util.async_ import run_coroutine_threadsafe USER = 'greg' DEVICE = 'phone' @@ -275,982 +273,1016 @@ def build_message(test_params, default_params): BAD_JSON_SUFFIX = '** and it ends here ^^' -# def raise_on_not_implemented(hass, context, message): -def raise_on_not_implemented(): - """Throw NotImplemented.""" - raise NotImplementedError("oopsie") - - -class BaseMQTT(unittest.TestCase): - """Base MQTT assert functions.""" - - hass = None - - def send_message(self, topic, message, corrupt=False): - """Test the sending of a message.""" - str_message = json.dumps(message) - if corrupt: - mod_message = BAD_JSON_PREFIX + str_message + BAD_JSON_SUFFIX - else: - mod_message = str_message - fire_mqtt_message(self.hass, topic, mod_message) - self.hass.block_till_done() - - def assert_location_state(self, location): - """Test the assertion of a location state.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.state == location - - def assert_location_latitude(self, latitude): - """Test the assertion of a location latitude.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.attributes.get('latitude') == latitude - - def assert_location_longitude(self, longitude): - """Test the assertion of a location longitude.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.attributes.get('longitude') == longitude - - def assert_location_accuracy(self, accuracy): - """Test the assertion of a location accuracy.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.attributes.get('gps_accuracy') == accuracy - - def assert_location_source_type(self, source_type): - """Test the assertion of source_type.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.attributes.get('source_type') == source_type - - -class TestDeviceTrackerOwnTracks(BaseMQTT): - """Test the OwnTrack sensor.""" - - # pylint: disable=invalid-name - def setup_method(self, _): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - mock_mqtt_component(self.hass) - mock_component(self.hass, 'group') - mock_component(self.hass, 'zone') - - patcher = patch('homeassistant.components.device_tracker.' - 'DeviceTracker.async_update_config') - patcher.start() - self.addCleanup(patcher.stop) - - orig_context = owntracks.OwnTracksContext - - def store_context(*args): - self.context = orig_context(*args) - return self.context - - with patch('homeassistant.components.device_tracker.async_load_config', - return_value=mock_coro([])), \ - patch('homeassistant.components.device_tracker.' - 'load_yaml_config_file', return_value=mock_coro({})), \ - patch.object(owntracks, 'OwnTracksContext', store_context), \ - assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { +@pytest.fixture +def setup_comp(hass): + """Initialize components.""" + mock_component(hass, 'group') + mock_component(hass, 'zone') + hass.loop.run_until_complete(async_mock_mqtt_component(hass)) + + hass.states.async_set( + 'zone.inner', 'zoning', INNER_ZONE) + + hass.states.async_set( + 'zone.inner_2', 'zoning', INNER_ZONE) + + hass.states.async_set( + 'zone.outer', 'zoning', OUTER_ZONE) + + +@pytest.fixture +def context(hass, setup_comp): + """Set up the mocked context.""" + patcher = patch('homeassistant.components.device_tracker.' + 'DeviceTracker.async_update_config') + patcher.start() + + orig_context = owntracks.OwnTracksContext + + context = None + + def store_context(*args): + nonlocal context + context = orig_context(*args) + return context + + with patch('homeassistant.components.device_tracker.async_load_config', + return_value=mock_coro([])), \ + patch('homeassistant.components.device_tracker.' + 'load_yaml_config_file', return_value=mock_coro({})), \ + patch.object(owntracks, 'OwnTracksContext', store_context), \ + assert_setup_component(1, device_tracker.DOMAIN): + assert hass.loop.run_until_complete(async_setup_component( + hass, device_tracker.DOMAIN, { device_tracker.DOMAIN: { CONF_PLATFORM: 'owntracks', CONF_MAX_GPS_ACCURACY: 200, CONF_WAYPOINT_IMPORT: True, CONF_WAYPOINT_WHITELIST: ['jon', 'greg'] - }}) + }})) - self.hass.states.set( - 'zone.inner', 'zoning', INNER_ZONE) - - self.hass.states.set( - 'zone.inner_2', 'zoning', INNER_ZONE) - - self.hass.states.set( - 'zone.outer', 'zoning', OUTER_ZONE) - - # Clear state between tests - # NB: state "None" is not a state that is created by Device - # so when we compare state to None in the tests this - # is really checking that it is still in its original - # test case state. See Device.async_update. - self.hass.states.set(DEVICE_TRACKER_STATE, None) - - def teardown_method(self, _): - """Stop everything that was started.""" - self.hass.stop() - - def assert_mobile_tracker_state(self, location, beacon=IBEACON_DEVICE): - """Test the assertion of a mobile beacon tracker state.""" - dev_id = MOBILE_BEACON_FMT.format(beacon) - state = self.hass.states.get(dev_id) - assert state.state == location - - def assert_mobile_tracker_latitude(self, latitude, beacon=IBEACON_DEVICE): - """Test the assertion of a mobile beacon tracker latitude.""" - dev_id = MOBILE_BEACON_FMT.format(beacon) - state = self.hass.states.get(dev_id) - assert state.attributes.get('latitude') == latitude - - def assert_mobile_tracker_accuracy(self, accuracy, beacon=IBEACON_DEVICE): - """Test the assertion of a mobile beacon tracker accuracy.""" - dev_id = MOBILE_BEACON_FMT.format(beacon) - state = self.hass.states.get(dev_id) - assert state.attributes.get('gps_accuracy') == accuracy - - def test_location_invalid_devid(self): # pylint: disable=invalid-name - """Test the update of a location.""" - self.send_message('owntracks/paulus/nexus-5x', LOCATION_MESSAGE) - state = self.hass.states.get('device_tracker.paulus_nexus5x') - assert state.state == 'outer' - - def test_location_update(self): - """Test the update of a location.""" - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_accuracy(LOCATION_MESSAGE['acc']) - self.assert_location_state('outer') - - def test_location_inaccurate_gps(self): - """Test the location for inaccurate GPS information.""" - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_INACCURATE) - - # Ignored inaccurate GPS. Location remains at previous. - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_longitude(LOCATION_MESSAGE['lon']) - - def test_location_zero_accuracy_gps(self): - """Ignore the location for zero accuracy GPS information.""" - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_ZERO_ACCURACY) - - # Ignored inaccurate GPS. Location remains at previous. - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_longitude(LOCATION_MESSAGE['lon']) - - # ------------------------------------------------------------------------ - # GPS based event entry / exit testing - - def test_event_gps_entry_exit(self): - """Test the entry event.""" - # Entering the owntracks circular region named "inner" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - - # Enter uses the zone's gps co-ords - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Updates ignored when in a zone - # note that LOCATION_MESSAGE is actually pretty far - # from INNER_ZONE and has good accuracy. I haven't - # received a transition message though so I'm still - # associated with the inner zone regardless of GPS. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - - # Exit switches back to GPS - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_accuracy(REGION_GPS_LEAVE_MESSAGE['acc']) - self.assert_location_state('outer') - - # Left clean zone state - assert not self.context.regions_entered[USER] - - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Now sending a location update moves me again. - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_accuracy(LOCATION_MESSAGE['acc']) - - def test_event_gps_with_spaces(self): - """Test the entry event.""" - message = build_message({'desc': "inner 2"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner 2') - - message = build_message({'desc': "inner 2"}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # Left clean zone state - assert not self.context.regions_entered[USER] - - def test_event_gps_entry_inaccurate(self): - """Test the event for inaccurate entry.""" - # Set location to the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_INACCURATE) - - # I enter the zone even though the message GPS was inaccurate. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - def test_event_gps_entry_exit_inaccurate(self): - """Test the event for inaccurate exit.""" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - - # Enter uses the zone's gps co-ords - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_INACCURATE) - - # Exit doesn't use inaccurate gps - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - # But does exit region correctly - assert not self.context.regions_entered[USER] - - def test_event_gps_entry_exit_zero_accuracy(self): - """Test entry/exit events with accuracy zero.""" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_ZERO) - - # Enter uses the zone's gps co-ords - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_ZERO) - - # Exit doesn't use zero gps - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - # But does exit region correctly - assert not self.context.regions_entered[USER] - - def test_event_gps_exit_outside_zone_sets_away(self): - """Test the event for exit zone.""" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_state('inner') - - # Exit message far away GPS location - message = build_message( - {'lon': 90.0, - 'lat': 90.0}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # Exit forces zone change to away - self.assert_location_state(STATE_NOT_HOME) - - def test_event_gps_entry_exit_right_order(self): - """Test the event for ordering.""" - # Enter inner zone - # Set location to the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_state('inner') - - # Enter inner2 zone - message = build_message( - {'desc': "inner_2"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner_2') - - # Exit inner_2 - should be in 'inner' - message = build_message( - {'desc': "inner_2"}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') - - # Exit inner - should be in 'outer' - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_accuracy(REGION_GPS_LEAVE_MESSAGE['acc']) - self.assert_location_state('outer') - - def test_event_gps_entry_exit_wrong_order(self): - """Test the event for wrong order.""" - # Enter inner zone - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_state('inner') - - # Enter inner2 zone - message = build_message( - {'desc': "inner_2"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner_2') - - # Exit inner - should still be in 'inner_2' - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.assert_location_state('inner_2') - - # Exit inner_2 - should be in 'outer' - message = build_message( - {'desc': "inner_2"}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_accuracy(REGION_GPS_LEAVE_MESSAGE['acc']) - self.assert_location_state('outer') - - def test_event_gps_entry_unknown_zone(self): - """Test the event for unknown zone.""" - # Just treat as location update - message = build_message( - {'desc': "unknown"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_latitude(REGION_GPS_ENTER_MESSAGE['lat']) - self.assert_location_state('inner') - - def test_event_gps_exit_unknown_zone(self): - """Test the event for unknown zone.""" - # Just treat as location update - message = build_message( - {'desc': "unknown"}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_state('outer') - - def test_event_entry_zone_loading_dash(self): - """Test the event for zone landing.""" - # Make sure the leading - is ignored - # Owntracks uses this to switch on hold - message = build_message( - {'desc': "-inner"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') - - def test_events_only_on(self): - """Test events_only config suppresses location updates.""" - # Sending a location message that is not home - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - self.assert_location_state(STATE_NOT_HOME) - - self.context.events_only = True - - # Enter and Leave messages - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER) - self.assert_location_state('outer') - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) - self.assert_location_state(STATE_NOT_HOME) - - # Sending a location message that is inside outer zone - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Ignored location update. Location remains at previous. - self.assert_location_state(STATE_NOT_HOME) - - def test_events_only_off(self): - """Test when events_only is False.""" - # Sending a location message that is not home - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - self.assert_location_state(STATE_NOT_HOME) - - self.context.events_only = False - - # Enter and Leave messages - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER) - self.assert_location_state('outer') - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) - self.assert_location_state(STATE_NOT_HOME) - - # Sending a location message that is inside outer zone - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Location update processed - self.assert_location_state('outer') - - def test_event_source_type_entry_exit(self): - """Test the entry and exit events of source type.""" - # Entering the owntracks circular region named "inner" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - - # source_type should be gps when entering using gps. - self.assert_location_source_type('gps') - - # owntracks shouldn't send beacon events with acc = 0 - self.send_message(EVENT_TOPIC, build_message( - {'acc': 1}, REGION_BEACON_ENTER_MESSAGE)) - - # We should be able to enter a beacon zone even inside a gps zone - self.assert_location_source_type('bluetooth_le') - - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - - # source_type should be gps when leaving using gps. - self.assert_location_source_type('gps') - - # owntracks shouldn't send beacon events with acc = 0 - self.send_message(EVENT_TOPIC, build_message( - {'acc': 1}, REGION_BEACON_LEAVE_MESSAGE)) - - self.assert_location_source_type('bluetooth_le') - - # Region Beacon based event entry / exit testing - - def test_event_region_entry_exit(self): - """Test the entry event.""" - # Seeing a beacon named "inner" - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - - # Enter uses the zone's gps co-ords - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Updates ignored when in a zone - # note that LOCATION_MESSAGE is actually pretty far - # from INNER_ZONE and has good accuracy. I haven't - # received a transition message though so I'm still - # associated with the inner zone regardless of GPS. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - - # Exit switches back to GPS but the beacon has no coords - # so I am still located at the center of the inner region - # until I receive a location update. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - # Left clean zone state - assert not self.context.regions_entered[USER] - - # Now sending a location update moves me again. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_accuracy(LOCATION_MESSAGE['acc']) - - def test_event_region_with_spaces(self): - """Test the entry event.""" - message = build_message({'desc': "inner 2"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner 2') - - message = build_message({'desc': "inner 2"}, - REGION_BEACON_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # Left clean zone state - assert not self.context.regions_entered[USER] - - def test_event_region_entry_exit_right_order(self): - """Test the event for ordering.""" - # Enter inner zone - # Set location to the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # See 'inner' region beacon - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.assert_location_state('inner') - - # See 'inner_2' region beacon - message = build_message( - {'desc': "inner_2"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner_2') - - # Exit inner_2 - should be in 'inner' - message = build_message( - {'desc': "inner_2"}, - REGION_BEACON_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') - - # Exit inner - should be in 'outer' - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - - # I have not had an actual location update yet and my - # coordinates are set to the center of the last region I - # entered which puts me in the inner zone. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - def test_event_region_entry_exit_wrong_order(self): - """Test the event for wrong order.""" - # Enter inner zone - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.assert_location_state('inner') - - # Enter inner2 zone - message = build_message( - {'desc': "inner_2"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner_2') - - # Exit inner - should still be in 'inner_2' - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - self.assert_location_state('inner_2') - - # Exit inner_2 - should be in 'outer' - message = build_message( - {'desc': "inner_2"}, - REGION_BEACON_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # I have not had an actual location update yet and my - # coordinates are set to the center of the last region I - # entered which puts me in the inner_2 zone. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner_2') - - def test_event_beacon_unknown_zone_no_location(self): - """Test the event for unknown zone.""" - # A beacon which does not match a HA zone is the - # definition of a mobile beacon. In this case, "unknown" - # will be turned into device_tracker.beacon_unknown and - # that will be tracked at my current location. Except - # in this case my Device hasn't had a location message - # yet so it's in an odd state where it has state.state - # None and no GPS coords so set the beacon to. - - message = build_message( - {'desc': "unknown"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # My current state is None because I haven't seen a - # location message or a GPS or Region # Beacon event - # message. None is the state the test harness set for - # the Device during test case setup. - self.assert_location_state('None') - - # home is the state of a Device constructed through - # the normal code path on it's first observation with - # the conditions I pass along. - self.assert_mobile_tracker_state('home', 'unknown') - - def test_event_beacon_unknown_zone(self): - """Test the event for unknown zone.""" - # A beacon which does not match a HA zone is the - # definition of a mobile beacon. In this case, "unknown" - # will be turned into device_tracker.beacon_unknown and - # that will be tracked at my current location. First I - # set my location so that my state is 'outer' - - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.assert_location_state('outer') - - message = build_message( - {'desc': "unknown"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # My state is still outer and now the unknown beacon - # has joined me at outer. - self.assert_location_state('outer') - self.assert_mobile_tracker_state('outer', 'unknown') - - def test_event_beacon_entry_zone_loading_dash(self): - """Test the event for beacon zone landing.""" - # Make sure the leading - is ignored - # Owntracks uses this to switch on hold - - message = build_message( - {'desc': "-inner"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') - - # ------------------------------------------------------------------------ - # Mobile Beacon based event entry / exit testing - - def test_mobile_enter_move_beacon(self): - """Test the movement of a beacon.""" - # I am in the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # I see the 'keys' beacon. I set the location of the - # beacon_keys tracker to my current device location. - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - - self.assert_mobile_tracker_latitude(LOCATION_MESSAGE['lat']) - self.assert_mobile_tracker_state('outer') - - # Location update to outside of defined zones. - # I am now 'not home' and neither are my keys. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - - self.assert_location_state(STATE_NOT_HOME) - self.assert_mobile_tracker_state(STATE_NOT_HOME) - - not_home_lat = LOCATION_MESSAGE_NOT_HOME['lat'] - self.assert_location_latitude(not_home_lat) - self.assert_mobile_tracker_latitude(not_home_lat) - - def test_mobile_enter_exit_region_beacon(self): - """Test the enter and the exit of a mobile beacon.""" - # I am in the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # I see a new mobile beacon - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.assert_mobile_tracker_latitude(OUTER_ZONE['latitude']) - self.assert_mobile_tracker_state('outer') - - # GPS enter message should move beacon - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - - self.assert_mobile_tracker_latitude(INNER_ZONE['latitude']) - self.assert_mobile_tracker_state(REGION_GPS_ENTER_MESSAGE['desc']) - - # Exit inner zone to outer zone should move beacon to - # center of outer zone - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.assert_mobile_tracker_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_mobile_tracker_state('outer') - - def test_mobile_exit_move_beacon(self): - """Test the exit move of a beacon.""" - # I am in the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # I see a new mobile beacon - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.assert_mobile_tracker_latitude(OUTER_ZONE['latitude']) - self.assert_mobile_tracker_state('outer') - - # Exit mobile beacon, should set location - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - - self.assert_mobile_tracker_latitude(OUTER_ZONE['latitude']) - self.assert_mobile_tracker_state('outer') - - # Move after exit should do nothing - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - self.assert_mobile_tracker_latitude(OUTER_ZONE['latitude']) - self.assert_mobile_tracker_state('outer') - - def test_mobile_multiple_async_enter_exit(self): - """Test the multiple entering.""" - # Test race condition - for _ in range(0, 20): - fire_mqtt_message( - self.hass, EVENT_TOPIC, - json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE)) - fire_mqtt_message( - self.hass, EVENT_TOPIC, - json.dumps(MOBILE_BEACON_LEAVE_EVENT_MESSAGE)) - - fire_mqtt_message( - self.hass, EVENT_TOPIC, - json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE)) + def get_context(): + """Get the current context.""" + return context - self.hass.block_till_done() - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - assert len(self.context.mobile_beacons_active['greg_phone']) == \ - 0 - - def test_mobile_multiple_enter_exit(self): - """Test the multiple entering.""" - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - - assert len(self.context.mobile_beacons_active['greg_phone']) == \ - 0 - - def test_complex_movement(self): - """Test a complex sequence representative of real-world use.""" - # I am in the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.assert_location_state('outer') - - # gps to inner location and event, as actually happens with OwnTracks - location_message = build_message( - {'lat': REGION_GPS_ENTER_MESSAGE['lat'], - 'lon': REGION_GPS_ENTER_MESSAGE['lon']}, - LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - - # region beacon enter inner event and location as actually happens - # with OwnTracks - location_message = build_message( - {'lat': location_message['lat'] + FIVE_M, - 'lon': location_message['lon'] + FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - - # see keys mobile beacon and location message as actually happens - location_message = build_message( - {'lat': location_message['lat'] + FIVE_M, - 'lon': location_message['lon'] + FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_mobile_tracker_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') - - # Slightly odd, I leave the location by gps before I lose - # sight of the region beacon. This is also a little odd in - # that my GPS coords are now in the 'outer' zone but I did not - # "enter" that zone when I started up so my location is not - # the center of OUTER_ZONE, but rather just my GPS location. - - # gps out of inner event and location - location_message = build_message( - {'lat': REGION_GPS_LEAVE_MESSAGE['lat'], - 'lon': REGION_GPS_LEAVE_MESSAGE['lon']}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_mobile_tracker_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_state('outer') - self.assert_mobile_tracker_state('outer') - - # region beacon leave inner - location_message = build_message( - {'lat': location_message['lat'] - FIVE_M, - 'lon': location_message['lon'] - FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(location_message['lat']) - self.assert_mobile_tracker_latitude(location_message['lat']) - self.assert_location_state('outer') - self.assert_mobile_tracker_state('outer') - - # lose keys mobile beacon - lost_keys_location_message = build_message( - {'lat': location_message['lat'] - FIVE_M, - 'lon': location_message['lon'] - FIVE_M}, - LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, lost_keys_location_message) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - self.assert_location_latitude(lost_keys_location_message['lat']) - self.assert_mobile_tracker_latitude(lost_keys_location_message['lat']) - self.assert_location_state('outer') - self.assert_mobile_tracker_state('outer') - - # gps leave outer - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) - self.assert_location_latitude(LOCATION_MESSAGE_NOT_HOME['lat']) - self.assert_mobile_tracker_latitude(lost_keys_location_message['lat']) - self.assert_location_state('not_home') - self.assert_mobile_tracker_state('outer') - - # location move not home - location_message = build_message( - {'lat': LOCATION_MESSAGE_NOT_HOME['lat'] - FIVE_M, - 'lon': LOCATION_MESSAGE_NOT_HOME['lon'] - FIVE_M}, - LOCATION_MESSAGE_NOT_HOME) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(location_message['lat']) - self.assert_mobile_tracker_latitude(lost_keys_location_message['lat']) - self.assert_location_state('not_home') - self.assert_mobile_tracker_state('outer') - - def test_complex_movement_sticky_keys_beacon(self): - """Test a complex sequence which was previously broken.""" - # I am not_home - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.assert_location_state('outer') - - # gps to inner location and event, as actually happens with OwnTracks - location_message = build_message( - {'lat': REGION_GPS_ENTER_MESSAGE['lat'], - 'lon': REGION_GPS_ENTER_MESSAGE['lon']}, - LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - - # see keys mobile beacon and location message as actually happens - location_message = build_message( - {'lat': location_message['lat'] + FIVE_M, - 'lon': location_message['lon'] + FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_mobile_tracker_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') - - # region beacon enter inner event and location as actually happens - # with OwnTracks - location_message = build_message( - {'lat': location_message['lat'] + FIVE_M, - 'lon': location_message['lon'] + FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - - # This sequence of moves would cause keys to follow - # greg_phone around even after the OwnTracks sent - # a mobile beacon 'leave' event for the keys. - # leave keys - self.send_message(LOCATION_TOPIC, location_message) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') - - # leave inner region beacon - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') - - # enter inner region beacon - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - - # enter keys - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') - - # leave keys - self.send_message(LOCATION_TOPIC, location_message) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') - - # leave inner region beacon - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') - - # GPS leave inner region, I'm in the 'outer' region now - # but on GPS coords - leave_location_message = build_message( - {'lat': REGION_GPS_LEAVE_MESSAGE['lat'], - 'lon': REGION_GPS_LEAVE_MESSAGE['lon']}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, leave_location_message) - self.assert_location_state('outer') - self.assert_mobile_tracker_state('inner') - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_mobile_tracker_latitude(INNER_ZONE['latitude']) - - def test_waypoint_import_simple(self): - """Test a simple import of list of waypoints.""" - waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC, waypoints_message) - # Check if it made it into states - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) - assert wayp is not None - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[1]) - assert wayp is not None - - def test_waypoint_import_blacklist(self): - """Test import of list of waypoints for blacklisted user.""" - waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC_BLOCKED, waypoints_message) - # Check if it made it into states - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) - assert wayp is None - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[3]) - assert wayp is None - - def test_waypoint_import_no_whitelist(self): - """Test import of list of waypoints with no whitelist set.""" - @asyncio.coroutine - def mock_see(**kwargs): - """Fake see method for owntracks.""" - return - - test_config = { - CONF_PLATFORM: 'owntracks', - CONF_MAX_GPS_ACCURACY: 200, - CONF_WAYPOINT_IMPORT: True, - CONF_MQTT_TOPIC: 'owntracks/#', - } - run_coroutine_threadsafe(owntracks.async_setup_scanner( - self.hass, test_config, mock_see), self.hass.loop).result() - waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC_BLOCKED, waypoints_message) - # Check if it made it into states - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) - assert wayp is not None - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[3]) - assert wayp is not None - - def test_waypoint_import_bad_json(self): - """Test importing a bad JSON payload.""" - waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC, waypoints_message, True) - # Check if it made it into states - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) - assert wayp is None - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[3]) - assert wayp is None - - def test_waypoint_import_existing(self): - """Test importing a zone that exists.""" - waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC, waypoints_message) - # Get the first waypoint exported - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) - # Send an update - waypoints_message = WAYPOINTS_UPDATED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC, waypoints_message) - new_wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) - assert wayp == new_wayp - - def test_single_waypoint_import(self): - """Test single waypoint message.""" - waypoint_message = WAYPOINT_MESSAGE.copy() - self.send_message(WAYPOINT_TOPIC, waypoint_message) - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) - assert wayp is not None - - def test_not_implemented_message(self): - """Handle not implemented message type.""" - patch_handler = patch('homeassistant.components.device_tracker.' - 'owntracks.async_handle_not_impl_msg', - return_value=mock_coro(False)) - patch_handler.start() - assert not self.send_message(LWT_TOPIC, LWT_MESSAGE) - patch_handler.stop() - - def test_unsupported_message(self): - """Handle not implemented message type.""" - patch_handler = patch('homeassistant.components.device_tracker.' - 'owntracks.async_handle_unsupported_msg', - return_value=mock_coro(False)) - patch_handler.start() - assert not self.send_message(BAD_TOPIC, BAD_MESSAGE) - patch_handler.stop() + yield get_context + + patcher.stop() + + +async def send_message(hass, topic, message, corrupt=False): + """Test the sending of a message.""" + str_message = json.dumps(message) + if corrupt: + mod_message = BAD_JSON_PREFIX + str_message + BAD_JSON_SUFFIX + else: + mod_message = str_message + async_fire_mqtt_message(hass, topic, mod_message) + await hass.async_block_till_done() + await hass.async_block_till_done() + + +def assert_location_state(hass, location): + """Test the assertion of a location state.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.state == location + + +def assert_location_latitude(hass, latitude): + """Test the assertion of a location latitude.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.attributes.get('latitude') == latitude + + +def assert_location_longitude(hass, longitude): + """Test the assertion of a location longitude.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.attributes.get('longitude') == longitude + + +def assert_location_accuracy(hass, accuracy): + """Test the assertion of a location accuracy.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.attributes.get('gps_accuracy') == accuracy + + +def assert_location_source_type(hass, source_type): + """Test the assertion of source_type.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.attributes.get('source_type') == source_type + + +def assert_mobile_tracker_state(hass, location, beacon=IBEACON_DEVICE): + """Test the assertion of a mobile beacon tracker state.""" + dev_id = MOBILE_BEACON_FMT.format(beacon) + state = hass.states.get(dev_id) + assert state.state == location + + +def assert_mobile_tracker_latitude(hass, latitude, beacon=IBEACON_DEVICE): + """Test the assertion of a mobile beacon tracker latitude.""" + dev_id = MOBILE_BEACON_FMT.format(beacon) + state = hass.states.get(dev_id) + assert state.attributes.get('latitude') == latitude + + +def assert_mobile_tracker_accuracy(hass, accuracy, beacon=IBEACON_DEVICE): + """Test the assertion of a mobile beacon tracker accuracy.""" + dev_id = MOBILE_BEACON_FMT.format(beacon) + state = hass.states.get(dev_id) + assert state.attributes.get('gps_accuracy') == accuracy + + +async def test_location_invalid_devid(hass, context): + """Test the update of a location.""" + await send_message(hass, 'owntracks/paulus/nexus-5x', LOCATION_MESSAGE) + state = hass.states.get('device_tracker.paulus_nexus5x') + assert state.state == 'outer' + + +async def test_location_update(hass, context): + """Test the update of a location.""" + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_accuracy(hass, LOCATION_MESSAGE['acc']) + assert_location_state(hass, 'outer') + + +async def test_location_inaccurate_gps(hass, context): + """Test the location for inaccurate GPS information.""" + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_INACCURATE) + + # Ignored inaccurate GPS. Location remains at previous. + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_longitude(hass, LOCATION_MESSAGE['lon']) + + +async def test_location_zero_accuracy_gps(hass, context): + """Ignore the location for zero accuracy GPS information.""" + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_ZERO_ACCURACY) + + # Ignored inaccurate GPS. Location remains at previous. + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_longitude(hass, LOCATION_MESSAGE['lon']) + + +# ------------------------------------------------------------------------ +# GPS based event entry / exit testing +async def test_event_gps_entry_exit(hass, context): + """Test the entry event.""" + # Entering the owntracks circular region named "inner" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + + # Enter uses the zone's gps co-ords + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Updates ignored when in a zone + # note that LOCATION_MESSAGE is actually pretty far + # from INNER_ZONE and has good accuracy. I haven't + # received a transition message though so I'm still + # associated with the inner zone regardless of GPS. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + + # Exit switches back to GPS + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_accuracy(hass, REGION_GPS_LEAVE_MESSAGE['acc']) + assert_location_state(hass, 'outer') + + # Left clean zone state + assert not context().regions_entered[USER] + + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Now sending a location update moves me again. + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_accuracy(hass, LOCATION_MESSAGE['acc']) + + +async def test_event_gps_with_spaces(hass, context): + """Test the entry event.""" + message = build_message({'desc': "inner 2"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner 2') + + message = build_message({'desc': "inner 2"}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # Left clean zone state + assert not context().regions_entered[USER] + + +async def test_event_gps_entry_inaccurate(hass, context): + """Test the event for inaccurate entry.""" + # Set location to the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_INACCURATE) + + # I enter the zone even though the message GPS was inaccurate. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + +async def test_event_gps_entry_exit_inaccurate(hass, context): + """Test the event for inaccurate exit.""" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + + # Enter uses the zone's gps co-ords + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_INACCURATE) + + # Exit doesn't use inaccurate gps + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + # But does exit region correctly + assert not context().regions_entered[USER] + + +async def test_event_gps_entry_exit_zero_accuracy(hass, context): + """Test entry/exit events with accuracy zero.""" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_ZERO) + + # Enter uses the zone's gps co-ords + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_ZERO) + + # Exit doesn't use zero gps + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + # But does exit region correctly + assert not context().regions_entered[USER] + + +async def test_event_gps_exit_outside_zone_sets_away(hass, context): + """Test the event for exit zone.""" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # Exit message far away GPS location + message = build_message( + {'lon': 90.0, + 'lat': 90.0}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # Exit forces zone change to away + assert_location_state(hass, STATE_NOT_HOME) + + +async def test_event_gps_entry_exit_right_order(hass, context): + """Test the event for ordering.""" + # Enter inner zone + # Set location to the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # Enter inner2 zone + message = build_message( + {'desc': "inner_2"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner_2') + + # Exit inner_2 - should be in 'inner' + message = build_message( + {'desc': "inner_2"}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') + + # Exit inner - should be in 'outer' + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_accuracy(hass, REGION_GPS_LEAVE_MESSAGE['acc']) + assert_location_state(hass, 'outer') + + +async def test_event_gps_entry_exit_wrong_order(hass, context): + """Test the event for wrong order.""" + # Enter inner zone + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # Enter inner2 zone + message = build_message( + {'desc': "inner_2"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner_2') + + # Exit inner - should still be in 'inner_2' + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + assert_location_state(hass, 'inner_2') + + # Exit inner_2 - should be in 'outer' + message = build_message( + {'desc': "inner_2"}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_accuracy(hass, REGION_GPS_LEAVE_MESSAGE['acc']) + assert_location_state(hass, 'outer') + + +async def test_event_gps_entry_unknown_zone(hass, context): + """Test the event for unknown zone.""" + # Just treat as location update + message = build_message( + {'desc': "unknown"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_latitude(hass, REGION_GPS_ENTER_MESSAGE['lat']) + assert_location_state(hass, 'inner') + + +async def test_event_gps_exit_unknown_zone(hass, context): + """Test the event for unknown zone.""" + # Just treat as location update + message = build_message( + {'desc': "unknown"}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_state(hass, 'outer') + + +async def test_event_entry_zone_loading_dash(hass, context): + """Test the event for zone landing.""" + # Make sure the leading - is ignored + # Owntracks uses this to switch on hold + message = build_message( + {'desc': "-inner"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') + + +async def test_events_only_on(hass, context): + """Test events_only config suppresses location updates.""" + # Sending a location message that is not home + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + assert_location_state(hass, STATE_NOT_HOME) + + context().events_only = True + + # Enter and Leave messages + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER) + assert_location_state(hass, 'outer') + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) + assert_location_state(hass, STATE_NOT_HOME) + + # Sending a location message that is inside outer zone + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Ignored location update. Location remains at previous. + assert_location_state(hass, STATE_NOT_HOME) + + +async def test_events_only_off(hass, context): + """Test when events_only is False.""" + # Sending a location message that is not home + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + assert_location_state(hass, STATE_NOT_HOME) + + context().events_only = False + + # Enter and Leave messages + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER) + assert_location_state(hass, 'outer') + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) + assert_location_state(hass, STATE_NOT_HOME) + + # Sending a location message that is inside outer zone + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Location update processed + assert_location_state(hass, 'outer') + + +async def test_event_source_type_entry_exit(hass, context): + """Test the entry and exit events of source type.""" + # Entering the owntracks circular region named "inner" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + + # source_type should be gps when entering using gps. + assert_location_source_type(hass, 'gps') + + # owntracks shouldn't send beacon events with acc = 0 + await send_message(hass, EVENT_TOPIC, build_message( + {'acc': 1}, REGION_BEACON_ENTER_MESSAGE)) + + # We should be able to enter a beacon zone even inside a gps zone + assert_location_source_type(hass, 'bluetooth_le') + + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + + # source_type should be gps when leaving using gps. + assert_location_source_type(hass, 'gps') + + # owntracks shouldn't send beacon events with acc = 0 + await send_message(hass, EVENT_TOPIC, build_message( + {'acc': 1}, REGION_BEACON_LEAVE_MESSAGE)) + + assert_location_source_type(hass, 'bluetooth_le') + + +# Region Beacon based event entry / exit testing +async def test_event_region_entry_exit(hass, context): + """Test the entry event.""" + # Seeing a beacon named "inner" + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + + # Enter uses the zone's gps co-ords + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Updates ignored when in a zone + # note that LOCATION_MESSAGE is actually pretty far + # from INNER_ZONE and has good accuracy. I haven't + # received a transition message though so I'm still + # associated with the inner zone regardless of GPS. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + + # Exit switches back to GPS but the beacon has no coords + # so I am still located at the center of the inner region + # until I receive a location update. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + # Left clean zone state + assert not context().regions_entered[USER] + + # Now sending a location update moves me again. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_accuracy(hass, LOCATION_MESSAGE['acc']) + + +async def test_event_region_with_spaces(hass, context): + """Test the entry event.""" + message = build_message({'desc': "inner 2"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner 2') + + message = build_message({'desc': "inner 2"}, + REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # Left clean zone state + assert not context().regions_entered[USER] + + +async def test_event_region_entry_exit_right_order(hass, context): + """Test the event for ordering.""" + # Enter inner zone + # Set location to the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # See 'inner' region beacon + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # See 'inner_2' region beacon + message = build_message( + {'desc': "inner_2"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner_2') + + # Exit inner_2 - should be in 'inner' + message = build_message( + {'desc': "inner_2"}, + REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') + + # Exit inner - should be in 'outer' + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + + # I have not had an actual location update yet and my + # coordinates are set to the center of the last region I + # entered which puts me in the inner zone. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + +async def test_event_region_entry_exit_wrong_order(hass, context): + """Test the event for wrong order.""" + # Enter inner zone + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # Enter inner2 zone + message = build_message( + {'desc': "inner_2"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner_2') + + # Exit inner - should still be in 'inner_2' + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + assert_location_state(hass, 'inner_2') + + # Exit inner_2 - should be in 'outer' + message = build_message( + {'desc': "inner_2"}, + REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # I have not had an actual location update yet and my + # coordinates are set to the center of the last region I + # entered which puts me in the inner_2 zone. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner_2') + + +async def test_event_beacon_unknown_zone_no_location(hass, context): + """Test the event for unknown zone.""" + # A beacon which does not match a HA zone is the + # definition of a mobile beacon. In this case, "unknown" + # will be turned into device_tracker.beacon_unknown and + # that will be tracked at my current location. Except + # in this case my Device hasn't had a location message + # yet so it's in an odd state where it has state.state + # None and no GPS coords so set the beacon to. + hass.states.async_set(DEVICE_TRACKER_STATE, None) + + message = build_message( + {'desc': "unknown"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # My current state is None because I haven't seen a + # location message or a GPS or Region # Beacon event + # message. None is the state the test harness set for + # the Device during test case setup. + assert_location_state(hass, 'None') + + # home is the state of a Device constructed through + # the normal code path on it's first observation with + # the conditions I pass along. + assert_mobile_tracker_state(hass, 'home', 'unknown') + + +async def test_event_beacon_unknown_zone(hass, context): + """Test the event for unknown zone.""" + # A beacon which does not match a HA zone is the + # definition of a mobile beacon. In this case, "unknown" + # will be turned into device_tracker.beacon_unknown and + # that will be tracked at my current location. First I + # set my location so that my state is 'outer' + + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + assert_location_state(hass, 'outer') + + message = build_message( + {'desc': "unknown"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # My state is still outer and now the unknown beacon + # has joined me at outer. + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'outer', 'unknown') + + +async def test_event_beacon_entry_zone_loading_dash(hass, context): + """Test the event for beacon zone landing.""" + # Make sure the leading - is ignored + # Owntracks uses this to switch on hold + + message = build_message( + {'desc': "-inner"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') + + +# ------------------------------------------------------------------------ +# Mobile Beacon based event entry / exit testing +async def test_mobile_enter_move_beacon(hass, context): + """Test the movement of a beacon.""" + # I am in the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # I see the 'keys' beacon. I set the location of the + # beacon_keys tracker to my current device location. + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + + assert_mobile_tracker_latitude(hass, LOCATION_MESSAGE['lat']) + assert_mobile_tracker_state(hass, 'outer') + + # Location update to outside of defined zones. + # I am now 'not home' and neither are my keys. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + + assert_location_state(hass, STATE_NOT_HOME) + assert_mobile_tracker_state(hass, STATE_NOT_HOME) + + not_home_lat = LOCATION_MESSAGE_NOT_HOME['lat'] + assert_location_latitude(hass, not_home_lat) + assert_mobile_tracker_latitude(hass, not_home_lat) + + +async def test_mobile_enter_exit_region_beacon(hass, context): + """Test the enter and the exit of a mobile beacon.""" + # I am in the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # I see a new mobile beacon + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + assert_mobile_tracker_latitude(hass, OUTER_ZONE['latitude']) + assert_mobile_tracker_state(hass, 'outer') + + # GPS enter message should move beacon + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + + assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude']) + assert_mobile_tracker_state(hass, REGION_GPS_ENTER_MESSAGE['desc']) + + # Exit inner zone to outer zone should move beacon to + # center of outer zone + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + assert_mobile_tracker_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_mobile_tracker_state(hass, 'outer') + + +async def test_mobile_exit_move_beacon(hass, context): + """Test the exit move of a beacon.""" + # I am in the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # I see a new mobile beacon + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + assert_mobile_tracker_latitude(hass, OUTER_ZONE['latitude']) + assert_mobile_tracker_state(hass, 'outer') + + # Exit mobile beacon, should set location + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + + assert_mobile_tracker_latitude(hass, OUTER_ZONE['latitude']) + assert_mobile_tracker_state(hass, 'outer') + + # Move after exit should do nothing + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + assert_mobile_tracker_latitude(hass, OUTER_ZONE['latitude']) + assert_mobile_tracker_state(hass, 'outer') + + +async def test_mobile_multiple_async_enter_exit(hass, context): + """Test the multiple entering.""" + # Test race condition + for _ in range(0, 20): + async_fire_mqtt_message( + hass, EVENT_TOPIC, + json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE)) + async_fire_mqtt_message( + hass, EVENT_TOPIC, + json.dumps(MOBILE_BEACON_LEAVE_EVENT_MESSAGE)) + + async_fire_mqtt_message( + hass, EVENT_TOPIC, + json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE)) + + await hass.async_block_till_done() + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + assert len(context().mobile_beacons_active['greg_phone']) == \ + 0 + + +async def test_mobile_multiple_enter_exit(hass, context): + """Test the multiple entering.""" + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + + assert len(context().mobile_beacons_active['greg_phone']) == \ + 0 + + +async def test_complex_movement(hass, context): + """Test a complex sequence representative of real-world use.""" + # I am in the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + assert_location_state(hass, 'outer') + + # gps to inner location and event, as actually happens with OwnTracks + location_message = build_message( + {'lat': REGION_GPS_ENTER_MESSAGE['lat'], + 'lon': REGION_GPS_ENTER_MESSAGE['lon']}, + LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + + # region beacon enter inner event and location as actually happens + # with OwnTracks + location_message = build_message( + {'lat': location_message['lat'] + FIVE_M, + 'lon': location_message['lon'] + FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + + # see keys mobile beacon and location message as actually happens + location_message = build_message( + {'lat': location_message['lat'] + FIVE_M, + 'lon': location_message['lon'] + FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') + + # Slightly odd, I leave the location by gps before I lose + # sight of the region beacon. This is also a little odd in + # that my GPS coords are now in the 'outer' zone but I did not + # "enter" that zone when I started up so my location is not + # the center of OUTER_ZONE, but rather just my GPS location. + + # gps out of inner event and location + location_message = build_message( + {'lat': REGION_GPS_LEAVE_MESSAGE['lat'], + 'lon': REGION_GPS_LEAVE_MESSAGE['lon']}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_mobile_tracker_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'outer') + + # region beacon leave inner + location_message = build_message( + {'lat': location_message['lat'] - FIVE_M, + 'lon': location_message['lon'] - FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, location_message['lat']) + assert_mobile_tracker_latitude(hass, location_message['lat']) + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'outer') + + # lose keys mobile beacon + lost_keys_location_message = build_message( + {'lat': location_message['lat'] - FIVE_M, + 'lon': location_message['lon'] - FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, lost_keys_location_message) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + assert_location_latitude(hass, lost_keys_location_message['lat']) + assert_mobile_tracker_latitude(hass, lost_keys_location_message['lat']) + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'outer') + + # gps leave outer + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) + assert_location_latitude(hass, LOCATION_MESSAGE_NOT_HOME['lat']) + assert_mobile_tracker_latitude(hass, lost_keys_location_message['lat']) + assert_location_state(hass, 'not_home') + assert_mobile_tracker_state(hass, 'outer') + + # location move not home + location_message = build_message( + {'lat': LOCATION_MESSAGE_NOT_HOME['lat'] - FIVE_M, + 'lon': LOCATION_MESSAGE_NOT_HOME['lon'] - FIVE_M}, + LOCATION_MESSAGE_NOT_HOME) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, location_message['lat']) + assert_mobile_tracker_latitude(hass, lost_keys_location_message['lat']) + assert_location_state(hass, 'not_home') + assert_mobile_tracker_state(hass, 'outer') + + +async def test_complex_movement_sticky_keys_beacon(hass, context): + """Test a complex sequence which was previously broken.""" + # I am not_home + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + assert_location_state(hass, 'outer') + + # gps to inner location and event, as actually happens with OwnTracks + location_message = build_message( + {'lat': REGION_GPS_ENTER_MESSAGE['lat'], + 'lon': REGION_GPS_ENTER_MESSAGE['lon']}, + LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + + # see keys mobile beacon and location message as actually happens + location_message = build_message( + {'lat': location_message['lat'] + FIVE_M, + 'lon': location_message['lon'] + FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') + + # region beacon enter inner event and location as actually happens + # with OwnTracks + location_message = build_message( + {'lat': location_message['lat'] + FIVE_M, + 'lon': location_message['lon'] + FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + + # This sequence of moves would cause keys to follow + # greg_phone around even after the OwnTracks sent + # a mobile beacon 'leave' event for the keys. + # leave keys + await send_message(hass, LOCATION_TOPIC, location_message) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') + + # leave inner region beacon + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') + + # enter inner region beacon + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + + # enter keys + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') + + # leave keys + await send_message(hass, LOCATION_TOPIC, location_message) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') + + # leave inner region beacon + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') + + # GPS leave inner region, I'm in the 'outer' region now + # but on GPS coords + leave_location_message = build_message( + {'lat': REGION_GPS_LEAVE_MESSAGE['lat'], + 'lon': REGION_GPS_LEAVE_MESSAGE['lon']}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, leave_location_message) + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'inner') + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude']) + + +async def test_waypoint_import_simple(hass, context): + """Test a simple import of list of waypoints.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC, waypoints_message) + # Check if it made it into states + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + assert wayp is not None + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[1]) + assert wayp is not None + + +async def test_waypoint_import_blacklist(hass, context): + """Test import of list of waypoints for blacklisted user.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC_BLOCKED, waypoints_message) + # Check if it made it into states + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[2]) + assert wayp is None + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[3]) + assert wayp is None + + +async def test_waypoint_import_no_whitelist(hass, context): + """Test import of list of waypoints with no whitelist set.""" + async def mock_see(**kwargs): + """Fake see method for owntracks.""" + return + + test_config = { + CONF_PLATFORM: 'owntracks', + CONF_MAX_GPS_ACCURACY: 200, + CONF_WAYPOINT_IMPORT: True, + CONF_MQTT_TOPIC: 'owntracks/#', + } + await owntracks.async_setup_scanner(hass, test_config, mock_see) + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC_BLOCKED, waypoints_message) + # Check if it made it into states + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[2]) + assert wayp is not None + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[3]) + assert wayp is not None + + +async def test_waypoint_import_bad_json(hass, context): + """Test importing a bad JSON payload.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC, waypoints_message, True) + # Check if it made it into states + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[2]) + assert wayp is None + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[3]) + assert wayp is None + + +async def test_waypoint_import_existing(hass, context): + """Test importing a zone that exists.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC, waypoints_message) + # Get the first waypoint exported + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + # Send an update + waypoints_message = WAYPOINTS_UPDATED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC, waypoints_message) + new_wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + assert wayp == new_wayp + + +async def test_single_waypoint_import(hass, context): + """Test single waypoint message.""" + waypoint_message = WAYPOINT_MESSAGE.copy() + await send_message(hass, WAYPOINT_TOPIC, waypoint_message) + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + assert wayp is not None + + +async def test_not_implemented_message(hass, context): + """Handle not implemented message type.""" + patch_handler = patch('homeassistant.components.device_tracker.' + 'owntracks.async_handle_not_impl_msg', + return_value=mock_coro(False)) + patch_handler.start() + assert not await send_message(hass, LWT_TOPIC, LWT_MESSAGE) + patch_handler.stop() + + +async def test_unsupported_message(hass, context): + """Handle not implemented message type.""" + patch_handler = patch('homeassistant.components.device_tracker.' + 'owntracks.async_handle_unsupported_msg', + return_value=mock_coro(False)) + patch_handler.start() + assert not await send_message(hass, BAD_TOPIC, BAD_MESSAGE) + patch_handler.stop() def generate_ciphers(secret): @@ -1310,162 +1342,162 @@ def mock_decrypt(ciphertext, key): return len(TEST_SECRET_KEY), mock_decrypt -class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): - """Test the OwnTrack sensor.""" - - # pylint: disable=invalid-name - - def setup_method(self, method): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - mock_mqtt_component(self.hass) - mock_component(self.hass, 'group') - mock_component(self.hass, 'zone') - - self.patch_load = patch( - 'homeassistant.components.device_tracker.async_load_config', - return_value=mock_coro([])) - self.patch_load.start() - - self.patch_save = patch('homeassistant.components.device_tracker.' - 'DeviceTracker.async_update_config') - self.patch_save.start() - - def teardown_method(self, method): - """Tear down resources.""" - self.patch_load.stop() - self.patch_save.stop() - self.hass.stop() - - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload(self): - """Test encrypted payload.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: TEST_SECRET_KEY, - }}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_topic_key(self): - """Test encrypted payload with a topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - LOCATION_TOPIC: TEST_SECRET_KEY, - }}}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_no_key(self): - """Test encrypted payload with no key, .""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - # key missing - }}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - assert self.hass.states.get(DEVICE_TRACKER_STATE) is None - - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_wrong_key(self): - """Test encrypted payload with wrong key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: 'wrong key', - }}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - assert self.hass.states.get(DEVICE_TRACKER_STATE) is None - - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_wrong_topic_key(self): - """Test encrypted payload with wrong topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - LOCATION_TOPIC: 'wrong key' - }}}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - assert self.hass.states.get(DEVICE_TRACKER_STATE) is None - - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_no_topic_key(self): - """Test encrypted payload with no topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - 'owntracks/{}/{}'.format(USER, 'otherdevice'): 'foobar' - }}}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - assert self.hass.states.get(DEVICE_TRACKER_STATE) is None - +@pytest.fixture +def config_context(hass, setup_comp): + """Set up the mocked context.""" + patch_load = patch( + 'homeassistant.components.device_tracker.async_load_config', + return_value=mock_coro([])) + patch_load.start() + + patch_save = patch('homeassistant.components.device_tracker.' + 'DeviceTracker.async_update_config') + patch_save.start() + + yield + + patch_load.stop() + patch_save.stop() + + +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload(hass, config_context): + """Test encrypted payload.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_SECRET: TEST_SECRET_KEY, + }}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + + +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_topic_key(hass, config_context): + """Test encrypted payload with a topic key.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_SECRET: { + LOCATION_TOPIC: TEST_SECRET_KEY, + }}}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + + +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_no_key(hass, config_context): + """Test encrypted payload with no key, .""" + assert hass.states.get(DEVICE_TRACKER_STATE) is None + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + # key missing + }}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert hass.states.get(DEVICE_TRACKER_STATE) is None + + +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_wrong_key(hass, config_context): + """Test encrypted payload with wrong key.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_SECRET: 'wrong key', + }}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert hass.states.get(DEVICE_TRACKER_STATE) is None + + +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_wrong_topic_key(hass, config_context): + """Test encrypted payload with wrong topic key.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_SECRET: { + LOCATION_TOPIC: 'wrong key' + }}}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert hass.states.get(DEVICE_TRACKER_STATE) is None + + +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_no_topic_key(hass, config_context): + """Test encrypted payload with no topic key.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_SECRET: { + 'owntracks/{}/{}'.format(USER, 'otherdevice'): 'foobar' + }}}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert hass.states.get(DEVICE_TRACKER_STATE) is None + + +async def test_encrypted_payload_libsodium(hass, config_context): + """Test sending encrypted message payload.""" try: - import libnacl + import libnacl # noqa: F401 except (ImportError, OSError): - libnacl = None + pytest.skip("libnacl/libsodium is not installed") + return + + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_SECRET: TEST_SECRET_KEY, + }}) - @unittest.skipUnless(libnacl, "libnacl/libsodium is not installed") - def test_encrypted_payload_libsodium(self): - """Test sending encrypted message payload.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: TEST_SECRET_KEY, - }}) + await send_message(hass, LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) - self.send_message(LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - def test_customized_mqtt_topic(self): - """Test subscribing to a custom mqtt topic.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_MQTT_TOPIC: 'mytracks/#', - }}) +async def test_customized_mqtt_topic(hass, config_context): + """Test subscribing to a custom mqtt topic.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_MQTT_TOPIC: 'mytracks/#', + }}) - topic = 'mytracks/{}/{}'.format(USER, DEVICE) + topic = 'mytracks/{}/{}'.format(USER, DEVICE) - self.send_message(topic, LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) + await send_message(hass, topic, LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) - def test_region_mapping(self): - """Test region to zone mapping.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_REGION_MAPPING: { - 'foo': 'inner' - }, - }}) - self.hass.states.set( - 'zone.inner', 'zoning', INNER_ZONE) +async def test_region_mapping(hass, config_context): + """Test region to zone mapping.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_REGION_MAPPING: { + 'foo': 'inner' + }, + }}) + + hass.states.async_set( + 'zone.inner', 'zoning', INNER_ZONE) - message = build_message({'desc': 'foo'}, REGION_GPS_ENTER_MESSAGE) - assert message['desc'] == 'foo' + message = build_message({'desc': 'foo'}, REGION_GPS_ENTER_MESSAGE) + assert message['desc'] == 'foo' - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') From 50a30d4dc91ed2a044499de0504573c0ec200ac6 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Sat, 24 Nov 2018 15:10:57 -0500 Subject: [PATCH 044/325] Async tests for remaining device trackers (#18682) --- .../components/device_tracker/test_tplink.py | 30 +-- .../device_tracker/test_unifi_direct.py | 238 +++++++++--------- .../components/device_tracker/test_xiaomi.py | 218 ++++++++-------- 3 files changed, 231 insertions(+), 255 deletions(-) diff --git a/tests/components/device_tracker/test_tplink.py b/tests/components/device_tracker/test_tplink.py index b50d1c6751131..8f226f449b056 100644 --- a/tests/components/device_tracker/test_tplink.py +++ b/tests/components/device_tracker/test_tplink.py @@ -1,7 +1,7 @@ """The tests for the tplink device tracker platform.""" import os -import unittest +import pytest from homeassistant.components import device_tracker from homeassistant.components.device_tracker.tplink import Tplink4DeviceScanner @@ -9,27 +9,19 @@ CONF_HOST) import requests_mock -from tests.common import get_test_home_assistant +@pytest.fixture(autouse=True) +def setup_comp(hass): + """Initialize components.""" + yaml_devices = hass.config.path(device_tracker.YAML_DEVICES) + yield + if os.path.isfile(yaml_devices): + os.remove(yaml_devices) -class TestTplink4DeviceScanner(unittest.TestCase): - """Tests for the Tplink4DeviceScanner class.""" - def setUp(self): # pylint: disable=invalid-name - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that was started.""" - self.hass.stop() - try: - os.remove(self.hass.config.path(device_tracker.YAML_DEVICES)) - except FileNotFoundError: - pass - - @requests_mock.mock() - def test_get_mac_addresses_from_both_bands(self, m): - """Test grabbing the mac addresses from 2.4 and 5 GHz clients pages.""" +async def test_get_mac_addresses_from_both_bands(hass): + """Test grabbing the mac addresses from 2.4 and 5 GHz clients pages.""" + with requests_mock.Mocker() as m: conf_dict = { CONF_PLATFORM: 'tplink', CONF_HOST: 'fake-host', diff --git a/tests/components/device_tracker/test_unifi_direct.py b/tests/components/device_tracker/test_unifi_direct.py index 6e2830eee52ea..1b1dc1a7cb582 100644 --- a/tests/components/device_tracker/test_unifi_direct.py +++ b/tests/components/device_tracker/test_unifi_direct.py @@ -1,14 +1,12 @@ """The tests for the Unifi direct device tracker platform.""" import os from datetime import timedelta -import unittest -from unittest import mock -from unittest.mock import patch +from asynctest import mock, patch import pytest import voluptuous as vol -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component from homeassistant.components import device_tracker from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, CONF_TRACK_NEW, CONF_AWAY_HIDE, @@ -19,133 +17,129 @@ CONF_HOST) from tests.common import ( - get_test_home_assistant, assert_setup_component, - mock_component, load_fixture) - - -class TestComponentsDeviceTrackerUnifiDirect(unittest.TestCase): - """Tests for the Unifi direct device tracker platform.""" - - hass = None - scanner_path = 'homeassistant.components.device_tracker.' + \ - 'unifi_direct.UnifiDeviceScanner' - - def setup_method(self, _): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - mock_component(self.hass, 'zone') - - def teardown_method(self, _): - """Stop everything that was started.""" - self.hass.stop() - try: - os.remove(self.hass.config.path(device_tracker.YAML_DEVICES)) - except FileNotFoundError: - pass - - @mock.patch(scanner_path, - return_value=mock.MagicMock()) - def test_get_scanner(self, unifi_mock): - """Test creating an Unifi direct scanner with a password.""" - conf_dict = { - DOMAIN: { - CONF_PLATFORM: 'unifi_direct', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: 'fake_pass', + assert_setup_component, mock_component, load_fixture) + +scanner_path = 'homeassistant.components.device_tracker.' + \ + 'unifi_direct.UnifiDeviceScanner' + + +@pytest.fixture(autouse=True) +def setup_comp(hass): + """Initialize components.""" + mock_component(hass, 'zone') + yaml_devices = hass.config.path(device_tracker.YAML_DEVICES) + yield + if os.path.isfile(yaml_devices): + os.remove(yaml_devices) + + +@patch(scanner_path, return_value=mock.MagicMock()) +async def test_get_scanner(unifi_mock, hass): + """Test creating an Unifi direct scanner with a password.""" + conf_dict = { + DOMAIN: { + CONF_PLATFORM: 'unifi_direct', + CONF_HOST: 'fake_host', + CONF_USERNAME: 'fake_user', + CONF_PASSWORD: 'fake_pass', + CONF_TRACK_NEW: True, + CONF_CONSIDER_HOME: timedelta(seconds=180), + CONF_NEW_DEVICE_DEFAULTS: { CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180), - CONF_NEW_DEVICE_DEFAULTS: { - CONF_TRACK_NEW: True, - CONF_AWAY_HIDE: False - } + CONF_AWAY_HIDE: False } } - - with assert_setup_component(1, DOMAIN): - assert setup_component(self.hass, DOMAIN, conf_dict) - - conf_dict[DOMAIN][CONF_PORT] = 22 - assert unifi_mock.call_args == mock.call(conf_dict[DOMAIN]) - - @patch('pexpect.pxssh.pxssh') - def test_get_device_name(self, mock_ssh): - """Testing MAC matching.""" - conf_dict = { - DOMAIN: { - CONF_PLATFORM: 'unifi_direct', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: 'fake_pass', - CONF_PORT: 22, - CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180) - } + } + + with assert_setup_component(1, DOMAIN): + assert await async_setup_component(hass, DOMAIN, conf_dict) + + conf_dict[DOMAIN][CONF_PORT] = 22 + assert unifi_mock.call_args == mock.call(conf_dict[DOMAIN]) + + +@patch('pexpect.pxssh.pxssh') +async def test_get_device_name(mock_ssh, hass): + """Testing MAC matching.""" + conf_dict = { + DOMAIN: { + CONF_PLATFORM: 'unifi_direct', + CONF_HOST: 'fake_host', + CONF_USERNAME: 'fake_user', + CONF_PASSWORD: 'fake_pass', + CONF_PORT: 22, + CONF_TRACK_NEW: True, + CONF_CONSIDER_HOME: timedelta(seconds=180) } - mock_ssh.return_value.before = load_fixture('unifi_direct.txt') - scanner = get_scanner(self.hass, conf_dict) - devices = scanner.scan_devices() - assert 23 == len(devices) - assert "iPhone" == \ - scanner.get_device_name("98:00:c6:56:34:12") - assert "iPhone" == \ - scanner.get_device_name("98:00:C6:56:34:12") - - @patch('pexpect.pxssh.pxssh.logout') - @patch('pexpect.pxssh.pxssh.login') - def test_failed_to_log_in(self, mock_login, mock_logout): - """Testing exception at login results in False.""" - from pexpect import exceptions - - conf_dict = { - DOMAIN: { - CONF_PLATFORM: 'unifi_direct', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: 'fake_pass', - CONF_PORT: 22, - CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180) - } + } + mock_ssh.return_value.before = load_fixture('unifi_direct.txt') + scanner = get_scanner(hass, conf_dict) + devices = scanner.scan_devices() + assert 23 == len(devices) + assert "iPhone" == \ + scanner.get_device_name("98:00:c6:56:34:12") + assert "iPhone" == \ + scanner.get_device_name("98:00:C6:56:34:12") + + +@patch('pexpect.pxssh.pxssh.logout') +@patch('pexpect.pxssh.pxssh.login') +async def test_failed_to_log_in(mock_login, mock_logout, hass): + """Testing exception at login results in False.""" + from pexpect import exceptions + + conf_dict = { + DOMAIN: { + CONF_PLATFORM: 'unifi_direct', + CONF_HOST: 'fake_host', + CONF_USERNAME: 'fake_user', + CONF_PASSWORD: 'fake_pass', + CONF_PORT: 22, + CONF_TRACK_NEW: True, + CONF_CONSIDER_HOME: timedelta(seconds=180) } - - mock_login.side_effect = exceptions.EOF("Test") - scanner = get_scanner(self.hass, conf_dict) - assert not scanner - - @patch('pexpect.pxssh.pxssh.logout') - @patch('pexpect.pxssh.pxssh.login', autospec=True) - @patch('pexpect.pxssh.pxssh.prompt') - @patch('pexpect.pxssh.pxssh.sendline') - def test_to_get_update(self, mock_sendline, mock_prompt, mock_login, - mock_logout): - """Testing exception in get_update matching.""" - conf_dict = { - DOMAIN: { - CONF_PLATFORM: 'unifi_direct', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: 'fake_pass', - CONF_PORT: 22, - CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180) - } + } + + mock_login.side_effect = exceptions.EOF("Test") + scanner = get_scanner(hass, conf_dict) + assert not scanner + + +@patch('pexpect.pxssh.pxssh.logout') +@patch('pexpect.pxssh.pxssh.login', autospec=True) +@patch('pexpect.pxssh.pxssh.prompt') +@patch('pexpect.pxssh.pxssh.sendline') +async def test_to_get_update(mock_sendline, mock_prompt, mock_login, + mock_logout, hass): + """Testing exception in get_update matching.""" + conf_dict = { + DOMAIN: { + CONF_PLATFORM: 'unifi_direct', + CONF_HOST: 'fake_host', + CONF_USERNAME: 'fake_user', + CONF_PASSWORD: 'fake_pass', + CONF_PORT: 22, + CONF_TRACK_NEW: True, + CONF_CONSIDER_HOME: timedelta(seconds=180) } + } + + scanner = get_scanner(hass, conf_dict) + # mock_sendline.side_effect = AssertionError("Test") + mock_prompt.side_effect = AssertionError("Test") + devices = scanner._get_update() # pylint: disable=protected-access + assert devices is None + - scanner = get_scanner(self.hass, conf_dict) - # mock_sendline.side_effect = AssertionError("Test") - mock_prompt.side_effect = AssertionError("Test") - devices = scanner._get_update() # pylint: disable=protected-access - assert devices is None +def test_good_response_parses(hass): + """Test that the response form the AP parses to JSON correctly.""" + response = _response_to_json(load_fixture('unifi_direct.txt')) + assert response != {} - def test_good_response_parses(self): - """Test that the response form the AP parses to JSON correctly.""" - response = _response_to_json(load_fixture('unifi_direct.txt')) - assert response != {} - def test_bad_response_returns_none(self): - """Test that a bad response form the AP parses to JSON correctly.""" - assert _response_to_json("{(}") == {} +def test_bad_response_returns_none(hass): + """Test that a bad response form the AP parses to JSON correctly.""" + assert _response_to_json("{(}") == {} def test_config_error(): diff --git a/tests/components/device_tracker/test_xiaomi.py b/tests/components/device_tracker/test_xiaomi.py index 9c7c13ee741ba..7b141159256cb 100644 --- a/tests/components/device_tracker/test_xiaomi.py +++ b/tests/components/device_tracker/test_xiaomi.py @@ -1,8 +1,6 @@ """The tests for the Xiaomi router device tracker platform.""" import logging -import unittest -from unittest import mock -from unittest.mock import patch +from asynctest import mock, patch import requests @@ -10,7 +8,6 @@ from homeassistant.components.device_tracker.xiaomi import get_scanner from homeassistant.const import (CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PLATFORM) -from tests.common import get_test_home_assistant _LOGGER = logging.getLogger(__name__) @@ -152,113 +149,106 @@ def raise_for_status(self): _LOGGER.debug('UNKNOWN ROUTE') -class TestXiaomiDeviceScanner(unittest.TestCase): - """Xiaomi device scanner test class.""" - - def setUp(self): - """Initialize values for this testcase class.""" - self.hass = get_test_home_assistant() - - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() - - @mock.patch( - 'homeassistant.components.device_tracker.xiaomi.XiaomiDeviceScanner', - return_value=mock.MagicMock()) - def test_config(self, xiaomi_mock): - """Testing minimal configuration.""" - config = { - DOMAIN: xiaomi.PLATFORM_SCHEMA({ - CONF_PLATFORM: xiaomi.DOMAIN, - CONF_HOST: '192.168.0.1', - CONF_PASSWORD: 'passwordTest' - }) - } - xiaomi.get_scanner(self.hass, config) - assert xiaomi_mock.call_count == 1 - assert xiaomi_mock.call_args == mock.call(config[DOMAIN]) - call_arg = xiaomi_mock.call_args[0][0] - assert call_arg['username'] == 'admin' - assert call_arg['password'] == 'passwordTest' - assert call_arg['host'] == '192.168.0.1' - assert call_arg['platform'] == 'device_tracker' - - @mock.patch( - 'homeassistant.components.device_tracker.xiaomi.XiaomiDeviceScanner', - return_value=mock.MagicMock()) - def test_config_full(self, xiaomi_mock): - """Testing full configuration.""" - config = { - DOMAIN: xiaomi.PLATFORM_SCHEMA({ - CONF_PLATFORM: xiaomi.DOMAIN, - CONF_HOST: '192.168.0.1', - CONF_USERNAME: 'alternativeAdminName', - CONF_PASSWORD: 'passwordTest' - }) - } - xiaomi.get_scanner(self.hass, config) - assert xiaomi_mock.call_count == 1 - assert xiaomi_mock.call_args == mock.call(config[DOMAIN]) - call_arg = xiaomi_mock.call_args[0][0] - assert call_arg['username'] == 'alternativeAdminName' - assert call_arg['password'] == 'passwordTest' - assert call_arg['host'] == '192.168.0.1' - assert call_arg['platform'] == 'device_tracker' - - @patch('requests.get', side_effect=mocked_requests) - @patch('requests.post', side_effect=mocked_requests) - def test_invalid_credential(self, mock_get, mock_post): - """Testing invalid credential handling.""" - config = { - DOMAIN: xiaomi.PLATFORM_SCHEMA({ - CONF_PLATFORM: xiaomi.DOMAIN, - CONF_HOST: '192.168.0.1', - CONF_USERNAME: INVALID_USERNAME, - CONF_PASSWORD: 'passwordTest' - }) - } - assert get_scanner(self.hass, config) is None - - @patch('requests.get', side_effect=mocked_requests) - @patch('requests.post', side_effect=mocked_requests) - def test_valid_credential(self, mock_get, mock_post): - """Testing valid refresh.""" - config = { - DOMAIN: xiaomi.PLATFORM_SCHEMA({ - CONF_PLATFORM: xiaomi.DOMAIN, - CONF_HOST: '192.168.0.1', - CONF_USERNAME: 'admin', - CONF_PASSWORD: 'passwordTest' - }) - } - scanner = get_scanner(self.hass, config) - assert scanner is not None - assert 2 == len(scanner.scan_devices()) - assert "Device1" == \ - scanner.get_device_name("23:83:BF:F6:38:A0") - assert "Device2" == \ - scanner.get_device_name("1D:98:EC:5E:D5:A6") - - @patch('requests.get', side_effect=mocked_requests) - @patch('requests.post', side_effect=mocked_requests) - def test_token_timed_out(self, mock_get, mock_post): - """Testing refresh with a timed out token. - - New token is requested and list is downloaded a second time. - """ - config = { - DOMAIN: xiaomi.PLATFORM_SCHEMA({ - CONF_PLATFORM: xiaomi.DOMAIN, - CONF_HOST: '192.168.0.1', - CONF_USERNAME: TOKEN_TIMEOUT_USERNAME, - CONF_PASSWORD: 'passwordTest' - }) - } - scanner = get_scanner(self.hass, config) - assert scanner is not None - assert 2 == len(scanner.scan_devices()) - assert "Device1" == \ - scanner.get_device_name("23:83:BF:F6:38:A0") - assert "Device2" == \ - scanner.get_device_name("1D:98:EC:5E:D5:A6") +@patch( + 'homeassistant.components.device_tracker.xiaomi.XiaomiDeviceScanner', + return_value=mock.MagicMock()) +async def test_config(xiaomi_mock, hass): + """Testing minimal configuration.""" + config = { + DOMAIN: xiaomi.PLATFORM_SCHEMA({ + CONF_PLATFORM: xiaomi.DOMAIN, + CONF_HOST: '192.168.0.1', + CONF_PASSWORD: 'passwordTest' + }) + } + xiaomi.get_scanner(hass, config) + assert xiaomi_mock.call_count == 1 + assert xiaomi_mock.call_args == mock.call(config[DOMAIN]) + call_arg = xiaomi_mock.call_args[0][0] + assert call_arg['username'] == 'admin' + assert call_arg['password'] == 'passwordTest' + assert call_arg['host'] == '192.168.0.1' + assert call_arg['platform'] == 'device_tracker' + + +@patch( + 'homeassistant.components.device_tracker.xiaomi.XiaomiDeviceScanner', + return_value=mock.MagicMock()) +async def test_config_full(xiaomi_mock, hass): + """Testing full configuration.""" + config = { + DOMAIN: xiaomi.PLATFORM_SCHEMA({ + CONF_PLATFORM: xiaomi.DOMAIN, + CONF_HOST: '192.168.0.1', + CONF_USERNAME: 'alternativeAdminName', + CONF_PASSWORD: 'passwordTest' + }) + } + xiaomi.get_scanner(hass, config) + assert xiaomi_mock.call_count == 1 + assert xiaomi_mock.call_args == mock.call(config[DOMAIN]) + call_arg = xiaomi_mock.call_args[0][0] + assert call_arg['username'] == 'alternativeAdminName' + assert call_arg['password'] == 'passwordTest' + assert call_arg['host'] == '192.168.0.1' + assert call_arg['platform'] == 'device_tracker' + + +@patch('requests.get', side_effect=mocked_requests) +@patch('requests.post', side_effect=mocked_requests) +async def test_invalid_credential(mock_get, mock_post, hass): + """Testing invalid credential handling.""" + config = { + DOMAIN: xiaomi.PLATFORM_SCHEMA({ + CONF_PLATFORM: xiaomi.DOMAIN, + CONF_HOST: '192.168.0.1', + CONF_USERNAME: INVALID_USERNAME, + CONF_PASSWORD: 'passwordTest' + }) + } + assert get_scanner(hass, config) is None + + +@patch('requests.get', side_effect=mocked_requests) +@patch('requests.post', side_effect=mocked_requests) +async def test_valid_credential(mock_get, mock_post, hass): + """Testing valid refresh.""" + config = { + DOMAIN: xiaomi.PLATFORM_SCHEMA({ + CONF_PLATFORM: xiaomi.DOMAIN, + CONF_HOST: '192.168.0.1', + CONF_USERNAME: 'admin', + CONF_PASSWORD: 'passwordTest' + }) + } + scanner = get_scanner(hass, config) + assert scanner is not None + assert 2 == len(scanner.scan_devices()) + assert "Device1" == \ + scanner.get_device_name("23:83:BF:F6:38:A0") + assert "Device2" == \ + scanner.get_device_name("1D:98:EC:5E:D5:A6") + + +@patch('requests.get', side_effect=mocked_requests) +@patch('requests.post', side_effect=mocked_requests) +async def test_token_timed_out(mock_get, mock_post, hass): + """Testing refresh with a timed out token. + + New token is requested and list is downloaded a second time. + """ + config = { + DOMAIN: xiaomi.PLATFORM_SCHEMA({ + CONF_PLATFORM: xiaomi.DOMAIN, + CONF_HOST: '192.168.0.1', + CONF_USERNAME: TOKEN_TIMEOUT_USERNAME, + CONF_PASSWORD: 'passwordTest' + }) + } + scanner = get_scanner(hass, config) + assert scanner is not None + assert 2 == len(scanner.scan_devices()) + assert "Device1" == \ + scanner.get_device_name("23:83:BF:F6:38:A0") + assert "Device2" == \ + scanner.get_device_name("1D:98:EC:5E:D5:A6") From 66f1643de54b089e99b3c04dfbf2acca6fcf274b Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Sat, 24 Nov 2018 16:12:19 -0500 Subject: [PATCH 045/325] Async timer tests (#18683) --- tests/components/timer/test_init.py | 103 ++++++++++++---------------- 1 file changed, 45 insertions(+), 58 deletions(-) diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index afd2b1412dce3..62a57efb04087 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -1,12 +1,11 @@ """The tests for the timer component.""" # pylint: disable=protected-access import asyncio -import unittest import logging from datetime import timedelta from homeassistant.core import CoreState -from homeassistant.setup import setup_component, async_setup_component +from homeassistant.setup import async_setup_component from homeassistant.components.timer import ( DOMAIN, CONF_DURATION, CONF_NAME, STATUS_ACTIVE, STATUS_IDLE, STATUS_PAUSED, CONF_ICON, ATTR_DURATION, EVENT_TIMER_FINISHED, @@ -15,74 +14,62 @@ from homeassistant.const import (ATTR_ICON, ATTR_FRIENDLY_NAME, CONF_ENTITY_ID) from homeassistant.util.dt import utcnow -from tests.common import (get_test_home_assistant, async_fire_time_changed) +from tests.common import async_fire_time_changed _LOGGER = logging.getLogger(__name__) -class TestTimer(unittest.TestCase): - """Test the timer component.""" - - # pylint: disable=invalid-name - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - # pylint: disable=invalid-name - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() - - def test_config(self): - """Test config.""" - invalid_configs = [ - None, - 1, - {}, - {'name with space': None}, - ] - - for cfg in invalid_configs: - assert not setup_component(self.hass, DOMAIN, {DOMAIN: cfg}) - - def test_config_options(self): - """Test configuration options.""" - count_start = len(self.hass.states.entity_ids()) - - _LOGGER.debug('ENTITIES @ start: %s', self.hass.states.entity_ids()) - - config = { - DOMAIN: { - 'test_1': {}, - 'test_2': { - CONF_NAME: 'Hello World', - CONF_ICON: 'mdi:work', - CONF_DURATION: 10, - } +async def test_config(hass): + """Test config.""" + invalid_configs = [ + None, + 1, + {}, + {'name with space': None}, + ] + + for cfg in invalid_configs: + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + + +async def test_config_options(hass): + """Test configuration options.""" + count_start = len(hass.states.async_entity_ids()) + + _LOGGER.debug('ENTITIES @ start: %s', hass.states.async_entity_ids()) + + config = { + DOMAIN: { + 'test_1': {}, + 'test_2': { + CONF_NAME: 'Hello World', + CONF_ICON: 'mdi:work', + CONF_DURATION: 10, } } + } - assert setup_component(self.hass, 'timer', config) - self.hass.block_till_done() + assert await async_setup_component(hass, 'timer', config) + await hass.async_block_till_done() - assert count_start + 2 == len(self.hass.states.entity_ids()) - self.hass.block_till_done() + assert count_start + 2 == len(hass.states.async_entity_ids()) + await hass.async_block_till_done() - state_1 = self.hass.states.get('timer.test_1') - state_2 = self.hass.states.get('timer.test_2') + state_1 = hass.states.get('timer.test_1') + state_2 = hass.states.get('timer.test_2') - assert state_1 is not None - assert state_2 is not None + assert state_1 is not None + assert state_2 is not None - assert STATUS_IDLE == state_1.state - assert ATTR_ICON not in state_1.attributes - assert ATTR_FRIENDLY_NAME not in state_1.attributes + assert STATUS_IDLE == state_1.state + assert ATTR_ICON not in state_1.attributes + assert ATTR_FRIENDLY_NAME not in state_1.attributes - assert STATUS_IDLE == state_2.state - assert 'Hello World' == \ - state_2.attributes.get(ATTR_FRIENDLY_NAME) - assert 'mdi:work' == state_2.attributes.get(ATTR_ICON) - assert '0:00:10' == state_2.attributes.get(ATTR_DURATION) + assert STATUS_IDLE == state_2.state + assert 'Hello World' == \ + state_2.attributes.get(ATTR_FRIENDLY_NAME) + assert 'mdi:work' == state_2.attributes.get(ATTR_ICON) + assert '0:00:10' == state_2.attributes.get(ATTR_DURATION) @asyncio.coroutine From 6f0a3b4b225cd811eb81f7650833e3bce678938c Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Sat, 24 Nov 2018 16:12:29 -0500 Subject: [PATCH 046/325] Async tests for counter (#18684) --- tests/components/counter/common.py | 18 --- tests/components/counter/test_init.py | 214 ++++++++++++-------------- 2 files changed, 102 insertions(+), 130 deletions(-) diff --git a/tests/components/counter/common.py b/tests/components/counter/common.py index 36d09979d0d56..2fad06027fc4b 100644 --- a/tests/components/counter/common.py +++ b/tests/components/counter/common.py @@ -10,12 +10,6 @@ from homeassistant.loader import bind_hass -@bind_hass -def increment(hass, entity_id): - """Increment a counter.""" - hass.add_job(async_increment, hass, entity_id) - - @callback @bind_hass def async_increment(hass, entity_id): @@ -24,12 +18,6 @@ def async_increment(hass, entity_id): DOMAIN, SERVICE_INCREMENT, {ATTR_ENTITY_ID: entity_id})) -@bind_hass -def decrement(hass, entity_id): - """Decrement a counter.""" - hass.add_job(async_decrement, hass, entity_id) - - @callback @bind_hass def async_decrement(hass, entity_id): @@ -38,12 +26,6 @@ def async_decrement(hass, entity_id): DOMAIN, SERVICE_DECREMENT, {ATTR_ENTITY_ID: entity_id})) -@bind_hass -def reset(hass, entity_id): - """Reset a counter.""" - hass.add_job(async_reset, hass, entity_id) - - @callback @bind_hass def async_reset(hass, entity_id): diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py index c8411bf2fdecc..97a39cdeb73b4 100644 --- a/tests/components/counter/test_init.py +++ b/tests/components/counter/test_init.py @@ -1,163 +1,153 @@ """The tests for the counter component.""" # pylint: disable=protected-access import asyncio -import unittest import logging from homeassistant.core import CoreState, State, Context -from homeassistant.setup import setup_component, async_setup_component +from homeassistant.setup import async_setup_component from homeassistant.components.counter import ( DOMAIN, CONF_INITIAL, CONF_RESTORE, CONF_STEP, CONF_NAME, CONF_ICON) from homeassistant.const import (ATTR_ICON, ATTR_FRIENDLY_NAME) -from tests.common import (get_test_home_assistant, mock_restore_cache) -from tests.components.counter.common import decrement, increment, reset +from tests.common import mock_restore_cache +from tests.components.counter.common import ( + async_decrement, async_increment, async_reset) _LOGGER = logging.getLogger(__name__) -class TestCounter(unittest.TestCase): - """Test the counter component.""" - - # pylint: disable=invalid-name - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - # pylint: disable=invalid-name - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() - - def test_config(self): - """Test config.""" - invalid_configs = [ - None, - 1, - {}, - {'name with space': None}, - ] - - for cfg in invalid_configs: - assert not setup_component(self.hass, DOMAIN, {DOMAIN: cfg}) - - def test_config_options(self): - """Test configuration options.""" - count_start = len(self.hass.states.entity_ids()) - - _LOGGER.debug('ENTITIES @ start: %s', self.hass.states.entity_ids()) - - config = { - DOMAIN: { - 'test_1': {}, - 'test_2': { - CONF_NAME: 'Hello World', - CONF_ICON: 'mdi:work', - CONF_INITIAL: 10, - CONF_RESTORE: False, - CONF_STEP: 5, - } +async def test_config(hass): + """Test config.""" + invalid_configs = [ + None, + 1, + {}, + {'name with space': None}, + ] + + for cfg in invalid_configs: + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + + +async def test_config_options(hass): + """Test configuration options.""" + count_start = len(hass.states.async_entity_ids()) + + _LOGGER.debug('ENTITIES @ start: %s', hass.states.async_entity_ids()) + + config = { + DOMAIN: { + 'test_1': {}, + 'test_2': { + CONF_NAME: 'Hello World', + CONF_ICON: 'mdi:work', + CONF_INITIAL: 10, + CONF_RESTORE: False, + CONF_STEP: 5, } } + } - assert setup_component(self.hass, 'counter', config) - self.hass.block_till_done() + assert await async_setup_component(hass, 'counter', config) + await hass.async_block_till_done() - _LOGGER.debug('ENTITIES: %s', self.hass.states.entity_ids()) + _LOGGER.debug('ENTITIES: %s', hass.states.async_entity_ids()) - assert count_start + 2 == len(self.hass.states.entity_ids()) - self.hass.block_till_done() + assert count_start + 2 == len(hass.states.async_entity_ids()) + await hass.async_block_till_done() - state_1 = self.hass.states.get('counter.test_1') - state_2 = self.hass.states.get('counter.test_2') + state_1 = hass.states.get('counter.test_1') + state_2 = hass.states.get('counter.test_2') - assert state_1 is not None - assert state_2 is not None + assert state_1 is not None + assert state_2 is not None - assert 0 == int(state_1.state) - assert ATTR_ICON not in state_1.attributes - assert ATTR_FRIENDLY_NAME not in state_1.attributes + assert 0 == int(state_1.state) + assert ATTR_ICON not in state_1.attributes + assert ATTR_FRIENDLY_NAME not in state_1.attributes - assert 10 == int(state_2.state) - assert 'Hello World' == \ - state_2.attributes.get(ATTR_FRIENDLY_NAME) - assert 'mdi:work' == state_2.attributes.get(ATTR_ICON) + assert 10 == int(state_2.state) + assert 'Hello World' == \ + state_2.attributes.get(ATTR_FRIENDLY_NAME) + assert 'mdi:work' == state_2.attributes.get(ATTR_ICON) - def test_methods(self): - """Test increment, decrement, and reset methods.""" - config = { - DOMAIN: { - 'test_1': {}, - } + +async def test_methods(hass): + """Test increment, decrement, and reset methods.""" + config = { + DOMAIN: { + 'test_1': {}, } + } - assert setup_component(self.hass, 'counter', config) + assert await async_setup_component(hass, 'counter', config) - entity_id = 'counter.test_1' + entity_id = 'counter.test_1' - state = self.hass.states.get(entity_id) - assert 0 == int(state.state) + state = hass.states.get(entity_id) + assert 0 == int(state.state) - increment(self.hass, entity_id) - self.hass.block_till_done() + async_increment(hass, entity_id) + await hass.async_block_till_done() - state = self.hass.states.get(entity_id) - assert 1 == int(state.state) + state = hass.states.get(entity_id) + assert 1 == int(state.state) - increment(self.hass, entity_id) - self.hass.block_till_done() + async_increment(hass, entity_id) + await hass.async_block_till_done() - state = self.hass.states.get(entity_id) - assert 2 == int(state.state) + state = hass.states.get(entity_id) + assert 2 == int(state.state) - decrement(self.hass, entity_id) - self.hass.block_till_done() + async_decrement(hass, entity_id) + await hass.async_block_till_done() - state = self.hass.states.get(entity_id) - assert 1 == int(state.state) + state = hass.states.get(entity_id) + assert 1 == int(state.state) - reset(self.hass, entity_id) - self.hass.block_till_done() + async_reset(hass, entity_id) + await hass.async_block_till_done() - state = self.hass.states.get(entity_id) - assert 0 == int(state.state) + state = hass.states.get(entity_id) + assert 0 == int(state.state) - def test_methods_with_config(self): - """Test increment, decrement, and reset methods with configuration.""" - config = { - DOMAIN: { - 'test': { - CONF_NAME: 'Hello World', - CONF_INITIAL: 10, - CONF_STEP: 5, - } + +async def test_methods_with_config(hass): + """Test increment, decrement, and reset methods with configuration.""" + config = { + DOMAIN: { + 'test': { + CONF_NAME: 'Hello World', + CONF_INITIAL: 10, + CONF_STEP: 5, } } + } - assert setup_component(self.hass, 'counter', config) + assert await async_setup_component(hass, 'counter', config) - entity_id = 'counter.test' + entity_id = 'counter.test' - state = self.hass.states.get(entity_id) - assert 10 == int(state.state) + state = hass.states.get(entity_id) + assert 10 == int(state.state) - increment(self.hass, entity_id) - self.hass.block_till_done() + async_increment(hass, entity_id) + await hass.async_block_till_done() - state = self.hass.states.get(entity_id) - assert 15 == int(state.state) + state = hass.states.get(entity_id) + assert 15 == int(state.state) - increment(self.hass, entity_id) - self.hass.block_till_done() + async_increment(hass, entity_id) + await hass.async_block_till_done() - state = self.hass.states.get(entity_id) - assert 20 == int(state.state) + state = hass.states.get(entity_id) + assert 20 == int(state.state) - decrement(self.hass, entity_id) - self.hass.block_till_done() + async_decrement(hass, entity_id) + await hass.async_block_till_done() - state = self.hass.states.get(entity_id) - assert 15 == int(state.state) + state = hass.states.get(entity_id) + assert 15 == int(state.state) @asyncio.coroutine From 00c9ca64c8e1cbfe067974743805cfd4e3f5d428 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Sat, 24 Nov 2018 17:08:28 -0500 Subject: [PATCH 047/325] Async tests for mqtt switch (#18685) --- tests/components/switch/test_mqtt.py | 460 ++++++++++++++------------- 1 file changed, 240 insertions(+), 220 deletions(-) diff --git a/tests/components/switch/test_mqtt.py b/tests/components/switch/test_mqtt.py index 5cdd7d230637d..4099a5b7951de 100644 --- a/tests/components/switch/test_mqtt.py +++ b/tests/components/switch/test_mqtt.py @@ -1,9 +1,9 @@ """The tests for the MQTT switch platform.""" import json -import unittest -from unittest.mock import patch +from asynctest import patch +import pytest -from homeassistant.setup import setup_component, async_setup_component +from homeassistant.setup import async_setup_component from homeassistant.const import STATE_ON, STATE_OFF, STATE_UNAVAILABLE,\ ATTR_ASSUMED_STATE import homeassistant.core as ha @@ -11,279 +11,297 @@ from homeassistant.components.mqtt.discovery import async_start from tests.common import ( - mock_mqtt_component, fire_mqtt_message, get_test_home_assistant, mock_coro, - async_mock_mqtt_component, async_fire_mqtt_message, MockConfigEntry) + mock_coro, async_mock_mqtt_component, async_fire_mqtt_message, + MockConfigEntry) from tests.components.switch import common -class TestSwitchMQTT(unittest.TestCase): - """Test the MQTT switch.""" +@pytest.fixture +def mock_publish(hass): + """Initialize components.""" + yield hass.loop.run_until_complete(async_mock_mqtt_component(hass)) - def setUp(self): # pylint: disable=invalid-name - """Set up 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() +async def test_controlling_state_via_topic(hass, mock_publish): + """Test the controlling state via topic.""" + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'payload_on': 1, + 'payload_off': 0 + } + }) + + state = hass.states.get('switch.test') + assert STATE_OFF == state.state + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, 'state-topic', '1') + await hass.async_block_till_done() + + state = hass.states.get('switch.test') + assert STATE_ON == state.state + + async_fire_mqtt_message(hass, 'state-topic', '0') + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('switch.test') + assert STATE_OFF == state.state - def test_controlling_state_via_topic(self): - """Test the controlling state via topic.""" - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'mqtt', - 'name': 'test', - 'state_topic': 'state-topic', - 'command_topic': 'command-topic', - 'payload_on': 1, - 'payload_off': 0 - } - }) - state = self.hass.states.get('switch.test') - assert STATE_OFF == state.state - assert not state.attributes.get(ATTR_ASSUMED_STATE) - - fire_mqtt_message(self.hass, 'state-topic', '1') - self.hass.block_till_done() - - state = self.hass.states.get('switch.test') - assert STATE_ON == state.state - - fire_mqtt_message(self.hass, 'state-topic', '0') - self.hass.block_till_done() - - state = self.hass.states.get('switch.test') - assert STATE_OFF == state.state - - def test_sending_mqtt_commands_and_optimistic(self): - """Test the sending MQTT commands in optimistic mode.""" - fake_state = ha.State('switch.test', 'on') - - with patch('homeassistant.components.switch.mqtt.async_get_last_state', - return_value=mock_coro(fake_state)): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'mqtt', - 'name': 'test', - 'command_topic': 'command-topic', - 'payload_on': 'beer on', - 'payload_off': 'beer off', - 'qos': '2' - } - }) - - state = self.hass.states.get('switch.test') - assert STATE_ON == state.state - assert state.attributes.get(ATTR_ASSUMED_STATE) - - common.turn_on(self.hass, 'switch.test') - self.hass.block_till_done() - - 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') - assert STATE_ON == state.state - - common.turn_off(self.hass, 'switch.test') - self.hass.block_till_done() - - self.mock_publish.async_publish.assert_called_once_with( - 'command-topic', 'beer off', 2, False) - state = self.hass.states.get('switch.test') - assert STATE_OFF == state.state - - def test_controlling_state_via_topic_and_json_message(self): - """Test the controlling state via topic and JSON message.""" - assert setup_component(self.hass, switch.DOMAIN, { +async def test_sending_mqtt_commands_and_optimistic(hass, mock_publish): + """Test the sending MQTT commands in optimistic mode.""" + fake_state = ha.State('switch.test', 'on') + + with patch('homeassistant.components.switch.mqtt.async_get_last_state', + return_value=mock_coro(fake_state)): + assert await async_setup_component(hass, switch.DOMAIN, { switch.DOMAIN: { 'platform': 'mqtt', 'name': 'test', - 'state_topic': 'state-topic', 'command_topic': 'command-topic', 'payload_on': 'beer on', 'payload_off': 'beer off', - 'value_template': '{{ value_json.val }}' + 'qos': '2' } }) - state = self.hass.states.get('switch.test') - assert STATE_OFF == state.state + state = hass.states.get('switch.test') + assert STATE_ON == state.state + assert state.attributes.get(ATTR_ASSUMED_STATE) - fire_mqtt_message(self.hass, 'state-topic', '{"val":"beer on"}') - self.hass.block_till_done() + common.turn_on(hass, 'switch.test') + await hass.async_block_till_done() - state = self.hass.states.get('switch.test') - assert STATE_ON == state.state + mock_publish.async_publish.assert_called_once_with( + 'command-topic', 'beer on', 2, False) + mock_publish.async_publish.reset_mock() + state = hass.states.get('switch.test') + assert STATE_ON == state.state - fire_mqtt_message(self.hass, 'state-topic', '{"val":"beer off"}') - self.hass.block_till_done() + common.turn_off(hass, 'switch.test') + await hass.async_block_till_done() + await hass.async_block_till_done() - state = self.hass.states.get('switch.test') - assert STATE_OFF == state.state + mock_publish.async_publish.assert_called_once_with( + 'command-topic', 'beer off', 2, False) + state = hass.states.get('switch.test') + assert STATE_OFF == state.state - def test_controlling_availability(self): - """Test the controlling state via topic.""" - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'mqtt', - 'name': 'test', - 'state_topic': 'state-topic', - 'command_topic': 'command-topic', - 'availability_topic': 'availability_topic', - 'payload_on': 1, - 'payload_off': 0, - 'payload_available': 1, - 'payload_not_available': 0 - } - }) - state = self.hass.states.get('switch.test') - assert STATE_UNAVAILABLE == state.state +async def test_controlling_state_via_topic_and_json_message( + hass, mock_publish): + """Test the controlling state via topic and JSON message.""" + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'payload_on': 'beer on', + 'payload_off': 'beer off', + 'value_template': '{{ value_json.val }}' + } + }) - fire_mqtt_message(self.hass, 'availability_topic', '1') - self.hass.block_till_done() + state = hass.states.get('switch.test') + assert STATE_OFF == state.state - state = self.hass.states.get('switch.test') - assert STATE_OFF == state.state - assert not state.attributes.get(ATTR_ASSUMED_STATE) + async_fire_mqtt_message(hass, 'state-topic', '{"val":"beer on"}') + await hass.async_block_till_done() + await hass.async_block_till_done() - fire_mqtt_message(self.hass, 'availability_topic', '0') - self.hass.block_till_done() + state = hass.states.get('switch.test') + assert STATE_ON == state.state - state = self.hass.states.get('switch.test') - assert STATE_UNAVAILABLE == state.state + async_fire_mqtt_message(hass, 'state-topic', '{"val":"beer off"}') + await hass.async_block_till_done() + await hass.async_block_till_done() - fire_mqtt_message(self.hass, 'state-topic', '1') - self.hass.block_till_done() + state = hass.states.get('switch.test') + assert STATE_OFF == state.state - state = self.hass.states.get('switch.test') - assert STATE_UNAVAILABLE == state.state - fire_mqtt_message(self.hass, 'availability_topic', '1') - self.hass.block_till_done() +async def test_controlling_availability(hass, mock_publish): + """Test the controlling state via topic.""" + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'availability_topic': 'availability_topic', + 'payload_on': 1, + 'payload_off': 0, + 'payload_available': 1, + 'payload_not_available': 0 + } + }) - state = self.hass.states.get('switch.test') - assert STATE_ON == state.state + state = hass.states.get('switch.test') + assert STATE_UNAVAILABLE == state.state - def test_default_availability_payload(self): - """Test the availability payload.""" - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'mqtt', - 'name': 'test', - 'state_topic': 'state-topic', - 'command_topic': 'command-topic', - 'availability_topic': 'availability_topic', - 'payload_on': 1, - 'payload_off': 0 - } - }) + async_fire_mqtt_message(hass, 'availability_topic', '1') + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('switch.test') + assert STATE_OFF == state.state + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, 'availability_topic', '0') + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('switch.test') + assert STATE_UNAVAILABLE == state.state + + async_fire_mqtt_message(hass, 'state-topic', '1') + await hass.async_block_till_done() + await hass.async_block_till_done() - state = self.hass.states.get('switch.test') - assert STATE_UNAVAILABLE == state.state + state = hass.states.get('switch.test') + assert STATE_UNAVAILABLE == state.state - fire_mqtt_message(self.hass, 'availability_topic', 'online') - self.hass.block_till_done() + async_fire_mqtt_message(hass, 'availability_topic', '1') + await hass.async_block_till_done() + await hass.async_block_till_done() - state = self.hass.states.get('switch.test') - assert STATE_OFF == state.state - assert not state.attributes.get(ATTR_ASSUMED_STATE) + state = hass.states.get('switch.test') + assert STATE_ON == state.state - fire_mqtt_message(self.hass, 'availability_topic', 'offline') - self.hass.block_till_done() - state = self.hass.states.get('switch.test') - assert STATE_UNAVAILABLE == state.state +async def test_default_availability_payload(hass, mock_publish): + """Test the availability payload.""" + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'availability_topic': 'availability_topic', + 'payload_on': 1, + 'payload_off': 0 + } + }) - fire_mqtt_message(self.hass, 'state-topic', '1') - self.hass.block_till_done() + state = hass.states.get('switch.test') + assert STATE_UNAVAILABLE == state.state - state = self.hass.states.get('switch.test') - assert STATE_UNAVAILABLE == state.state + async_fire_mqtt_message(hass, 'availability_topic', 'online') + await hass.async_block_till_done() + await hass.async_block_till_done() - fire_mqtt_message(self.hass, 'availability_topic', 'online') - self.hass.block_till_done() + state = hass.states.get('switch.test') + assert STATE_OFF == state.state + assert not state.attributes.get(ATTR_ASSUMED_STATE) - state = self.hass.states.get('switch.test') - assert STATE_ON == state.state + async_fire_mqtt_message(hass, 'availability_topic', 'offline') + await hass.async_block_till_done() + await hass.async_block_till_done() - def test_custom_availability_payload(self): - """Test the availability payload.""" - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'mqtt', - 'name': 'test', - 'state_topic': 'state-topic', - 'command_topic': 'command-topic', - 'availability_topic': 'availability_topic', - 'payload_on': 1, - 'payload_off': 0, - 'payload_available': 'good', - 'payload_not_available': 'nogood' - } - }) + state = hass.states.get('switch.test') + assert STATE_UNAVAILABLE == state.state - state = self.hass.states.get('switch.test') - assert STATE_UNAVAILABLE == state.state + async_fire_mqtt_message(hass, 'state-topic', '1') + await hass.async_block_till_done() + await hass.async_block_till_done() - fire_mqtt_message(self.hass, 'availability_topic', 'good') - self.hass.block_till_done() + state = hass.states.get('switch.test') + assert STATE_UNAVAILABLE == state.state - state = self.hass.states.get('switch.test') - assert STATE_OFF == state.state - assert not state.attributes.get(ATTR_ASSUMED_STATE) + async_fire_mqtt_message(hass, 'availability_topic', 'online') + await hass.async_block_till_done() + await hass.async_block_till_done() - fire_mqtt_message(self.hass, 'availability_topic', 'nogood') - self.hass.block_till_done() + state = hass.states.get('switch.test') + assert STATE_ON == state.state - state = self.hass.states.get('switch.test') - assert STATE_UNAVAILABLE == state.state - fire_mqtt_message(self.hass, 'state-topic', '1') - self.hass.block_till_done() +async def test_custom_availability_payload(hass, mock_publish): + """Test the availability payload.""" + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'availability_topic': 'availability_topic', + 'payload_on': 1, + 'payload_off': 0, + 'payload_available': 'good', + 'payload_not_available': 'nogood' + } + }) - state = self.hass.states.get('switch.test') - assert STATE_UNAVAILABLE == state.state + state = hass.states.get('switch.test') + assert STATE_UNAVAILABLE == state.state - fire_mqtt_message(self.hass, 'availability_topic', 'good') - self.hass.block_till_done() + async_fire_mqtt_message(hass, 'availability_topic', 'good') + await hass.async_block_till_done() - state = self.hass.states.get('switch.test') - assert STATE_ON == state.state + state = hass.states.get('switch.test') + assert STATE_OFF == state.state + assert not state.attributes.get(ATTR_ASSUMED_STATE) - def test_custom_state_payload(self): - """Test the state payload.""" - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'mqtt', - 'name': 'test', - 'state_topic': 'state-topic', - 'command_topic': 'command-topic', - 'payload_on': 1, - 'payload_off': 0, - 'state_on': "HIGH", - 'state_off': "LOW", - } - }) + async_fire_mqtt_message(hass, 'availability_topic', 'nogood') + await hass.async_block_till_done() + await hass.async_block_till_done() - state = self.hass.states.get('switch.test') - assert STATE_OFF == state.state - assert not state.attributes.get(ATTR_ASSUMED_STATE) + state = hass.states.get('switch.test') + assert STATE_UNAVAILABLE == state.state - fire_mqtt_message(self.hass, 'state-topic', 'HIGH') - self.hass.block_till_done() + async_fire_mqtt_message(hass, 'state-topic', '1') + await hass.async_block_till_done() + await hass.async_block_till_done() - state = self.hass.states.get('switch.test') - assert STATE_ON == state.state + state = hass.states.get('switch.test') + assert STATE_UNAVAILABLE == state.state - fire_mqtt_message(self.hass, 'state-topic', 'LOW') - self.hass.block_till_done() + async_fire_mqtt_message(hass, 'availability_topic', 'good') + await hass.async_block_till_done() + await hass.async_block_till_done() - state = self.hass.states.get('switch.test') - assert STATE_OFF == state.state + state = hass.states.get('switch.test') + assert STATE_ON == state.state + + +async def test_custom_state_payload(hass, mock_publish): + """Test the state payload.""" + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'payload_on': 1, + 'payload_off': 0, + 'state_on': "HIGH", + 'state_off': "LOW", + } + }) + + state = hass.states.get('switch.test') + assert STATE_OFF == state.state + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, 'state-topic', 'HIGH') + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('switch.test') + assert STATE_ON == state.state + + async_fire_mqtt_message(hass, 'state-topic', 'LOW') + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('switch.test') + assert STATE_OFF == state.state async def test_unique_id(hass): @@ -307,6 +325,7 @@ async def test_unique_id(hass): async_fire_mqtt_message(hass, 'test-topic', 'payload') await hass.async_block_till_done() + await hass.async_block_till_done() assert len(hass.states.async_entity_ids()) == 2 # all switches group is 1, unique id created is 1 @@ -326,6 +345,7 @@ async def test_discovery_removal_switch(hass, mqtt_mock, caplog): async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config', data) await hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('switch.beer') assert state is not None From eb6b6ed87d095d28b5258b70ff09e3adc233b3e7 Mon Sep 17 00:00:00 2001 From: Andrew Hayworth Date: Sun, 25 Nov 2018 02:01:19 -0600 Subject: [PATCH 048/325] Add Awair sensor platform (#18570) * Awair Sensor Platform This commit adds a sensor platform for Awair devices, by accessing their beta API. Awair heavily rate-limits this API, so we throttle updates based on the number of devices found. We also allow for the user to bypass API device listing entirely, because the device list endpoint is limited to only 6 calls per day. A crashing or restarting server would quickly hit that limit. This sensor platform uses the python_awair library (also written as part of this PR), which is available for async usage. * Disable pylint warning for broad try/catch It's true that this is generally not a great idea, but we really don't want to crash here. If we can't set up the platform, logging it and continuing is the right answer. * Add space to satisfy the linter * Awair platform PR feedback - Bump python_awair to 0.0.2, which has support for more granular exceptions - Ensure we have python_awair available in test - Raise PlatformNotReady if we can't set up Awair - Make the 'Awair score' its own sensor, rather than exposing it other ways - Set the platform up as polling, and set a sensible default - Pass in throttling parameters to the underlying data class, rather than use hacky global variable access to dynamically set the interval - Switch to dict access for required variables - Use pytest coroutines, set up components via async_setup_component, and test/modify/assert in generally better ways - Commit test data as fixtures * Awair PR feedback, volume 2 - Don't force updates in test, instead modify time itself and let homeassistant update things "normally". - Remove unneeded polling attribute - Rename timestamp attribute to 'last_api_update', to better reflect that it is the timestamp of the last time the Awair API servers received data from this device. - Use that attribute to flag the component as unavailable when data is stale. My own Awair device periodically goes offline and it really hardly indicates that at all. - Dynamically set fixture timestamps to the test run utcnow() value, so that we don't have to worry about ancient timestamps in tests blowing up down the line. - Don't assert on entities directly, for the most part. Find desired attributes in ... the attributes dict. * Patch an instance of utcnow I overlooked * Switch to using a context manager for timestream modification Honestly, it's just a lot easier to keep track of patches. Moreover, the ones I seem to have missed are now caught, and tests seem to consistently pass. Also, switch test_throttle_async_update to manipulating time more explicitly. * Missing blank line, thank you hound * Fix pydocstyle error I very much need to set up a script to do this quickly w/o tox, because running flake8 is not enough! * PR feedback * PR feedback --- homeassistant/components/sensor/awair.py | 227 ++++++++++++++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/sensor/test_awair.py | 282 ++++++++++++++++++ tests/fixtures/awair_air_data_latest.json | 50 ++++ .../awair_air_data_latest_updated.json | 50 ++++ tests/fixtures/awair_devices.json | 25 ++ 8 files changed, 641 insertions(+) create mode 100644 homeassistant/components/sensor/awair.py create mode 100644 tests/components/sensor/test_awair.py create mode 100644 tests/fixtures/awair_air_data_latest.json create mode 100644 tests/fixtures/awair_air_data_latest_updated.json create mode 100644 tests/fixtures/awair_devices.json diff --git a/homeassistant/components/sensor/awair.py b/homeassistant/components/sensor/awair.py new file mode 100644 index 0000000000000..3995309de421c --- /dev/null +++ b/homeassistant/components/sensor/awair.py @@ -0,0 +1,227 @@ +""" +Support for the Awair indoor air quality monitor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.awair/ +""" + +from datetime import timedelta +import logging +import math + +import voluptuous as vol + +from homeassistant.const import ( + CONF_ACCESS_TOKEN, CONF_DEVICES, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS) +from homeassistant.exceptions import PlatformNotReady +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, dt + +REQUIREMENTS = ['python_awair==0.0.2'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_SCORE = 'score' +ATTR_TIMESTAMP = 'timestamp' +ATTR_LAST_API_UPDATE = 'last_api_update' +ATTR_COMPONENT = 'component' +ATTR_VALUE = 'value' +ATTR_SENSORS = 'sensors' + +CONF_UUID = 'uuid' + +DEVICE_CLASS_PM2_5 = 'PM2.5' +DEVICE_CLASS_PM10 = 'PM10' +DEVICE_CLASS_CARBON_DIOXIDE = 'CO2' +DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS = 'VOC' +DEVICE_CLASS_SCORE = 'score' + +SENSOR_TYPES = { + 'TEMP': {'device_class': DEVICE_CLASS_TEMPERATURE, + 'unit_of_measurement': TEMP_CELSIUS, + 'icon': 'mdi:thermometer'}, + 'HUMID': {'device_class': DEVICE_CLASS_HUMIDITY, + 'unit_of_measurement': '%', + 'icon': 'mdi:water-percent'}, + 'CO2': {'device_class': DEVICE_CLASS_CARBON_DIOXIDE, + 'unit_of_measurement': 'ppm', + 'icon': 'mdi:periodic-table-co2'}, + 'VOC': {'device_class': DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + 'unit_of_measurement': 'ppb', + 'icon': 'mdi:cloud'}, + # Awair docs don't actually specify the size they measure for 'dust', + # but 2.5 allows the sensor to show up in HomeKit + 'DUST': {'device_class': DEVICE_CLASS_PM2_5, + 'unit_of_measurement': 'µg/m3', + 'icon': 'mdi:cloud'}, + 'PM25': {'device_class': DEVICE_CLASS_PM2_5, + 'unit_of_measurement': 'µg/m3', + 'icon': 'mdi:cloud'}, + 'PM10': {'device_class': DEVICE_CLASS_PM10, + 'unit_of_measurement': 'µg/m3', + 'icon': 'mdi:cloud'}, + 'score': {'device_class': DEVICE_CLASS_SCORE, + 'unit_of_measurement': '%', + 'icon': 'mdi:percent'}, +} + +AWAIR_QUOTA = 300 + +# This is the minimum time between throttled update calls. +# Don't bother asking us for state more often than that. +SCAN_INTERVAL = timedelta(minutes=5) + +AWAIR_DEVICE_SCHEMA = vol.Schema({ + vol.Required(CONF_UUID): cv.string, +}) + +PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Optional(CONF_DEVICES): vol.All( + cv.ensure_list, [AWAIR_DEVICE_SCHEMA]), +}) + + +# Awair *heavily* throttles calls that get user information, +# and calls that get the list of user-owned devices - they +# allow 30 per DAY. So, we permit a user to provide a static +# list of devices, and they may provide the same set of information +# that the devices() call would return. However, the only thing +# used at this time is the `uuid` value. +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Connect to the Awair API and find devices.""" + from python_awair import AwairClient + + token = config[CONF_ACCESS_TOKEN] + client = AwairClient(token, session=async_get_clientsession(hass)) + + try: + all_devices = [] + devices = config.get(CONF_DEVICES, await client.devices()) + + # Try to throttle dynamically based on quota and number of devices. + throttle_minutes = math.ceil(60 / ((AWAIR_QUOTA / len(devices)) / 24)) + throttle = timedelta(minutes=throttle_minutes) + + for device in devices: + _LOGGER.debug("Found awair device: %s", device) + awair_data = AwairData(client, device[CONF_UUID], throttle) + await awair_data.async_update() + for sensor in SENSOR_TYPES: + if sensor in awair_data.data: + awair_sensor = AwairSensor(awair_data, device, + sensor, throttle) + all_devices.append(awair_sensor) + + async_add_entities(all_devices, True) + return + except AwairClient.AuthError: + _LOGGER.error("Awair API access_token invalid") + except AwairClient.RatelimitError: + _LOGGER.error("Awair API ratelimit exceeded.") + except (AwairClient.QueryError, AwairClient.NotFoundError, + AwairClient.GenericError) as error: + _LOGGER.error("Unexpected Awair API error: %s", error) + + raise PlatformNotReady + + +class AwairSensor(Entity): + """Implementation of an Awair device.""" + + def __init__(self, data, device, sensor_type, throttle): + """Initialize the sensor.""" + self._uuid = device[CONF_UUID] + self._device_class = SENSOR_TYPES[sensor_type]['device_class'] + self._name = 'Awair {}'.format(self._device_class) + unit = SENSOR_TYPES[sensor_type]['unit_of_measurement'] + self._unit_of_measurement = unit + self._data = data + self._type = sensor_type + self._throttle = throttle + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @property + def icon(self): + """Icon to use in the frontend.""" + return SENSOR_TYPES[self._type]['icon'] + + @property + def state(self): + """Return the state of the device.""" + return self._data.data[self._type] + + @property + def device_state_attributes(self): + """Return additional attributes.""" + return self._data.attrs + + # The Awair device should be reporting metrics in quite regularly. + # Based on the raw data from the API, it looks like every ~10 seconds + # is normal. Here we assert that the device is not available if the + # last known API timestamp is more than (3 * throttle) minutes in the + # past. It implies that either hass is somehow unable to query the API + # for new data or that the device is not checking in. Either condition + # fits the definition for 'not available'. We pick (3 * throttle) minutes + # to allow for transient errors to correct themselves. + @property + def available(self): + """Device availability based on the last update timestamp.""" + if ATTR_LAST_API_UPDATE not in self.device_state_attributes: + return False + + last_api_data = self.device_state_attributes[ATTR_LAST_API_UPDATE] + return (dt.utcnow() - last_api_data) < (3 * self._throttle) + + @property + def unique_id(self): + """Return the unique id of this entity.""" + return "{}_{}".format(self._uuid, self._type) + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return self._unit_of_measurement + + async def async_update(self): + """Get the latest data.""" + await self._data.async_update() + + +class AwairData: + """Get data from Awair API.""" + + def __init__(self, client, uuid, throttle): + """Initialize the data object.""" + self._client = client + self._uuid = uuid + self.data = {} + self.attrs = {} + self.async_update = Throttle(throttle)(self._async_update) + + async def _async_update(self): + """Get the data from Awair API.""" + resp = await self._client.air_data_latest(self._uuid) + timestamp = dt.parse_datetime(resp[0][ATTR_TIMESTAMP]) + self.attrs[ATTR_LAST_API_UPDATE] = timestamp + self.data[ATTR_SCORE] = resp[0][ATTR_SCORE] + + # The air_data_latest call only returns one item, so this should + # be safe to only process one entry. + for sensor in resp[0][ATTR_SENSORS]: + self.data[sensor[ATTR_COMPONENT]] = sensor[ATTR_VALUE] + + _LOGGER.debug("Got Awair Data for %s: %s", self._uuid, self.data) diff --git a/requirements_all.txt b/requirements_all.txt index 1fa86a9daf5a1..339c212f23775 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1266,6 +1266,9 @@ python-vlc==1.1.2 # homeassistant.components.wink python-wink==1.10.1 +# homeassistant.components.sensor.awair +python_awair==0.0.2 + # homeassistant.components.sensor.swiss_public_transport python_opendata_transport==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ebc180908e76..a73d80b199a5f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -198,6 +198,9 @@ python-forecastio==1.4.0 # homeassistant.components.nest python-nest==4.0.5 +# homeassistant.components.sensor.awair +python_awair==0.0.2 + # homeassistant.components.sensor.whois pythonwhois==2.4.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 76a9e05de3397..b0ad953e2b5a8 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -91,6 +91,7 @@ 'pyspcwebgw', 'python-forecastio', 'python-nest', + 'python_awair', 'pytradfri\\[async\\]', 'pyunifi', 'pyupnp-async', diff --git a/tests/components/sensor/test_awair.py b/tests/components/sensor/test_awair.py new file mode 100644 index 0000000000000..b539bdbfe7da8 --- /dev/null +++ b/tests/components/sensor/test_awair.py @@ -0,0 +1,282 @@ +"""Tests for the Awair sensor platform.""" + +from contextlib import contextmanager +from datetime import timedelta +import json +import logging +from unittest.mock import patch + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor.awair import ( + ATTR_LAST_API_UPDATE, ATTR_TIMESTAMP, DEVICE_CLASS_CARBON_DIOXIDE, + DEVICE_CLASS_PM2_5, DEVICE_CLASS_SCORE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS) +from homeassistant.const import ( + DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, STATE_UNAVAILABLE, + TEMP_CELSIUS) +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import parse_datetime, utcnow + +from tests.common import async_fire_time_changed, load_fixture, mock_coro + +DISCOVERY_CONFIG = { + 'sensor': { + 'platform': 'awair', + 'access_token': 'qwerty', + } +} + +MANUAL_CONFIG = { + 'sensor': { + 'platform': 'awair', + 'access_token': 'qwerty', + 'devices': [ + {'uuid': 'awair_foo'} + ] + } +} + +_LOGGER = logging.getLogger(__name__) + +NOW = utcnow() +AIR_DATA_FIXTURE = json.loads(load_fixture('awair_air_data_latest.json')) +AIR_DATA_FIXTURE[0][ATTR_TIMESTAMP] = str(NOW) +AIR_DATA_FIXTURE_UPDATED = json.loads( + load_fixture('awair_air_data_latest_updated.json')) +AIR_DATA_FIXTURE_UPDATED[0][ATTR_TIMESTAMP] = str(NOW + timedelta(minutes=5)) + + +@contextmanager +def alter_time(retval): + """Manage multiple time mocks.""" + patch_one = patch('homeassistant.util.dt.utcnow', return_value=retval) + patch_two = patch('homeassistant.util.utcnow', return_value=retval) + patch_three = patch('homeassistant.components.sensor.awair.dt.utcnow', + return_value=retval) + + with patch_one, patch_two, patch_three: + yield + + +async def setup_awair(hass, config=None): + """Load the Awair platform.""" + devices_json = json.loads(load_fixture('awair_devices.json')) + devices_mock = mock_coro(devices_json) + devices_patch = patch('python_awair.AwairClient.devices', + return_value=devices_mock) + air_data_mock = mock_coro(AIR_DATA_FIXTURE) + air_data_patch = patch('python_awair.AwairClient.air_data_latest', + return_value=air_data_mock) + + if config is None: + config = DISCOVERY_CONFIG + + with devices_patch, air_data_patch, alter_time(NOW): + assert await async_setup_component(hass, SENSOR_DOMAIN, config) + await hass.async_block_till_done() + + +async def test_platform_manually_configured(hass): + """Test that we can manually configure devices.""" + await setup_awair(hass, MANUAL_CONFIG) + + assert len(hass.states.async_all()) == 6 + + # Ensure that we loaded the device with uuid 'awair_foo', not the + # 'awair_12345' device that we stub out for API device discovery + entity = hass.data[SENSOR_DOMAIN].get_entity('sensor.awair_co2') + assert entity.unique_id == 'awair_foo_CO2' + + +async def test_platform_automatically_configured(hass): + """Test that we can discover devices from the API.""" + await setup_awair(hass) + + assert len(hass.states.async_all()) == 6 + + # Ensure that we loaded the device with uuid 'awair_12345', which is + # the device that we stub out for API device discovery + entity = hass.data[SENSOR_DOMAIN].get_entity('sensor.awair_co2') + assert entity.unique_id == 'awair_12345_CO2' + + +async def test_bad_platform_setup(hass): + """Tests that we throw correct exceptions when setting up Awair.""" + from python_awair import AwairClient + + auth_patch = patch('python_awair.AwairClient.devices', + side_effect=AwairClient.AuthError) + rate_patch = patch('python_awair.AwairClient.devices', + side_effect=AwairClient.RatelimitError) + generic_patch = patch('python_awair.AwairClient.devices', + side_effect=AwairClient.GenericError) + + with auth_patch: + assert await async_setup_component(hass, SENSOR_DOMAIN, + DISCOVERY_CONFIG) + assert not hass.states.async_all() + + with rate_patch: + assert await async_setup_component(hass, SENSOR_DOMAIN, + DISCOVERY_CONFIG) + assert not hass.states.async_all() + + with generic_patch: + assert await async_setup_component(hass, SENSOR_DOMAIN, + DISCOVERY_CONFIG) + assert not hass.states.async_all() + + +async def test_awair_misc_attributes(hass): + """Test that desired attributes are set.""" + await setup_awair(hass) + + attributes = hass.states.get('sensor.awair_co2').attributes + assert (attributes[ATTR_LAST_API_UPDATE] == + parse_datetime(AIR_DATA_FIXTURE[0][ATTR_TIMESTAMP])) + + +async def test_awair_score(hass): + """Test that we create a sensor for the 'Awair score'.""" + await setup_awair(hass) + + sensor = hass.states.get('sensor.awair_score') + assert sensor.state == '78' + assert sensor.attributes['device_class'] == DEVICE_CLASS_SCORE + assert sensor.attributes['unit_of_measurement'] == '%' + + +async def test_awair_temp(hass): + """Test that we create a temperature sensor.""" + await setup_awair(hass) + + sensor = hass.states.get('sensor.awair_temperature') + assert sensor.state == '22.4' + assert sensor.attributes['device_class'] == DEVICE_CLASS_TEMPERATURE + assert sensor.attributes['unit_of_measurement'] == TEMP_CELSIUS + + +async def test_awair_humid(hass): + """Test that we create a humidity sensor.""" + await setup_awair(hass) + + sensor = hass.states.get('sensor.awair_humidity') + assert sensor.state == '32.73' + assert sensor.attributes['device_class'] == DEVICE_CLASS_HUMIDITY + assert sensor.attributes['unit_of_measurement'] == '%' + + +async def test_awair_co2(hass): + """Test that we create a CO2 sensor.""" + await setup_awair(hass) + + sensor = hass.states.get('sensor.awair_co2') + assert sensor.state == '612' + assert sensor.attributes['device_class'] == DEVICE_CLASS_CARBON_DIOXIDE + assert sensor.attributes['unit_of_measurement'] == 'ppm' + + +async def test_awair_voc(hass): + """Test that we create a CO2 sensor.""" + await setup_awair(hass) + + sensor = hass.states.get('sensor.awair_voc') + assert sensor.state == '1012' + assert (sensor.attributes['device_class'] == + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS) + assert sensor.attributes['unit_of_measurement'] == 'ppb' + + +async def test_awair_dust(hass): + """Test that we create a pm25 sensor.""" + await setup_awair(hass) + + # The Awair Gen1 that we mock actually returns 'DUST', but that + # is mapped to pm25 internally so that it shows up in Homekit + sensor = hass.states.get('sensor.awair_pm25') + assert sensor.state == '6.2' + assert sensor.attributes['device_class'] == DEVICE_CLASS_PM2_5 + assert sensor.attributes['unit_of_measurement'] == 'µg/m3' + + +async def test_awair_unsupported_sensors(hass): + """Ensure we don't create sensors the stubbed device doesn't support.""" + await setup_awair(hass) + + # Our tests mock an Awair Gen 1 device, which should never return + # PM10 sensor readings. Assert that we didn't create a pm10 sensor, + # which could happen if someone were ever to refactor incorrectly. + assert hass.states.get('sensor.awair_pm10') is None + + +async def test_availability(hass): + """Ensure that we mark the component available/unavailable correctly.""" + await setup_awair(hass) + + assert hass.states.get('sensor.awair_score').state == '78' + + future = NOW + timedelta(minutes=30) + data_patch = patch('python_awair.AwairClient.air_data_latest', + return_value=mock_coro(AIR_DATA_FIXTURE)) + + with data_patch, alter_time(future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert hass.states.get('sensor.awair_score').state == STATE_UNAVAILABLE + + future = NOW + timedelta(hours=1) + fixture = AIR_DATA_FIXTURE_UPDATED + fixture[0][ATTR_TIMESTAMP] = str(future) + data_patch = patch('python_awair.AwairClient.air_data_latest', + return_value=mock_coro(fixture)) + + with data_patch, alter_time(future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert hass.states.get('sensor.awair_score').state == '79' + + +async def test_async_update(hass): + """Ensure we can update sensors.""" + await setup_awair(hass) + + future = NOW + timedelta(minutes=10) + data_patch = patch('python_awair.AwairClient.air_data_latest', + return_value=mock_coro(AIR_DATA_FIXTURE_UPDATED)) + + with data_patch, alter_time(future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + score_sensor = hass.states.get('sensor.awair_score') + assert score_sensor.state == '79' + + assert hass.states.get('sensor.awair_temperature').state == '23.4' + assert hass.states.get('sensor.awair_humidity').state == '33.73' + assert hass.states.get('sensor.awair_co2').state == '613' + assert hass.states.get('sensor.awair_voc').state == '1013' + assert hass.states.get('sensor.awair_pm25').state == '7.2' + + +async def test_throttle_async_update(hass): + """Ensure we throttle updates.""" + await setup_awair(hass) + + future = NOW + timedelta(minutes=1) + data_patch = patch('python_awair.AwairClient.air_data_latest', + return_value=mock_coro(AIR_DATA_FIXTURE_UPDATED)) + + with data_patch, alter_time(future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert hass.states.get('sensor.awair_score').state == '78' + + future = NOW + timedelta(minutes=15) + with data_patch, alter_time(future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert hass.states.get('sensor.awair_score').state == '79' diff --git a/tests/fixtures/awair_air_data_latest.json b/tests/fixtures/awair_air_data_latest.json new file mode 100644 index 0000000000000..674c066219768 --- /dev/null +++ b/tests/fixtures/awair_air_data_latest.json @@ -0,0 +1,50 @@ +[ + { + "timestamp": "2018-11-21T15:46:16.346Z", + "score": 78, + "sensors": [ + { + "component": "TEMP", + "value": 22.4 + }, + { + "component": "HUMID", + "value": 32.73 + }, + { + "component": "CO2", + "value": 612 + }, + { + "component": "VOC", + "value": 1012 + }, + { + "component": "DUST", + "value": 6.2 + } + ], + "indices": [ + { + "component": "TEMP", + "value": 0 + }, + { + "component": "HUMID", + "value": -2 + }, + { + "component": "CO2", + "value": 0 + }, + { + "component": "VOC", + "value": 2 + }, + { + "component": "DUST", + "value": 0 + } + ] + } +] diff --git a/tests/fixtures/awair_air_data_latest_updated.json b/tests/fixtures/awair_air_data_latest_updated.json new file mode 100644 index 0000000000000..05ad837123254 --- /dev/null +++ b/tests/fixtures/awair_air_data_latest_updated.json @@ -0,0 +1,50 @@ +[ + { + "timestamp": "2018-11-21T15:46:16.346Z", + "score": 79, + "sensors": [ + { + "component": "TEMP", + "value": 23.4 + }, + { + "component": "HUMID", + "value": 33.73 + }, + { + "component": "CO2", + "value": 613 + }, + { + "component": "VOC", + "value": 1013 + }, + { + "component": "DUST", + "value": 7.2 + } + ], + "indices": [ + { + "component": "TEMP", + "value": 0 + }, + { + "component": "HUMID", + "value": -2 + }, + { + "component": "CO2", + "value": 0 + }, + { + "component": "VOC", + "value": 2 + }, + { + "component": "DUST", + "value": 0 + } + ] + } +] diff --git a/tests/fixtures/awair_devices.json b/tests/fixtures/awair_devices.json new file mode 100644 index 0000000000000..899ad4eed72ba --- /dev/null +++ b/tests/fixtures/awair_devices.json @@ -0,0 +1,25 @@ +[ + { + "uuid": "awair_12345", + "deviceType": "awair", + "deviceId": "12345", + "name": "Awair", + "preference": "GENERAL", + "macAddress": "FFFFFFFFFFFF", + "room": { + "id": "ffffffff-ffff-ffff-ffff-ffffffffffff", + "name": "My Room", + "kind": "LIVING_ROOM", + "Space": { + "id": "ffffffff-ffff-ffff-ffff-ffffffffffff", + "kind": "HOME", + "location": { + "name": "Chicago, IL", + "timezone": "", + "lat": 0, + "lon": -0 + } + } + } + } +] From ad2e8b3174bee7f4a2eced1b0c41b7f05f261396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Sun, 25 Nov 2018 09:39:06 +0100 Subject: [PATCH 049/325] update mill lib, handle bad data from mill server (#18693) --- homeassistant/components/climate/mill.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/climate/mill.py b/homeassistant/components/climate/mill.py index 6be4fe183b7f8..5ea48614f6b09 100644 --- a/homeassistant/components/climate/mill.py +++ b/homeassistant/components/climate/mill.py @@ -19,7 +19,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['millheater==0.2.8'] +REQUIREMENTS = ['millheater==0.2.9'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 339c212f23775..2b253523656b5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -634,7 +634,7 @@ mficlient==0.3.0 miflora==0.4.0 # homeassistant.components.climate.mill -millheater==0.2.8 +millheater==0.2.9 # homeassistant.components.sensor.mitemp_bt mitemp_bt==0.0.1 From 5a5cbe4e72b05ae3e616458f48114f9ff50675b0 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 25 Nov 2018 11:41:49 +0100 Subject: [PATCH 050/325] Upgrade youtube_dl to 2018.11.23 (#18694) --- 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 de60f7eee933e..296c6c8d75dd1 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -14,7 +14,7 @@ SERVICE_PLAY_MEDIA) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2018.11.07'] +REQUIREMENTS = ['youtube_dl==2018.11.23'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 2b253523656b5..f12a41b75aafa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1646,7 +1646,7 @@ yeelight==0.4.3 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2018.11.07 +youtube_dl==2018.11.23 # homeassistant.components.light.zengge zengge==0.2 From cd773455f0c4e098bba2bc565a1496066901391c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?So=C3=B3s=20P=C3=A9ter?= Date: Sun, 25 Nov 2018 12:21:26 +0100 Subject: [PATCH 051/325] Fix false log message on CAPsMAN only devices (#18687) * Fix false log message on CAPsMAN only devices False debug log message appeared on CAPsMAN only devices without physichal wireless interfaces. This fix eliminates them. * Fixed indentation to pass flake8 test --- homeassistant/components/device_tracker/mikrotik.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/mikrotik.py b/homeassistant/components/device_tracker/mikrotik.py index 5b69c13afa64c..587872db8396a 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -128,7 +128,8 @@ def connect_to_device(self): librouteros.exceptions.ConnectionError): self.wireless_exist = False - if not self.wireless_exist or self.method == 'ip': + if not self.wireless_exist and not self.capsman_exist \ + or self.method == 'ip': _LOGGER.info( "Mikrotik %s: Wireless adapters not found. Try to " "use DHCP lease table as presence tracker source. " From 23f5d785c44904daf415a33710ddf8a92c3324a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sun, 25 Nov 2018 12:30:38 +0100 Subject: [PATCH 052/325] Set correct default offset (#18678) --- homeassistant/components/sensor/ruter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/ruter.py b/homeassistant/components/sensor/ruter.py index ddad6a43c7517..7b02b51d0c0b5 100644 --- a/homeassistant/components/sensor/ruter.py +++ b/homeassistant/components/sensor/ruter.py @@ -28,7 +28,7 @@ vol.Required(CONF_STOP_ID): cv.positive_int, vol.Optional(CONF_DESTINATION): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_OFFSET, default=1): cv.positive_int, + vol.Optional(CONF_OFFSET, default=0): cv.positive_int, }) From f3ce463862f31853d3bb79eb37696a597be71c62 Mon Sep 17 00:00:00 2001 From: Jens Date: Sun, 25 Nov 2018 13:47:16 +0100 Subject: [PATCH 053/325] Adds SomfyContactIOSystemSensor to TaHoma (#18560) * Sorts all TAHOME_TYPES and adds SomfyContactIOSystemSensor as it wasn't added with https://github.com/home-assistant/home-assistant/commit/558b659f7caad4027e5d696dfa4d581cf5240a41 * Fixes syntax errors related to sorting of entries. --- homeassistant/components/tahoma.py | 31 +++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/tahoma.py b/homeassistant/components/tahoma.py index 366799b872c94..645d67b3dc206 100644 --- a/homeassistant/components/tahoma.py +++ b/homeassistant/components/tahoma.py @@ -36,26 +36,27 @@ ] TAHOMA_TYPES = { - 'rts:RollerShutterRTSComponent': 'cover', - 'rts:CurtainRTSComponent': 'cover', - 'rts:BlindRTSComponent': 'cover', - 'rts:VenetianBlindRTSComponent': 'cover', - 'rts:DualCurtainRTSComponent': 'cover', - 'rts:ExteriorVenetianBlindRTSComponent': 'cover', 'io:ExteriorVenetianBlindIOComponent': 'cover', + 'io:HorizontalAwningIOComponent': 'cover', + 'io:LightIOSystemSensor': 'sensor', + 'io:OnOffLightIOComponent': 'switch', + 'io:RollerShutterGenericIOComponent': 'cover', 'io:RollerShutterUnoIOComponent': 'cover', - 'io:RollerShutterWithLowSpeedManagementIOComponent': 'cover', 'io:RollerShutterVeluxIOComponent': 'cover', - 'io:RollerShutterGenericIOComponent': 'cover', - 'io:WindowOpenerVeluxIOComponent': 'cover', - 'io:LightIOSystemSensor': 'sensor', - 'rts:GarageDoor4TRTSComponent': 'switch', + 'io:RollerShutterWithLowSpeedManagementIOComponent': 'cover', + 'io:SomfyContactIOSystemSensor': 'sensor', 'io:VerticalExteriorAwningIOComponent': 'cover', - 'io:HorizontalAwningIOComponent': 'cover', - 'io:OnOffLightIOComponent': 'switch', - 'rtds:RTDSSmokeSensor': 'smoke', + 'io:WindowOpenerVeluxIOComponent': 'cover', 'rtds:RTDSContactSensor': 'sensor', - 'rtds:RTDSMotionSensor': 'sensor' + 'rtds:RTDSMotionSensor': 'sensor', + 'rtds:RTDSSmokeSensor': 'smoke', + 'rts:BlindRTSComponent': 'cover', + 'rts:CurtainRTSComponent': 'cover', + 'rts:DualCurtainRTSComponent': 'cover', + 'rts:ExteriorVenetianBlindRTSComponent': 'cover', + 'rts:GarageDoor4TRTSComponent': 'switch', + 'rts:RollerShutterRTSComponent': 'cover', + 'rts:VenetianBlindRTSComponent': 'cover' } From 91c526d9fedc49570a6a1a7f009350297101a66c Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Sun, 25 Nov 2018 11:39:18 -0500 Subject: [PATCH 054/325] Async device sun light trigger tests (#18689) --- .../test_device_sun_light_trigger.py | 165 +++++++++--------- 1 file changed, 82 insertions(+), 83 deletions(-) diff --git a/tests/components/test_device_sun_light_trigger.py b/tests/components/test_device_sun_light_trigger.py index b9f63922ba335..f1ef6aa5dd03c 100644 --- a/tests/components/test_device_sun_light_trigger.py +++ b/tests/components/test_device_sun_light_trigger.py @@ -1,115 +1,114 @@ """The tests device sun light trigger component.""" # pylint: disable=protected-access from datetime import datetime -import unittest -from unittest.mock import patch +from asynctest import patch +import pytest -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component import homeassistant.loader as loader from homeassistant.const import CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME from homeassistant.components import ( device_tracker, light, device_sun_light_trigger) from homeassistant.util import dt as dt_util -from tests.common import get_test_home_assistant, fire_time_changed +from tests.common import async_fire_time_changed from tests.components.light import common as common_light -class TestDeviceSunLightTrigger(unittest.TestCase): - """Test the device sun light trigger module.""" - - def setUp(self): # pylint: disable=invalid-name - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - self.scanner = loader.get_component( - self.hass, 'device_tracker.test').get_scanner(None, None) - - self.scanner.reset() - self.scanner.come_home('DEV1') - - loader.get_component(self.hass, 'light.test').init() - - with patch( - 'homeassistant.components.device_tracker.load_yaml_config_file', - return_value={ - 'device_1': { - 'hide_if_away': False, - 'mac': 'DEV1', - 'name': 'Unnamed Device', - 'picture': 'http://example.com/dev1.jpg', - 'track': True, - 'vendor': None - }, - 'device_2': { - 'hide_if_away': False, - 'mac': 'DEV2', - 'name': 'Unnamed Device', - 'picture': 'http://example.com/dev2.jpg', - 'track': True, - 'vendor': None} - }): - assert setup_component(self.hass, device_tracker.DOMAIN, { +@pytest.fixture +def scanner(hass): + """Initialize components.""" + scanner = loader.get_component( + hass, 'device_tracker.test').get_scanner(None, None) + + scanner.reset() + scanner.come_home('DEV1') + + loader.get_component(hass, 'light.test').init() + + with patch( + 'homeassistant.components.device_tracker.load_yaml_config_file', + return_value={ + 'device_1': { + 'hide_if_away': False, + 'mac': 'DEV1', + 'name': 'Unnamed Device', + 'picture': 'http://example.com/dev1.jpg', + 'track': True, + 'vendor': None + }, + 'device_2': { + 'hide_if_away': False, + 'mac': 'DEV2', + 'name': 'Unnamed Device', + 'picture': 'http://example.com/dev2.jpg', + 'track': True, + 'vendor': None} + }): + assert hass.loop.run_until_complete(async_setup_component( + hass, device_tracker.DOMAIN, { device_tracker.DOMAIN: {CONF_PLATFORM: 'test'} - }) + })) - assert setup_component(self.hass, light.DOMAIN, { + assert hass.loop.run_until_complete(async_setup_component( + hass, light.DOMAIN, { light.DOMAIN: {CONF_PLATFORM: 'test'} - }) + })) - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that was started.""" - self.hass.stop() + return scanner - def test_lights_on_when_sun_sets(self): - """Test lights go on when there is someone home and the sun sets.""" - test_time = datetime(2017, 4, 5, 1, 2, 3, tzinfo=dt_util.UTC) - with patch('homeassistant.util.dt.utcnow', return_value=test_time): - assert setup_component( - self.hass, device_sun_light_trigger.DOMAIN, { - device_sun_light_trigger.DOMAIN: {}}) - common_light.turn_off(self.hass) +async def test_lights_on_when_sun_sets(hass, scanner): + """Test lights go on when there is someone home and the sun sets.""" + test_time = datetime(2017, 4, 5, 1, 2, 3, tzinfo=dt_util.UTC) + with patch('homeassistant.util.dt.utcnow', return_value=test_time): + assert await async_setup_component( + hass, device_sun_light_trigger.DOMAIN, { + device_sun_light_trigger.DOMAIN: {}}) - self.hass.block_till_done() + common_light.async_turn_off(hass) - test_time = test_time.replace(hour=3) - with patch('homeassistant.util.dt.utcnow', return_value=test_time): - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() + await hass.async_block_till_done() - assert light.is_on(self.hass) + test_time = test_time.replace(hour=3) + with patch('homeassistant.util.dt.utcnow', return_value=test_time): + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() - def test_lights_turn_off_when_everyone_leaves(self): - """Test lights turn off when everyone leaves the house.""" - common_light.turn_on(self.hass) + assert light.is_on(hass) - self.hass.block_till_done() - assert setup_component( - self.hass, device_sun_light_trigger.DOMAIN, { - device_sun_light_trigger.DOMAIN: {}}) +async def test_lights_turn_off_when_everyone_leaves(hass, scanner): + """Test lights turn off when everyone leaves the house.""" + common_light.async_turn_on(hass) + + await hass.async_block_till_done() - self.hass.states.set(device_tracker.ENTITY_ID_ALL_DEVICES, - STATE_NOT_HOME) + assert await async_setup_component( + hass, device_sun_light_trigger.DOMAIN, { + device_sun_light_trigger.DOMAIN: {}}) - self.hass.block_till_done() + hass.states.async_set(device_tracker.ENTITY_ID_ALL_DEVICES, + STATE_NOT_HOME) - assert not light.is_on(self.hass) + await hass.async_block_till_done() - def test_lights_turn_on_when_coming_home_after_sun_set(self): - """Test lights turn on when coming home after sun set.""" - test_time = datetime(2017, 4, 5, 3, 2, 3, tzinfo=dt_util.UTC) - with patch('homeassistant.util.dt.utcnow', return_value=test_time): - common_light.turn_off(self.hass) - self.hass.block_till_done() + assert not light.is_on(hass) - assert setup_component( - self.hass, device_sun_light_trigger.DOMAIN, { - device_sun_light_trigger.DOMAIN: {}}) - self.hass.states.set( - device_tracker.ENTITY_ID_FORMAT.format('device_2'), STATE_HOME) +async def test_lights_turn_on_when_coming_home_after_sun_set(hass, scanner): + """Test lights turn on when coming home after sun set.""" + test_time = datetime(2017, 4, 5, 3, 2, 3, tzinfo=dt_util.UTC) + with patch('homeassistant.util.dt.utcnow', return_value=test_time): + common_light.async_turn_off(hass) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, device_sun_light_trigger.DOMAIN, { + device_sun_light_trigger.DOMAIN: {}}) + + hass.states.async_set( + device_tracker.ENTITY_ID_FORMAT.format('device_2'), STATE_HOME) - self.hass.block_till_done() - assert light.is_on(self.hass) + await hass.async_block_till_done() + assert light.is_on(hass) From 78b90be116651306297af35981f3cf70385310f7 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Sun, 25 Nov 2018 11:39:35 -0500 Subject: [PATCH 055/325] Async cover template tests (#18690) --- tests/components/cover/test_template.py | 1391 +++++++++++------------ 1 file changed, 695 insertions(+), 696 deletions(-) diff --git a/tests/components/cover/test_template.py b/tests/components/cover/test_template.py index 3c820f1a0acd6..4d46882c9ea64 100644 --- a/tests/components/cover/test_template.py +++ b/tests/components/cover/test_template.py @@ -1,9 +1,8 @@ """The tests the cover command line platform.""" import logging -import unittest +import pytest from homeassistant import setup -from homeassistant.core import callback from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN) from homeassistant.const import ( @@ -12,748 +11,748 @@ SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, STATE_CLOSED, STATE_OPEN) -from tests.common import ( - get_test_home_assistant, assert_setup_component) +from tests.common import assert_setup_component, async_mock_service _LOGGER = logging.getLogger(__name__) ENTITY_COVER = 'cover.test_template_cover' -class TestTemplateCover(unittest.TestCase): - """Test the cover command line platform.""" - - hass = None - calls = None - # pylint: disable=invalid-name - - def setup_method(self, method): - """Initialize services when tests are started.""" - self.hass = get_test_home_assistant() - self.calls = [] - - @callback - def record_call(service): - """Track function calls..""" - self.calls.append(service) - - self.hass.services.register('test', 'automation', record_call) - - def teardown_method(self, method): - """Stop everything that was started.""" - self.hass.stop() - - def test_template_state_text(self): - """Test the state text of a template.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'value_template': - "{{ states.cover.test_state.state }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - } +@pytest.fixture +def calls(hass): + """Track calls to a mock serivce.""" + return async_mock_service(hass, 'test', 'automation') + + +async def test_template_state_text(hass, calls): + """Test the state text of a template.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'value_template': + "{{ states.cover.test_state.state }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, } } - }) - - self.hass.start() - self.hass.block_till_done() - - state = self.hass.states.set('cover.test_state', STATE_OPEN) - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.state == STATE_OPEN - - state = self.hass.states.set('cover.test_state', STATE_CLOSED) - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.state == STATE_CLOSED - - def test_template_state_boolean(self): - """Test the value_template attribute.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'value_template': - "{{ 1 == 1 }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.async_set('cover.test_state', STATE_OPEN) + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.state == STATE_OPEN + + state = hass.states.async_set('cover.test_state', STATE_CLOSED) + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.state == STATE_CLOSED + + +async def test_template_state_boolean(hass, calls): + """Test the value_template attribute.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'value_template': + "{{ 1 == 1 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, } } - }) - - self.hass.start() - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.state == STATE_OPEN - - def test_template_position(self): - """Test the position_template attribute.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'position_template': - "{{ states.cover.test.attributes.position }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test' - }, - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.state == STATE_OPEN + + +async def test_template_position(hass, calls): + """Test the position_template attribute.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ states.cover.test.attributes.position }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test' + }, } } - }) - - self.hass.start() - self.hass.block_till_done() - - state = self.hass.states.set('cover.test', STATE_CLOSED) - self.hass.block_till_done() - - entity = self.hass.states.get('cover.test') - attrs = dict() - attrs['position'] = 42 - self.hass.states.set( - entity.entity_id, entity.state, - attributes=attrs) - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_position') == 42.0 - assert state.state == STATE_OPEN - - state = self.hass.states.set('cover.test', STATE_OPEN) - self.hass.block_till_done() - entity = self.hass.states.get('cover.test') - attrs['position'] = 0.0 - self.hass.states.set( - entity.entity_id, entity.state, - attributes=attrs) - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_position') == 0.0 - assert state.state == STATE_CLOSED - - def test_template_tilt(self): - """Test the tilt_template attribute.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'value_template': - "{{ 1 == 1 }}", - 'tilt_template': - "{{ 42 }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.async_set('cover.test', STATE_CLOSED) + await hass.async_block_till_done() + + entity = hass.states.get('cover.test') + attrs = dict() + attrs['position'] = 42 + hass.states.async_set( + entity.entity_id, entity.state, + attributes=attrs) + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_position') == 42.0 + assert state.state == STATE_OPEN + + state = hass.states.async_set('cover.test', STATE_OPEN) + await hass.async_block_till_done() + entity = hass.states.get('cover.test') + attrs['position'] = 0.0 + hass.states.async_set( + entity.entity_id, entity.state, + attributes=attrs) + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_position') == 0.0 + assert state.state == STATE_CLOSED + + +async def test_template_tilt(hass, calls): + """Test the tilt_template attribute.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'value_template': + "{{ 1 == 1 }}", + 'tilt_template': + "{{ 42 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, } } - }) - - self.hass.start() - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_tilt_position') == 42.0 - - def test_template_out_of_bounds(self): - """Test template out-of-bounds condition.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'position_template': - "{{ -1 }}", - 'tilt_template': - "{{ 110 }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') == 42.0 + + +async def test_template_out_of_bounds(hass, calls): + """Test template out-of-bounds condition.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ -1 }}", + 'tilt_template': + "{{ 110 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, } } - }) - - self.hass.start() - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_tilt_position') is None - assert state.attributes.get('current_position') is None - - def test_template_mutex(self): - """Test that only value or position template can be used.""" - with assert_setup_component(0, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'value_template': - "{{ 1 == 1 }}", - 'position_template': - "{{ 42 }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - 'icon_template': - "{% if states.cover.test_state.state %}" - "mdi:check" - "{% endif %}" - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') is None + assert state.attributes.get('current_position') is None + + +async def test_template_mutex(hass, calls): + """Test that only value or position template can be used.""" + with assert_setup_component(0, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'value_template': + "{{ 1 == 1 }}", + 'position_template': + "{{ 42 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'icon_template': + "{% if states.cover.test_state.state %}" + "mdi:check" + "{% endif %}" } } - }) - - self.hass.start() - self.hass.block_till_done() - - assert self.hass.states.all() == [] - - def test_template_open_or_position(self): - """Test that at least one of open_cover or set_position is used.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'value_template': - "{{ 1 == 1 }}", - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + assert hass.states.async_all() == [] + + +async def test_template_open_or_position(hass, calls): + """Test that at least one of open_cover or set_position is used.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'value_template': + "{{ 1 == 1 }}", } } - }) - - self.hass.start() - self.hass.block_till_done() - - assert self.hass.states.all() == [] - - def test_template_open_and_close(self): - """Test that if open_cover is specified, close_cover is too.""" - with assert_setup_component(0, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'value_template': - "{{ 1 == 1 }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + assert hass.states.async_all() == [] + + +async def test_template_open_and_close(hass, calls): + """Test that if open_cover is specified, close_cover is too.""" + with assert_setup_component(0, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'value_template': + "{{ 1 == 1 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' }, - } + }, } - }) - - self.hass.start() - self.hass.block_till_done() - - assert self.hass.states.all() == [] - - def test_template_non_numeric(self): - """Test that tilt_template values are numeric.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'position_template': - "{{ on }}", - 'tilt_template': - "{% if states.cover.test_state.state %}" - "on" - "{% else %}" - "off" - "{% endif %}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + assert hass.states.async_all() == [] + + +async def test_template_non_numeric(hass, calls): + """Test that tilt_template values are numeric.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ on }}", + 'tilt_template': + "{% if states.cover.test_state.state %}" + "on" + "{% else %}" + "off" + "{% endif %}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, } } - }) - - self.hass.start() - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_tilt_position') is None - assert state.attributes.get('current_position') is None - - def test_open_action(self): - """Test the open_cover command.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'position_template': - "{{ 0 }}", - 'open_cover': { - 'service': 'test.automation', - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') is None + assert state.attributes.get('current_position') is None + + +async def test_open_action(hass, calls): + """Test the open_cover command.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ 0 }}", + 'open_cover': { + 'service': 'test.automation', + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, } } - }) - - self.hass.start() - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.state == STATE_CLOSED - - self.hass.services.call( - DOMAIN, SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() - - assert len(self.calls) == 1 - - def test_close_stop_action(self): - """Test the close-cover and stop_cover commands.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'position_template': - "{{ 100 }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'test.automation', - }, - 'stop_cover': { - 'service': 'test.automation', - }, - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.state == STATE_CLOSED + + await hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + + assert len(calls) == 1 + + +async def test_close_stop_action(hass, calls): + """Test the close-cover and stop_cover commands.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ 100 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'test.automation', + }, + 'stop_cover': { + 'service': 'test.automation', + }, } } - }) - - self.hass.start() - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.state == STATE_OPEN - - self.hass.services.call( - DOMAIN, SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() - - self.hass.services.call( - DOMAIN, SERVICE_STOP_COVER, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() - - assert len(self.calls) == 2 - - def test_set_position(self): - """Test the set_position command.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'input_number', { - 'input_number': { - 'test': { - 'min': '0', - 'max': '100', - 'initial': '42', - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.state == STATE_OPEN + + await hass.services.async_call( + DOMAIN, SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + + assert len(calls) == 2 + + +async def test_set_position(hass, calls): + """Test the set_position command.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'input_number', { + 'input_number': { + 'test': { + 'min': '0', + 'max': '100', + 'initial': '42', } - }) - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'position_template': - "{{ states.input_number.test.state | int }}", - 'set_cover_position': { - 'service': 'input_number.set_value', - 'entity_id': 'input_number.test', - 'data_template': { - 'value': '{{ position }}' - }, + } + }) + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ states.input_number.test.state | int }}", + 'set_cover_position': { + 'service': 'input_number.set_value', + 'entity_id': 'input_number.test', + 'data_template': { + 'value': '{{ position }}' }, - } + }, } } - }) - - self.hass.start() - self.hass.block_till_done() - - state = self.hass.states.set('input_number.test', 42) - self.hass.block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.state == STATE_OPEN - - self.hass.services.call( - DOMAIN, SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_position') == 100.0 - - self.hass.services.call( - DOMAIN, SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_position') == 0.0 - - self.hass.services.call( - DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 25}, blocking=True) - self.hass.block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_position') == 25.0 - - def test_set_tilt_position(self): - """Test the set_tilt_position command.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'position_template': - "{{ 100 }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - 'set_cover_tilt_position': { - 'service': 'test.automation', - }, - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.async_set('input_number.test', 42) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.state == STATE_OPEN + + await hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_position') == 100.0 + + await hass.services.async_call( + DOMAIN, SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_position') == 0.0 + + await hass.services.async_call( + DOMAIN, SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 25}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_position') == 25.0 + + +async def test_set_tilt_position(hass, calls): + """Test the set_tilt_position command.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ 100 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'set_cover_tilt_position': { + 'service': 'test.automation', + }, } } - }) - - self.hass.start() - self.hass.block_till_done() - - self.hass.services.call( - DOMAIN, SERVICE_SET_COVER_TILT_POSITION, - {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 42}, - blocking=True) - self.hass.block_till_done() - - assert len(self.calls) == 1 - - def test_open_tilt_action(self): - """Test the open_cover_tilt command.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'position_template': - "{{ 100 }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - 'set_cover_tilt_position': { - 'service': 'test.automation', - }, - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 42}, + blocking=True) + await hass.async_block_till_done() + + assert len(calls) == 1 + + +async def test_open_tilt_action(hass, calls): + """Test the open_cover_tilt command.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ 100 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'set_cover_tilt_position': { + 'service': 'test.automation', + }, } } - }) - - self.hass.start() - self.hass.block_till_done() - - self.hass.services.call( - DOMAIN, SERVICE_OPEN_COVER_TILT, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() - - assert len(self.calls) == 1 - - def test_close_tilt_action(self): - """Test the close_cover_tilt command.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'position_template': - "{{ 100 }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - 'set_cover_tilt_position': { - 'service': 'test.automation', - }, - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + + assert len(calls) == 1 + + +async def test_close_tilt_action(hass, calls): + """Test the close_cover_tilt command.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ 100 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'set_cover_tilt_position': { + 'service': 'test.automation', + }, } } - }) - - self.hass.start() - self.hass.block_till_done() - - self.hass.services.call( - DOMAIN, SERVICE_CLOSE_COVER_TILT, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() - - assert len(self.calls) == 1 - - def test_set_position_optimistic(self): - """Test optimistic position mode.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'set_cover_position': { - 'service': 'test.automation', - }, - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + + assert len(calls) == 1 + + +async def test_set_position_optimistic(hass, calls): + """Test optimistic position mode.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'set_cover_position': { + 'service': 'test.automation', + }, } } - }) - self.hass.start() - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_position') is None - - self.hass.services.call( - DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 42}, blocking=True) - self.hass.block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_position') == 42.0 - - self.hass.services.call( - DOMAIN, SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.state == STATE_CLOSED - - self.hass.services.call( - DOMAIN, SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.state == STATE_OPEN - - def test_set_tilt_position_optimistic(self): - """Test the optimistic tilt_position mode.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'position_template': - "{{ 100 }}", - 'set_cover_position': { - 'service': 'test.automation', - }, - 'set_cover_tilt_position': { - 'service': 'test.automation', - }, - } + } + }) + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_position') is None + + await hass.services.async_call( + DOMAIN, SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 42}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_position') == 42.0 + + await hass.services.async_call( + DOMAIN, SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.state == STATE_CLOSED + + await hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.state == STATE_OPEN + + +async def test_set_tilt_position_optimistic(hass, calls): + """Test the optimistic tilt_position mode.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ 100 }}", + 'set_cover_position': { + 'service': 'test.automation', + }, + 'set_cover_tilt_position': { + 'service': 'test.automation', + }, } } - }) - self.hass.start() - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_tilt_position') is None - - self.hass.services.call( - DOMAIN, SERVICE_SET_COVER_TILT_POSITION, - {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 42}, - blocking=True) - self.hass.block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_tilt_position') == 42.0 - - self.hass.services.call( - DOMAIN, SERVICE_CLOSE_COVER_TILT, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_tilt_position') == 0.0 - - self.hass.services.call( - DOMAIN, SERVICE_OPEN_COVER_TILT, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_tilt_position') == 100.0 - - def test_icon_template(self): - """Test icon template.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'value_template': - "{{ states.cover.test_state.state }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - 'icon_template': - "{% if states.cover.test_state.state %}" - "mdi:check" - "{% endif %}" - } + } + }) + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') is None + + await hass.services.async_call( + DOMAIN, SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 42}, + blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') == 42.0 + + await hass.services.async_call( + DOMAIN, SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') == 0.0 + + await hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') == 100.0 + + +async def test_icon_template(hass, calls): + """Test icon template.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'value_template': + "{{ states.cover.test_state.state }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'icon_template': + "{% if states.cover.test_state.state %}" + "mdi:check" + "{% endif %}" } } - }) - - self.hass.start() - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('icon') == '' - - state = self.hass.states.set('cover.test_state', STATE_OPEN) - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - - assert state.attributes['icon'] == 'mdi:check' - - def test_entity_picture_template(self): - """Test icon template.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'value_template': - "{{ states.cover.test_state.state }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - 'entity_picture_template': - "{% if states.cover.test_state.state %}" - "/local/cover.png" - "{% endif %}" - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('icon') == '' + + state = hass.states.async_set('cover.test_state', STATE_OPEN) + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + + assert state.attributes['icon'] == 'mdi:check' + + +async def test_entity_picture_template(hass, calls): + """Test icon template.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'value_template': + "{{ states.cover.test_state.state }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'entity_picture_template': + "{% if states.cover.test_state.state %}" + "/local/cover.png" + "{% endif %}" } } - }) + } + }) - self.hass.start() - self.hass.block_till_done() + await hass.async_start() + await hass.async_block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('entity_picture') == '' + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('entity_picture') == '' - state = self.hass.states.set('cover.test_state', STATE_OPEN) - self.hass.block_till_done() + state = hass.states.async_set('cover.test_state', STATE_OPEN) + await hass.async_block_till_done() - state = self.hass.states.get('cover.test_template_cover') + state = hass.states.get('cover.test_template_cover') - assert state.attributes['entity_picture'] == '/local/cover.png' + assert state.attributes['entity_picture'] == '/local/cover.png' From f387cdec59e6723f7d7830cd51857472586ca5e8 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 25 Nov 2018 17:59:14 +0100 Subject: [PATCH 056/325] Upgrade pysnmp to 4.4.6 (#18695) --- homeassistant/components/device_tracker/snmp.py | 2 +- homeassistant/components/sensor/snmp.py | 2 +- homeassistant/components/switch/snmp.py | 2 +- requirements_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py index a9afc76e67c27..40a6f48d88903 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -14,7 +14,7 @@ DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST -REQUIREMENTS = ['pysnmp==4.4.5'] +REQUIREMENTS = ['pysnmp==4.4.6'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/snmp.py b/homeassistant/components/sensor/snmp.py index 718e4f6fb0d49..b9997345c36d5 100644 --- a/homeassistant/components/sensor/snmp.py +++ b/homeassistant/components/sensor/snmp.py @@ -16,7 +16,7 @@ CONF_HOST, CONF_NAME, CONF_PORT, CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, CONF_USERNAME, CONF_VALUE_TEMPLATE) -REQUIREMENTS = ['pysnmp==4.4.5'] +REQUIREMENTS = ['pysnmp==4.4.6'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/snmp.py b/homeassistant/components/switch/snmp.py index 62636b67003b8..e1da12d317e48 100644 --- a/homeassistant/components/switch/snmp.py +++ b/homeassistant/components/switch/snmp.py @@ -14,7 +14,7 @@ CONF_USERNAME) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pysnmp==4.4.5'] +REQUIREMENTS = ['pysnmp==4.4.6'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index f12a41b75aafa..dd77bd723150d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1140,7 +1140,7 @@ pysma==0.2.2 # homeassistant.components.device_tracker.snmp # homeassistant.components.sensor.snmp # homeassistant.components.switch.snmp -pysnmp==4.4.5 +pysnmp==4.4.6 # homeassistant.components.sonos pysonos==0.0.5 From 8b8629a5f416e6f04bd246f71f13250a75451033 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 25 Nov 2018 18:04:48 +0100 Subject: [PATCH 057/325] Add permission checks to Rest API (#18639) * Add permission checks to Rest API * Clean up unnecessary method * Remove all the tuple stuff from entity check * Simplify perms * Correct param name for owner permission * Hass.io make/update user to be admin * Types --- homeassistant/auth/__init__.py | 17 +++- homeassistant/auth/auth_store.py | 27 +++++++ homeassistant/auth/models.py | 16 +++- homeassistant/auth/permissions/__init__.py | 61 +++++---------- homeassistant/auth/permissions/entities.py | 40 +++++----- homeassistant/components/api.py | 27 ++++++- homeassistant/components/hassio/__init__.py | 9 ++- homeassistant/components/http/view.py | 10 ++- homeassistant/helpers/service.py | 10 +-- tests/auth/permissions/test_entities.py | 50 ++++++------ tests/auth/permissions/test_init.py | 34 -------- tests/common.py | 7 +- tests/components/conftest.py | 5 +- tests/components/hassio/test_init.py | 28 +++++++ tests/components/test_api.py | 86 +++++++++++++++++++-- 15 files changed, 282 insertions(+), 145 deletions(-) delete mode 100644 tests/auth/permissions/test_init.py diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index e69dec37df28d..7d8ef13d2bb92 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -132,13 +132,15 @@ async def async_get_user_by_credentials( return None - async def async_create_system_user(self, name: str) -> models.User: + async def async_create_system_user( + self, name: str, + group_ids: Optional[List[str]] = None) -> models.User: """Create a system user.""" user = await self._store.async_create_user( name=name, system_generated=True, is_active=True, - group_ids=[], + group_ids=group_ids or [], ) self.hass.bus.async_fire(EVENT_USER_ADDED, { @@ -217,6 +219,17 @@ async def async_remove_user(self, user: models.User) -> None: 'user_id': user.id }) + async def async_update_user(self, user: models.User, + name: Optional[str] = None, + group_ids: Optional[List[str]] = None) -> None: + """Update a user.""" + kwargs = {} # type: Dict[str,Any] + if name is not None: + kwargs['name'] = name + if group_ids is not None: + kwargs['group_ids'] = group_ids + await self._store.async_update_user(user, **kwargs) + async def async_activate_user(self, user: models.User) -> None: """Activate a user.""" await self._store.async_activate_user(user) diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 867d5357a583a..cf82c40a4d374 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -133,6 +133,33 @@ async def async_remove_user(self, user: models.User) -> None: self._users.pop(user.id) self._async_schedule_save() + async def async_update_user( + self, user: models.User, name: Optional[str] = None, + is_active: Optional[bool] = None, + group_ids: Optional[List[str]] = None) -> None: + """Update a user.""" + assert self._groups is not None + + if group_ids is not None: + groups = [] + for grid in group_ids: + group = self._groups.get(grid) + if group is None: + raise ValueError("Invalid group specified.") + groups.append(group) + + user.groups = groups + user.invalidate_permission_cache() + + for attr_name, value in ( + ('name', name), + ('is_active', is_active), + ): + if value is not None: + setattr(user, attr_name, value) + + self._async_schedule_save() + async def async_activate_user(self, user: models.User) -> None: """Activate a user.""" user.is_active = True diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index cefaabe752140..4b192c35898e1 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -8,6 +8,7 @@ from homeassistant.util import dt as dt_util from . import permissions as perm_mdl +from .const import GROUP_ID_ADMIN from .util import generate_secret TOKEN_TYPE_NORMAL = 'normal' @@ -48,7 +49,7 @@ class User: ) # type: Dict[str, RefreshToken] _permissions = attr.ib( - type=perm_mdl.PolicyPermissions, + type=Optional[perm_mdl.PolicyPermissions], init=False, cmp=False, default=None, @@ -69,6 +70,19 @@ def permissions(self) -> perm_mdl.AbstractPermissions: return self._permissions + @property + def is_admin(self) -> bool: + """Return if user is part of the admin group.""" + if self.is_owner: + return True + + return self.is_active and any( + gr.id == GROUP_ID_ADMIN for gr in self.groups) + + def invalidate_permission_cache(self) -> None: + """Invalidate permission cache.""" + self._permissions = None + @attr.s(slots=True) class RefreshToken: diff --git a/homeassistant/auth/permissions/__init__.py b/homeassistant/auth/permissions/__init__.py index fd3cf81f02958..9113f2b03a956 100644 --- a/homeassistant/auth/permissions/__init__.py +++ b/homeassistant/auth/permissions/__init__.py @@ -5,10 +5,8 @@ import voluptuous as vol -from homeassistant.core import State - from .const import CAT_ENTITIES -from .types import CategoryType, PolicyType +from .types import PolicyType from .entities import ENTITY_POLICY_SCHEMA, compile_entities from .merge import merge_policies # noqa @@ -22,14 +20,21 @@ class AbstractPermissions: """Default permissions class.""" - def check_entity(self, entity_id: str, key: str) -> bool: - """Test if we can access entity.""" - raise NotImplementedError + _cached_entity_func = None - def filter_states(self, states: List[State]) -> List[State]: - """Filter a list of states for what the user is allowed to see.""" + def _entity_func(self) -> Callable[[str, str], bool]: + """Return a function that can test entity access.""" raise NotImplementedError + def check_entity(self, entity_id: str, key: str) -> bool: + """Check if we can access entity.""" + entity_func = self._cached_entity_func + + if entity_func is None: + entity_func = self._cached_entity_func = self._entity_func() + + return entity_func(entity_id, key) + class PolicyPermissions(AbstractPermissions): """Handle permissions.""" @@ -37,34 +42,10 @@ class PolicyPermissions(AbstractPermissions): def __init__(self, policy: PolicyType) -> None: """Initialize the permission class.""" self._policy = policy - self._compiled = {} # type: Dict[str, Callable[..., bool]] - - def check_entity(self, entity_id: str, key: str) -> bool: - """Test if we can access entity.""" - func = self._policy_func(CAT_ENTITIES, compile_entities) - return func(entity_id, (key,)) - - def filter_states(self, states: List[State]) -> List[State]: - """Filter a list of states for what the user is allowed to see.""" - func = self._policy_func(CAT_ENTITIES, compile_entities) - keys = ('read',) - return [entity for entity in states if func(entity.entity_id, keys)] - - def _policy_func(self, category: str, - compile_func: Callable[[CategoryType], Callable]) \ - -> Callable[..., bool]: - """Get a policy function.""" - func = self._compiled.get(category) - - if func: - return func - func = self._compiled[category] = compile_func( - self._policy.get(category)) - - _LOGGER.debug("Compiled %s func: %s", category, func) - - return func + def _entity_func(self) -> Callable[[str, str], bool]: + """Return a function that can test entity access.""" + return compile_entities(self._policy.get(CAT_ENTITIES)) def __eq__(self, other: Any) -> bool: """Equals check.""" @@ -78,13 +59,9 @@ class _OwnerPermissions(AbstractPermissions): # pylint: disable=no-self-use - def check_entity(self, entity_id: str, key: str) -> bool: - """Test if we can access entity.""" - return True - - def filter_states(self, states: List[State]) -> List[State]: - """Filter a list of states for what the user is allowed to see.""" - return states + def _entity_func(self) -> Callable[[str, str], bool]: + """Return a function that can test entity access.""" + return lambda entity_id, key: True OwnerPermissions = _OwnerPermissions() # pylint: disable=invalid-name diff --git a/homeassistant/auth/permissions/entities.py b/homeassistant/auth/permissions/entities.py index 89b9398628c0c..74a43246fd179 100644 --- a/homeassistant/auth/permissions/entities.py +++ b/homeassistant/auth/permissions/entities.py @@ -28,28 +28,28 @@ })) -def _entity_allowed(schema: ValueType, keys: Tuple[str]) \ +def _entity_allowed(schema: ValueType, key: str) \ -> Union[bool, None]: """Test if an entity is allowed based on the keys.""" if schema is None or isinstance(schema, bool): return schema assert isinstance(schema, dict) - return schema.get(keys[0]) + return schema.get(key) def compile_entities(policy: CategoryType) \ - -> Callable[[str, Tuple[str]], bool]: + -> Callable[[str, str], bool]: """Compile policy into a function that tests policy.""" # None, Empty Dict, False if not policy: - def apply_policy_deny_all(entity_id: str, keys: Tuple[str]) -> bool: + def apply_policy_deny_all(entity_id: str, key: str) -> bool: """Decline all.""" return False return apply_policy_deny_all if policy is True: - def apply_policy_allow_all(entity_id: str, keys: Tuple[str]) -> bool: + def apply_policy_allow_all(entity_id: str, key: str) -> bool: """Approve all.""" return True @@ -61,7 +61,7 @@ def apply_policy_allow_all(entity_id: str, keys: Tuple[str]) -> bool: entity_ids = policy.get(ENTITY_ENTITY_IDS) all_entities = policy.get(SUBCAT_ALL) - funcs = [] # type: List[Callable[[str, Tuple[str]], Union[None, bool]]] + funcs = [] # type: List[Callable[[str, str], Union[None, bool]]] # The order of these functions matter. The more precise are at the top. # If a function returns None, they cannot handle it. @@ -70,23 +70,23 @@ def apply_policy_allow_all(entity_id: str, keys: Tuple[str]) -> bool: # Setting entity_ids to a boolean is final decision for permissions # So return right away. if isinstance(entity_ids, bool): - def allowed_entity_id_bool(entity_id: str, keys: Tuple[str]) -> bool: + def allowed_entity_id_bool(entity_id: str, key: str) -> bool: """Test if allowed entity_id.""" return entity_ids # type: ignore return allowed_entity_id_bool if entity_ids is not None: - def allowed_entity_id_dict(entity_id: str, keys: Tuple[str]) \ + def allowed_entity_id_dict(entity_id: str, key: str) \ -> Union[None, bool]: """Test if allowed entity_id.""" return _entity_allowed( - entity_ids.get(entity_id), keys) # type: ignore + entity_ids.get(entity_id), key) # type: ignore funcs.append(allowed_entity_id_dict) if isinstance(domains, bool): - def allowed_domain_bool(entity_id: str, keys: Tuple[str]) \ + def allowed_domain_bool(entity_id: str, key: str) \ -> Union[None, bool]: """Test if allowed domain.""" return domains @@ -94,31 +94,31 @@ def allowed_domain_bool(entity_id: str, keys: Tuple[str]) \ funcs.append(allowed_domain_bool) elif domains is not None: - def allowed_domain_dict(entity_id: str, keys: Tuple[str]) \ + def allowed_domain_dict(entity_id: str, key: str) \ -> Union[None, bool]: """Test if allowed domain.""" domain = entity_id.split(".", 1)[0] - return _entity_allowed(domains.get(domain), keys) # type: ignore + return _entity_allowed(domains.get(domain), key) # type: ignore funcs.append(allowed_domain_dict) if isinstance(all_entities, bool): - def allowed_all_entities_bool(entity_id: str, keys: Tuple[str]) \ + def allowed_all_entities_bool(entity_id: str, key: str) \ -> Union[None, bool]: """Test if allowed domain.""" return all_entities funcs.append(allowed_all_entities_bool) elif all_entities is not None: - def allowed_all_entities_dict(entity_id: str, keys: Tuple[str]) \ + def allowed_all_entities_dict(entity_id: str, key: str) \ -> Union[None, bool]: """Test if allowed domain.""" - return _entity_allowed(all_entities, keys) + return _entity_allowed(all_entities, key) funcs.append(allowed_all_entities_dict) # Can happen if no valid subcategories specified if not funcs: - def apply_policy_deny_all_2(entity_id: str, keys: Tuple[str]) -> bool: + def apply_policy_deny_all_2(entity_id: str, key: str) -> bool: """Decline all.""" return False @@ -128,16 +128,16 @@ def apply_policy_deny_all_2(entity_id: str, keys: Tuple[str]) -> bool: func = funcs[0] @wraps(func) - def apply_policy_func(entity_id: str, keys: Tuple[str]) -> bool: + def apply_policy_func(entity_id: str, key: str) -> bool: """Apply a single policy function.""" - return func(entity_id, keys) is True + return func(entity_id, key) is True return apply_policy_func - def apply_policy_funcs(entity_id: str, keys: Tuple[str]) -> bool: + def apply_policy_funcs(entity_id: str, key: str) -> bool: """Apply several policy functions.""" for func in funcs: - result = func(entity_id, keys) + result = func(entity_id, key) if result is not None: return result return False diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index cbe404537ebd5..b001bcd043725 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -20,7 +20,8 @@ URL_API_SERVICES, URL_API_STATES, URL_API_STATES_ENTITY, URL_API_STREAM, URL_API_TEMPLATE, __version__) import homeassistant.core as ha -from homeassistant.exceptions import TemplateError +from homeassistant.auth.permissions.const import POLICY_READ +from homeassistant.exceptions import TemplateError, Unauthorized from homeassistant.helpers import template from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.helpers.state import AsyncTrackStates @@ -81,6 +82,8 @@ class APIEventStream(HomeAssistantView): async def get(self, request): """Provide a streaming interface for the event bus.""" + if not request['hass_user'].is_admin: + raise Unauthorized() hass = request.app['hass'] stop_obj = object() to_write = asyncio.Queue(loop=hass.loop) @@ -185,7 +188,13 @@ class APIStatesView(HomeAssistantView): @ha.callback def get(self, request): """Get current states.""" - return self.json(request.app['hass'].states.async_all()) + user = request['hass_user'] + entity_perm = user.permissions.check_entity + states = [ + state for state in request.app['hass'].states.async_all() + if entity_perm(state.entity_id, 'read') + ] + return self.json(states) class APIEntityStateView(HomeAssistantView): @@ -197,6 +206,10 @@ class APIEntityStateView(HomeAssistantView): @ha.callback def get(self, request, entity_id): """Retrieve state of entity.""" + user = request['hass_user'] + if not user.permissions.check_entity(entity_id, POLICY_READ): + raise Unauthorized(entity_id=entity_id) + state = request.app['hass'].states.get(entity_id) if state: return self.json(state) @@ -204,6 +217,8 @@ def get(self, request, entity_id): async def post(self, request, entity_id): """Update state of entity.""" + if not request['hass_user'].is_admin: + raise Unauthorized(entity_id=entity_id) hass = request.app['hass'] try: data = await request.json() @@ -236,6 +251,8 @@ async def post(self, request, entity_id): @ha.callback def delete(self, request, entity_id): """Remove entity.""" + if not request['hass_user'].is_admin: + raise Unauthorized(entity_id=entity_id) if request.app['hass'].states.async_remove(entity_id): return self.json_message("Entity removed.") return self.json_message("Entity not found.", HTTP_NOT_FOUND) @@ -261,6 +278,8 @@ class APIEventView(HomeAssistantView): async def post(self, request, event_type): """Fire events.""" + if not request['hass_user'].is_admin: + raise Unauthorized() body = await request.text() try: event_data = json.loads(body) if body else None @@ -346,6 +365,8 @@ class APITemplateView(HomeAssistantView): async def post(self, request): """Render a template.""" + if not request['hass_user'].is_admin: + raise Unauthorized() try: data = await request.json() tpl = template.Template(data['template'], request.app['hass']) @@ -363,6 +384,8 @@ class APIErrorLog(HomeAssistantView): async def get(self, request): """Retrieve API error log.""" + if not request['hass_user'].is_admin: + raise Unauthorized() return web.FileResponse(request.app['hass'].data[DATA_LOGGING]) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 4c13cb799a63c..6bfcaaa5d85ab 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -10,6 +10,7 @@ import voluptuous as vol +from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components import SERVICE_CHECK_CONFIG from homeassistant.const import ( ATTR_NAME, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP) @@ -181,8 +182,14 @@ async def async_setup(hass, config): if user and user.refresh_tokens: refresh_token = list(user.refresh_tokens.values())[0] + # Migrate old hass.io users to be admin. + if not user.is_admin: + await hass.auth.async_update_user( + user, group_ids=[GROUP_ID_ADMIN]) + if refresh_token is None: - user = await hass.auth.async_create_system_user('Hass.io') + user = await hass.auth.async_create_system_user( + 'Hass.io', [GROUP_ID_ADMIN]) refresh_token = await hass.auth.async_create_refresh_token(user) data['hassio_user'] = user.id await store.async_save(data) diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index b3b2587fc458e..30d4ed0ab8da7 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -14,6 +14,7 @@ from homeassistant.components.http.ban import process_success_login from homeassistant.core import Context, is_callback from homeassistant.const import CONTENT_TYPE_JSON +from homeassistant import exceptions from homeassistant.helpers.json import JSONEncoder from .const import KEY_AUTHENTICATED, KEY_REAL_IP @@ -107,10 +108,13 @@ async def handle(request): _LOGGER.info('Serving %s to %s (auth: %s)', request.path, request.get(KEY_REAL_IP), authenticated) - result = handler(request, **request.match_info) + try: + result = handler(request, **request.match_info) - if asyncio.iscoroutine(result): - result = await result + if asyncio.iscoroutine(result): + result = await result + except exceptions.Unauthorized: + raise HTTPUnauthorized() if isinstance(result, web.StreamResponse): # The method handler returned a ready-made Response, how nice of it diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 5e0d9c7e88afc..e8068f5728649 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -192,9 +192,9 @@ async def entity_service_call(hass, platforms, func, call): user = await hass.auth.async_get_user(call.context.user_id) if user is None: raise UnknownUser(context=call.context) - perms = user.permissions + entity_perms = user.permissions.check_entity else: - perms = None + entity_perms = None # Are we trying to target all entities target_all_entities = ATTR_ENTITY_ID not in call.data @@ -218,7 +218,7 @@ async def entity_service_call(hass, platforms, func, call): # the service on. platforms_entities = [] - if perms is None: + if entity_perms is None: for platform in platforms: if target_all_entities: platforms_entities.append(list(platform.entities.values())) @@ -234,7 +234,7 @@ async def entity_service_call(hass, platforms, func, call): for platform in platforms: platforms_entities.append([ entity for entity in platform.entities.values() - if perms.check_entity(entity.entity_id, POLICY_CONTROL)]) + if entity_perms(entity.entity_id, POLICY_CONTROL)]) else: for platform in platforms: @@ -243,7 +243,7 @@ async def entity_service_call(hass, platforms, func, call): if entity.entity_id not in entity_ids: continue - if not perms.check_entity(entity.entity_id, POLICY_CONTROL): + if not entity_perms(entity.entity_id, POLICY_CONTROL): raise Unauthorized( context=call.context, entity_id=entity.entity_id, diff --git a/tests/auth/permissions/test_entities.py b/tests/auth/permissions/test_entities.py index 33c164d12b4bf..40de5ca73343b 100644 --- a/tests/auth/permissions/test_entities.py +++ b/tests/auth/permissions/test_entities.py @@ -10,7 +10,7 @@ def test_entities_none(): """Test entity ID policy.""" policy = None compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is False + assert compiled('light.kitchen', 'read') is False def test_entities_empty(): @@ -18,7 +18,7 @@ def test_entities_empty(): policy = {} ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is False + assert compiled('light.kitchen', 'read') is False def test_entities_false(): @@ -33,7 +33,7 @@ def test_entities_true(): policy = True ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True def test_entities_domains_true(): @@ -43,7 +43,7 @@ def test_entities_domains_true(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True def test_entities_domains_domain_true(): @@ -55,8 +55,8 @@ def test_entities_domains_domain_true(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('switch.kitchen', ('read',)) is False + assert compiled('light.kitchen', 'read') is True + assert compiled('switch.kitchen', 'read') is False def test_entities_domains_domain_false(): @@ -77,7 +77,7 @@ def test_entities_entity_ids_true(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True def test_entities_entity_ids_false(): @@ -98,8 +98,8 @@ def test_entities_entity_ids_entity_id_true(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('switch.kitchen', ('read',)) is False + assert compiled('light.kitchen', 'read') is True + assert compiled('switch.kitchen', 'read') is False def test_entities_entity_ids_entity_id_false(): @@ -124,9 +124,9 @@ def test_entities_control_only(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('light.kitchen', ('control',)) is False - assert compiled('light.kitchen', ('edit',)) is False + assert compiled('light.kitchen', 'read') is True + assert compiled('light.kitchen', 'control') is False + assert compiled('light.kitchen', 'edit') is False def test_entities_read_control(): @@ -141,9 +141,9 @@ def test_entities_read_control(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('light.kitchen', ('control',)) is True - assert compiled('light.kitchen', ('edit',)) is False + assert compiled('light.kitchen', 'read') is True + assert compiled('light.kitchen', 'control') is True + assert compiled('light.kitchen', 'edit') is False def test_entities_all_allow(): @@ -153,9 +153,9 @@ def test_entities_all_allow(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('light.kitchen', ('control',)) is True - assert compiled('switch.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True + assert compiled('light.kitchen', 'control') is True + assert compiled('switch.kitchen', 'read') is True def test_entities_all_read(): @@ -167,9 +167,9 @@ def test_entities_all_read(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('light.kitchen', ('control',)) is False - assert compiled('switch.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True + assert compiled('light.kitchen', 'control') is False + assert compiled('switch.kitchen', 'read') is True def test_entities_all_control(): @@ -181,7 +181,7 @@ def test_entities_all_control(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is False - assert compiled('light.kitchen', ('control',)) is True - assert compiled('switch.kitchen', ('read',)) is False - assert compiled('switch.kitchen', ('control',)) is True + assert compiled('light.kitchen', 'read') is False + assert compiled('light.kitchen', 'control') is True + assert compiled('switch.kitchen', 'read') is False + assert compiled('switch.kitchen', 'control') is True diff --git a/tests/auth/permissions/test_init.py b/tests/auth/permissions/test_init.py deleted file mode 100644 index fdc5440a9d5b2..0000000000000 --- a/tests/auth/permissions/test_init.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Tests for the auth permission system.""" -from homeassistant.core import State -from homeassistant.auth import permissions - - -def test_policy_perm_filter_states(): - """Test filtering entitites.""" - states = [ - State('light.kitchen', 'on'), - State('light.living_room', 'off'), - State('light.balcony', 'on'), - ] - perm = permissions.PolicyPermissions({ - 'entities': { - 'entity_ids': { - 'light.kitchen': True, - 'light.balcony': True, - } - } - }) - filtered = perm.filter_states(states) - assert len(filtered) == 2 - assert filtered == [states[0], states[2]] - - -def test_owner_permissions(): - """Test owner permissions access all.""" - assert permissions.OwnerPermissions.check_entity('light.kitchen', 'write') - states = [ - State('light.kitchen', 'on'), - State('light.living_room', 'off'), - State('light.balcony', 'on'), - ] - assert permissions.OwnerPermissions.filter_states(states) == states diff --git a/tests/common.py b/tests/common.py index c6a75fcb63d8e..d5056e220f015 100644 --- a/tests/common.py +++ b/tests/common.py @@ -14,7 +14,8 @@ from homeassistant import auth, core as ha, config_entries from homeassistant.auth import ( - models as auth_models, auth_store, providers as auth_providers) + models as auth_models, auth_store, providers as auth_providers, + permissions as auth_permissions) from homeassistant.auth.permissions import system_policies from homeassistant.setup import setup_component, async_setup_component from homeassistant.config import async_process_component_config @@ -400,6 +401,10 @@ def add_to_auth_manager(self, auth_mgr): auth_mgr._store._users[self.id] = self return self + def mock_policy(self, policy): + """Mock a policy for a user.""" + self._permissions = auth_permissions.PolicyPermissions(policy) + async def register_auth_provider(hass, config): """Register an auth provider.""" diff --git a/tests/components/conftest.py b/tests/components/conftest.py index b519b8e936d6b..46d75a56ad675 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -80,11 +80,10 @@ async def create_client(hass, access_token=None): @pytest.fixture -def hass_access_token(hass): +def hass_access_token(hass, hass_admin_user): """Return an access token to access Home Assistant.""" - user = MockUser().add_to_hass(hass) refresh_token = hass.loop.run_until_complete( - hass.auth.async_create_refresh_token(user, CLIENT_ID)) + hass.auth.async_create_refresh_token(hass_admin_user, CLIENT_ID)) yield hass.auth.async_create_access_token(refresh_token) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 4fd59dd3f7aae..51fca931faaaf 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -5,6 +5,7 @@ import pytest +from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.setup import async_setup_component from homeassistant.components.hassio import ( STORAGE_KEY, async_check_config) @@ -106,6 +107,8 @@ async def test_setup_api_push_api_data_default(hass, aioclient_mock, ) assert hassio_user is not None assert hassio_user.system_generated + assert len(hassio_user.groups) == 1 + assert hassio_user.groups[0].id == GROUP_ID_ADMIN for token in hassio_user.refresh_tokens.values(): if token.token == refresh_token: break @@ -113,6 +116,31 @@ async def test_setup_api_push_api_data_default(hass, aioclient_mock, assert False, 'refresh token not found' +async def test_setup_adds_admin_group_to_user(hass, aioclient_mock, + hass_storage): + """Test setup with API push default data.""" + # Create user without admin + user = await hass.auth.async_create_system_user('Hass.io') + assert not user.is_admin + await hass.auth.async_create_refresh_token(user) + + hass_storage[STORAGE_KEY] = { + 'data': {'hassio_user': user.id}, + 'key': STORAGE_KEY, + 'version': 1 + } + + with patch.dict(os.environ, MOCK_ENVIRON), \ + patch('homeassistant.auth.AuthManager.active', return_value=True): + result = await async_setup_component(hass, 'hassio', { + 'http': {}, + 'hassio': {} + }) + assert result + + assert user.is_admin + + async def test_setup_api_push_api_data_no_auth(hass, aioclient_mock, hass_storage): """Test setup with API push default data.""" diff --git a/tests/components/test_api.py b/tests/components/test_api.py index 6f6b4e93068e2..3ebfa05a3d39c 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -16,10 +16,12 @@ @pytest.fixture -def mock_api_client(hass, aiohttp_client): - """Start the Hass HTTP component.""" +def mock_api_client(hass, aiohttp_client, hass_access_token): + """Start the Hass HTTP component and return admin API client.""" hass.loop.run_until_complete(async_setup_component(hass, 'api', {})) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + return hass.loop.run_until_complete(aiohttp_client(hass.http.app, headers={ + 'Authorization': 'Bearer {}'.format(hass_access_token) + })) @asyncio.coroutine @@ -405,7 +407,8 @@ def _listen_count(hass): return sum(hass.bus.async_listeners().values()) -async def test_api_error_log(hass, aiohttp_client): +async def test_api_error_log(hass, aiohttp_client, hass_access_token, + hass_admin_user): """Test if we can fetch the error log.""" hass.data[DATA_LOGGING] = '/some/path' await async_setup_component(hass, 'api', { @@ -416,7 +419,7 @@ async def test_api_error_log(hass, aiohttp_client): client = await aiohttp_client(hass.http.app) resp = await client.get(const.URL_API_ERROR_LOG) - # Verufy auth required + # Verify auth required assert resp.status == 401 with patch( @@ -424,7 +427,7 @@ async def test_api_error_log(hass, aiohttp_client): return_value=web.Response(status=200, text='Hello') ) as mock_file: resp = await client.get(const.URL_API_ERROR_LOG, headers={ - 'x-ha-access': 'yolo' + 'Authorization': 'Bearer {}'.format(hass_access_token) }) assert len(mock_file.mock_calls) == 1 @@ -432,6 +435,13 @@ async def test_api_error_log(hass, aiohttp_client): assert resp.status == 200 assert await resp.text() == 'Hello' + # Verify we require admin user + hass_admin_user.groups = [] + resp = await client.get(const.URL_API_ERROR_LOG, headers={ + 'Authorization': 'Bearer {}'.format(hass_access_token) + }) + assert resp.status == 401 + async def test_api_fire_event_context(hass, mock_api_client, hass_access_token): @@ -494,3 +504,67 @@ async def test_api_set_state_context(hass, mock_api_client, hass_access_token): state = hass.states.get('light.kitchen') assert state.context.user_id == refresh_token.user.id + + +async def test_event_stream_requires_admin(hass, mock_api_client, + hass_admin_user): + """Test user needs to be admin to access event stream.""" + hass_admin_user.groups = [] + resp = await mock_api_client.get('/api/stream') + assert resp.status == 401 + + +async def test_states_view_filters(hass, mock_api_client, hass_admin_user): + """Test filtering only visible states.""" + hass_admin_user.mock_policy({ + 'entities': { + 'entity_ids': { + 'test.entity': True + } + } + }) + hass.states.async_set('test.entity', 'hello') + hass.states.async_set('test.not_visible_entity', 'invisible') + resp = await mock_api_client.get(const.URL_API_STATES) + assert resp.status == 200 + json = await resp.json() + assert len(json) == 1 + assert json[0]['entity_id'] == 'test.entity' + + +async def test_get_entity_state_read_perm(hass, mock_api_client, + hass_admin_user): + """Test getting a state requires read permission.""" + hass_admin_user.mock_policy({}) + resp = await mock_api_client.get('/api/states/light.test') + assert resp.status == 401 + + +async def test_post_entity_state_admin(hass, mock_api_client, hass_admin_user): + """Test updating state requires admin.""" + hass_admin_user.groups = [] + resp = await mock_api_client.post('/api/states/light.test') + assert resp.status == 401 + + +async def test_delete_entity_state_admin(hass, mock_api_client, + hass_admin_user): + """Test deleting entity requires admin.""" + hass_admin_user.groups = [] + resp = await mock_api_client.delete('/api/states/light.test') + assert resp.status == 401 + + +async def test_post_event_admin(hass, mock_api_client, hass_admin_user): + """Test sending event requires admin.""" + hass_admin_user.groups = [] + resp = await mock_api_client.post('/api/events/state_changed') + assert resp.status == 401 + + +async def test_rendering_template_admin(hass, mock_api_client, + hass_admin_user): + """Test rendering a template requires admin.""" + hass_admin_user.groups = [] + resp = await mock_api_client.post('/api/template') + assert resp.status == 401 From 2cbe0834604142949e2baeed7e1be1b98cee41df Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 25 Nov 2018 20:52:09 +0100 Subject: [PATCH 058/325] :arrow_up: Upgrades InfluxDB dependency to 5.2.0 (#18668) --- homeassistant/components/influxdb.py | 2 +- homeassistant/components/sensor/influxdb.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/influxdb.py b/homeassistant/components/influxdb.py index 6d54324542add..c28527886b1ac 100644 --- a/homeassistant/components/influxdb.py +++ b/homeassistant/components/influxdb.py @@ -23,7 +23,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_values import EntityValues -REQUIREMENTS = ['influxdb==5.0.0'] +REQUIREMENTS = ['influxdb==5.2.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/influxdb.py b/homeassistant/components/sensor/influxdb.py index 87e2bdb5c9cff..0fc31ef273ff2 100644 --- a/homeassistant/components/sensor/influxdb.py +++ b/homeassistant/components/sensor/influxdb.py @@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['influxdb==5.0.0'] +REQUIREMENTS = ['influxdb==5.2.0'] DEFAULT_HOST = 'localhost' DEFAULT_PORT = 8086 diff --git a/requirements_all.txt b/requirements_all.txt index dd77bd723150d..b0e317387f04e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -522,7 +522,7 @@ ihcsdk==2.2.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb -influxdb==5.0.0 +influxdb==5.2.0 # homeassistant.components.insteon insteonplm==0.15.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a73d80b199a5f..fc7a1443d95fb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -104,7 +104,7 @@ homematicip==0.9.8 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb -influxdb==5.0.0 +influxdb==5.2.0 # homeassistant.components.dyson libpurecoollink==0.4.2 From d290ce3c9e0886269d9d33500fd86fd028eb4f15 Mon Sep 17 00:00:00 2001 From: emontnemery Date: Sun, 25 Nov 2018 20:53:03 +0100 Subject: [PATCH 059/325] Small refactoring of MQTT binary_sensor (#18674) --- .../components/binary_sensor/mqtt.py | 88 ++++++------------- 1 file changed, 29 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index f7bd353f3d123..4d7e2c07eba65 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -45,8 +45,8 @@ vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, vol.Optional(CONF_OFF_DELAY): vol.All(vol.Coerce(int), vol.Range(min=0)), - # Integrations shouldn't never expose unique_id through configuration - # this here is an exception because MQTT is a msg transport, not a protocol + # Integrations should never expose unique_id through configuration. + # This is an exception because MQTT is a message transport, not a protocol vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @@ -55,7 +55,7 @@ async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None): """Set up MQTT binary sensor through configuration.yaml.""" - await _async_setup_entity(hass, config, async_add_entities) + await _async_setup_entity(config, async_add_entities) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -63,7 +63,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add a MQTT binary sensor.""" config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(hass, config, async_add_entities, + await _async_setup_entity(config, async_add_entities, discovery_payload[ATTR_DISCOVERY_HASH]) async_dispatcher_connect( @@ -71,17 +71,9 @@ async def async_discover(discovery_payload): async_discover) -async def _async_setup_entity(hass, config, async_add_entities, - discovery_hash=None): +async def _async_setup_entity(config, async_add_entities, discovery_hash=None): """Set up the MQTT binary sensor.""" - value_template = config.get(CONF_VALUE_TEMPLATE) - if value_template is not None: - value_template.hass = hass - - async_add_entities([MqttBinarySensor( - config, - discovery_hash - )]) + async_add_entities([MqttBinarySensor(config, discovery_hash)]) class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, @@ -91,30 +83,18 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, def __init__(self, config, discovery_hash): """Initialize the MQTT binary sensor.""" self._config = config + self._unique_id = config.get(CONF_UNIQUE_ID) self._state = None self._sub_state = None self._delay_listener = None - self._name = None - self._state_topic = None - self._device_class = None - self._payload_on = None - self._payload_off = None - self._qos = None - self._force_update = None - self._off_delay = None - self._template = None - self._unique_id = None - - # Load config - self._setup_from_config(config) - availability_topic = config.get(CONF_AVAILABILITY_TOPIC) payload_available = config.get(CONF_PAYLOAD_AVAILABLE) payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) + qos = config.get(CONF_QOS) device_config = config.get(CONF_DEVICE) - MqttAvailability.__init__(self, availability_topic, self._qos, + MqttAvailability.__init__(self, availability_topic, qos, payload_available, payload_not_available) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) @@ -129,30 +109,17 @@ async def async_added_to_hass(self): async def discovery_update(self, discovery_payload): """Handle updated discovery message.""" config = PLATFORM_SCHEMA(discovery_payload) - self._setup_from_config(config) + self._config = config await self.availability_discovery_update(config) await self._subscribe_topics() self.async_schedule_update_ha_state() - def _setup_from_config(self, config): - """(Re)Setup the entity.""" - self._name = config.get(CONF_NAME) - self._state_topic = config.get(CONF_STATE_TOPIC) - self._device_class = config.get(CONF_DEVICE_CLASS) - self._qos = config.get(CONF_QOS) - self._force_update = config.get(CONF_FORCE_UPDATE) - self._off_delay = config.get(CONF_OFF_DELAY) - self._payload_on = config.get(CONF_PAYLOAD_ON) - self._payload_off = config.get(CONF_PAYLOAD_OFF) - value_template = config.get(CONF_VALUE_TEMPLATE) - if value_template is not None and value_template.hass is None: - value_template.hass = self.hass - self._template = value_template - - self._unique_id = config.get(CONF_UNIQUE_ID) - async def _subscribe_topics(self): """(Re)Subscribe to topics.""" + value_template = self._config.get(CONF_VALUE_TEMPLATE) + if value_template is not None: + value_template.hass = self.hass + @callback def off_delay_listener(now): """Switch device off after a delay.""" @@ -163,34 +130,37 @@ def off_delay_listener(now): @callback def state_message_received(_topic, payload, _qos): """Handle a new received MQTT state message.""" - if self._template is not None: - payload = self._template.async_render_with_possible_json_value( + value_template = self._config.get(CONF_VALUE_TEMPLATE) + if value_template is not None: + payload = value_template.async_render_with_possible_json_value( payload) - if payload == self._payload_on: + if payload == self._config.get(CONF_PAYLOAD_ON): self._state = True - elif payload == self._payload_off: + elif payload == self._config.get(CONF_PAYLOAD_OFF): self._state = False else: # Payload is not for this entity _LOGGER.warning('No matching payload found' ' for entity: %s with state_topic: %s', - self._name, self._state_topic) + self._config.get(CONF_NAME), + self._config.get(CONF_STATE_TOPIC)) return if self._delay_listener is not None: self._delay_listener() self._delay_listener = None - if (self._state and self._off_delay is not None): + off_delay = self._config.get(CONF_OFF_DELAY) + if (self._state and off_delay is not None): self._delay_listener = evt.async_call_later( - self.hass, self._off_delay, off_delay_listener) + self.hass, off_delay, off_delay_listener) self.async_schedule_update_ha_state() self._sub_state = await subscription.async_subscribe_topics( self.hass, self._sub_state, - {'state_topic': {'topic': self._state_topic, + {'state_topic': {'topic': self._config.get(CONF_STATE_TOPIC), 'msg_callback': state_message_received, - 'qos': self._qos}}) + 'qos': self._config.get(CONF_QOS)}}) async def async_will_remove_from_hass(self): """Unsubscribe when removed.""" @@ -205,7 +175,7 @@ def should_poll(self): @property def name(self): """Return the name of the binary sensor.""" - return self._name + return self._config.get(CONF_NAME) @property def is_on(self): @@ -215,12 +185,12 @@ def is_on(self): @property def device_class(self): """Return the class of this sensor.""" - return self._device_class + return self._config.get(CONF_DEVICE_CLASS) @property def force_update(self): """Force update.""" - return self._force_update + return self._config.get(CONF_FORCE_UPDATE) @property def unique_id(self): From b5b5bc2de88b59b1d6af10c87341a7a88bab313e Mon Sep 17 00:00:00 2001 From: Ian Richardson Date: Mon, 26 Nov 2018 02:59:53 -0600 Subject: [PATCH 060/325] Convert shopping-list update to WebSockets (#18713) * Convert shopping-list update to WebSockets * Update shopping_list.py * Update test_shopping_list.py --- homeassistant/components/shopping_list.py | 31 ++++++++ tests/components/test_shopping_list.py | 86 ++++++++++++++++++++++- 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/shopping_list.py b/homeassistant/components/shopping_list.py index 650d23fe1dfdf..ad4680982b424 100644 --- a/homeassistant/components/shopping_list.py +++ b/homeassistant/components/shopping_list.py @@ -39,6 +39,7 @@ WS_TYPE_SHOPPING_LIST_ITEMS = 'shopping_list/items' WS_TYPE_SHOPPING_LIST_ADD_ITEM = 'shopping_list/items/add' +WS_TYPE_SHOPPING_LIST_UPDATE_ITEM = 'shopping_list/items/update' SCHEMA_WEBSOCKET_ITEMS = \ websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ @@ -51,6 +52,14 @@ vol.Required('name'): str }) +SCHEMA_WEBSOCKET_UPDATE_ITEM = \ + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_SHOPPING_LIST_UPDATE_ITEM, + vol.Required('item_id'): str, + vol.Optional('name'): str, + vol.Optional('complete'): bool + }) + @asyncio.coroutine def async_setup(hass, config): @@ -114,6 +123,10 @@ def complete_item_service(call): WS_TYPE_SHOPPING_LIST_ADD_ITEM, websocket_handle_add, SCHEMA_WEBSOCKET_ADD_ITEM) + hass.components.websocket_api.async_register_command( + WS_TYPE_SHOPPING_LIST_UPDATE_ITEM, + websocket_handle_update, + SCHEMA_WEBSOCKET_UPDATE_ITEM) return True @@ -296,3 +309,21 @@ def websocket_handle_add(hass, connection, msg): hass.bus.async_fire(EVENT) connection.send_message(websocket_api.result_message( msg['id'], item)) + + +@websocket_api.async_response +async def websocket_handle_update(hass, connection, msg): + """Handle update shopping_list item.""" + msg_id = msg.pop('id') + item_id = msg.pop('item_id') + msg.pop('type') + data = msg + + try: + item = hass.data[DOMAIN].async_update(item_id, data) + hass.bus.async_fire(EVENT) + connection.send_message(websocket_api.result_message( + msg_id, item)) + except KeyError: + connection.send_message(websocket_api.error_message( + msg_id, 'item_not_found', 'Item not found')) diff --git a/tests/components/test_shopping_list.py b/tests/components/test_shopping_list.py index 44714138eb355..c2899f6b7535c 100644 --- a/tests/components/test_shopping_list.py +++ b/tests/components/test_shopping_list.py @@ -110,7 +110,7 @@ async def test_ws_get_items(hass, hass_ws_client): @asyncio.coroutine -def test_api_update(hass, aiohttp_client): +def test_deprecated_api_update(hass, aiohttp_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -164,6 +164,61 @@ def test_api_update(hass, aiohttp_client): } +async def test_ws_update_item(hass, hass_ws_client): + """Test update shopping_list item websocket command.""" + await async_setup_component(hass, 'shopping_list', {}) + await intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}} + ) + await intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'wine'}} + ) + + beer_id = hass.data['shopping_list'].items[0]['id'] + wine_id = hass.data['shopping_list'].items[1]['id'] + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'shopping_list/items/update', + 'item_id': beer_id, + 'name': 'soda' + }) + msg = await client.receive_json() + assert msg['success'] is True + data = msg['result'] + assert data == { + 'id': beer_id, + 'name': 'soda', + 'complete': False + } + await client.send_json({ + 'id': 6, + 'type': 'shopping_list/items/update', + 'item_id': wine_id, + 'complete': True + }) + msg = await client.receive_json() + assert msg['success'] is True + data = msg['result'] + assert data == { + 'id': wine_id, + 'name': 'wine', + 'complete': True + } + + beer, wine = hass.data['shopping_list'].items + assert beer == { + 'id': beer_id, + 'name': 'soda', + 'complete': False + } + assert wine == { + 'id': wine_id, + 'name': 'wine', + 'complete': True + } + + @asyncio.coroutine def test_api_update_fails(hass, aiohttp_client): """Test the API.""" @@ -190,6 +245,35 @@ def test_api_update_fails(hass, aiohttp_client): assert resp.status == 400 +async def test_ws_update_item_fail(hass, hass_ws_client): + """Test failure of update shopping_list item websocket command.""" + await async_setup_component(hass, 'shopping_list', {}) + await intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}} + ) + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'shopping_list/items/update', + 'item_id': 'non_existing', + 'name': 'soda' + }) + msg = await client.receive_json() + assert msg['success'] is False + data = msg['error'] + assert data == { + 'code': 'item_not_found', + 'message': 'Item not found' + } + await client.send_json({ + 'id': 6, + 'type': 'shopping_list/items/update', + 'name': 123, + }) + msg = await client.receive_json() + assert msg['success'] is False + + @asyncio.coroutine def test_api_clear_completed(hass, aiohttp_client): """Test the API.""" From 4a661e351fcd5bd4368d9fd609204781f4d5fb4a Mon Sep 17 00:00:00 2001 From: pbalogh77 Date: Mon, 26 Nov 2018 13:17:56 +0100 Subject: [PATCH 061/325] Use asyncio Lock for fibaro light (#18622) * Use asyncio Lock for fibaro light * line length and empty line at end * async turn_off Turned the turn_off into async as well * bless you, blank lines... My local flake8 lies to me. Not cool. --- homeassistant/components/light/fibaro.py | 150 +++++++++++++---------- 1 file changed, 84 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/light/fibaro.py b/homeassistant/components/light/fibaro.py index cfc28e12218a9..96069d50335fe 100644 --- a/homeassistant/components/light/fibaro.py +++ b/homeassistant/components/light/fibaro.py @@ -6,7 +6,8 @@ """ import logging -import threading +import asyncio +from functools import partial from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_WHITE_VALUE, ENTITY_ID_FORMAT, @@ -37,12 +38,15 @@ def scaleto100(value): return max(0, min(100, ((value * 100.4) / 255.0))) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, + config, + async_add_entities, + discovery_info=None): """Perform the setup for Fibaro controller devices.""" if discovery_info is None: return - add_entities( + async_add_entities( [FibaroLight(device, hass.data[FIBARO_CONTROLLER]) for device in hass.data[FIBARO_DEVICES]['light']], True) @@ -58,7 +62,7 @@ def __init__(self, fibaro_device, controller): self._brightness = None self._white = 0 - self._update_lock = threading.RLock() + self._update_lock = asyncio.Lock() if 'levelChange' in fibaro_device.interfaces: self._supported_flags |= SUPPORT_BRIGHTNESS if 'color' in fibaro_device.properties: @@ -88,78 +92,92 @@ def supported_features(self): """Flag supported features.""" return self._supported_flags - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the light on.""" - with self._update_lock: - if self._supported_flags & SUPPORT_BRIGHTNESS: - target_brightness = kwargs.get(ATTR_BRIGHTNESS) - - # No brightness specified, so we either restore it to - # last brightness or switch it on at maximum level - if target_brightness is None: - if self._brightness == 0: - if self._last_brightness: - self._brightness = self._last_brightness - else: - self._brightness = 100 - else: - # We set it to the target brightness and turn it on - self._brightness = scaleto100(target_brightness) - - if self._supported_flags & SUPPORT_COLOR: - # Update based on parameters - self._white = kwargs.get(ATTR_WHITE_VALUE, self._white) - self._color = kwargs.get(ATTR_HS_COLOR, self._color) - rgb = color_util.color_hs_to_RGB(*self._color) - self.call_set_color( - int(rgb[0] * self._brightness / 99.0 + 0.5), - int(rgb[1] * self._brightness / 99.0 + 0.5), - int(rgb[2] * self._brightness / 99.0 + 0.5), - int(self._white * self._brightness / 99.0 + - 0.5)) - if self.state == 'off': - self.set_level(int(self._brightness)) - return - - if self._supported_flags & SUPPORT_BRIGHTNESS: + async with self._update_lock: + await self.hass.async_add_executor_job( + partial(self._turn_on, **kwargs)) + + def _turn_on(self, **kwargs): + """Really turn the light on.""" + if self._supported_flags & SUPPORT_BRIGHTNESS: + target_brightness = kwargs.get(ATTR_BRIGHTNESS) + + # No brightness specified, so we either restore it to + # last brightness or switch it on at maximum level + if target_brightness is None: + if self._brightness == 0: + if self._last_brightness: + self._brightness = self._last_brightness + else: + self._brightness = 100 + else: + # We set it to the target brightness and turn it on + self._brightness = scaleto100(target_brightness) + + if self._supported_flags & SUPPORT_COLOR: + # Update based on parameters + self._white = kwargs.get(ATTR_WHITE_VALUE, self._white) + self._color = kwargs.get(ATTR_HS_COLOR, self._color) + rgb = color_util.color_hs_to_RGB(*self._color) + self.call_set_color( + int(rgb[0] * self._brightness / 99.0 + 0.5), + int(rgb[1] * self._brightness / 99.0 + 0.5), + int(rgb[2] * self._brightness / 99.0 + 0.5), + int(self._white * self._brightness / 99.0 + + 0.5)) + if self.state == 'off': self.set_level(int(self._brightness)) - return + return - # The simplest case is left for last. No dimming, just switch on - self.call_turn_on() + if self._supported_flags & SUPPORT_BRIGHTNESS: + self.set_level(int(self._brightness)) + return - def turn_off(self, **kwargs): + # The simplest case is left for last. No dimming, just switch on + self.call_turn_on() + + async def async_turn_off(self, **kwargs): """Turn the light off.""" + async with self._update_lock: + await self.hass.async_add_executor_job( + partial(self._turn_off, **kwargs)) + + def _turn_off(self, **kwargs): + """Really turn the light off.""" # Let's save the last brightness level before we switch it off - with self._update_lock: - if (self._supported_flags & SUPPORT_BRIGHTNESS) and \ - self._brightness and self._brightness > 0: - self._last_brightness = self._brightness - self._brightness = 0 - self.call_turn_off() + if (self._supported_flags & SUPPORT_BRIGHTNESS) and \ + self._brightness and self._brightness > 0: + self._last_brightness = self._brightness + self._brightness = 0 + self.call_turn_off() @property def is_on(self): """Return true if device is on.""" return self.current_binary_state - def update(self): - """Call to update state.""" + async def async_update(self): + """Update the state.""" + async with self._update_lock: + await self.hass.async_add_executor_job(self._update) + + def _update(self): + """Really update the state.""" # Brightness handling - with self._update_lock: - if self._supported_flags & SUPPORT_BRIGHTNESS: - self._brightness = float(self.fibaro_device.properties.value) - # Color handling - if self._supported_flags & SUPPORT_COLOR: - # Fibaro communicates the color as an 'R, G, B, W' string - rgbw_s = self.fibaro_device.properties.color - if rgbw_s == '0,0,0,0' and\ - 'lastColorSet' in self.fibaro_device.properties: - rgbw_s = self.fibaro_device.properties.lastColorSet - rgbw_list = [int(i) for i in rgbw_s.split(",")][:4] - if rgbw_list[0] or rgbw_list[1] or rgbw_list[2]: - self._color = color_util.color_RGB_to_hs(*rgbw_list[:3]) - if (self._supported_flags & SUPPORT_WHITE_VALUE) and \ - self.brightness != 0: - self._white = min(255, max(0, rgbw_list[3]*100.0 / - self._brightness)) + if self._supported_flags & SUPPORT_BRIGHTNESS: + self._brightness = float(self.fibaro_device.properties.value) + # Color handling + if self._supported_flags & SUPPORT_COLOR: + # Fibaro communicates the color as an 'R, G, B, W' string + rgbw_s = self.fibaro_device.properties.color + if rgbw_s == '0,0,0,0' and\ + 'lastColorSet' in self.fibaro_device.properties: + rgbw_s = self.fibaro_device.properties.lastColorSet + rgbw_list = [int(i) for i in rgbw_s.split(",")][:4] + if rgbw_list[0] or rgbw_list[1] or rgbw_list[2]: + self._color = color_util.color_RGB_to_hs(*rgbw_list[:3]) + if (self._supported_flags & SUPPORT_WHITE_VALUE) and \ + self.brightness != 0: + self._white = min(255, max(0, rgbw_list[3]*100.0 / + self._brightness)) From 7848381f437691b8c6d0f50122f79ff6f837f06c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 26 Nov 2018 14:10:18 +0100 Subject: [PATCH 062/325] Allow managing cloud webhook (#18672) * Add cloud webhook support * Simplify payload * Add cloud http api tests * Fix tests * Lint * Handle cloud webhooks * Fix things * Fix name * Rename it to cloudhook * Final rename * Final final rename? * Fix docstring * More tests * Lint * Add types * Fix things --- homeassistant/components/cloud/__init__.py | 9 +- homeassistant/components/cloud/cloud_api.py | 25 +++++ homeassistant/components/cloud/cloudhooks.py | 66 +++++++++++++ homeassistant/components/cloud/const.py | 4 +- homeassistant/components/cloud/http_api.py | 98 +++++++++++++++++--- homeassistant/components/cloud/iot.py | 93 +++++++++++++++++++ homeassistant/components/cloud/prefs.py | 12 ++- homeassistant/components/webhook.py | 40 ++++---- homeassistant/util/aiohttp.py | 53 +++++++++++ tests/components/cloud/test_cloud_api.py | 33 +++++++ tests/components/cloud/test_cloudhooks.py | 70 ++++++++++++++ tests/components/cloud/test_http_api.py | 42 +++++++++ tests/components/cloud/test_init.py | 4 +- tests/components/cloud/test_iot.py | 47 +++++++++- tests/util/test_aiohttp.py | 54 +++++++++++ 15 files changed, 611 insertions(+), 39 deletions(-) create mode 100644 homeassistant/components/cloud/cloud_api.py create mode 100644 homeassistant/components/cloud/cloudhooks.py create mode 100644 homeassistant/util/aiohttp.py create mode 100644 tests/components/cloud/test_cloud_api.py create mode 100644 tests/components/cloud/test_cloudhooks.py create mode 100644 tests/util/test_aiohttp.py diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index b968850668d8b..183dddf2c52a6 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -20,7 +20,7 @@ from homeassistant.components.google_assistant import helpers as ga_h from homeassistant.components.google_assistant import const as ga_c -from . import http_api, iot, auth_api, prefs +from . import http_api, iot, auth_api, prefs, cloudhooks from .const import CONFIG_DIR, DOMAIN, SERVERS REQUIREMENTS = ['warrant==0.6.1'] @@ -37,6 +37,7 @@ CONF_USER_POOL_ID = 'user_pool_id' CONF_GOOGLE_ACTIONS_SYNC_URL = 'google_actions_sync_url' CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url' +CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_url' DEFAULT_MODE = 'production' DEPENDENCIES = ['http'] @@ -78,6 +79,7 @@ vol.Optional(CONF_RELAYER): str, vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): str, vol.Optional(CONF_SUBSCRIPTION_INFO_URL): str, + vol.Optional(CONF_CLOUDHOOK_CREATE_URL): str, vol.Optional(CONF_ALEXA): ALEXA_SCHEMA, vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA, }), @@ -113,7 +115,7 @@ class Cloud: def __init__(self, hass, mode, alexa, google_actions, cognito_client_id=None, user_pool_id=None, region=None, relayer=None, google_actions_sync_url=None, - subscription_info_url=None): + subscription_info_url=None, cloudhook_create_url=None): """Create an instance of Cloud.""" self.hass = hass self.mode = mode @@ -125,6 +127,7 @@ def __init__(self, hass, mode, alexa, google_actions, self.access_token = None self.refresh_token = None self.iot = iot.CloudIoT(self) + self.cloudhooks = cloudhooks.Cloudhooks(self) if mode == MODE_DEV: self.cognito_client_id = cognito_client_id @@ -133,6 +136,7 @@ def __init__(self, hass, mode, alexa, google_actions, self.relayer = relayer self.google_actions_sync_url = google_actions_sync_url self.subscription_info_url = subscription_info_url + self.cloudhook_create_url = cloudhook_create_url else: info = SERVERS[mode] @@ -143,6 +147,7 @@ def __init__(self, hass, mode, alexa, google_actions, self.relayer = info['relayer'] self.google_actions_sync_url = info['google_actions_sync_url'] self.subscription_info_url = info['subscription_info_url'] + self.cloudhook_create_url = info['cloudhook_create_url'] @property def is_logged_in(self): diff --git a/homeassistant/components/cloud/cloud_api.py b/homeassistant/components/cloud/cloud_api.py new file mode 100644 index 0000000000000..13575068a3e10 --- /dev/null +++ b/homeassistant/components/cloud/cloud_api.py @@ -0,0 +1,25 @@ +"""Cloud APIs.""" +from functools import wraps + +from . import auth_api + + +def _check_token(func): + """Decorate a function to verify valid token.""" + @wraps(func) + async def check_token(cloud, *args): + """Validate token, then call func.""" + await cloud.hass.async_add_executor_job(auth_api.check_token, cloud) + return await func(cloud, *args) + + return check_token + + +@_check_token +async def async_create_cloudhook(cloud): + """Create a cloudhook.""" + websession = cloud.hass.helpers.aiohttp_client.async_get_clientsession() + return await websession.post( + cloud.cloudhook_create_url, headers={ + 'authorization': cloud.id_token + }) diff --git a/homeassistant/components/cloud/cloudhooks.py b/homeassistant/components/cloud/cloudhooks.py new file mode 100644 index 0000000000000..fdf7bb2a12e80 --- /dev/null +++ b/homeassistant/components/cloud/cloudhooks.py @@ -0,0 +1,66 @@ +"""Manage cloud cloudhooks.""" +import async_timeout + +from . import cloud_api + + +class Cloudhooks: + """Class to help manage cloudhooks.""" + + def __init__(self, cloud): + """Initialize cloudhooks.""" + self.cloud = cloud + self.cloud.iot.register_on_connect(self.async_publish_cloudhooks) + + async def async_publish_cloudhooks(self): + """Inform the Relayer of the cloudhooks that we support.""" + cloudhooks = self.cloud.prefs.cloudhooks + await self.cloud.iot.async_send_message('webhook-register', { + 'cloudhook_ids': [info['cloudhook_id'] for info + in cloudhooks.values()] + }) + + async def async_create(self, webhook_id): + """Create a cloud webhook.""" + cloudhooks = self.cloud.prefs.cloudhooks + + if webhook_id in cloudhooks: + raise ValueError('Hook is already enabled for the cloud.') + + if not self.cloud.iot.connected: + raise ValueError("Cloud is not connected") + + # Create cloud hook + with async_timeout.timeout(10): + resp = await cloud_api.async_create_cloudhook(self.cloud) + + data = await resp.json() + cloudhook_id = data['cloudhook_id'] + cloudhook_url = data['url'] + + # Store hook + cloudhooks = dict(cloudhooks) + hook = cloudhooks[webhook_id] = { + 'webhook_id': webhook_id, + 'cloudhook_id': cloudhook_id, + 'cloudhook_url': cloudhook_url + } + await self.cloud.prefs.async_update(cloudhooks=cloudhooks) + + await self.async_publish_cloudhooks() + + return hook + + async def async_delete(self, webhook_id): + """Delete a cloud webhook.""" + cloudhooks = self.cloud.prefs.cloudhooks + + if webhook_id not in cloudhooks: + raise ValueError('Hook is not enabled for the cloud.') + + # Remove hook + cloudhooks = dict(cloudhooks) + cloudhooks.pop(webhook_id) + await self.cloud.prefs.async_update(cloudhooks=cloudhooks) + + await self.async_publish_cloudhooks() diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index abc72da796cf6..01d92c6f50fe4 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -6,6 +6,7 @@ PREF_ENABLE_ALEXA = 'alexa_enabled' PREF_ENABLE_GOOGLE = 'google_enabled' PREF_GOOGLE_ALLOW_UNLOCK = 'google_allow_unlock' +PREF_CLOUDHOOKS = 'cloudhooks' SERVERS = { 'production': { @@ -16,7 +17,8 @@ 'google_actions_sync_url': ('https://24ab3v80xd.execute-api.us-east-1.' 'amazonaws.com/prod/smart_home_sync'), 'subscription_info_url': ('https://stripe-api.nabucasa.com/payments/' - 'subscription_info') + 'subscription_info'), + 'cloudhook_create_url': 'https://webhook-api.nabucasa.com/generate' } } diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 7b509f4eae25b..03a77c08d4b68 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -3,6 +3,7 @@ from functools import wraps import logging +import aiohttp import async_timeout import voluptuous as vol @@ -44,6 +45,20 @@ }) +WS_TYPE_HOOK_CREATE = 'cloud/cloudhook/create' +SCHEMA_WS_HOOK_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_HOOK_CREATE, + vol.Required('webhook_id'): str +}) + + +WS_TYPE_HOOK_DELETE = 'cloud/cloudhook/delete' +SCHEMA_WS_HOOK_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_HOOK_DELETE, + vol.Required('webhook_id'): str +}) + + async def async_setup(hass): """Initialize the HTTP API.""" hass.components.websocket_api.async_register_command( @@ -58,6 +73,14 @@ async def async_setup(hass): WS_TYPE_UPDATE_PREFS, websocket_update_prefs, SCHEMA_WS_UPDATE_PREFS ) + hass.components.websocket_api.async_register_command( + WS_TYPE_HOOK_CREATE, websocket_hook_create, + SCHEMA_WS_HOOK_CREATE + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_HOOK_DELETE, websocket_hook_delete, + SCHEMA_WS_HOOK_DELETE + ) hass.http.register_view(GoogleActionsSyncView) hass.http.register_view(CloudLoginView) hass.http.register_view(CloudLogoutView) @@ -76,7 +99,7 @@ async def async_setup(hass): def _handle_cloud_errors(handler): - """Handle auth errors.""" + """Webview decorator to handle auth errors.""" @wraps(handler) async def error_handler(view, request, *args, **kwargs): """Handle exceptions that raise from the wrapped request handler.""" @@ -240,17 +263,49 @@ def websocket_cloud_status(hass, connection, msg): websocket_api.result_message(msg['id'], _account_data(cloud))) +def _require_cloud_login(handler): + """Websocket decorator that requires cloud to be logged in.""" + @wraps(handler) + def with_cloud_auth(hass, connection, msg): + """Require to be logged into the cloud.""" + cloud = hass.data[DOMAIN] + if not cloud.is_logged_in: + connection.send_message(websocket_api.error_message( + msg['id'], 'not_logged_in', + 'You need to be logged in to the cloud.')) + return + + handler(hass, connection, msg) + + return with_cloud_auth + + +def _handle_aiohttp_errors(handler): + """Websocket decorator that handlers aiohttp errors. + + Can only wrap async handlers. + """ + @wraps(handler) + async def with_error_handling(hass, connection, msg): + """Handle aiohttp errors.""" + try: + await handler(hass, connection, msg) + except asyncio.TimeoutError: + connection.send_message(websocket_api.error_message( + msg['id'], 'timeout', 'Command timed out.')) + except aiohttp.ClientError: + connection.send_message(websocket_api.error_message( + msg['id'], 'unknown', 'Error making request.')) + + return with_error_handling + + +@_require_cloud_login @websocket_api.async_response async def websocket_subscription(hass, connection, msg): """Handle request for account info.""" cloud = hass.data[DOMAIN] - if not cloud.is_logged_in: - connection.send_message(websocket_api.error_message( - msg['id'], 'not_logged_in', - 'You need to be logged in to the cloud.')) - return - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): response = await cloud.fetch_subscription_info() @@ -277,24 +332,37 @@ async def websocket_subscription(hass, connection, msg): connection.send_message(websocket_api.result_message(msg['id'], data)) +@_require_cloud_login @websocket_api.async_response async def websocket_update_prefs(hass, connection, msg): """Handle request for account info.""" cloud = hass.data[DOMAIN] - if not cloud.is_logged_in: - connection.send_message(websocket_api.error_message( - msg['id'], 'not_logged_in', - 'You need to be logged in to the cloud.')) - return - changes = dict(msg) changes.pop('id') changes.pop('type') await cloud.prefs.async_update(**changes) - connection.send_message(websocket_api.result_message( - msg['id'], {'success': True})) + connection.send_message(websocket_api.result_message(msg['id'])) + + +@_require_cloud_login +@websocket_api.async_response +@_handle_aiohttp_errors +async def websocket_hook_create(hass, connection, msg): + """Handle request for account info.""" + cloud = hass.data[DOMAIN] + hook = await cloud.cloudhooks.async_create(msg['webhook_id']) + connection.send_message(websocket_api.result_message(msg['id'], hook)) + + +@_require_cloud_login +@websocket_api.async_response +async def websocket_hook_delete(hass, connection, msg): + """Handle request for account info.""" + cloud = hass.data[DOMAIN] + await cloud.cloudhooks.async_delete(msg['webhook_id']) + connection.send_message(websocket_api.result_message(msg['id'])) def _account_data(cloud): diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index c5657ae97292f..3c7275afa7a47 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -2,13 +2,16 @@ import asyncio import logging import pprint +import uuid from aiohttp import hdrs, client_exceptions, WSMsgType from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.components.alexa import smart_home as alexa from homeassistant.components.google_assistant import smart_home as ga +from homeassistant.core import callback from homeassistant.util.decorator import Registry +from homeassistant.util.aiohttp import MockRequest, serialize_response from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import auth_api from .const import MESSAGE_EXPIRATION, MESSAGE_AUTH_FAIL @@ -25,6 +28,15 @@ class UnknownHandler(Exception): """Exception raised when trying to handle unknown handler.""" +class ErrorMessage(Exception): + """Exception raised when there was error handling message in the cloud.""" + + def __init__(self, error): + """Initialize Error Message.""" + super().__init__(self, "Error in Cloud") + self.error = error + + class CloudIoT: """Class to manage the IoT connection.""" @@ -41,6 +53,19 @@ def __init__(self, cloud): self.tries = 0 # Current state of the connection self.state = STATE_DISCONNECTED + # Local code waiting for a response + self._response_handler = {} + self._on_connect = [] + + @callback + def register_on_connect(self, on_connect_cb): + """Register an async on_connect callback.""" + self._on_connect.append(on_connect_cb) + + @property + def connected(self): + """Return if we're currently connected.""" + return self.state == STATE_CONNECTED @asyncio.coroutine def connect(self): @@ -91,6 +116,20 @@ def _handle_hass_stop(event): if remove_hass_stop_listener is not None: remove_hass_stop_listener() + async def async_send_message(self, handler, payload): + """Send a message.""" + msgid = uuid.uuid4().hex + self._response_handler[msgid] = asyncio.Future() + message = { + 'msgid': msgid, + 'handler': handler, + 'payload': payload, + } + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Publishing message:\n%s\n", + pprint.pformat(message)) + await self.client.send_json(message) + @asyncio.coroutine def _handle_connection(self): """Connect to the IoT broker.""" @@ -134,6 +173,9 @@ def _handle_connection(self): _LOGGER.info("Connected") self.state = STATE_CONNECTED + if self._on_connect: + yield from asyncio.wait([cb() for cb in self._on_connect]) + while not client.closed: msg = yield from client.receive() @@ -159,6 +201,17 @@ def _handle_connection(self): _LOGGER.debug("Received message:\n%s\n", pprint.pformat(msg)) + response_handler = self._response_handler.pop(msg['msgid'], + None) + + if response_handler is not None: + if 'payload' in msg: + response_handler.set_result(msg["payload"]) + else: + response_handler.set_exception( + ErrorMessage(msg['error'])) + continue + response = { 'msgid': msg['msgid'], } @@ -257,3 +310,43 @@ def async_handle_cloud(hass, cloud, payload): payload['reason']) else: _LOGGER.warning("Received unknown cloud action: %s", action) + + +@HANDLERS.register('webhook') +async def async_handle_webhook(hass, cloud, payload): + """Handle an incoming IoT message for cloud webhooks.""" + cloudhook_id = payload['cloudhook_id'] + + found = None + for cloudhook in cloud.prefs.cloudhooks.values(): + if cloudhook['cloudhook_id'] == cloudhook_id: + found = cloudhook + break + + if found is None: + return { + 'status': 200 + } + + request = MockRequest( + content=payload['body'].encode('utf-8'), + headers=payload['headers'], + method=payload['method'], + query_string=payload['query'], + ) + + response = await hass.components.webhook.async_handle_webhook( + found['webhook_id'], request) + + response_dict = serialize_response(response) + body = response_dict.get('body') + if body: + body = body.decode('utf-8') + + return { + 'body': body, + 'status': response_dict['status'], + 'headers': { + 'Content-Type': response.content_type + } + } diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index d29b356cfc0a0..b2ed83fc6b219 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -1,7 +1,7 @@ """Preference management for cloud.""" from .const import ( DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, - PREF_GOOGLE_ALLOW_UNLOCK) + PREF_GOOGLE_ALLOW_UNLOCK, PREF_CLOUDHOOKS) STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 @@ -26,17 +26,20 @@ async def async_initialize(self, logged_in): PREF_ENABLE_ALEXA: logged_in, PREF_ENABLE_GOOGLE: logged_in, PREF_GOOGLE_ALLOW_UNLOCK: False, + PREF_CLOUDHOOKS: {} } self._prefs = prefs async def async_update(self, *, google_enabled=_UNDEF, - alexa_enabled=_UNDEF, google_allow_unlock=_UNDEF): + alexa_enabled=_UNDEF, google_allow_unlock=_UNDEF, + cloudhooks=_UNDEF): """Update user preferences.""" for key, value in ( (PREF_ENABLE_GOOGLE, google_enabled), (PREF_ENABLE_ALEXA, alexa_enabled), (PREF_GOOGLE_ALLOW_UNLOCK, google_allow_unlock), + (PREF_CLOUDHOOKS, cloudhooks), ): if value is not _UNDEF: self._prefs[key] = value @@ -61,3 +64,8 @@ def google_enabled(self): def google_allow_unlock(self): """Return if Google is allowed to unlock locks.""" return self._prefs.get(PREF_GOOGLE_ALLOW_UNLOCK, False) + + @property + def cloudhooks(self): + """Return the published cloud webhooks.""" + return self._prefs.get(PREF_CLOUDHOOKS, {}) diff --git a/homeassistant/components/webhook.py b/homeassistant/components/webhook.py index ad23ba6f544e7..6742f33c72dc5 100644 --- a/homeassistant/components/webhook.py +++ b/homeassistant/components/webhook.py @@ -62,6 +62,28 @@ def async_generate_url(hass, webhook_id): return "{}/api/webhook/{}".format(hass.config.api.base_url, webhook_id) +@bind_hass +async def async_handle_webhook(hass, webhook_id, request): + """Handle a webhook.""" + handlers = hass.data.setdefault(DOMAIN, {}) + webhook = handlers.get(webhook_id) + + # Always respond successfully to not give away if a hook exists or not. + if webhook is None: + _LOGGER.warning( + 'Received message for unregistered webhook %s', webhook_id) + return Response(status=200) + + try: + response = await webhook['handler'](hass, webhook_id, request) + if response is None: + response = Response(status=200) + return response + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error processing webhook %s", webhook_id) + return Response(status=200) + + async def async_setup(hass, config): """Initialize the webhook component.""" hass.http.register_view(WebhookView) @@ -82,23 +104,7 @@ class WebhookView(HomeAssistantView): async def post(self, request, webhook_id): """Handle webhook call.""" hass = request.app['hass'] - handlers = hass.data.setdefault(DOMAIN, {}) - webhook = handlers.get(webhook_id) - - # Always respond successfully to not give away if a hook exists or not. - if webhook is None: - _LOGGER.warning( - 'Received message for unregistered webhook %s', webhook_id) - return Response(status=200) - - try: - response = await webhook['handler'](hass, webhook_id, request) - if response is None: - response = Response(status=200) - return response - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error processing webhook %s", webhook_id) - return Response(status=200) + return await async_handle_webhook(hass, webhook_id, request) @callback diff --git a/homeassistant/util/aiohttp.py b/homeassistant/util/aiohttp.py new file mode 100644 index 0000000000000..d648ed43110f2 --- /dev/null +++ b/homeassistant/util/aiohttp.py @@ -0,0 +1,53 @@ +"""Utilities to help with aiohttp.""" +import json +from urllib.parse import parse_qsl +from typing import Any, Dict, Optional + +from aiohttp import web +from multidict import CIMultiDict, MultiDict + + +class MockRequest: + """Mock an aiohttp request.""" + + def __init__(self, content: bytes, method: str = 'GET', + status: int = 200, headers: Optional[Dict[str, str]] = None, + query_string: Optional[str] = None, url: str = '') -> None: + """Initialize a request.""" + self.method = method + self.url = url + self.status = status + self.headers = CIMultiDict(headers or {}) # type: CIMultiDict[str] + self.query_string = query_string or '' + self._content = content + + @property + def query(self) -> 'MultiDict[str]': + """Return a dictionary with the query variables.""" + return MultiDict(parse_qsl(self.query_string, keep_blank_values=True)) + + @property + def _text(self) -> str: + """Return the body as text.""" + return self._content.decode('utf-8') + + async def json(self) -> Any: + """Return the body as JSON.""" + return json.loads(self._text) + + async def post(self) -> 'MultiDict[str]': + """Return POST parameters.""" + return MultiDict(parse_qsl(self._text, keep_blank_values=True)) + + async def text(self) -> str: + """Return the body as text.""" + return self._text + + +def serialize_response(response: web.Response) -> Dict[str, Any]: + """Serialize an aiohttp response to a dictionary.""" + return { + 'status': response.status, + 'body': response.body, + 'headers': dict(response.headers), + } diff --git a/tests/components/cloud/test_cloud_api.py b/tests/components/cloud/test_cloud_api.py new file mode 100644 index 0000000000000..0ddb8ecce500d --- /dev/null +++ b/tests/components/cloud/test_cloud_api.py @@ -0,0 +1,33 @@ +"""Test cloud API.""" +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.components.cloud import cloud_api + + +@pytest.fixture(autouse=True) +def mock_check_token(): + """Mock check token.""" + with patch('homeassistant.components.cloud.auth_api.' + 'check_token') as mock_check_token: + yield mock_check_token + + +async def test_create_cloudhook(hass, aioclient_mock): + """Test creating a cloudhook.""" + aioclient_mock.post('https://example.com/bla', json={ + 'cloudhook_id': 'mock-webhook', + 'url': 'https://blabla' + }) + cloud = Mock( + hass=hass, + id_token='mock-id-token', + cloudhook_create_url='https://example.com/bla', + ) + resp = await cloud_api.async_create_cloudhook(cloud) + assert len(aioclient_mock.mock_calls) == 1 + assert await resp.json() == { + 'cloudhook_id': 'mock-webhook', + 'url': 'https://blabla' + } diff --git a/tests/components/cloud/test_cloudhooks.py b/tests/components/cloud/test_cloudhooks.py new file mode 100644 index 0000000000000..b65046331a754 --- /dev/null +++ b/tests/components/cloud/test_cloudhooks.py @@ -0,0 +1,70 @@ +"""Test cloud cloudhooks.""" +from unittest.mock import Mock + +import pytest + +from homeassistant.components.cloud import prefs, cloudhooks + +from tests.common import mock_coro + + +@pytest.fixture +def mock_cloudhooks(hass): + """Mock cloudhooks class.""" + cloud = Mock() + cloud.hass = hass + cloud.hass.async_add_executor_job = Mock(return_value=mock_coro()) + cloud.iot = Mock(async_send_message=Mock(return_value=mock_coro())) + cloud.cloudhook_create_url = 'https://webhook-create.url' + cloud.prefs = prefs.CloudPreferences(hass) + hass.loop.run_until_complete(cloud.prefs.async_initialize(True)) + return cloudhooks.Cloudhooks(cloud) + + +async def test_enable(mock_cloudhooks, aioclient_mock): + """Test enabling cloudhooks.""" + aioclient_mock.post('https://webhook-create.url', json={ + 'cloudhook_id': 'mock-cloud-id', + 'url': 'https://hooks.nabu.casa/ZXCZCXZ', + }) + + hook = { + 'webhook_id': 'mock-webhook-id', + 'cloudhook_id': 'mock-cloud-id', + 'cloudhook_url': 'https://hooks.nabu.casa/ZXCZCXZ', + } + + assert hook == await mock_cloudhooks.async_create('mock-webhook-id') + + assert mock_cloudhooks.cloud.prefs.cloudhooks == { + 'mock-webhook-id': hook + } + + publish_calls = mock_cloudhooks.cloud.iot.async_send_message.mock_calls + assert len(publish_calls) == 1 + assert publish_calls[0][1][0] == 'webhook-register' + assert publish_calls[0][1][1] == { + 'cloudhook_ids': ['mock-cloud-id'] + } + + +async def test_disable(mock_cloudhooks): + """Test disabling cloudhooks.""" + mock_cloudhooks.cloud.prefs._prefs['cloudhooks'] = { + 'mock-webhook-id': { + 'webhook_id': 'mock-webhook-id', + 'cloudhook_id': 'mock-cloud-id', + 'cloudhook_url': 'https://hooks.nabu.casa/ZXCZCXZ', + } + } + + await mock_cloudhooks.async_delete('mock-webhook-id') + + assert mock_cloudhooks.cloud.prefs.cloudhooks == {} + + publish_calls = mock_cloudhooks.cloud.iot.async_send_message.mock_calls + assert len(publish_calls) == 1 + assert publish_calls[0][1][0] == 'webhook-register' + assert publish_calls[0][1][1] == { + 'cloudhook_ids': [] + } diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 4abf5b8501d9a..57e92ba762820 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -527,3 +527,45 @@ async def test_websocket_update_preferences(hass, hass_ws_client, assert not setup_api[PREF_ENABLE_GOOGLE] assert not setup_api[PREF_ENABLE_ALEXA] assert not setup_api[PREF_GOOGLE_ALLOW_UNLOCK] + + +async def test_enabling_webhook(hass, hass_ws_client, setup_api): + """Test we call right code to enable webhooks.""" + hass.data[DOMAIN].id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': '2018-01-03' + }, 'test') + client = await hass_ws_client(hass) + with patch('homeassistant.components.cloud.cloudhooks.Cloudhooks' + '.async_create', return_value=mock_coro()) as mock_enable: + await client.send_json({ + 'id': 5, + 'type': 'cloud/cloudhook/create', + 'webhook_id': 'mock-webhook-id', + }) + response = await client.receive_json() + assert response['success'] + + assert len(mock_enable.mock_calls) == 1 + assert mock_enable.mock_calls[0][1][0] == 'mock-webhook-id' + + +async def test_disabling_webhook(hass, hass_ws_client, setup_api): + """Test we call right code to disable webhooks.""" + hass.data[DOMAIN].id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': '2018-01-03' + }, 'test') + client = await hass_ws_client(hass) + with patch('homeassistant.components.cloud.cloudhooks.Cloudhooks' + '.async_delete', return_value=mock_coro()) as mock_disable: + await client.send_json({ + 'id': 5, + 'type': 'cloud/cloudhook/delete', + 'webhook_id': 'mock-webhook-id', + }) + response = await client.receive_json() + assert response['success'] + + assert len(mock_disable.mock_calls) == 1 + assert mock_disable.mock_calls[0][1][0] == 'mock-webhook-id' diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 44d56566f7566..baf6747aead78 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -30,7 +30,8 @@ def test_constructor_loads_info_from_constant(): 'region': 'test-region', 'relayer': 'test-relayer', 'google_actions_sync_url': 'test-google_actions_sync_url', - 'subscription_info_url': 'test-subscription-info-url' + 'subscription_info_url': 'test-subscription-info-url', + 'cloudhook_create_url': 'test-cloudhook_create_url', } }): result = yield from cloud.async_setup(hass, { @@ -46,6 +47,7 @@ def test_constructor_loads_info_from_constant(): assert cl.relayer == 'test-relayer' assert cl.google_actions_sync_url == 'test-google_actions_sync_url' assert cl.subscription_info_url == 'test-subscription-info-url' + assert cl.cloudhook_create_url == 'test-cloudhook_create_url' @asyncio.coroutine diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index c900fc3a7a85d..10488779dd83e 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -2,7 +2,7 @@ import asyncio from unittest.mock import patch, MagicMock, PropertyMock -from aiohttp import WSMsgType, client_exceptions +from aiohttp import WSMsgType, client_exceptions, web import pytest from homeassistant.setup import async_setup_component @@ -406,3 +406,48 @@ async def test_refresh_token_expired(hass): assert len(mock_check_token.mock_calls) == 1 assert len(mock_create.mock_calls) == 1 + + +async def test_webhook_msg(hass): + """Test webhook msg.""" + cloud = Cloud(hass, MODE_DEV, None, None) + await cloud.prefs.async_initialize(True) + await cloud.prefs.async_update(cloudhooks={ + 'hello': { + 'webhook_id': 'mock-webhook-id', + 'cloudhook_id': 'mock-cloud-id' + } + }) + + received = [] + + async def handler(hass, webhook_id, request): + """Handle a webhook.""" + received.append(request) + return web.json_response({'from': 'handler'}) + + hass.components.webhook.async_register( + 'test', 'Test', 'mock-webhook-id', handler) + + response = await iot.async_handle_webhook(hass, cloud, { + 'cloudhook_id': 'mock-cloud-id', + 'body': '{"hello": "world"}', + 'headers': { + 'content-type': 'application/json' + }, + 'method': 'POST', + 'query': None, + }) + + assert response == { + 'status': 200, + 'body': '{"from": "handler"}', + 'headers': { + 'Content-Type': 'application/json' + } + } + + assert len(received) == 1 + assert await received[0].json() == { + 'hello': 'world' + } diff --git a/tests/util/test_aiohttp.py b/tests/util/test_aiohttp.py new file mode 100644 index 0000000000000..8f528376cce74 --- /dev/null +++ b/tests/util/test_aiohttp.py @@ -0,0 +1,54 @@ +"""Test aiohttp request helper.""" +from aiohttp import web + +from homeassistant.util import aiohttp + + +async def test_request_json(): + """Test a JSON request.""" + request = aiohttp.MockRequest(b'{"hello": 2}') + assert request.status == 200 + assert await request.json() == { + 'hello': 2 + } + + +async def test_request_text(): + """Test a JSON request.""" + request = aiohttp.MockRequest(b'hello', status=201) + assert request.status == 201 + assert await request.text() == 'hello' + + +async def test_request_post_query(): + """Test a JSON request.""" + request = aiohttp.MockRequest( + b'hello=2&post=true', query_string='get=true', method='POST') + assert request.method == 'POST' + assert await request.post() == { + 'hello': '2', + 'post': 'true' + } + assert request.query == { + 'get': 'true' + } + + +def test_serialize_text(): + """Test serializing a text response.""" + response = web.Response(status=201, text='Hello') + assert aiohttp.serialize_response(response) == { + 'status': 201, + 'body': b'Hello', + 'headers': {'Content-Type': 'text/plain; charset=utf-8'}, + } + + +def test_serialize_json(): + """Test serializing a JSON response.""" + response = web.json_response({"how": "what"}) + assert aiohttp.serialize_response(response) == { + 'status': 200, + 'body': b'{"how": "what"}', + 'headers': {'Content-Type': 'application/json; charset=utf-8'}, + } From 6bcedb3ac5adcf7ff39ff27f034cc2ee6c35a6c4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 26 Nov 2018 14:16:30 +0100 Subject: [PATCH 063/325] Updated frontend to 20181121.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 3768a59788e02..d8ea057a4f08b 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20181121.0'] +REQUIREMENTS = ['home-assistant-frontend==20181121.1'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 4ddc81686b4ae..8072940ddbd4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -485,7 +485,7 @@ hole==0.3.0 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181121.0 +home-assistant-frontend==20181121.1 # homeassistant.components.zwave homeassistant-pyozw==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ebc180908e76..f722377189106 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -97,7 +97,7 @@ hdate==0.7.5 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181121.0 +home-assistant-frontend==20181121.1 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From cf22060c5ed5c7a7fe52c612042f9ed89bedd38b Mon Sep 17 00:00:00 2001 From: pbalogh77 Date: Mon, 26 Nov 2018 13:17:56 +0100 Subject: [PATCH 064/325] Use asyncio Lock for fibaro light (#18622) * Use asyncio Lock for fibaro light * line length and empty line at end * async turn_off Turned the turn_off into async as well * bless you, blank lines... My local flake8 lies to me. Not cool. --- homeassistant/components/light/fibaro.py | 150 +++++++++++++---------- 1 file changed, 84 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/light/fibaro.py b/homeassistant/components/light/fibaro.py index cfc28e12218a9..96069d50335fe 100644 --- a/homeassistant/components/light/fibaro.py +++ b/homeassistant/components/light/fibaro.py @@ -6,7 +6,8 @@ """ import logging -import threading +import asyncio +from functools import partial from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_WHITE_VALUE, ENTITY_ID_FORMAT, @@ -37,12 +38,15 @@ def scaleto100(value): return max(0, min(100, ((value * 100.4) / 255.0))) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, + config, + async_add_entities, + discovery_info=None): """Perform the setup for Fibaro controller devices.""" if discovery_info is None: return - add_entities( + async_add_entities( [FibaroLight(device, hass.data[FIBARO_CONTROLLER]) for device in hass.data[FIBARO_DEVICES]['light']], True) @@ -58,7 +62,7 @@ def __init__(self, fibaro_device, controller): self._brightness = None self._white = 0 - self._update_lock = threading.RLock() + self._update_lock = asyncio.Lock() if 'levelChange' in fibaro_device.interfaces: self._supported_flags |= SUPPORT_BRIGHTNESS if 'color' in fibaro_device.properties: @@ -88,78 +92,92 @@ def supported_features(self): """Flag supported features.""" return self._supported_flags - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the light on.""" - with self._update_lock: - if self._supported_flags & SUPPORT_BRIGHTNESS: - target_brightness = kwargs.get(ATTR_BRIGHTNESS) - - # No brightness specified, so we either restore it to - # last brightness or switch it on at maximum level - if target_brightness is None: - if self._brightness == 0: - if self._last_brightness: - self._brightness = self._last_brightness - else: - self._brightness = 100 - else: - # We set it to the target brightness and turn it on - self._brightness = scaleto100(target_brightness) - - if self._supported_flags & SUPPORT_COLOR: - # Update based on parameters - self._white = kwargs.get(ATTR_WHITE_VALUE, self._white) - self._color = kwargs.get(ATTR_HS_COLOR, self._color) - rgb = color_util.color_hs_to_RGB(*self._color) - self.call_set_color( - int(rgb[0] * self._brightness / 99.0 + 0.5), - int(rgb[1] * self._brightness / 99.0 + 0.5), - int(rgb[2] * self._brightness / 99.0 + 0.5), - int(self._white * self._brightness / 99.0 + - 0.5)) - if self.state == 'off': - self.set_level(int(self._brightness)) - return - - if self._supported_flags & SUPPORT_BRIGHTNESS: + async with self._update_lock: + await self.hass.async_add_executor_job( + partial(self._turn_on, **kwargs)) + + def _turn_on(self, **kwargs): + """Really turn the light on.""" + if self._supported_flags & SUPPORT_BRIGHTNESS: + target_brightness = kwargs.get(ATTR_BRIGHTNESS) + + # No brightness specified, so we either restore it to + # last brightness or switch it on at maximum level + if target_brightness is None: + if self._brightness == 0: + if self._last_brightness: + self._brightness = self._last_brightness + else: + self._brightness = 100 + else: + # We set it to the target brightness and turn it on + self._brightness = scaleto100(target_brightness) + + if self._supported_flags & SUPPORT_COLOR: + # Update based on parameters + self._white = kwargs.get(ATTR_WHITE_VALUE, self._white) + self._color = kwargs.get(ATTR_HS_COLOR, self._color) + rgb = color_util.color_hs_to_RGB(*self._color) + self.call_set_color( + int(rgb[0] * self._brightness / 99.0 + 0.5), + int(rgb[1] * self._brightness / 99.0 + 0.5), + int(rgb[2] * self._brightness / 99.0 + 0.5), + int(self._white * self._brightness / 99.0 + + 0.5)) + if self.state == 'off': self.set_level(int(self._brightness)) - return + return - # The simplest case is left for last. No dimming, just switch on - self.call_turn_on() + if self._supported_flags & SUPPORT_BRIGHTNESS: + self.set_level(int(self._brightness)) + return - def turn_off(self, **kwargs): + # The simplest case is left for last. No dimming, just switch on + self.call_turn_on() + + async def async_turn_off(self, **kwargs): """Turn the light off.""" + async with self._update_lock: + await self.hass.async_add_executor_job( + partial(self._turn_off, **kwargs)) + + def _turn_off(self, **kwargs): + """Really turn the light off.""" # Let's save the last brightness level before we switch it off - with self._update_lock: - if (self._supported_flags & SUPPORT_BRIGHTNESS) and \ - self._brightness and self._brightness > 0: - self._last_brightness = self._brightness - self._brightness = 0 - self.call_turn_off() + if (self._supported_flags & SUPPORT_BRIGHTNESS) and \ + self._brightness and self._brightness > 0: + self._last_brightness = self._brightness + self._brightness = 0 + self.call_turn_off() @property def is_on(self): """Return true if device is on.""" return self.current_binary_state - def update(self): - """Call to update state.""" + async def async_update(self): + """Update the state.""" + async with self._update_lock: + await self.hass.async_add_executor_job(self._update) + + def _update(self): + """Really update the state.""" # Brightness handling - with self._update_lock: - if self._supported_flags & SUPPORT_BRIGHTNESS: - self._brightness = float(self.fibaro_device.properties.value) - # Color handling - if self._supported_flags & SUPPORT_COLOR: - # Fibaro communicates the color as an 'R, G, B, W' string - rgbw_s = self.fibaro_device.properties.color - if rgbw_s == '0,0,0,0' and\ - 'lastColorSet' in self.fibaro_device.properties: - rgbw_s = self.fibaro_device.properties.lastColorSet - rgbw_list = [int(i) for i in rgbw_s.split(",")][:4] - if rgbw_list[0] or rgbw_list[1] or rgbw_list[2]: - self._color = color_util.color_RGB_to_hs(*rgbw_list[:3]) - if (self._supported_flags & SUPPORT_WHITE_VALUE) and \ - self.brightness != 0: - self._white = min(255, max(0, rgbw_list[3]*100.0 / - self._brightness)) + if self._supported_flags & SUPPORT_BRIGHTNESS: + self._brightness = float(self.fibaro_device.properties.value) + # Color handling + if self._supported_flags & SUPPORT_COLOR: + # Fibaro communicates the color as an 'R, G, B, W' string + rgbw_s = self.fibaro_device.properties.color + if rgbw_s == '0,0,0,0' and\ + 'lastColorSet' in self.fibaro_device.properties: + rgbw_s = self.fibaro_device.properties.lastColorSet + rgbw_list = [int(i) for i in rgbw_s.split(",")][:4] + if rgbw_list[0] or rgbw_list[1] or rgbw_list[2]: + self._color = color_util.color_RGB_to_hs(*rgbw_list[:3]) + if (self._supported_flags & SUPPORT_WHITE_VALUE) and \ + self.brightness != 0: + self._white = min(255, max(0, rgbw_list[3]*100.0 / + self._brightness)) From 2f581b1a1ea17a69db0c20223a9527c503dbeab1 Mon Sep 17 00:00:00 2001 From: Eliseo Martelli Date: Fri, 23 Nov 2018 01:46:22 +0100 Subject: [PATCH 065/325] fixed wording that may confuse user (#18628) --- homeassistant/components/recorder/migration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index a6a6ed461742d..825f402aef21d 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -29,7 +29,7 @@ def migrate_schema(instance): with open(progress_path, 'w'): pass - _LOGGER.warning("Database requires upgrade. Schema version: %s", + _LOGGER.warning("Database is about to upgrade. Schema version: %s", current_version) if current_version is None: From bb75a39cf165a88bd2cd72d4289408c53685f85e Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Thu, 22 Nov 2018 16:43:10 +0100 Subject: [PATCH 066/325] Updated webhook_register, version bump pypoint (#18635) * Updated webhook_register, version bump pypoint * A binary_sensor should be a BinarySensorDevice --- homeassistant/components/binary_sensor/point.py | 3 ++- homeassistant/components/point/__init__.py | 6 +++--- requirements_all.txt | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/binary_sensor/point.py b/homeassistant/components/binary_sensor/point.py index a2ed9eabebf43..90a8b0b581348 100644 --- a/homeassistant/components/binary_sensor/point.py +++ b/homeassistant/components/binary_sensor/point.py @@ -7,6 +7,7 @@ import logging +from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.point import MinutPointEntity from homeassistant.components.point.const import ( DOMAIN as POINT_DOMAIN, NEW_DEVICE, SIGNAL_WEBHOOK) @@ -45,7 +46,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for device_class in EVENTS), True) -class MinutPointBinarySensor(MinutPointEntity): +class MinutPointBinarySensor(MinutPointEntity, BinarySensorDevice): """The platform class required by Home Assistant.""" def __init__(self, point_client, device_id, device_class): diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index fcbd5ddb06450..36215da78935d 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -25,7 +25,7 @@ CONF_WEBHOOK_URL, DOMAIN, EVENT_RECEIVED, NEW_DEVICE, SCAN_INTERVAL, SIGNAL_UPDATE_ENTITY, SIGNAL_WEBHOOK) -REQUIREMENTS = ['pypoint==1.0.5'] +REQUIREMENTS = ['pypoint==1.0.6'] DEPENDENCIES = ['webhook'] _LOGGER = logging.getLogger(__name__) @@ -113,8 +113,8 @@ async def async_setup_webhook(hass: HomeAssistantType, entry: ConfigEntry, session.update_webhook(entry.data[CONF_WEBHOOK_URL], entry.data[CONF_WEBHOOK_ID]) - hass.components.webhook.async_register(entry.data[CONF_WEBHOOK_ID], - handle_webhook) + hass.components.webhook.async_register( + DOMAIN, 'Point', entry.data[CONF_WEBHOOK_ID], handle_webhook) async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): diff --git a/requirements_all.txt b/requirements_all.txt index 8072940ddbd4d..bc53dbce24e79 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1099,7 +1099,7 @@ pyowm==2.9.0 pypjlink2==1.2.0 # homeassistant.components.point -pypoint==1.0.5 +pypoint==1.0.6 # homeassistant.components.sensor.pollen pypollencom==2.2.2 From 56c7c8ccc514e8b26c3dc443137acc234fbf9319 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 22 Nov 2018 12:48:50 +0100 Subject: [PATCH 067/325] Fix vol Dict -> dict (#18637) --- homeassistant/components/lovelace/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 39644bd047b3f..5234dbaf29d4a 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -63,7 +63,7 @@ SCHEMA_UPDATE_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_UPDATE_CARD, vol.Required('card_id'): str, - vol.Required('card_config'): vol.Any(str, Dict), + vol.Required('card_config'): vol.Any(str, dict), vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, FORMAT_YAML), }) @@ -71,7 +71,7 @@ SCHEMA_ADD_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_ADD_CARD, vol.Required('view_id'): str, - vol.Required('card_config'): vol.Any(str, Dict), + vol.Required('card_config'): vol.Any(str, dict), vol.Optional('position'): int, vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, FORMAT_YAML), @@ -99,14 +99,14 @@ SCHEMA_UPDATE_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_UPDATE_VIEW, vol.Required('view_id'): str, - vol.Required('view_config'): vol.Any(str, Dict), + vol.Required('view_config'): vol.Any(str, dict), vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, FORMAT_YAML), }) SCHEMA_ADD_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_ADD_VIEW, - vol.Required('view_config'): vol.Any(str, Dict), + vol.Required('view_config'): vol.Any(str, dict), vol.Optional('position'): int, vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, FORMAT_YAML), From c3b76b40f6b4fa4433bde27472a370412053f24a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sun, 25 Nov 2018 12:30:38 +0100 Subject: [PATCH 068/325] Set correct default offset (#18678) --- homeassistant/components/sensor/ruter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/ruter.py b/homeassistant/components/sensor/ruter.py index ddad6a43c7517..7b02b51d0c0b5 100644 --- a/homeassistant/components/sensor/ruter.py +++ b/homeassistant/components/sensor/ruter.py @@ -28,7 +28,7 @@ vol.Required(CONF_STOP_ID): cv.positive_int, vol.Optional(CONF_DESTINATION): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_OFFSET, default=1): cv.positive_int, + vol.Optional(CONF_OFFSET, default=0): cv.positive_int, }) From f9f71c4a6dc7dfdfb156a2f4f96970cb895262e3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 26 Nov 2018 14:20:56 +0100 Subject: [PATCH 069/325] Bumped version to 0.83.0b1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 29e01faaa4812..9866bb5dad95e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 83 -PATCH_VERSION = '0b0' +PATCH_VERSION = '0b1' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 3c92aa9ecb957c1a1e35495eee571fb22f078230 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 26 Nov 2018 14:30:21 +0100 Subject: [PATCH 070/325] Update translations --- .../components/auth/.translations/cs.json | 10 +++++- .../components/ios/.translations/cs.json | 1 + .../components/mqtt/.translations/cs.json | 1 + .../components/openuv/.translations/cs.json | 20 ++++++++++++ .../components/point/.translations/cs.json | 31 ++++++++++++++++++ .../components/point/.translations/no.json | 32 +++++++++++++++++++ .../components/point/.translations/pl.json | 32 +++++++++++++++++++ .../point/.translations/zh-Hant.json | 32 +++++++++++++++++++ .../rainmachine/.translations/cs.json | 19 +++++++++++ .../rainmachine/.translations/pl.json | 19 +++++++++++ .../components/tradfri/.translations/cs.json | 1 + .../components/unifi/.translations/cs.json | 1 + 12 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/openuv/.translations/cs.json create mode 100644 homeassistant/components/point/.translations/cs.json create mode 100644 homeassistant/components/point/.translations/no.json create mode 100644 homeassistant/components/point/.translations/pl.json create mode 100644 homeassistant/components/point/.translations/zh-Hant.json create mode 100644 homeassistant/components/rainmachine/.translations/cs.json create mode 100644 homeassistant/components/rainmachine/.translations/pl.json diff --git a/homeassistant/components/auth/.translations/cs.json b/homeassistant/components/auth/.translations/cs.json index 508ffac67394b..da234c3dd5dd5 100644 --- a/homeassistant/components/auth/.translations/cs.json +++ b/homeassistant/components/auth/.translations/cs.json @@ -13,6 +13,7 @@ "title": "Nastavte jednor\u00e1zov\u00e9 heslo dodan\u00e9 komponentou notify" }, "setup": { + "description": "Jednor\u00e1zov\u00e9 heslo bylo odesl\u00e1no prost\u0159ednictv\u00edm **notify.{notify_service}**. Zadejte jej n\u00ed\u017ee:", "title": "Ov\u011b\u0159en\u00ed nastaven\u00ed" } } @@ -20,7 +21,14 @@ "totp": { "error": { "invalid_code": "Neplatn\u00fd k\u00f3d, zkuste to znovu. Pokud se tato chyba opakuje, ujist\u011bte se, \u017ee hodiny syst\u00e9mu Home Assistant jsou spr\u00e1vn\u011b nastaveny." - } + }, + "step": { + "init": { + "description": "Chcete-li aktivovat dvoufaktorovou autentizaci pomoc\u00ed jednor\u00e1zov\u00fdch hesel zalo\u017een\u00fdch na \u010dase, na\u010dt\u011bte k\u00f3d QR pomoc\u00ed va\u0161\u00ed autentiza\u010dn\u00ed aplikace. Pokud ji nem\u00e1te, doporu\u010dujeme bu\u010f [Google Authenticator](https://support.google.com/accounts/answer/1066447) nebo [Authy](https://authy.com/). \n\n {qr_code} \n \n Po skenov\u00e1n\u00ed k\u00f3du zadejte \u0161estcifern\u00fd k\u00f3d z aplikace a ov\u011b\u0159te nastaven\u00ed. Pokud m\u00e1te probl\u00e9my se skenov\u00e1n\u00edm k\u00f3du QR, prove\u010fte ru\u010dn\u00ed nastaven\u00ed s k\u00f3dem **`{code}`**.", + "title": "Nastavte dvoufaktorovou autentizaci pomoc\u00ed TOTP" + } + }, + "title": "TOTP" } } } \ No newline at end of file diff --git a/homeassistant/components/ios/.translations/cs.json b/homeassistant/components/ios/.translations/cs.json index 95d675076daa4..a311daa6f9ea5 100644 --- a/homeassistant/components/ios/.translations/cs.json +++ b/homeassistant/components/ios/.translations/cs.json @@ -2,6 +2,7 @@ "config": { "step": { "confirm": { + "description": "Chcete nastavit komponenty Home Assistant iOS?", "title": "Home Assistant iOS" } }, diff --git a/homeassistant/components/mqtt/.translations/cs.json b/homeassistant/components/mqtt/.translations/cs.json index e76577a5dc8fb..dbda456587eb3 100644 --- a/homeassistant/components/mqtt/.translations/cs.json +++ b/homeassistant/components/mqtt/.translations/cs.json @@ -15,6 +15,7 @@ "port": "Port", "username": "U\u017eivatelsk\u00e9 jm\u00e9no" }, + "description": "Zadejte informace proo p\u0159ipojen\u00ed zprost\u0159edkovatele protokolu MQTT.", "title": "MQTT" }, "hassio_confirm": { diff --git a/homeassistant/components/openuv/.translations/cs.json b/homeassistant/components/openuv/.translations/cs.json new file mode 100644 index 0000000000000..9f6ad4f8d47f5 --- /dev/null +++ b/homeassistant/components/openuv/.translations/cs.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "identifier_exists": "Sou\u0159adnice jsou ji\u017e zaregistrovan\u00e9", + "invalid_api_key": "Neplatn\u00fd kl\u00ed\u010d API" + }, + "step": { + "user": { + "data": { + "api_key": "OpenUV API Kl\u00ed\u010d", + "elevation": "Nadmo\u0159sk\u00e1 v\u00fd\u0161ka", + "latitude": "Zem\u011bpisn\u00e1 \u0161\u00ed\u0159ka", + "longitude": "Zem\u011bpisn\u00e1 d\u00e9lka" + }, + "title": "Vypl\u0148te va\u0161e \u00fadaje" + } + }, + "title": "OpenUV" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/cs.json b/homeassistant/components/point/.translations/cs.json new file mode 100644 index 0000000000000..71f13959b412b --- /dev/null +++ b/homeassistant/components/point/.translations/cs.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "authorize_url_fail": "Nezn\u00e1m\u00e1 chyba p\u0159i generov\u00e1n\u00ed autoriza\u010dn\u00ed URL.", + "authorize_url_timeout": "\u010casov\u00fd limit autoriza\u010dn\u00edho URL vypr\u0161el", + "external_setup": "Point \u00fasp\u011b\u0161n\u011b nakonfigurov\u00e1n z jin\u00e9ho toku.", + "no_flows": "Mus\u00edte nakonfigurovat Point, abyste se s n\u00edm mohli ov\u011b\u0159it. [P\u0159e\u010dt\u011bte si pros\u00edm pokyny](https://www.home-assistant.io/components/point/)." + }, + "create_entry": { + "default": "\u00dasp\u011b\u0161n\u011b ov\u011b\u0159eno pomoc\u00ed n\u00e1stroje Minut pro va\u0161e za\u0159\u00edzen\u00ed Point" + }, + "error": { + "follow_link": "P\u0159edt\u00edm, ne\u017e stisknete tla\u010d\u00edtko Odeslat, postupujte podle tohoto odkazu a autentizujte se", + "no_token": "Nen\u00ed ov\u011b\u0159en s Minut" + }, + "step": { + "auth": { + "description": "Postupujte podle n\u00ed\u017ee uveden\u00e9ho odkazu a P\u0159ijm\u011bte p\u0159\u00edstup k \u00fa\u010dtu Minut, pot\u00e9 se vra\u0165te zp\u011bt a stiskn\u011bte n\u00ed\u017ee Odeslat . \n\n [Odkaz]({authorization_url})", + "title": "Ov\u011b\u0159en\u00ed Point" + }, + "user": { + "data": { + "flow_impl": "Poskytovatel" + }, + "description": "Zvolte pomoc\u00ed kter\u00e9ho poskytovatele ov\u011b\u0159ov\u00e1n\u00ed chcete ov\u011b\u0159it Point.", + "title": "Poskytovatel ov\u011b\u0159en\u00ed" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/no.json b/homeassistant/components/point/.translations/no.json new file mode 100644 index 0000000000000..c5e4a7b2e86fd --- /dev/null +++ b/homeassistant/components/point/.translations/no.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan kun konfigurere \u00e9n Point-konto.", + "authorize_url_fail": "Ukjent feil ved generering en autoriseringsadresse.", + "authorize_url_timeout": "Tidsavbrudd ved generering av autoriseringsadresse.", + "external_setup": "Point vellykket konfigurasjon fra en annen flow.", + "no_flows": "Du m\u00e5 konfigurere Point f\u00f8r du kan autentisere med den. [Vennligst les instruksjonene](https://www.home-assistant.io/components/point/)." + }, + "create_entry": { + "default": "Vellykket godkjenning med Minut for din(e) Point enhet(er)" + }, + "error": { + "follow_link": "Vennligst f\u00f8lg lenken og godkjen f\u00f8r du trykker p\u00e5 Send", + "no_token": "Ikke godkjent med Minut" + }, + "step": { + "auth": { + "description": "Vennligst f\u00f8lg lenken nedenfor og Godta tilgang til Minut-kontoen din, kom tilbake og trykk Send inn nedenfor. \n\n [Link]({authorization_url})", + "title": "Godkjenne Point" + }, + "user": { + "data": { + "flow_impl": "Tilbyder" + }, + "description": "Velg fra hvilken godkjenningsleverand\u00f8r du vil godkjenne med Point.", + "title": "Godkjenningsleverand\u00f8r" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/pl.json b/homeassistant/components/point/.translations/pl.json new file mode 100644 index 0000000000000..98fa79573b0b1 --- /dev/null +++ b/homeassistant/components/point/.translations/pl.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_setup": "Mo\u017cesz skonfigurowa\u0107 tylko konto Point.", + "authorize_url_fail": "Nieznany b\u0142\u0105d podczas generowania url autoryzacji.", + "authorize_url_timeout": "Min\u0105\u0142 limit czasu generowania url autoryzacji.", + "external_setup": "Punkt pomy\u015blnie skonfigurowany.", + "no_flows": "Musisz skonfigurowa\u0107 Point, zanim b\u0119dziesz m\u00f3g\u0142 si\u0119 z nim uwierzytelni\u0107. [Przeczytaj instrukcje](https://www.home-assistant.io/components/point/)." + }, + "create_entry": { + "default": "Pomy\u015blnie uwierzytelniono przy u\u017cyciu Minut dla urz\u0105dze\u0144 Point" + }, + "error": { + "follow_link": "Prosz\u0119 klikn\u0105\u0107 link i uwierzytelni\u0107 przed naci\u015bni\u0119ciem przycisku Prze\u015blij", + "no_token": "Brak uwierzytelnienia za pomoc\u0105 Minut" + }, + "step": { + "auth": { + "description": "Kliknij poni\u017cszy link i Zaakceptuj dost\u0119p do swojego konta Minut, a nast\u0119pnie wr\u00f3\u0107 i naci\u015bnij Prze\u015blij poni\u017cej. \n\n [Link]({authorization_url})", + "title": "Uwierzytelnienie Point" + }, + "user": { + "data": { + "flow_impl": "Dostawca" + }, + "description": "Wybierz, kt\u00f3rego dostawc\u0119 uwierzytelnienia chcesz u\u017cywa\u0107 z Point.", + "title": "Dostawca uwierzytelnienia" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/zh-Hant.json b/homeassistant/components/point/.translations/zh-Hant.json new file mode 100644 index 0000000000000..91a86f5e3dba1 --- /dev/null +++ b/homeassistant/components/point/.translations/zh-Hant.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_setup": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44 Point \u5e33\u865f\u3002", + "authorize_url_fail": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u767c\u751f\u672a\u77e5\u932f\u8aa4", + "authorize_url_timeout": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u903e\u6642", + "external_setup": "\u5df2\u7531\u5176\u4ed6\u6d41\u7a0b\u6210\u529f\u8a2d\u5b9a Point\u3002", + "no_flows": "\u5fc5\u9808\u5148\u8a2d\u5b9a Point \u65b9\u80fd\u9032\u884c\u8a8d\u8b49\u3002[\u8acb\u53c3\u95b1\u6559\u5b78\u6307\u5f15]\uff08https://www.home-assistant.io/components/point/\uff09\u3002" + }, + "create_entry": { + "default": "\u5df2\u6210\u529f\u8a8d\u8b49 Minut Point \u88dd\u7f6e\u3002" + }, + "error": { + "follow_link": "\u8acb\u65bc\u50b3\u9001\u524d\uff0c\u5148\u4f7f\u7528\u9023\u7d50\u4e26\u9032\u884c\u8a8d\u8b49\u3002", + "no_token": "Minut \u672a\u6388\u6b0a" + }, + "step": { + "auth": { + "description": "\u8acb\u4f7f\u7528\u4e0b\u65b9\u9023\u7d50\u4e26\u9ede\u9078\u63a5\u53d7\u4ee5\u5b58\u53d6 Minut \u5e33\u865f\uff0c\u7136\u5f8c\u8fd4\u56de\u6b64\u9801\u9762\u4e26\u9ede\u9078\u4e0b\u65b9\u7684\u50b3\u9001\u3002\n\n[Link]({authorization_url})", + "title": "\u8a8d\u8b49 Point" + }, + "user": { + "data": { + "flow_impl": "\u63d0\u4f9b\u8005" + }, + "description": "\u65bc\u8a8d\u8b49\u63d0\u4f9b\u8005\u4e2d\u6311\u9078\u6240\u8981\u9032\u884c Point \u8a8d\u8b49\u63d0\u4f9b\u8005\u3002", + "title": "\u8a8d\u8b49\u63d0\u4f9b\u8005" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/cs.json b/homeassistant/components/rainmachine/.translations/cs.json new file mode 100644 index 0000000000000..919956b8c34cb --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/cs.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "\u00da\u010det je ji\u017e zaregistrov\u00e1n", + "invalid_credentials": "Neplatn\u00e9 p\u0159ihla\u0161ovac\u00ed \u00fadaje" + }, + "step": { + "user": { + "data": { + "ip_address": "N\u00e1zev hostitele nebo adresa IP", + "password": "Heslo", + "port": "Port" + }, + "title": "Vypl\u0148te va\u0161e \u00fadaje" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/pl.json b/homeassistant/components/rainmachine/.translations/pl.json new file mode 100644 index 0000000000000..9891ac50f4811 --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/pl.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Konto zosta\u0142o ju\u017c zarejestrowane", + "invalid_credentials": "Nieprawid\u0142owe po\u015bwiadczenia" + }, + "step": { + "user": { + "data": { + "ip_address": "Nazwa hosta lub adres IP", + "password": "Has\u0142o", + "port": "Port" + }, + "title": "Wprowad\u017a swoje dane" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/cs.json b/homeassistant/components/tradfri/.translations/cs.json index 97a0e25d75416..58782a1b42118 100644 --- a/homeassistant/components/tradfri/.translations/cs.json +++ b/homeassistant/components/tradfri/.translations/cs.json @@ -5,6 +5,7 @@ }, "error": { "cannot_connect": "Nelze se p\u0159ipojit k br\u00e1n\u011b.", + "invalid_key": "Nepoda\u0159ilo se zaregistrovat pomoc\u00ed zadan\u00e9ho kl\u00ed\u010de. Pokud se situace opakuje, zkuste restartovat gateway.", "timeout": "\u010casov\u00fd limit ov\u011b\u0159ov\u00e1n\u00ed k\u00f3du vypr\u0161el" }, "step": { diff --git a/homeassistant/components/unifi/.translations/cs.json b/homeassistant/components/unifi/.translations/cs.json index 95ba46597da6a..3ea631ec86ccd 100644 --- a/homeassistant/components/unifi/.translations/cs.json +++ b/homeassistant/components/unifi/.translations/cs.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "\u0158adi\u010d je ji\u017e nakonfigurov\u00e1n", "user_privilege": "U\u017eivatel mus\u00ed b\u00fdt spr\u00e1vcem" }, "error": { From 1f123ebcc1922aab6dba539abb9e07dc5c8bd51b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 26 Nov 2018 14:40:43 +0100 Subject: [PATCH 071/325] Updated frontend to 20181126.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 3768a59788e02..c16907007cf31 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20181121.0'] +REQUIREMENTS = ['home-assistant-frontend==20181126.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index b0e317387f04e..59a29eb88b342 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -485,7 +485,7 @@ hole==0.3.0 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181121.0 +home-assistant-frontend==20181126.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fc7a1443d95fb..7a107f2bb0aee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -97,7 +97,7 @@ hdate==0.7.5 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181121.0 +home-assistant-frontend==20181126.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From 9894eff732831285981d2481488896c50276d4d1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 26 Nov 2018 19:53:24 +0100 Subject: [PATCH 072/325] Fix logbook filtering entities (#18721) * Fix logbook filtering entities * Fix flaky test --- homeassistant/components/logbook.py | 6 +++--- tests/components/test_logbook.py | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index ada8bf78ab0da..c7a37411f1eaa 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -391,9 +391,9 @@ def _get_events(hass, config, start_day, end_day, entity_id=None): .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.state_id.is_(None))) \ - .filter(States.entity_id.in_(entity_ids)) + .filter(((States.last_updated == States.last_changed) & + States.entity_id.in_(entity_ids)) + | (States.state_id.is_(None))) events = execute(query) diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index 5229d34b74ca8..ae1e3d1d51aba 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -62,6 +62,12 @@ def event_listener(event): # Our service call will unblock when the event listeners have been # scheduled. This means that they may not have been processed yet. self.hass.block_till_done() + self.hass.data[recorder.DATA_INSTANCE].block_till_done() + + events = list(logbook._get_events( + self.hass, {}, dt_util.utcnow() - timedelta(hours=1), + dt_util.utcnow() + timedelta(hours=1))) + assert len(events) == 2 assert 1 == len(calls) last_call = calls[-1] From b4e2f2a6efcdb593c0226b6fa73da271d6f7f872 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 26 Nov 2018 23:43:14 +0200 Subject: [PATCH 073/325] Upgrade pytest and -timeout (#18722) * Upgrade pytest to 4.0.1 * Upgrade pytest-timeout to 1.3.3 --- requirements_test.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 204bc67b0867b..8d761c1e614ba 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,6 +12,6 @@ pylint==2.1.1 pytest-aiohttp==0.3.0 pytest-cov==2.6.0 pytest-sugar==0.9.2 -pytest-timeout==1.3.2 -pytest==4.0.0 +pytest-timeout==1.3.3 +pytest==4.0.1 requests_mock==1.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a107f2bb0aee..d204cfa7da924 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -13,8 +13,8 @@ pylint==2.1.1 pytest-aiohttp==0.3.0 pytest-cov==2.6.0 pytest-sugar==0.9.2 -pytest-timeout==1.3.2 -pytest==4.0.0 +pytest-timeout==1.3.3 +pytest==4.0.1 requests_mock==1.5.2 From 7248c9cb0e4f070d1e6d0513122850098c78b0fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 27 Nov 2018 10:35:35 +0200 Subject: [PATCH 074/325] Remove some unused imports (#18732) --- homeassistant/auth/mfa_modules/notify.py | 2 +- homeassistant/auth/permissions/entities.py | 3 +-- homeassistant/auth/permissions/types.py | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py index 03be4c74d32bf..8eea3acb6ed2b 100644 --- a/homeassistant/auth/mfa_modules/notify.py +++ b/homeassistant/auth/mfa_modules/notify.py @@ -4,7 +4,7 @@ """ import logging from collections import OrderedDict -from typing import Any, Dict, Optional, Tuple, List # noqa: F401 +from typing import Any, Dict, Optional, List import attr import voluptuous as vol diff --git a/homeassistant/auth/permissions/entities.py b/homeassistant/auth/permissions/entities.py index 74a43246fd179..59bba468a5983 100644 --- a/homeassistant/auth/permissions/entities.py +++ b/homeassistant/auth/permissions/entities.py @@ -1,7 +1,6 @@ """Entity permissions.""" from functools import wraps -from typing import ( # noqa: F401 - Callable, Dict, List, Tuple, Union) +from typing import Callable, List, Union # noqa: F401 import voluptuous as vol diff --git a/homeassistant/auth/permissions/types.py b/homeassistant/auth/permissions/types.py index 1871861f29102..78d13b9679f74 100644 --- a/homeassistant/auth/permissions/types.py +++ b/homeassistant/auth/permissions/types.py @@ -1,6 +1,5 @@ """Common code for permissions.""" -from typing import ( # noqa: F401 - Mapping, Union, Any) +from typing import Mapping, Union # MyPy doesn't support recursion yet. So writing it out as far as we need. From 9d7b1fc3a75fb02e92410b1abdd4f133a10d5963 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 27 Nov 2018 10:12:31 +0100 Subject: [PATCH 075/325] Enforce permissions for Websocket API (#18719) * Handle unauth exceptions in websocket * Enforce permissions in websocket API --- .../components/websocket_api/commands.py | 12 +++++- .../components/websocket_api/connection.py | 25 +++++++++--- .../components/websocket_api/const.py | 11 +++--- .../components/websocket_api/decorators.py | 6 +-- tests/components/websocket_api/conftest.py | 5 ++- .../components/websocket_api/test_commands.py | 39 +++++++++++++++++++ 6 files changed, 81 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 771a6a57f4fa6..53d1e9af807a0 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -3,6 +3,7 @@ from homeassistant.const import MATCH_ALL, EVENT_TIME_CHANGED from homeassistant.core import callback, DOMAIN as HASS_DOMAIN +from homeassistant.exceptions import Unauthorized from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_get_all_descriptions @@ -98,6 +99,9 @@ def handle_subscribe_events(hass, connection, msg): Async friendly. """ + if not connection.user.is_admin: + raise Unauthorized + async def forward_events(event): """Forward events to websocket.""" if event.event_type == EVENT_TIME_CHANGED: @@ -149,8 +153,14 @@ def handle_get_states(hass, connection, msg): Async friendly. """ + entity_perm = connection.user.permissions.check_entity + states = [ + state for state in hass.states.async_all() + if entity_perm(state.entity_id, 'read') + ] + connection.send_message(messages.result_message( - msg['id'], hass.states.async_all())) + msg['id'], states)) @decorators.async_response diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 1cb58591a0af5..60e2caa54acd5 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -2,6 +2,7 @@ import voluptuous as vol from homeassistant.core import callback, Context +from homeassistant.exceptions import Unauthorized from . import const, messages @@ -63,11 +64,8 @@ def async_handle(self, msg): try: handler(self.hass, self, schema(msg)) - except Exception: # pylint: disable=broad-except - self.logger.exception('Error handling message: %s', msg) - self.send_message(messages.error_message( - cur_id, const.ERR_UNKNOWN_ERROR, - 'Unknown error.')) + except Exception as err: # pylint: disable=broad-except + self.async_handle_exception(msg, err) self.last_id = cur_id @@ -76,3 +74,20 @@ def async_close(self): """Close down connection.""" for unsub in self.event_listeners.values(): unsub() + + @callback + def async_handle_exception(self, msg, err): + """Handle an exception while processing a handler.""" + if isinstance(err, Unauthorized): + code = const.ERR_UNAUTHORIZED + err_message = 'Unauthorized' + elif isinstance(err, vol.Invalid): + code = const.ERR_INVALID_FORMAT + err_message = 'Invalid format' + else: + self.logger.exception('Error handling message: %s', msg) + code = const.ERR_UNKNOWN_ERROR + err_message = 'Unknown error' + + self.send_message( + messages.error_message(msg['id'], code, err_message)) diff --git a/homeassistant/components/websocket_api/const.py b/homeassistant/components/websocket_api/const.py index 8d452959ca52b..fd8f7eb7b08a0 100644 --- a/homeassistant/components/websocket_api/const.py +++ b/homeassistant/components/websocket_api/const.py @@ -6,11 +6,12 @@ URL = '/api/websocket' MAX_PENDING_MSG = 512 -ERR_ID_REUSE = 1 -ERR_INVALID_FORMAT = 2 -ERR_NOT_FOUND = 3 -ERR_UNKNOWN_COMMAND = 4 -ERR_UNKNOWN_ERROR = 5 +ERR_ID_REUSE = 'id_reuse' +ERR_INVALID_FORMAT = 'invalid_format' +ERR_NOT_FOUND = 'not_found' +ERR_UNKNOWN_COMMAND = 'unknown_command' +ERR_UNKNOWN_ERROR = 'unknown_error' +ERR_UNAUTHORIZED = 'unauthorized' TYPE_RESULT = 'result' diff --git a/homeassistant/components/websocket_api/decorators.py b/homeassistant/components/websocket_api/decorators.py index 5f78790f5db3f..34250202a5e8b 100644 --- a/homeassistant/components/websocket_api/decorators.py +++ b/homeassistant/components/websocket_api/decorators.py @@ -14,10 +14,8 @@ async def _handle_async_response(func, hass, connection, msg): """Create a response and handle exception.""" try: await func(hass, connection, msg) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - connection.send_message(messages.error_message( - msg['id'], 'unknown', 'Unexpected error occurred')) + except Exception as err: # pylint: disable=broad-except + connection.async_handle_exception(msg, err) def async_response(func): diff --git a/tests/components/websocket_api/conftest.py b/tests/components/websocket_api/conftest.py index b7825600cb1a6..51d98df7f6061 100644 --- a/tests/components/websocket_api/conftest.py +++ b/tests/components/websocket_api/conftest.py @@ -9,9 +9,10 @@ @pytest.fixture -def websocket_client(hass, hass_ws_client): +def websocket_client(hass, hass_ws_client, hass_access_token): """Create a websocket client.""" - return hass.loop.run_until_complete(hass_ws_client(hass)) + return hass.loop.run_until_complete( + hass_ws_client(hass, hass_access_token)) @pytest.fixture diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 84c29533859ff..b83d4051356d6 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -261,3 +261,42 @@ async def test_call_service_context_no_user(hass, aiohttp_client): assert call.service == 'test_service' assert call.data == {'hello': 'world'} assert call.context.user_id is None + + +async def test_subscribe_requires_admin(websocket_client, hass_admin_user): + """Test subscribing events without being admin.""" + hass_admin_user.groups = [] + await websocket_client.send_json({ + 'id': 5, + 'type': commands.TYPE_SUBSCRIBE_EVENTS, + 'event_type': 'test_event' + }) + + msg = await websocket_client.receive_json() + assert not msg['success'] + assert msg['error']['code'] == const.ERR_UNAUTHORIZED + + +async def test_states_filters_visible(hass, hass_admin_user, websocket_client): + """Test we only get entities that we're allowed to see.""" + hass_admin_user.mock_policy({ + 'entities': { + 'entity_ids': { + 'test.entity': True + } + } + }) + hass.states.async_set('test.entity', 'hello') + hass.states.async_set('test.not_visible_entity', 'invisible') + await websocket_client.send_json({ + 'id': 5, + 'type': commands.TYPE_GET_STATES, + }) + + msg = await websocket_client.receive_json() + assert msg['id'] == 5 + assert msg['type'] == const.TYPE_RESULT + assert msg['success'] + + assert len(msg['result']) == 1 + assert msg['result'][0]['entity_id'] == 'test.entity' From c2f8dfcb9f704ed32b9b0fd0fda913d0ab75958d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 27 Nov 2018 10:41:44 +0100 Subject: [PATCH 076/325] Legacy api fix (#18733) * Set user for API password requests * Fix tests * Fix typing --- .../auth/providers/legacy_api_password.py | 29 +++++++++-- homeassistant/components/http/auth.py | 5 ++ tests/components/alexa/test_intent.py | 4 +- tests/components/alexa/test_smart_home.py | 12 ++--- tests/components/conftest.py | 39 ++++++++++++++- tests/components/hassio/conftest.py | 2 +- tests/components/http/test_auth.py | 11 +++-- tests/components/http/test_init.py | 2 +- tests/components/test_api.py | 22 ++++++--- tests/components/test_conversation.py | 12 ++--- tests/components/test_history.py | 4 +- tests/components/test_shopping_list.py | 24 +++++----- tests/components/test_spaceapi.py | 4 +- tests/components/test_system_log.py | 48 +++++++++---------- tests/components/test_webhook.py | 4 +- 15 files changed, 148 insertions(+), 74 deletions(-) diff --git a/homeassistant/auth/providers/legacy_api_password.py b/homeassistant/auth/providers/legacy_api_password.py index 111b9e7d39f34..6cdb12b7157fb 100644 --- a/homeassistant/auth/providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -4,16 +4,19 @@ It will be removed when auth system production ready """ import hmac -from typing import Any, Dict, Optional, cast +from typing import Any, Dict, Optional, cast, TYPE_CHECKING import voluptuous as vol -from homeassistant.components.http import HomeAssistantHTTP # noqa: F401 -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow -from ..models import Credentials, UserMeta +from .. import AuthManager +from ..models import Credentials, UserMeta, User + +if TYPE_CHECKING: + from homeassistant.components.http import HomeAssistantHTTP # noqa: F401 USER_SCHEMA = vol.Schema({ @@ -31,6 +34,24 @@ class InvalidAuthError(HomeAssistantError): """Raised when submitting invalid authentication.""" +async def async_get_user(hass: HomeAssistant) -> User: + """Return the legacy API password user.""" + auth = cast(AuthManager, hass.auth) # type: ignore + found = None + + for prv in auth.auth_providers: + if prv.type == 'legacy_api_password': + found = prv + break + + if found is None: + raise ValueError('Legacy API password provider not found') + + return await auth.async_get_or_create_user( + await found.async_get_or_create_credentials({}) + ) + + @AUTH_PROVIDERS.register('legacy_api_password') class LegacyApiPasswordAuthProvider(AuthProvider): """Example auth provider based on hardcoded usernames and passwords.""" diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 1f89dc5e4ca25..0e943b33fb834 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -10,6 +10,7 @@ from homeassistant.core import callback from homeassistant.const import HTTP_HEADER_HA_AUTH +from homeassistant.auth.providers import legacy_api_password from homeassistant.auth.util import generate_secret from homeassistant.util import dt as dt_util @@ -78,12 +79,16 @@ async def auth_middleware(request, handler): request.headers[HTTP_HEADER_HA_AUTH].encode('utf-8'))): # A valid auth header has been set authenticated = True + request['hass_user'] = await legacy_api_password.async_get_user( + app['hass']) elif (legacy_auth and DATA_API_PASSWORD in request.query and hmac.compare_digest( api_password.encode('utf-8'), request.query[DATA_API_PASSWORD].encode('utf-8'))): authenticated = True + request['hass_user'] = await legacy_api_password.async_get_user( + app['hass']) elif _is_trusted_ip(request, trusted_networks): authenticated = True diff --git a/tests/components/alexa/test_intent.py b/tests/components/alexa/test_intent.py index d15c7ccbb34ed..ab84dd2a3bc53 100644 --- a/tests/components/alexa/test_intent.py +++ b/tests/components/alexa/test_intent.py @@ -23,7 +23,7 @@ @pytest.fixture -def alexa_client(loop, hass, aiohttp_client): +def alexa_client(loop, hass, hass_client): """Initialize a Home Assistant server for testing this module.""" @callback def mock_service(call): @@ -95,7 +95,7 @@ def mock_service(call): }, } })) - return loop.run_until_complete(aiohttp_client(hass.http.app)) + return loop.run_until_complete(hass_client()) def _intent_req(client, data=None): diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 766075f8eb53f..3cfb8068177f7 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1437,10 +1437,10 @@ async def test_unsupported_domain(hass): assert not msg['payload']['endpoints'] -async def do_http_discovery(config, hass, aiohttp_client): +async def do_http_discovery(config, hass, hass_client): """Submit a request to the Smart Home HTTP API.""" await async_setup_component(hass, alexa.DOMAIN, config) - http_client = await aiohttp_client(hass.http.app) + http_client = await hass_client() request = get_new_request('Alexa.Discovery', 'Discover') response = await http_client.post( @@ -1450,7 +1450,7 @@ async def do_http_discovery(config, hass, aiohttp_client): return response -async def test_http_api(hass, aiohttp_client): +async def test_http_api(hass, hass_client): """With `smart_home:` HTTP API is exposed.""" config = { 'alexa': { @@ -1458,7 +1458,7 @@ async def test_http_api(hass, aiohttp_client): } } - response = await do_http_discovery(config, hass, aiohttp_client) + response = await do_http_discovery(config, hass, hass_client) response_data = await response.json() # Here we're testing just the HTTP view glue -- details of discovery are @@ -1466,12 +1466,12 @@ async def test_http_api(hass, aiohttp_client): assert response_data['event']['header']['name'] == 'Discover.Response' -async def test_http_api_disabled(hass, aiohttp_client): +async def test_http_api_disabled(hass, hass_client): """Without `smart_home:`, the HTTP API is disabled.""" config = { 'alexa': {} } - response = await do_http_discovery(config, hass, aiohttp_client) + response = await do_http_discovery(config, hass, hass_client) assert response.status == 404 diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 46d75a56ad675..110ba8d5ad6d6 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -4,6 +4,7 @@ import pytest from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY +from homeassistant.auth.providers import legacy_api_password, homeassistant from homeassistant.setup import async_setup_component from homeassistant.components.websocket_api.http import URL from homeassistant.components.websocket_api.auth import ( @@ -88,7 +89,7 @@ def hass_access_token(hass, hass_admin_user): @pytest.fixture -def hass_admin_user(hass): +def hass_admin_user(hass, local_auth): """Return a Home Assistant admin user.""" admin_group = hass.loop.run_until_complete(hass.auth.async_get_group( GROUP_ID_ADMIN)) @@ -96,8 +97,42 @@ def hass_admin_user(hass): @pytest.fixture -def hass_read_only_user(hass): +def hass_read_only_user(hass, local_auth): """Return a Home Assistant read only user.""" read_only_group = hass.loop.run_until_complete(hass.auth.async_get_group( GROUP_ID_READ_ONLY)) return MockUser(groups=[read_only_group]).add_to_hass(hass) + + +@pytest.fixture +def legacy_auth(hass): + """Load legacy API password provider.""" + prv = legacy_api_password.LegacyApiPasswordAuthProvider( + hass, hass.auth._store, { + 'type': 'legacy_api_password' + } + ) + hass.auth._providers[(prv.type, prv.id)] = prv + + +@pytest.fixture +def local_auth(hass): + """Load local auth provider.""" + prv = homeassistant.HassAuthProvider( + hass, hass.auth._store, { + 'type': 'homeassistant' + } + ) + hass.auth._providers[(prv.type, prv.id)] = prv + + +@pytest.fixture +def hass_client(hass, aiohttp_client, hass_access_token): + """Return an authenticated HTTP client.""" + async def auth_client(): + """Return an authenticated client.""" + return await aiohttp_client(hass.http.app, headers={ + 'Authorization': "Bearer {}".format(hass_access_token) + }) + + return auth_client diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index f9ad1c578de10..435de6d1edf95 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -27,7 +27,7 @@ def hassio_env(): @pytest.fixture -def hassio_client(hassio_env, hass, aiohttp_client): +def hassio_client(hassio_env, hass, aiohttp_client, legacy_auth): """Create mock hassio http client.""" with patch('homeassistant.components.hassio.HassIO.update_hass_api', Mock(return_value=mock_coro({"result": "ok"}))), \ diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 2746abcf15c54..979bfc28689ee 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -83,7 +83,8 @@ async def test_access_without_password(app, aiohttp_client): assert resp.status == 200 -async def test_access_with_password_in_header(app, aiohttp_client): +async def test_access_with_password_in_header(app, aiohttp_client, + legacy_auth): """Test access with password in header.""" setup_auth(app, [], False, api_password=API_PASSWORD) client = await aiohttp_client(app) @@ -97,7 +98,7 @@ async def test_access_with_password_in_header(app, aiohttp_client): assert req.status == 401 -async def test_access_with_password_in_query(app, aiohttp_client): +async def test_access_with_password_in_query(app, aiohttp_client, legacy_auth): """Test access with password in URL.""" setup_auth(app, [], False, api_password=API_PASSWORD) client = await aiohttp_client(app) @@ -219,7 +220,8 @@ async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client): "{} should be trusted".format(remote_addr) -async def test_auth_active_blocked_api_password_access(app, aiohttp_client): +async def test_auth_active_blocked_api_password_access( + app, aiohttp_client, legacy_auth): """Test access using api_password should be blocked when auth.active.""" setup_auth(app, [], True, api_password=API_PASSWORD) client = await aiohttp_client(app) @@ -239,7 +241,8 @@ async def test_auth_active_blocked_api_password_access(app, aiohttp_client): assert req.status == 401 -async def test_auth_legacy_support_api_password_access(app, aiohttp_client): +async def test_auth_legacy_support_api_password_access( + app, aiohttp_client, legacy_auth): """Test access using api_password if auth.support_legacy.""" setup_auth(app, [], True, support_legacy=True, api_password=API_PASSWORD) client = await aiohttp_client(app) diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 9f6441c52386f..1c1afe711c617 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -124,7 +124,7 @@ async def test_api_no_base_url(hass): assert hass.config.api.base_url == 'http://127.0.0.1:8123' -async def test_not_log_password(hass, aiohttp_client, caplog): +async def test_not_log_password(hass, aiohttp_client, caplog, legacy_auth): """Test access with password doesn't get logged.""" assert await async_setup_component(hass, 'api', { 'http': { diff --git a/tests/components/test_api.py b/tests/components/test_api.py index 3ebfa05a3d39c..0bc89292855e1 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -16,12 +16,10 @@ @pytest.fixture -def mock_api_client(hass, aiohttp_client, hass_access_token): +def mock_api_client(hass, hass_client): """Start the Hass HTTP component and return admin API client.""" hass.loop.run_until_complete(async_setup_component(hass, 'api', {})) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app, headers={ - 'Authorization': 'Bearer {}'.format(hass_access_token) - })) + return hass.loop.run_until_complete(hass_client()) @asyncio.coroutine @@ -408,7 +406,7 @@ def _listen_count(hass): async def test_api_error_log(hass, aiohttp_client, hass_access_token, - hass_admin_user): + hass_admin_user, legacy_auth): """Test if we can fetch the error log.""" hass.data[DATA_LOGGING] = '/some/path' await async_setup_component(hass, 'api', { @@ -566,5 +564,17 @@ async def test_rendering_template_admin(hass, mock_api_client, hass_admin_user): """Test rendering a template requires admin.""" hass_admin_user.groups = [] - resp = await mock_api_client.post('/api/template') + resp = await mock_api_client.post(const.URL_API_TEMPLATE) + assert resp.status == 401 + + +async def test_rendering_template_legacy_user( + hass, mock_api_client, aiohttp_client, legacy_auth): + """Test rendering a template with legacy API password.""" + hass.states.async_set('sensor.temperature', 10) + client = await aiohttp_client(hass.http.app) + resp = await client.post( + const.URL_API_TEMPLATE, + json={"template": '{{ states.sensor.temperature.state }}'} + ) assert resp.status == 401 diff --git a/tests/components/test_conversation.py b/tests/components/test_conversation.py index 7934e01628160..2aa1f499a768e 100644 --- a/tests/components/test_conversation.py +++ b/tests/components/test_conversation.py @@ -90,7 +90,7 @@ async def test_register_before_setup(hass): assert intent.text_input == 'I would like the Grolsch beer' -async def test_http_processing_intent(hass, aiohttp_client): +async def test_http_processing_intent(hass, hass_client): """Test processing intent via HTTP API.""" class TestIntentHandler(intent.IntentHandler): """Test Intent Handler.""" @@ -120,7 +120,7 @@ async def async_handle(self, intent): }) assert result - client = await aiohttp_client(hass.http.app) + client = await hass_client() resp = await client.post('/api/conversation/process', json={ 'text': 'I would like the Grolsch beer' }) @@ -244,7 +244,7 @@ async def test_toggle_intent(hass, sentence): assert call.data == {'entity_id': 'light.kitchen'} -async def test_http_api(hass, aiohttp_client): +async def test_http_api(hass, hass_client): """Test the HTTP conversation API.""" result = await component.async_setup(hass, {}) assert result @@ -252,7 +252,7 @@ async def test_http_api(hass, aiohttp_client): result = await async_setup_component(hass, 'conversation', {}) assert result - client = await aiohttp_client(hass.http.app) + client = await hass_client() hass.states.async_set('light.kitchen', 'off') calls = async_mock_service(hass, HASS_DOMAIN, 'turn_on') @@ -268,7 +268,7 @@ async def test_http_api(hass, aiohttp_client): assert call.data == {'entity_id': 'light.kitchen'} -async def test_http_api_wrong_data(hass, aiohttp_client): +async def test_http_api_wrong_data(hass, hass_client): """Test the HTTP conversation API.""" result = await component.async_setup(hass, {}) assert result @@ -276,7 +276,7 @@ async def test_http_api_wrong_data(hass, aiohttp_client): result = await async_setup_component(hass, 'conversation', {}) assert result - client = await aiohttp_client(hass.http.app) + client = await hass_client() resp = await client.post('/api/conversation/process', json={ 'text': 123 diff --git a/tests/components/test_history.py b/tests/components/test_history.py index 9764af1592ccc..641dff3b4e630 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -515,13 +515,13 @@ def set_state(entity_id, state, **kwargs): return zero, four, states -async def test_fetch_period_api(hass, aiohttp_client): +async def test_fetch_period_api(hass, hass_client): """Test the fetch period view for history.""" await hass.async_add_job(init_recorder_component, hass) await async_setup_component(hass, 'history', {}) await hass.components.recorder.wait_connection_ready() await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) - client = await aiohttp_client(hass.http.app) + client = await hass_client() response = await client.get( '/api/history/period/{}'.format(dt_util.utcnow().isoformat())) assert response.status == 200 diff --git a/tests/components/test_shopping_list.py b/tests/components/test_shopping_list.py index c2899f6b7535c..1e89287bcc106 100644 --- a/tests/components/test_shopping_list.py +++ b/tests/components/test_shopping_list.py @@ -55,7 +55,7 @@ def test_recent_items_intent(hass): @asyncio.coroutine -def test_deprecated_api_get_all(hass, aiohttp_client): +def test_deprecated_api_get_all(hass, hass_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -66,7 +66,7 @@ def test_deprecated_api_get_all(hass, aiohttp_client): hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'wine'}} ) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.get('/api/shopping_list') assert resp.status == 200 @@ -110,7 +110,7 @@ async def test_ws_get_items(hass, hass_ws_client): @asyncio.coroutine -def test_deprecated_api_update(hass, aiohttp_client): +def test_deprecated_api_update(hass, hass_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -124,7 +124,7 @@ def test_deprecated_api_update(hass, aiohttp_client): beer_id = hass.data['shopping_list'].items[0]['id'] wine_id = hass.data['shopping_list'].items[1]['id'] - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.post( '/api/shopping_list/item/{}'.format(beer_id), json={ 'name': 'soda' @@ -220,7 +220,7 @@ async def test_ws_update_item(hass, hass_ws_client): @asyncio.coroutine -def test_api_update_fails(hass, aiohttp_client): +def test_api_update_fails(hass, hass_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -228,7 +228,7 @@ def test_api_update_fails(hass, aiohttp_client): hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}} ) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.post( '/api/shopping_list/non_existing', json={ 'name': 'soda' @@ -275,7 +275,7 @@ async def test_ws_update_item_fail(hass, hass_ws_client): @asyncio.coroutine -def test_api_clear_completed(hass, aiohttp_client): +def test_api_clear_completed(hass, hass_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -289,7 +289,7 @@ def test_api_clear_completed(hass, aiohttp_client): beer_id = hass.data['shopping_list'].items[0]['id'] wine_id = hass.data['shopping_list'].items[1]['id'] - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() # Mark beer as completed resp = yield from client.post( @@ -312,11 +312,11 @@ def test_api_clear_completed(hass, aiohttp_client): @asyncio.coroutine -def test_deprecated_api_create(hass, aiohttp_client): +def test_deprecated_api_create(hass, hass_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.post('/api/shopping_list/item', json={ 'name': 'soda' }) @@ -333,11 +333,11 @@ def test_deprecated_api_create(hass, aiohttp_client): @asyncio.coroutine -def test_deprecated_api_create_fail(hass, aiohttp_client): +def test_deprecated_api_create_fail(hass, hass_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.post('/api/shopping_list/item', json={ 'name': 1234 }) diff --git a/tests/components/test_spaceapi.py b/tests/components/test_spaceapi.py index e7e7d158a31ad..61bb009ff8f21 100644 --- a/tests/components/test_spaceapi.py +++ b/tests/components/test_spaceapi.py @@ -56,7 +56,7 @@ @pytest.fixture -def mock_client(hass, aiohttp_client): +def mock_client(hass, hass_client): """Start the Home Assistant HTTP component.""" with patch('homeassistant.components.spaceapi', return_value=mock_coro(True)): @@ -70,7 +70,7 @@ def mock_client(hass, aiohttp_client): hass.states.async_set('test.hum1', 88, attributes={'unit_of_measurement': '%'}) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + return hass.loop.run_until_complete(hass_client()) async def test_spaceapi_get(hass, mock_client): diff --git a/tests/components/test_system_log.py b/tests/components/test_system_log.py index 5d48fd881273c..6afd792be9c6b 100644 --- a/tests/components/test_system_log.py +++ b/tests/components/test_system_log.py @@ -14,9 +14,9 @@ } -async def get_error_log(hass, aiohttp_client, expected_count): +async def get_error_log(hass, hass_client, expected_count): """Fetch all entries from system_log via the API.""" - client = await aiohttp_client(hass.http.app) + client = await hass_client() resp = await client.get('/api/error/all') assert resp.status == 200 @@ -45,37 +45,37 @@ def get_frame(name): return (name, None, None, None) -async def test_normal_logs(hass, aiohttp_client): +async def test_normal_logs(hass, hass_client): """Test that debug and info are not logged.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.debug('debug') _LOGGER.info('info') # Assert done by get_error_log - await get_error_log(hass, aiohttp_client, 0) + await get_error_log(hass, hass_client, 0) -async def test_exception(hass, aiohttp_client): +async def test_exception(hass, hass_client): """Test that exceptions are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _generate_and_log_exception('exception message', 'log message') - log = (await get_error_log(hass, aiohttp_client, 1))[0] + log = (await get_error_log(hass, hass_client, 1))[0] assert_log(log, 'exception message', 'log message', 'ERROR') -async def test_warning(hass, aiohttp_client): +async def test_warning(hass, hass_client): """Test that warning are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.warning('warning message') - log = (await get_error_log(hass, aiohttp_client, 1))[0] + log = (await get_error_log(hass, hass_client, 1))[0] assert_log(log, '', 'warning message', 'WARNING') -async def test_error(hass, aiohttp_client): +async def test_error(hass, hass_client): """Test that errors are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.error('error message') - log = (await get_error_log(hass, aiohttp_client, 1))[0] + log = (await get_error_log(hass, hass_client, 1))[0] assert_log(log, '', 'error message', 'ERROR') @@ -121,26 +121,26 @@ def event_listener(event): assert_log(events[0].data, '', 'error message', 'ERROR') -async def test_critical(hass, aiohttp_client): +async def test_critical(hass, hass_client): """Test that critical are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.critical('critical message') - log = (await get_error_log(hass, aiohttp_client, 1))[0] + log = (await get_error_log(hass, hass_client, 1))[0] assert_log(log, '', 'critical message', 'CRITICAL') -async def test_remove_older_logs(hass, aiohttp_client): +async def test_remove_older_logs(hass, hass_client): """Test that older logs are rotated out.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.error('error message 1') _LOGGER.error('error message 2') _LOGGER.error('error message 3') - log = await get_error_log(hass, aiohttp_client, 2) + log = await get_error_log(hass, hass_client, 2) assert_log(log[0], '', 'error message 3', 'ERROR') assert_log(log[1], '', 'error message 2', 'ERROR') -async def test_clear_logs(hass, aiohttp_client): +async def test_clear_logs(hass, hass_client): """Test that the log can be cleared via a service call.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.error('error message') @@ -151,7 +151,7 @@ async def test_clear_logs(hass, aiohttp_client): await hass.async_block_till_done() # Assert done by get_error_log - await get_error_log(hass, aiohttp_client, 0) + await get_error_log(hass, hass_client, 0) async def test_write_log(hass): @@ -197,13 +197,13 @@ async def test_write_choose_level(hass): assert logger.method_calls[0] == ('debug', ('test_message',)) -async def test_unknown_path(hass, aiohttp_client): +async def test_unknown_path(hass, hass_client): """Test error logged from unknown path.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.findCaller = MagicMock( return_value=('unknown_path', 0, None, None)) _LOGGER.error('error message') - log = (await get_error_log(hass, aiohttp_client, 1))[0] + log = (await get_error_log(hass, hass_client, 1))[0] assert log['source'] == 'unknown_path' @@ -222,31 +222,31 @@ def log_error_from_test_path(path): _LOGGER.error('error message') -async def test_homeassistant_path(hass, aiohttp_client): +async def test_homeassistant_path(hass, hass_client): """Test error logged from homeassistant path.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) with patch('homeassistant.components.system_log.HOMEASSISTANT_PATH', new=['venv_path/homeassistant']): log_error_from_test_path( 'venv_path/homeassistant/component/component.py') - log = (await get_error_log(hass, aiohttp_client, 1))[0] + log = (await get_error_log(hass, hass_client, 1))[0] assert log['source'] == 'component/component.py' -async def test_config_path(hass, aiohttp_client): +async def test_config_path(hass, hass_client): """Test error logged from config path.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) with patch.object(hass.config, 'config_dir', new='config'): log_error_from_test_path('config/custom_component/test.py') - log = (await get_error_log(hass, aiohttp_client, 1))[0] + log = (await get_error_log(hass, hass_client, 1))[0] assert log['source'] == 'custom_component/test.py' -async def test_netdisco_path(hass, aiohttp_client): +async def test_netdisco_path(hass, hass_client): """Test error logged from netdisco path.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) with patch.dict('sys.modules', netdisco=MagicMock(__path__=['venv_path/netdisco'])): log_error_from_test_path('venv_path/netdisco/disco_component.py') - log = (await get_error_log(hass, aiohttp_client, 1))[0] + log = (await get_error_log(hass, hass_client, 1))[0] assert log['source'] == 'disco_component.py' diff --git a/tests/components/test_webhook.py b/tests/components/test_webhook.py index c16fef3e0592a..e67cf7481ccbe 100644 --- a/tests/components/test_webhook.py +++ b/tests/components/test_webhook.py @@ -7,10 +7,10 @@ @pytest.fixture -def mock_client(hass, aiohttp_client): +def mock_client(hass, hass_client): """Create http client for webhooks.""" hass.loop.run_until_complete(async_setup_component(hass, 'webhook', {})) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + return hass.loop.run_until_complete(hass_client()) async def test_unregistering_webhook(hass, mock_client): From 4f2e7fc91254f588dc4f9d4d5d1511e38f2abaa6 Mon Sep 17 00:00:00 2001 From: Matt Hamilton Date: Tue, 27 Nov 2018 04:42:56 -0500 Subject: [PATCH 077/325] remove pbkdf2 upgrade path (#18736) --- homeassistant/auth/providers/homeassistant.py | 31 ------- tests/auth/providers/test_homeassistant.py | 89 ------------------- 2 files changed, 120 deletions(-) diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index 8710e7c60bc09..19aeea5b22e6f 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -1,8 +1,6 @@ """Home Assistant auth provider.""" import base64 from collections import OrderedDict -import hashlib -import hmac from typing import Any, Dict, List, Optional, cast import bcrypt @@ -11,7 +9,6 @@ from homeassistant.const import CONF_ID from homeassistant.core import callback, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.async_ import run_coroutine_threadsafe from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow @@ -94,39 +91,11 @@ def validate_login(self, username: str, password: str) -> None: user_hash = base64.b64decode(found['password']) - # if the hash is not a bcrypt hash... - # provide a transparant upgrade for old pbkdf2 hash format - if not (user_hash.startswith(b'$2a$') - or user_hash.startswith(b'$2b$') - or user_hash.startswith(b'$2x$') - or user_hash.startswith(b'$2y$')): - # IMPORTANT! validate the login, bail if invalid - hashed = self.legacy_hash_password(password) - if not hmac.compare_digest(hashed, user_hash): - raise InvalidAuth - # then re-hash the valid password with bcrypt - self.change_password(found['username'], password) - run_coroutine_threadsafe( - self.async_save(), self.hass.loop - ).result() - user_hash = base64.b64decode(found['password']) - # bcrypt.checkpw is timing-safe if not bcrypt.checkpw(password.encode(), user_hash): raise InvalidAuth - def legacy_hash_password(self, password: str, - for_storage: bool = False) -> bytes: - """LEGACY password encoding.""" - # We're no longer storing salts in data, but if one exists we - # should be able to retrieve it. - salt = self._data['salt'].encode() # type: ignore - hashed = hashlib.pbkdf2_hmac('sha512', password.encode(), salt, 100000) - if for_storage: - hashed = base64.b64encode(hashed) - return hashed - # pylint: disable=no-self-use def hash_password(self, password: str, for_storage: bool = False) -> bytes: """Encode a password.""" diff --git a/tests/auth/providers/test_homeassistant.py b/tests/auth/providers/test_homeassistant.py index 84beb8cdd3f47..d3fa27b9f5bcc 100644 --- a/tests/auth/providers/test_homeassistant.py +++ b/tests/auth/providers/test_homeassistant.py @@ -1,7 +1,6 @@ """Test the Home Assistant local auth provider.""" from unittest.mock import Mock -import base64 import pytest import voluptuous as vol @@ -134,91 +133,3 @@ async def test_new_users_populate_values(hass, data): user = await manager.async_get_or_create_user(credentials) assert user.name == 'hello' assert user.is_active - - -async def test_new_hashes_are_bcrypt(data, hass): - """Test that newly created hashes are using bcrypt.""" - data.add_auth('newuser', 'newpass') - found = None - for user in data.users: - if user['username'] == 'newuser': - found = user - assert found is not None - user_hash = base64.b64decode(found['password']) - assert (user_hash.startswith(b'$2a$') - or user_hash.startswith(b'$2b$') - or user_hash.startswith(b'$2x$') - or user_hash.startswith(b'$2y$')) - - -async def test_pbkdf2_to_bcrypt_hash_upgrade(hass_storage, hass): - """Test migrating user from pbkdf2 hash to bcrypt hash.""" - hass_storage[hass_auth.STORAGE_KEY] = { - 'version': hass_auth.STORAGE_VERSION, - 'key': hass_auth.STORAGE_KEY, - 'data': { - 'salt': '09c52f0b120eaa7dea5f73f9a9b985f3d493b30a08f3f2945ef613' - '0b08e6a3ea', - 'users': [ - { - 'password': 'L5PAbehB8LAQI2Ixu+d+PDNJKmljqLnBcYWYw35onC/8D' - 'BM1SpvT6A8ZFael5+deCt+s+43J08IcztnguouHSw==', - 'username': 'legacyuser' - } - ] - }, - } - data = hass_auth.Data(hass) - await data.async_load() - - # verify the correct (pbkdf2) password successfuly authenticates the user - await hass.async_add_executor_job( - data.validate_login, 'legacyuser', 'beer') - - # ...and that the hashes are now bcrypt hashes - user_hash = base64.b64decode( - hass_storage[hass_auth.STORAGE_KEY]['data']['users'][0]['password']) - assert (user_hash.startswith(b'$2a$') - or user_hash.startswith(b'$2b$') - or user_hash.startswith(b'$2x$') - or user_hash.startswith(b'$2y$')) - - -async def test_pbkdf2_to_bcrypt_hash_upgrade_with_incorrect_pass(hass_storage, - hass): - """Test migrating user from pbkdf2 hash to bcrypt hash.""" - hass_storage[hass_auth.STORAGE_KEY] = { - 'version': hass_auth.STORAGE_VERSION, - 'key': hass_auth.STORAGE_KEY, - 'data': { - 'salt': '09c52f0b120eaa7dea5f73f9a9b985f3d493b30a08f3f2945ef613' - '0b08e6a3ea', - 'users': [ - { - 'password': 'L5PAbehB8LAQI2Ixu+d+PDNJKmljqLnBcYWYw35onC/8D' - 'BM1SpvT6A8ZFael5+deCt+s+43J08IcztnguouHSw==', - 'username': 'legacyuser' - } - ] - }, - } - data = hass_auth.Data(hass) - await data.async_load() - - orig_user_hash = base64.b64decode( - hass_storage[hass_auth.STORAGE_KEY]['data']['users'][0]['password']) - - # Make sure invalid legacy passwords fail - with pytest.raises(hass_auth.InvalidAuth): - await hass.async_add_executor_job( - data.validate_login, 'legacyuser', 'wine') - - # Make sure we don't change the password/hash when password is incorrect - with pytest.raises(hass_auth.InvalidAuth): - await hass.async_add_executor_job( - data.validate_login, 'legacyuser', 'wine') - - same_user_hash = base64.b64decode( - hass_storage[hass_auth.STORAGE_KEY]['data']['users'][0]['password']) - - assert orig_user_hash == same_user_hash From 6170065a2cf936aedf92ae88ca026d37fc924a7f Mon Sep 17 00:00:00 2001 From: emontnemery Date: Tue, 27 Nov 2018 11:22:26 +0100 Subject: [PATCH 078/325] Reconfigure MQTT cover component if discovery info is changed (#18175) * Reconfigure MQTT cover component if discovery info is changed * Do not pass hass to MqttCover constructor --- homeassistant/components/cover/mqtt.py | 213 +++++++------- tests/components/cover/test_mqtt.py | 368 +++++++++++++++---------- 2 files changed, 334 insertions(+), 247 deletions(-) diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index f51cca8a276de..92394fc026bb8 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -5,7 +5,6 @@ https://home-assistant.io/components/cover.mqtt/ """ import logging -from typing import Optional import voluptuous as vol @@ -24,7 +23,7 @@ ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, valid_publish_topic, valid_subscribe_topic, - MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo) + MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -130,7 +129,7 @@ def validate_options(value): async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None): """Set up MQTT cover through configuration.yaml.""" - await _async_setup_entity(hass, config, async_add_entities) + await _async_setup_entity(config, async_add_entities) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -138,7 +137,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add an MQTT cover.""" config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(hass, config, async_add_entities, + await _async_setup_entity(config, async_add_entities, discovery_payload[ATTR_DISCOVERY_HASH]) async_dispatcher_connect( @@ -146,106 +145,117 @@ async def async_discover(discovery_payload): async_discover) -async def _async_setup_entity(hass, config, async_add_entities, +async def _async_setup_entity(config, async_add_entities, discovery_hash=None): """Set up the MQTT Cover.""" - value_template = config.get(CONF_VALUE_TEMPLATE) - if value_template is not None: - value_template.hass = hass - set_position_template = config.get(CONF_SET_POSITION_TEMPLATE) - if set_position_template is not None: - set_position_template.hass = hass - - async_add_entities([MqttCover( - config.get(CONF_NAME), - config.get(CONF_STATE_TOPIC), - config.get(CONF_GET_POSITION_TOPIC), - config.get(CONF_COMMAND_TOPIC), - config.get(CONF_AVAILABILITY_TOPIC), - config.get(CONF_TILT_COMMAND_TOPIC), - config.get(CONF_TILT_STATUS_TOPIC), - config.get(CONF_QOS), - config.get(CONF_RETAIN), - config.get(CONF_STATE_OPEN), - config.get(CONF_STATE_CLOSED), - config.get(CONF_POSITION_OPEN), - config.get(CONF_POSITION_CLOSED), - config.get(CONF_PAYLOAD_OPEN), - config.get(CONF_PAYLOAD_CLOSE), - config.get(CONF_PAYLOAD_STOP), - config.get(CONF_PAYLOAD_AVAILABLE), - config.get(CONF_PAYLOAD_NOT_AVAILABLE), - config.get(CONF_OPTIMISTIC), - value_template, - config.get(CONF_TILT_OPEN_POSITION), - config.get(CONF_TILT_CLOSED_POSITION), - config.get(CONF_TILT_MIN), - config.get(CONF_TILT_MAX), - config.get(CONF_TILT_STATE_OPTIMISTIC), - config.get(CONF_TILT_INVERT_STATE), - config.get(CONF_SET_POSITION_TOPIC), - set_position_template, - config.get(CONF_UNIQUE_ID), - config.get(CONF_DEVICE), - discovery_hash - )]) + async_add_entities([MqttCover(config, discovery_hash)]) class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, CoverDevice): """Representation of a cover that can be controlled using MQTT.""" - def __init__(self, name, state_topic, get_position_topic, - command_topic, availability_topic, - tilt_command_topic, tilt_status_topic, qos, retain, - state_open, state_closed, position_open, position_closed, - payload_open, payload_close, payload_stop, payload_available, - payload_not_available, optimistic, value_template, - tilt_open_position, tilt_closed_position, tilt_min, tilt_max, - tilt_optimistic, tilt_invert, set_position_topic, - set_position_template, unique_id: Optional[str], - device_config: Optional[ConfigType], discovery_hash): + def __init__(self, config, discovery_hash): """Initialize the cover.""" - MqttAvailability.__init__(self, availability_topic, qos, - payload_available, payload_not_available) - MqttDiscoveryUpdate.__init__(self, discovery_hash) - MqttEntityDeviceInfo.__init__(self, device_config) self._position = None self._state = None - self._name = name - self._state_topic = state_topic - self._get_position_topic = get_position_topic - self._command_topic = command_topic - self._tilt_command_topic = tilt_command_topic - self._tilt_status_topic = tilt_status_topic - self._qos = qos - self._payload_open = payload_open - self._payload_close = payload_close - self._payload_stop = payload_stop - self._state_open = state_open - self._state_closed = state_closed - self._position_open = position_open - self._position_closed = position_closed - self._retain = retain - self._tilt_open_position = tilt_open_position - self._tilt_closed_position = tilt_closed_position - self._optimistic = (optimistic or (state_topic is None and - get_position_topic is None)) - self._template = value_template + self._sub_state = None + + self._name = None + self._state_topic = None + self._get_position_topic = None + self._command_topic = None + self._tilt_command_topic = None + self._tilt_status_topic = None + self._qos = None + self._payload_open = None + self._payload_close = None + self._payload_stop = None + self._state_open = None + self._state_closed = None + self._position_open = None + self._position_closed = None + self._retain = None + self._tilt_open_position = None + self._tilt_closed_position = None + self._optimistic = None + self._template = None self._tilt_value = None - self._tilt_min = tilt_min - self._tilt_max = tilt_max - self._tilt_optimistic = tilt_optimistic - self._tilt_invert = tilt_invert - self._set_position_topic = set_position_topic - self._set_position_template = set_position_template - self._unique_id = unique_id - self._discovery_hash = discovery_hash + self._tilt_min = None + self._tilt_max = None + self._tilt_optimistic = None + self._tilt_invert = None + self._set_position_topic = None + self._set_position_template = None + self._unique_id = None + + # Load config + self._setup_from_config(config) + + availability_topic = config.get(CONF_AVAILABILITY_TOPIC) + payload_available = config.get(CONF_PAYLOAD_AVAILABLE) + payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) + device_config = config.get(CONF_DEVICE) + + MqttAvailability.__init__(self, availability_topic, self._qos, + payload_available, payload_not_available) + MqttDiscoveryUpdate.__init__(self, discovery_hash, + self.discovery_update) + MqttEntityDeviceInfo.__init__(self, device_config) async def async_added_to_hass(self): """Subscribe MQTT events.""" await MqttAvailability.async_added_to_hass(self) await MqttDiscoveryUpdate.async_added_to_hass(self) + await self._subscribe_topics() + + async def discovery_update(self, discovery_payload): + """Handle updated discovery message.""" + config = PLATFORM_SCHEMA(discovery_payload) + self._setup_from_config(config) + await self.availability_discovery_update(config) + await self._subscribe_topics() + self.async_schedule_update_ha_state() + + def _setup_from_config(self, config): + self._name = config.get(CONF_NAME) + self._state_topic = config.get(CONF_STATE_TOPIC) + self._get_position_topic = config.get(CONF_GET_POSITION_TOPIC) + self._command_topic = config.get(CONF_COMMAND_TOPIC) + self._tilt_command_topic = config.get(CONF_TILT_COMMAND_TOPIC) + self._tilt_status_topic = config.get(CONF_TILT_STATUS_TOPIC) + self._qos = config.get(CONF_QOS) + self._retain = config.get(CONF_RETAIN) + self._state_open = config.get(CONF_STATE_OPEN) + self._state_closed = config.get(CONF_STATE_CLOSED) + self._position_open = config.get(CONF_POSITION_OPEN) + self._position_closed = config.get(CONF_POSITION_CLOSED) + self._payload_open = config.get(CONF_PAYLOAD_OPEN) + self._payload_close = config.get(CONF_PAYLOAD_CLOSE) + self._payload_stop = config.get(CONF_PAYLOAD_STOP) + self._optimistic = (config.get(CONF_OPTIMISTIC) or + (self._state_topic is None and + self._get_position_topic is None)) + self._template = config.get(CONF_VALUE_TEMPLATE) + self._tilt_open_position = config.get(CONF_TILT_OPEN_POSITION) + self._tilt_closed_position = config.get(CONF_TILT_CLOSED_POSITION) + self._tilt_min = config.get(CONF_TILT_MIN) + self._tilt_max = config.get(CONF_TILT_MAX) + self._tilt_optimistic = config.get(CONF_TILT_STATE_OPTIMISTIC) + self._tilt_invert = config.get(CONF_TILT_INVERT_STATE) + self._set_position_topic = config.get(CONF_SET_POSITION_TOPIC) + self._set_position_template = config.get(CONF_SET_POSITION_TEMPLATE) + + self._unique_id = config.get(CONF_UNIQUE_ID) + + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" + if self._template is not None: + self._template.hass = self.hass + if self._set_position_template is not None: + self._set_position_template.hass = self.hass + + topics = {} @callback def tilt_updated(topic, payload, qos): @@ -293,13 +303,15 @@ def position_message_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._get_position_topic: - await mqtt.async_subscribe( - self.hass, self._get_position_topic, - position_message_received, self._qos) + topics['get_position_topic'] = { + 'topic': self._get_position_topic, + 'msg_callback': position_message_received, + 'qos': self._qos} elif self._state_topic: - await mqtt.async_subscribe( - self.hass, self._state_topic, - state_message_received, self._qos) + topics['state_topic'] = { + 'topic': self._state_topic, + 'msg_callback': state_message_received, + 'qos': self._qos} else: # Force into optimistic mode. self._optimistic = True @@ -309,8 +321,19 @@ def position_message_received(topic, payload, qos): else: self._tilt_optimistic = False self._tilt_value = STATE_UNKNOWN - await mqtt.async_subscribe( - self.hass, self._tilt_status_topic, tilt_updated, self._qos) + topics['tilt_status_topic'] = { + 'topic': self._tilt_status_topic, + 'msg_callback': tilt_updated, + 'qos': self._qos} + + self._sub_state = await subscription.async_subscribe_topics( + self.hass, self._sub_state, + topics) + + async def async_will_remove_from_hass(self): + """Unsubscribe when removed.""" + await subscription.async_unsubscribe_topics(self.hass, self._sub_state) + await MqttAvailability.async_will_remove_from_hass(self) @property def should_poll(self): diff --git a/tests/components/cover/test_mqtt.py b/tests/components/cover/test_mqtt.py index 26204ce6ebdf7..df47a6caf4852 100644 --- a/tests/components/cover/test_mqtt.py +++ b/tests/components/cover/test_mqtt.py @@ -734,25 +734,29 @@ def test_tilt_position_altered_range(self): def test_find_percentage_in_range_defaults(self): """Test find percentage in range with default range.""" mqtt_cover = MqttCover( - name='cover.test', - state_topic='state-topic', - get_position_topic=None, - command_topic='command-topic', - availability_topic=None, - tilt_command_topic='tilt-command-topic', - tilt_status_topic='tilt-status-topic', - qos=0, - retain=False, - state_open='OPEN', state_closed='CLOSE', - position_open=100, position_closed=0, - payload_open='OPEN', payload_close='CLOSE', payload_stop='STOP', - payload_available=None, payload_not_available=None, - optimistic=False, value_template=None, - tilt_open_position=100, tilt_closed_position=0, - tilt_min=0, tilt_max=100, tilt_optimistic=False, - tilt_invert=False, - set_position_topic=None, set_position_template=None, - unique_id=None, device_config=None, discovery_hash=None) + { + 'name': 'cover.test', + 'state_topic': 'state-topic', + 'get_position_topic': None, + 'command_topic': 'command-topic', + 'availability_topic': None, + 'tilt_command_topic': 'tilt-command-topic', + 'tilt_status_topic': 'tilt-status-topic', + 'qos': 0, + 'retain': False, + 'state_open': 'OPEN', 'state_closed': 'CLOSE', + 'position_open': 100, 'position_closed': 0, + 'payload_open': 'OPEN', 'payload_close': 'CLOSE', + 'payload_stop': 'STOP', + 'payload_available': None, 'payload_not_available': None, + 'optimistic': False, 'value_template': None, + 'tilt_open_position': 100, 'tilt_closed_position': 0, + 'tilt_min': 0, 'tilt_max': 100, 'tilt_optimistic': False, + 'tilt_invert_state': False, + 'set_position_topic': None, 'set_position_template': None, + 'unique_id': None, 'device_config': None, + }, + None) assert 44 == mqtt_cover.find_percentage_in_range(44) assert 44 == mqtt_cover.find_percentage_in_range(44, 'cover') @@ -760,25 +764,29 @@ def test_find_percentage_in_range_defaults(self): def test_find_percentage_in_range_altered(self): """Test find percentage in range with altered range.""" mqtt_cover = MqttCover( - name='cover.test', - state_topic='state-topic', - get_position_topic=None, - command_topic='command-topic', - availability_topic=None, - tilt_command_topic='tilt-command-topic', - tilt_status_topic='tilt-status-topic', - qos=0, - retain=False, - state_open='OPEN', state_closed='CLOSE', - position_open=180, position_closed=80, - payload_open='OPEN', payload_close='CLOSE', payload_stop='STOP', - payload_available=None, payload_not_available=None, - optimistic=False, value_template=None, - tilt_open_position=180, tilt_closed_position=80, - tilt_min=80, tilt_max=180, tilt_optimistic=False, - tilt_invert=False, - set_position_topic=None, set_position_template=None, - unique_id=None, device_config=None, discovery_hash=None) + { + 'name': 'cover.test', + 'state_topic': 'state-topic', + 'get_position_topic': None, + 'command_topic': 'command-topic', + 'availability_topic': None, + 'tilt_command_topic': 'tilt-command-topic', + 'tilt_status_topic': 'tilt-status-topic', + 'qos': 0, + 'retain': False, + 'state_open': 'OPEN', 'state_closed': 'CLOSE', + 'position_open': 180, 'position_closed': 80, + 'payload_open': 'OPEN', 'payload_close': 'CLOSE', + 'payload_stop': 'STOP', + 'payload_available': None, 'payload_not_available': None, + 'optimistic': False, 'value_template': None, + 'tilt_open_position': 180, 'tilt_closed_position': 80, + 'tilt_min': 80, 'tilt_max': 180, 'tilt_optimistic': False, + 'tilt_invert_state': False, + 'set_position_topic': None, 'set_position_template': None, + 'unique_id': None, 'device_config': None, + }, + None) assert 40 == mqtt_cover.find_percentage_in_range(120) assert 40 == mqtt_cover.find_percentage_in_range(120, 'cover') @@ -786,25 +794,29 @@ def test_find_percentage_in_range_altered(self): def test_find_percentage_in_range_defaults_inverted(self): """Test find percentage in range with default range but inverted.""" mqtt_cover = MqttCover( - name='cover.test', - state_topic='state-topic', - get_position_topic=None, - command_topic='command-topic', - availability_topic=None, - tilt_command_topic='tilt-command-topic', - tilt_status_topic='tilt-status-topic', - qos=0, - retain=False, - state_open='OPEN', state_closed='CLOSE', - position_open=0, position_closed=100, - payload_open='OPEN', payload_close='CLOSE', payload_stop='STOP', - payload_available=None, payload_not_available=None, - optimistic=False, value_template=None, - tilt_open_position=100, tilt_closed_position=0, - tilt_min=0, tilt_max=100, tilt_optimistic=False, - tilt_invert=True, - set_position_topic=None, set_position_template=None, - unique_id=None, device_config=None, discovery_hash=None) + { + 'name': 'cover.test', + 'state_topic': 'state-topic', + 'get_position_topic': None, + 'command_topic': 'command-topic', + 'availability_topic': None, + 'tilt_command_topic': 'tilt-command-topic', + 'tilt_status_topic': 'tilt-status-topic', + 'qos': 0, + 'retain': False, + 'state_open': 'OPEN', 'state_closed': 'CLOSE', + 'position_open': 0, 'position_closed': 100, + 'payload_open': 'OPEN', 'payload_close': 'CLOSE', + 'payload_stop': 'STOP', + 'payload_available': None, 'payload_not_available': None, + 'optimistic': False, 'value_template': None, + 'tilt_open_position': 100, 'tilt_closed_position': 0, + 'tilt_min': 0, 'tilt_max': 100, 'tilt_optimistic': False, + 'tilt_invert_state': True, + 'set_position_topic': None, 'set_position_template': None, + 'unique_id': None, 'device_config': None, + }, + None) assert 56 == mqtt_cover.find_percentage_in_range(44) assert 56 == mqtt_cover.find_percentage_in_range(44, 'cover') @@ -812,25 +824,29 @@ def test_find_percentage_in_range_defaults_inverted(self): def test_find_percentage_in_range_altered_inverted(self): """Test find percentage in range with altered range and inverted.""" mqtt_cover = MqttCover( - name='cover.test', - state_topic='state-topic', - get_position_topic=None, - command_topic='command-topic', - availability_topic=None, - tilt_command_topic='tilt-command-topic', - tilt_status_topic='tilt-status-topic', - qos=0, - retain=False, - state_open='OPEN', state_closed='CLOSE', - position_open=80, position_closed=180, - payload_open='OPEN', payload_close='CLOSE', payload_stop='STOP', - payload_available=None, payload_not_available=None, - optimistic=False, value_template=None, - tilt_open_position=180, tilt_closed_position=80, - tilt_min=80, tilt_max=180, tilt_optimistic=False, - tilt_invert=True, - set_position_topic=None, set_position_template=None, - unique_id=None, device_config=None, discovery_hash=None) + { + 'name': 'cover.test', + 'state_topic': 'state-topic', + 'get_position_topic': None, + 'command_topic': 'command-topic', + 'availability_topic': None, + 'tilt_command_topic': 'tilt-command-topic', + 'tilt_status_topic': 'tilt-status-topic', + 'qos': 0, + 'retain': False, + 'state_open': 'OPEN', 'state_closed': 'CLOSE', + 'position_open': 80, 'position_closed': 180, + 'payload_open': 'OPEN', 'payload_close': 'CLOSE', + 'payload_stop': 'STOP', + 'payload_available': None, 'payload_not_available': None, + 'optimistic': False, 'value_template': None, + 'tilt_open_position': 180, 'tilt_closed_position': 80, + 'tilt_min': 80, 'tilt_max': 180, 'tilt_optimistic': False, + 'tilt_invert_state': True, + 'set_position_topic': None, 'set_position_template': None, + 'unique_id': None, 'device_config': None, + }, + None) assert 60 == mqtt_cover.find_percentage_in_range(120) assert 60 == mqtt_cover.find_percentage_in_range(120, 'cover') @@ -838,25 +854,29 @@ def test_find_percentage_in_range_altered_inverted(self): def test_find_in_range_defaults(self): """Test find in range with default range.""" mqtt_cover = MqttCover( - name='cover.test', - state_topic='state-topic', - get_position_topic=None, - command_topic='command-topic', - availability_topic=None, - tilt_command_topic='tilt-command-topic', - tilt_status_topic='tilt-status-topic', - qos=0, - retain=False, - state_open='OPEN', state_closed='CLOSE', - position_open=100, position_closed=0, - payload_open='OPEN', payload_close='CLOSE', payload_stop='STOP', - payload_available=None, payload_not_available=None, - optimistic=False, value_template=None, - tilt_open_position=100, tilt_closed_position=0, - tilt_min=0, tilt_max=100, tilt_optimistic=False, - tilt_invert=False, - set_position_topic=None, set_position_template=None, - unique_id=None, device_config=None, discovery_hash=None) + { + 'name': 'cover.test', + 'state_topic': 'state-topic', + 'get_position_topic': None, + 'command_topic': 'command-topic', + 'availability_topic': None, + 'tilt_command_topic': 'tilt-command-topic', + 'tilt_status_topic': 'tilt-status-topic', + 'qos': 0, + 'retain': False, + 'state_open': 'OPEN', 'state_closed': 'CLOSE', + 'position_open': 100, 'position_closed': 0, + 'payload_open': 'OPEN', 'payload_close': 'CLOSE', + 'payload_stop': 'STOP', + 'payload_available': None, 'payload_not_available': None, + 'optimistic': False, 'value_template': None, + 'tilt_open_position': 100, 'tilt_closed_position': 0, + 'tilt_min': 0, 'tilt_max': 100, 'tilt_optimistic': False, + 'tilt_invert_state': False, + 'set_position_topic': None, 'set_position_template': None, + 'unique_id': None, 'device_config': None, + }, + None) assert 44 == mqtt_cover.find_in_range_from_percent(44) assert 44 == mqtt_cover.find_in_range_from_percent(44, 'cover') @@ -864,25 +884,29 @@ def test_find_in_range_defaults(self): def test_find_in_range_altered(self): """Test find in range with altered range.""" mqtt_cover = MqttCover( - name='cover.test', - state_topic='state-topic', - get_position_topic=None, - command_topic='command-topic', - availability_topic=None, - tilt_command_topic='tilt-command-topic', - tilt_status_topic='tilt-status-topic', - qos=0, - retain=False, - state_open='OPEN', state_closed='CLOSE', - position_open=180, position_closed=80, - payload_open='OPEN', payload_close='CLOSE', payload_stop='STOP', - payload_available=None, payload_not_available=None, - optimistic=False, value_template=None, - tilt_open_position=180, tilt_closed_position=80, - tilt_min=80, tilt_max=180, tilt_optimistic=False, - tilt_invert=False, - set_position_topic=None, set_position_template=None, - unique_id=None, device_config=None, discovery_hash=None) + { + 'name': 'cover.test', + 'state_topic': 'state-topic', + 'get_position_topic': None, + 'command_topic': 'command-topic', + 'availability_topic': None, + 'tilt_command_topic': 'tilt-command-topic', + 'tilt_status_topic': 'tilt-status-topic', + 'qos': 0, + 'retain': False, + 'state_open': 'OPEN', 'state_closed': 'CLOSE', + 'position_open': 180, 'position_closed': 80, + 'payload_open': 'OPEN', 'payload_close': 'CLOSE', + 'payload_stop': 'STOP', + 'payload_available': None, 'payload_not_available': None, + 'optimistic': False, 'value_template': None, + 'tilt_open_position': 180, 'tilt_closed_position': 80, + 'tilt_min': 80, 'tilt_max': 180, 'tilt_optimistic': False, + 'tilt_invert_state': False, + 'set_position_topic': None, 'set_position_template': None, + 'unique_id': None, 'device_config': None, + }, + None) assert 120 == mqtt_cover.find_in_range_from_percent(40) assert 120 == mqtt_cover.find_in_range_from_percent(40, 'cover') @@ -890,25 +914,29 @@ def test_find_in_range_altered(self): def test_find_in_range_defaults_inverted(self): """Test find in range with default range but inverted.""" mqtt_cover = MqttCover( - name='cover.test', - state_topic='state-topic', - get_position_topic=None, - command_topic='command-topic', - availability_topic=None, - tilt_command_topic='tilt-command-topic', - tilt_status_topic='tilt-status-topic', - qos=0, - retain=False, - state_open='OPEN', state_closed='CLOSE', - position_open=0, position_closed=100, - payload_open='OPEN', payload_close='CLOSE', payload_stop='STOP', - payload_available=None, payload_not_available=None, - optimistic=False, value_template=None, - tilt_open_position=100, tilt_closed_position=0, - tilt_min=0, tilt_max=100, tilt_optimistic=False, - tilt_invert=True, - set_position_topic=None, set_position_template=None, - unique_id=None, device_config=None, discovery_hash=None) + { + 'name': 'cover.test', + 'state_topic': 'state-topic', + 'get_position_topic': None, + 'command_topic': 'command-topic', + 'availability_topic': None, + 'tilt_command_topic': 'tilt-command-topic', + 'tilt_status_topic': 'tilt-status-topic', + 'qos': 0, + 'retain': False, + 'state_open': 'OPEN', 'state_closed': 'CLOSE', + 'position_open': 0, 'position_closed': 100, + 'payload_open': 'OPEN', 'payload_close': 'CLOSE', + 'payload_stop': 'STOP', + 'payload_available': None, 'payload_not_available': None, + 'optimistic': False, 'value_template': None, + 'tilt_open_position': 100, 'tilt_closed_position': 0, + 'tilt_min': 0, 'tilt_max': 100, 'tilt_optimistic': False, + 'tilt_invert_state': True, + 'set_position_topic': None, 'set_position_template': None, + 'unique_id': None, 'device_config': None, + }, + None) assert 44 == mqtt_cover.find_in_range_from_percent(56) assert 44 == mqtt_cover.find_in_range_from_percent(56, 'cover') @@ -916,25 +944,29 @@ def test_find_in_range_defaults_inverted(self): def test_find_in_range_altered_inverted(self): """Test find in range with altered range and inverted.""" mqtt_cover = MqttCover( - name='cover.test', - state_topic='state-topic', - get_position_topic=None, - command_topic='command-topic', - availability_topic=None, - tilt_command_topic='tilt-command-topic', - tilt_status_topic='tilt-status-topic', - qos=0, - retain=False, - state_open='OPEN', state_closed='CLOSE', - position_open=80, position_closed=180, - payload_open='OPEN', payload_close='CLOSE', payload_stop='STOP', - payload_available=None, payload_not_available=None, - optimistic=False, value_template=None, - tilt_open_position=180, tilt_closed_position=80, - tilt_min=80, tilt_max=180, tilt_optimistic=False, - tilt_invert=True, - set_position_topic=None, set_position_template=None, - unique_id=None, device_config=None, discovery_hash=None) + { + 'name': 'cover.test', + 'state_topic': 'state-topic', + 'get_position_topic': None, + 'command_topic': 'command-topic', + 'availability_topic': None, + 'tilt_command_topic': 'tilt-command-topic', + 'tilt_status_topic': 'tilt-status-topic', + 'qos': 0, + 'retain': False, + 'state_open': 'OPEN', 'state_closed': 'CLOSE', + 'position_open': 80, 'position_closed': 180, + 'payload_open': 'OPEN', 'payload_close': 'CLOSE', + 'payload_stop': 'STOP', + 'payload_available': None, 'payload_not_available': None, + 'optimistic': False, 'value_template': None, + 'tilt_open_position': 180, 'tilt_closed_position': 80, + 'tilt_min': 80, 'tilt_max': 180, 'tilt_optimistic': False, + 'tilt_invert_state': True, + 'set_position_topic': None, 'set_position_template': None, + 'unique_id': None, 'device_config': None, + }, + None) assert 120 == mqtt_cover.find_in_range_from_percent(60) assert 120 == mqtt_cover.find_in_range_from_percent(60, 'cover') @@ -1032,6 +1064,38 @@ async def test_discovery_removal_cover(hass, mqtt_mock, caplog): assert state is None +async def test_discovery_update_cover(hass, mqtt_mock, caplog): + """Test removal of discovered cover.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer",' + ' "command_topic": "test_topic" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "command_topic": "test_topic" }' + ) + async_fire_mqtt_message(hass, 'homeassistant/cover/bla/config', + data1) + await hass.async_block_till_done() + state = hass.states.get('cover.beer') + assert state is not None + assert state.name == 'Beer' + + async_fire_mqtt_message(hass, 'homeassistant/cover/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('cover.beer') + assert state is not None + assert state.name == 'Milk' + + state = hass.states.get('cover.milk') + assert state is None + + async def test_unique_id(hass): """Test unique_id option only creates one cover per id.""" await async_mock_mqtt_component(hass) From 4a4ed128dbf822a2c689aae79f7dbbaac5fd438c Mon Sep 17 00:00:00 2001 From: emontnemery Date: Tue, 27 Nov 2018 11:22:55 +0100 Subject: [PATCH 079/325] Reconfigure MQTT fan component if discovery info is changed (#18177) --- homeassistant/components/fan/mqtt.py | 167 ++++++++++++++++----------- tests/components/fan/test_mqtt.py | 32 +++++ 2 files changed, 134 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/fan/mqtt.py index 1ff04cd913a18..505a6e90720aa 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/fan/mqtt.py @@ -5,7 +5,6 @@ https://home-assistant.io/components/fan.mqtt/ """ import logging -from typing import Optional import voluptuous as vol @@ -18,7 +17,7 @@ ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, MqttAvailability, MqttDiscoveryUpdate, - MqttEntityDeviceInfo) + MqttEntityDeviceInfo, subscription) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import HomeAssistantType, ConfigType @@ -107,8 +106,67 @@ async def _async_setup_entity(hass, config, async_add_entities, discovery_hash=None): """Set up the MQTT fan.""" async_add_entities([MqttFan( - config.get(CONF_NAME), - { + config, + discovery_hash, + )]) + + +class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, + FanEntity): + """A MQTT fan component.""" + + def __init__(self, config, discovery_hash): + """Initialize the MQTT fan.""" + self._state = False + self._speed = None + self._oscillation = None + self._supported_features = 0 + self._sub_state = None + + self._name = None + self._topic = None + self._qos = None + self._retain = None + self._payload = None + self._templates = None + self._speed_list = None + self._optimistic = None + self._optimistic_oscillation = None + self._optimistic_speed = None + self._unique_id = None + + # Load config + self._setup_from_config(config) + + availability_topic = config.get(CONF_AVAILABILITY_TOPIC) + payload_available = config.get(CONF_PAYLOAD_AVAILABLE) + payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) + device_config = config.get(CONF_DEVICE) + + MqttAvailability.__init__(self, availability_topic, self._qos, + payload_available, payload_not_available) + MqttDiscoveryUpdate.__init__(self, discovery_hash, + self.discovery_update) + MqttEntityDeviceInfo.__init__(self, device_config) + + async def async_added_to_hass(self): + """Subscribe to MQTT events.""" + await MqttAvailability.async_added_to_hass(self) + await MqttDiscoveryUpdate.async_added_to_hass(self) + await self._subscribe_topics() + + async def discovery_update(self, discovery_payload): + """Handle updated discovery message.""" + config = PLATFORM_SCHEMA(discovery_payload) + self._setup_from_config(config) + await self.availability_discovery_update(config) + await self._subscribe_topics() + self.async_schedule_update_ha_state() + + def _setup_from_config(self, config): + """(Re)Setup the entity.""" + self._name = config.get(CONF_NAME) + self._topic = { key: config.get(key) for key in ( CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, @@ -117,15 +175,15 @@ async def _async_setup_entity(hass, config, async_add_entities, CONF_OSCILLATION_STATE_TOPIC, CONF_OSCILLATION_COMMAND_TOPIC, ) - }, - { + } + self._templates = { CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE), ATTR_SPEED: config.get(CONF_SPEED_VALUE_TEMPLATE), OSCILLATION: config.get(CONF_OSCILLATION_VALUE_TEMPLATE) - }, - config.get(CONF_QOS), - config.get(CONF_RETAIN), - { + } + self._qos = config.get(CONF_QOS) + self._retain = config.get(CONF_RETAIN) + self._payload = { STATE_ON: config.get(CONF_PAYLOAD_ON), STATE_OFF: config.get(CONF_PAYLOAD_OFF), OSCILLATE_ON_PAYLOAD: config.get(CONF_PAYLOAD_OSCILLATION_ON), @@ -133,59 +191,26 @@ async def _async_setup_entity(hass, config, async_add_entities, SPEED_LOW: config.get(CONF_PAYLOAD_LOW_SPEED), SPEED_MEDIUM: config.get(CONF_PAYLOAD_MEDIUM_SPEED), SPEED_HIGH: config.get(CONF_PAYLOAD_HIGH_SPEED), - }, - config.get(CONF_SPEED_LIST), - config.get(CONF_OPTIMISTIC), - config.get(CONF_AVAILABILITY_TOPIC), - config.get(CONF_PAYLOAD_AVAILABLE), - config.get(CONF_PAYLOAD_NOT_AVAILABLE), - config.get(CONF_UNIQUE_ID), - config.get(CONF_DEVICE), - discovery_hash, - )]) - - -class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, - FanEntity): - """A MQTT fan component.""" - - def __init__(self, name, topic, templates, qos, retain, payload, - speed_list, optimistic, availability_topic, payload_available, - payload_not_available, unique_id: Optional[str], - device_config: Optional[ConfigType], discovery_hash): - """Initialize the MQTT fan.""" - MqttAvailability.__init__(self, availability_topic, qos, - payload_available, payload_not_available) - MqttDiscoveryUpdate.__init__(self, discovery_hash) - MqttEntityDeviceInfo.__init__(self, device_config) - self._name = name - self._topic = topic - self._qos = qos - self._retain = retain - self._payload = payload - self._templates = templates - self._speed_list = speed_list - self._optimistic = optimistic or topic[CONF_STATE_TOPIC] is None + } + self._speed_list = config.get(CONF_SPEED_LIST) + optimistic = config.get(CONF_OPTIMISTIC) + self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None self._optimistic_oscillation = ( - optimistic or topic[CONF_OSCILLATION_STATE_TOPIC] is None) + optimistic or self._topic[CONF_OSCILLATION_STATE_TOPIC] is None) self._optimistic_speed = ( - optimistic or topic[CONF_SPEED_STATE_TOPIC] is None) - self._state = False - self._speed = None - self._oscillation = None + optimistic or self._topic[CONF_SPEED_STATE_TOPIC] is None) + self._supported_features = 0 - self._supported_features |= (topic[CONF_OSCILLATION_STATE_TOPIC] + self._supported_features |= (self._topic[CONF_OSCILLATION_STATE_TOPIC] is not None and SUPPORT_OSCILLATE) - self._supported_features |= (topic[CONF_SPEED_STATE_TOPIC] + self._supported_features |= (self._topic[CONF_SPEED_STATE_TOPIC] is not None and SUPPORT_SET_SPEED) - self._unique_id = unique_id - self._discovery_hash = discovery_hash - async def async_added_to_hass(self): - """Subscribe to MQTT events.""" - await MqttAvailability.async_added_to_hass(self) - await MqttDiscoveryUpdate.async_added_to_hass(self) + self._unique_id = config.get(CONF_UNIQUE_ID) + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" + topics = {} templates = {} for key, tpl in list(self._templates.items()): if tpl is None: @@ -205,9 +230,10 @@ def state_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_STATE_TOPIC], state_received, - self._qos) + topics[CONF_STATE_TOPIC] = { + 'topic': self._topic[CONF_STATE_TOPIC], + 'msg_callback': state_received, + 'qos': self._qos} @callback def speed_received(topic, payload, qos): @@ -222,9 +248,10 @@ def speed_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_SPEED_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_SPEED_STATE_TOPIC], speed_received, - self._qos) + topics[CONF_SPEED_STATE_TOPIC] = { + 'topic': self._topic[CONF_SPEED_STATE_TOPIC], + 'msg_callback': speed_received, + 'qos': self._qos} self._speed = SPEED_OFF @callback @@ -238,11 +265,21 @@ def oscillation_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_OSCILLATION_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_OSCILLATION_STATE_TOPIC], - oscillation_received, self._qos) + topics[CONF_OSCILLATION_STATE_TOPIC] = { + 'topic': self._topic[CONF_OSCILLATION_STATE_TOPIC], + 'msg_callback': oscillation_received, + 'qos': self._qos} self._oscillation = False + self._sub_state = await subscription.async_subscribe_topics( + self.hass, self._sub_state, + topics) + + async def async_will_remove_from_hass(self): + """Unsubscribe when removed.""" + await subscription.async_unsubscribe_topics(self.hass, self._sub_state) + await MqttAvailability.async_will_remove_from_hass(self) + @property def should_poll(self): """No polling needed for a MQTT fan.""" diff --git a/tests/components/fan/test_mqtt.py b/tests/components/fan/test_mqtt.py index a3f76058c76c1..a3e8b0e9f32d6 100644 --- a/tests/components/fan/test_mqtt.py +++ b/tests/components/fan/test_mqtt.py @@ -130,6 +130,38 @@ async def test_discovery_removal_fan(hass, mqtt_mock, caplog): assert state is None +async def test_discovery_update_fan(hass, mqtt_mock, caplog): + """Test removal of discovered fan.""" + entry = MockConfigEntry(domain='mqtt') + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer",' + ' "command_topic": "test_topic" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "command_topic": "test_topic" }' + ) + async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('fan.beer') + assert state is not None + assert state.name == 'Beer' + + async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('fan.beer') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('fan.milk') + assert state is None + + async def test_unique_id(hass): """Test unique_id option only creates one fan per id.""" await async_mock_mqtt_component(hass) From a03cb12c61b5a12e37bfb7a404b280e2eb66111d Mon Sep 17 00:00:00 2001 From: emontnemery Date: Tue, 27 Nov 2018 11:23:47 +0100 Subject: [PATCH 080/325] Reconfigure MQTT sensor component if discovery info is changed (#18178) * Reconfigure MQTT sensor component if discovery info is changed * Do not pass hass to MqttSensor constructor * Remove duplicated line --- homeassistant/components/sensor/mqtt.py | 122 ++++++++++++++---------- tests/components/sensor/test_mqtt.py | 33 +++++++ 2 files changed, 105 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/sensor/mqtt.py index 225ed07a6227a..68f49961cf960 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -16,7 +16,7 @@ from homeassistant.components.mqtt import ( ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, - MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo) + MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW from homeassistant.components.sensor import DEVICE_CLASSES_SCHEMA from homeassistant.const import ( @@ -58,7 +58,7 @@ async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None): """Set up MQTT sensors through configuration.yaml.""" - await _async_setup_entity(hass, config, async_add_entities) + await _async_setup_entity(config, async_add_entities) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -66,7 +66,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover_sensor(discovery_payload): """Discover and add a discovered MQTT sensor.""" config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(hass, config, async_add_entities, + await _async_setup_entity(config, async_add_entities, discovery_payload[ATTR_DISCOVERY_HASH]) async_dispatcher_connect(hass, @@ -74,67 +74,81 @@ async def async_discover_sensor(discovery_payload): async_discover_sensor) -async def _async_setup_entity(hass: HomeAssistantType, config: ConfigType, - async_add_entities, discovery_hash=None): +async def _async_setup_entity(config: ConfigType, async_add_entities, + discovery_hash=None): """Set up MQTT sensor.""" - value_template = config.get(CONF_VALUE_TEMPLATE) - if value_template is not None: - value_template.hass = hass - - async_add_entities([MqttSensor( - config.get(CONF_NAME), - config.get(CONF_STATE_TOPIC), - config.get(CONF_QOS), - config.get(CONF_UNIT_OF_MEASUREMENT), - config.get(CONF_FORCE_UPDATE), - config.get(CONF_EXPIRE_AFTER), - config.get(CONF_ICON), - config.get(CONF_DEVICE_CLASS), - value_template, - config.get(CONF_JSON_ATTRS), - config.get(CONF_UNIQUE_ID), - config.get(CONF_AVAILABILITY_TOPIC), - config.get(CONF_PAYLOAD_AVAILABLE), - config.get(CONF_PAYLOAD_NOT_AVAILABLE), - config.get(CONF_DEVICE), - discovery_hash, - )]) + async_add_entities([MqttSensor(config, discovery_hash)]) class MqttSensor(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, Entity): """Representation of a sensor that can be updated using MQTT.""" - def __init__(self, name, state_topic, qos, unit_of_measurement, - force_update, expire_after, icon, device_class: Optional[str], - value_template, json_attributes, unique_id: Optional[str], - availability_topic, payload_available, payload_not_available, - device_config: Optional[ConfigType], discovery_hash): + def __init__(self, config, discovery_hash): """Initialize the sensor.""" - MqttAvailability.__init__(self, availability_topic, qos, - payload_available, payload_not_available) - MqttDiscoveryUpdate.__init__(self, discovery_hash) - MqttEntityDeviceInfo.__init__(self, device_config) self._state = STATE_UNKNOWN - self._name = name - self._state_topic = state_topic - self._qos = qos - self._unit_of_measurement = unit_of_measurement - self._force_update = force_update - self._template = value_template - self._expire_after = expire_after - self._icon = icon - self._device_class = device_class + self._sub_state = None self._expiration_trigger = None - self._json_attributes = set(json_attributes) - self._unique_id = unique_id self._attributes = None - self._discovery_hash = discovery_hash + + self._name = None + self._state_topic = None + self._qos = None + self._unit_of_measurement = None + self._force_update = None + self._template = None + self._expire_after = None + self._icon = None + self._device_class = None + self._json_attributes = None + self._unique_id = None + + # Load config + self._setup_from_config(config) + + availability_topic = config.get(CONF_AVAILABILITY_TOPIC) + payload_available = config.get(CONF_PAYLOAD_AVAILABLE) + payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) + device_config = config.get(CONF_DEVICE) + + MqttAvailability.__init__(self, availability_topic, self._qos, + payload_available, payload_not_available) + MqttDiscoveryUpdate.__init__(self, discovery_hash, + self.discovery_update) + MqttEntityDeviceInfo.__init__(self, device_config) async def async_added_to_hass(self): """Subscribe to MQTT events.""" await MqttAvailability.async_added_to_hass(self) await MqttDiscoveryUpdate.async_added_to_hass(self) + await self._subscribe_topics() + + async def discovery_update(self, discovery_payload): + """Handle updated discovery message.""" + config = PLATFORM_SCHEMA(discovery_payload) + self._setup_from_config(config) + await self.availability_discovery_update(config) + await self._subscribe_topics() + self.async_schedule_update_ha_state() + + def _setup_from_config(self, config): + """(Re)Setup the entity.""" + self._name = config.get(CONF_NAME) + self._state_topic = config.get(CONF_STATE_TOPIC) + self._qos = config.get(CONF_QOS) + self._unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + self._force_update = config.get(CONF_FORCE_UPDATE) + self._expire_after = config.get(CONF_EXPIRE_AFTER) + self._icon = config.get(CONF_ICON) + self._device_class = config.get(CONF_DEVICE_CLASS) + self._template = config.get(CONF_VALUE_TEMPLATE) + self._json_attributes = set(config.get(CONF_JSON_ATTRS)) + self._unique_id = config.get(CONF_UNIQUE_ID) + + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" + if self._template is not None: + self._template.hass = self.hass @callback def message_received(topic, payload, qos): @@ -173,8 +187,16 @@ def message_received(topic, payload, qos): self._state = payload self.async_schedule_update_ha_state() - await mqtt.async_subscribe(self.hass, self._state_topic, - message_received, self._qos) + self._sub_state = await subscription.async_subscribe_topics( + self.hass, self._sub_state, + {'state_topic': {'topic': self._state_topic, + 'msg_callback': message_received, + 'qos': self._qos}}) + + async def async_will_remove_from_hass(self): + """Unsubscribe when removed.""" + await subscription.async_unsubscribe_topics(self.hass, self._sub_state) + await MqttAvailability.async_will_remove_from_hass(self) @callback def value_is_expired(self, *_): diff --git a/tests/components/sensor/test_mqtt.py b/tests/components/sensor/test_mqtt.py index 15042805a66f9..78de05e1ff38f 100644 --- a/tests/components/sensor/test_mqtt.py +++ b/tests/components/sensor/test_mqtt.py @@ -412,6 +412,39 @@ async def test_discovery_removal_sensor(hass, mqtt_mock, caplog): assert state is None +async def test_discovery_update_sensor(hass, mqtt_mock, caplog): + """Test removal of discovered sensor.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer",' + ' "status_topic": "test_topic" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "status_topic": "test_topic" }' + ) + async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('sensor.beer') + assert state is not None + assert state.name == 'Beer' + + async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('sensor.beer') + assert state is not None + assert state.name == 'Milk' + + state = hass.states.get('sensor.milk') + assert state is None + + async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT sensor device registry integration.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) From 9a25054a0d884b4b6690483151ec2d099124c0ae Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Tue, 27 Nov 2018 11:17:22 +0000 Subject: [PATCH 081/325] Add zones to evohome component (#18428) * Added Zones, and removed available() logic flesh out Zones tidy up init some more tidying up Nearly there - full functionality passed txo - ready to send PR Ready to PR, except to remove logging Add Zones and associated functionality to evohome component Add Zones to evohome (some more tidying up) Add Zones to evohome (Nearly there - full functionality) Add Zones to evohome (passed tox) Add Zones to evohome (except to remove logging) Add Zones and associated functionality to evohome component Revert _LOGGER.warn to .debug, as it should be Cleanup stupid REBASE * removed a duplicate/unwanted code block * tidy up comment * use async_added_to_hass instead of bus.listen * Pass evo_data instead of hass when instntiating * switch to async version of setup_platform/add_entities * Remove workaround for bug in client library - using github version for now, as awaiting new PyPi package * Avoid invalid-name lint - use 'zone_idx' instead of 'z' * Fix line too long error * remove commented-out line of code * fix a logic error, improve REDACTION of potentially-sensitive infomation * restore use of EVENT_HOMEASSISTANT_START to improve HA startup time * added a docstring to _flatten_json * Switch instantiation from component to platform * Use v0.2.8 of client api (resolves logging bug) * import rather than duplicate, and de-lint * We use evohomeclient v0.2.8 now * remove all the api logging * Changed scan_interal to Throttle * added a configurable scan_interval * small code tidy-up, removed sub-function * tidy up update() code * minimize use of self.hass.data[] * remove lint * remove unwanted logging * remove debug code * correct a small coding error * small tidyup of code * remove flatten_json * add @callback to _first_update() * switch back to load_platform * adhere to standards fro logging * use new format string formatting * minor change to comments * convert scan_interval to timedelta from int * restore rounding up of scan_interval * code tidy up * sync when in sync context * fix typo * remove raises not needed * tidy up typos, etc. * remove invalid-name lint * tidy up exception handling * de-lint/pretty-fy * move 'status' to a JSON node, so theirs room for 'config', 'schedule' in the future --- homeassistant/components/climate/evohome.py | 631 ++++++++++++------ homeassistant/components/climate/honeywell.py | 2 +- homeassistant/components/evohome.py | 130 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 497 insertions(+), 270 deletions(-) diff --git a/homeassistant/components/climate/evohome.py b/homeassistant/components/climate/evohome.py index f0631228fd843..fd58e6c01e868 100644 --- a/homeassistant/components/climate/evohome.py +++ b/homeassistant/components/climate/evohome.py @@ -1,7 +1,7 @@ -"""Support for Honeywell evohome (EMEA/EU-based systems only). +"""Support for Climate devices of (EMEA/EU-based) Honeywell evohome systems. Support for a temperature control system (TCS, controller) with 0+ heating -zones (e.g. TRVs, relays) and, optionally, a DHW controller. +zones (e.g. TRVs, relays). For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.evohome/ @@ -13,29 +13,34 @@ from requests.exceptions import HTTPError from homeassistant.components.climate import ( - ClimateDevice, - STATE_AUTO, - STATE_ECO, - STATE_OFF, - SUPPORT_OPERATION_MODE, + STATE_AUTO, STATE_ECO, STATE_MANUAL, STATE_OFF, SUPPORT_AWAY_MODE, + SUPPORT_ON_OFF, + SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, + ClimateDevice ) from homeassistant.components.evohome import ( - CONF_LOCATION_IDX, - DATA_EVOHOME, - MAX_TEMP, - MIN_TEMP, - SCAN_INTERVAL_MAX + DATA_EVOHOME, DISPATCHER_EVOHOME, + CONF_LOCATION_IDX, SCAN_INTERVAL_DEFAULT, + EVO_PARENT, EVO_CHILD, + GWS, TCS, ) from homeassistant.const import ( CONF_SCAN_INTERVAL, - PRECISION_TENTHS, - TEMP_CELSIUS, HTTP_TOO_MANY_REQUESTS, + PRECISION_HALVES, + TEMP_CELSIUS ) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import ( + dispatcher_send, + async_dispatcher_connect +) + _LOGGER = logging.getLogger(__name__) -# these are for the controller's opmode/state and the zone's state +# the Controller's opmode/state and the zone's (inherited) state EVO_RESET = 'AutoWithReset' EVO_AUTO = 'Auto' EVO_AUTOECO = 'AutoWithEco' @@ -44,7 +49,14 @@ EVO_CUSTOM = 'Custom' EVO_HEATOFF = 'HeatingOff' -EVO_STATE_TO_HA = { +# these are for Zones' opmode, and state +EVO_FOLLOW = 'FollowSchedule' +EVO_TEMPOVER = 'TemporaryOverride' +EVO_PERMOVER = 'PermanentOverride' + +# for the Controller. NB: evohome treats Away mode as a mode in/of itself, +# where HA considers it to 'override' the exising operating mode +TCS_STATE_TO_HA = { EVO_RESET: STATE_AUTO, EVO_AUTO: STATE_AUTO, EVO_AUTOECO: STATE_ECO, @@ -53,156 +65,415 @@ EVO_CUSTOM: STATE_AUTO, EVO_HEATOFF: STATE_OFF } - -HA_STATE_TO_EVO = { +HA_STATE_TO_TCS = { STATE_AUTO: EVO_AUTO, STATE_ECO: EVO_AUTOECO, STATE_OFF: EVO_HEATOFF } +TCS_OP_LIST = list(HA_STATE_TO_TCS) + +# the Zones' opmode; their state is usually 'inherited' from the TCS +EVO_FOLLOW = 'FollowSchedule' +EVO_TEMPOVER = 'TemporaryOverride' +EVO_PERMOVER = 'PermanentOverride' + +# for the Zones... +ZONE_STATE_TO_HA = { + EVO_FOLLOW: STATE_AUTO, + EVO_TEMPOVER: STATE_MANUAL, + EVO_PERMOVER: STATE_MANUAL +} +HA_STATE_TO_ZONE = { + STATE_AUTO: EVO_FOLLOW, + STATE_MANUAL: EVO_PERMOVER +} +ZONE_OP_LIST = list(HA_STATE_TO_ZONE) -HA_OP_LIST = list(HA_STATE_TO_EVO) - -# these are used to help prevent E501 (line too long) violations -GWS = 'gateways' -TCS = 'temperatureControlSystems' - -# debug codes - these happen occasionally, but the cause is unknown -EVO_DEBUG_NO_RECENT_UPDATES = '0x01' -EVO_DEBUG_NO_STATUS = '0x02' - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Create a Honeywell (EMEA/EU) evohome CH/DHW system. - - An evohome system consists of: a controller, with 0-12 heating zones (e.g. - TRVs, relays) and, optionally, a DHW controller (a HW boiler). - Here, we add the controller only. - """ +async def async_setup_platform(hass, hass_config, async_add_entities, + discovery_info=None): + """Create the evohome Controller, and its Zones, if any.""" evo_data = hass.data[DATA_EVOHOME] client = evo_data['client'] loc_idx = evo_data['params'][CONF_LOCATION_IDX] - # evohomeclient has no defined way of accessing non-default location other - # than using a protected member, such as below + # evohomeclient has exposed no means of accessing non-default location + # (i.e. loc_idx > 0) other than using a protected member, such as below tcs_obj_ref = client.locations[loc_idx]._gateways[0]._control_systems[0] # noqa E501; pylint: disable=protected-access _LOGGER.debug( - "setup_platform(): Found Controller: id: %s [%s], type: %s", + "setup_platform(): Found Controller, id=%s [%s], " + "name=%s (location_idx=%s)", tcs_obj_ref.systemId, + tcs_obj_ref.modelType, tcs_obj_ref.location.name, - tcs_obj_ref.modelType + loc_idx ) - parent = EvoController(evo_data, client, tcs_obj_ref) - add_entities([parent], update_before_add=True) + controller = EvoController(evo_data, client, tcs_obj_ref) + zones = [] -class EvoController(ClimateDevice): - """Base for a Honeywell evohome hub/Controller device. + for zone_idx in tcs_obj_ref.zones: + zone_obj_ref = tcs_obj_ref.zones[zone_idx] + _LOGGER.debug( + "setup_platform(): Found Zone, id=%s [%s], " + "name=%s", + zone_obj_ref.zoneId, + zone_obj_ref.zone_type, + zone_obj_ref.name + ) + zones.append(EvoZone(evo_data, client, zone_obj_ref)) - The Controller (aka TCS, temperature control system) is the parent of all - the child (CH/DHW) devices. - """ + entities = [controller] + zones - def __init__(self, evo_data, client, obj_ref): - """Initialize the evohome entity. + async_add_entities(entities, update_before_add=False) - Most read-only properties are set here. So are pseudo read-only, - for example name (which _could_ change between update()s). - """ - self.client = client - self._obj = obj_ref - self._id = obj_ref.systemId - self._name = evo_data['config']['locationInfo']['name'] +class EvoClimateDevice(ClimateDevice): + """Base for a Honeywell evohome Climate device.""" + + # pylint: disable=no-member + + def __init__(self, evo_data, client, obj_ref): + """Initialize the evohome entity.""" + self._client = client + self._obj = obj_ref - self._config = evo_data['config'][GWS][0][TCS][0] self._params = evo_data['params'] self._timers = evo_data['timers'] - - self._timers['statusUpdated'] = datetime.min self._status = {} self._available = False # should become True after first update() - def _handle_requests_exceptions(self, err): - # evohomeclient v2 api (>=0.2.7) exposes requests exceptions, incl.: - # - HTTP_BAD_REQUEST, is usually Bad user credentials - # - HTTP_TOO_MANY_REQUESTS, is api usuage limit exceeded - # - HTTP_SERVICE_UNAVAILABLE, is often Vendor's fault + async def async_added_to_hass(self): + """Run when entity about to be added.""" + async_dispatcher_connect(self.hass, DISPATCHER_EVOHOME, self._connect) + + @callback + def _connect(self, packet): + if packet['to'] & self._type and packet['signal'] == 'refresh': + self.async_schedule_update_ha_state(force_refresh=True) + def _handle_requests_exceptions(self, err): if err.response.status_code == HTTP_TOO_MANY_REQUESTS: - # execute a back off: pause, and reduce rate - old_scan_interval = self._params[CONF_SCAN_INTERVAL] - new_scan_interval = min(old_scan_interval * 2, SCAN_INTERVAL_MAX) - self._params[CONF_SCAN_INTERVAL] = new_scan_interval + # execute a backoff: pause, and also reduce rate + old_interval = self._params[CONF_SCAN_INTERVAL] + new_interval = min(old_interval, SCAN_INTERVAL_DEFAULT) * 2 + self._params[CONF_SCAN_INTERVAL] = new_interval _LOGGER.warning( - "API rate limit has been exceeded: increasing '%s' from %s to " - "%s seconds, and suspending polling for %s seconds.", + "API rate limit has been exceeded. Suspending polling for %s " + "seconds, and increasing '%s' from %s to %s seconds.", + new_interval * 3, CONF_SCAN_INTERVAL, - old_scan_interval, - new_scan_interval, - new_scan_interval * 3 + old_interval, + new_interval, ) - self._timers['statusUpdated'] = datetime.now() + \ - timedelta(seconds=new_scan_interval * 3) + self._timers['statusUpdated'] = datetime.now() + new_interval * 3 else: - raise err + raise err # we dont handle any other HTTPErrors @property - def name(self): + def name(self) -> str: """Return the name to use in the frontend UI.""" return self._name @property - def available(self): - """Return True if the device is available. + def icon(self): + """Return the icon to use in the frontend UI.""" + return self._icon - All evohome entities are initially unavailable. Once HA has started, - state data is then retrieved by the Controller, and then the children - will get a state (e.g. operating_mode, current_temperature). + @property + def device_state_attributes(self): + """Return the device state attributes of the evohome Climate device. - However, evohome entities can become unavailable for other reasons. + This is state data that is not available otherwise, due to the + restrictions placed upon ClimateDevice properties, etc. by HA. """ + return {'status': self._status} + + @property + def available(self) -> bool: + """Return True if the device is currently available.""" return self._available @property def supported_features(self): - """Get the list of supported features of the Controller.""" - return SUPPORT_OPERATION_MODE | SUPPORT_AWAY_MODE + """Get the list of supported features of the device.""" + return self._supported_features @property - def device_state_attributes(self): - """Return the device state attributes of the controller. + def operation_list(self): + """Return the list of available operations.""" + return self._operation_list + + @property + def temperature_unit(self): + """Return the temperature unit to use in the frontend UI.""" + return TEMP_CELSIUS + + @property + def precision(self): + """Return the temperature precision to use in the frontend UI.""" + return PRECISION_HALVES + + +class EvoZone(EvoClimateDevice): + """Base for a Honeywell evohome Zone device.""" + + def __init__(self, evo_data, client, obj_ref): + """Initialize the evohome Zone.""" + super().__init__(evo_data, client, obj_ref) + + self._id = obj_ref.zoneId + self._name = obj_ref.name + self._icon = "mdi:radiator" + self._type = EVO_CHILD + + for _zone in evo_data['config'][GWS][0][TCS][0]['zones']: + if _zone['zoneId'] == self._id: + self._config = _zone + break + self._status = {} - This is operating mode state data that is not available otherwise, due - to the restrictions placed upon ClimateDevice properties, etc by HA. + self._operation_list = ZONE_OP_LIST + self._supported_features = \ + SUPPORT_OPERATION_MODE | \ + SUPPORT_TARGET_TEMPERATURE | \ + SUPPORT_ON_OFF + + @property + def min_temp(self): + """Return the minimum target temperature of a evohome Zone. + + The default is 5 (in Celsius), but it is configurable within 5-35. """ - data = {} - data['systemMode'] = self._status['systemModeStatus']['mode'] - data['isPermanent'] = self._status['systemModeStatus']['isPermanent'] - if 'timeUntil' in self._status['systemModeStatus']: - data['timeUntil'] = self._status['systemModeStatus']['timeUntil'] - data['activeFaults'] = self._status['activeFaults'] - return data + return self._config['setpointCapabilities']['minHeatSetpoint'] @property - def operation_list(self): - """Return the list of available operations.""" - return HA_OP_LIST + def max_temp(self): + """Return the minimum target temperature of a evohome Zone. + + The default is 35 (in Celsius), but it is configurable within 5-35. + """ + return self._config['setpointCapabilities']['maxHeatSetpoint'] + + @property + def target_temperature(self): + """Return the target temperature of the evohome Zone.""" + return self._status['setpointStatus']['targetHeatTemperature'] + + @property + def current_temperature(self): + """Return the current temperature of the evohome Zone.""" + return self._status['temperatureStatus']['temperature'] @property def current_operation(self): - """Return the operation mode of the evohome entity.""" - return EVO_STATE_TO_HA.get(self._status['systemModeStatus']['mode']) + """Return the current operating mode of the evohome Zone. + + The evohome Zones that are in 'FollowSchedule' mode inherit their + actual operating mode from the Controller. + """ + evo_data = self.hass.data[DATA_EVOHOME] + + system_mode = evo_data['status']['systemModeStatus']['mode'] + setpoint_mode = self._status['setpointStatus']['setpointMode'] + + if setpoint_mode == EVO_FOLLOW: + # then inherit state from the controller + if system_mode == EVO_RESET: + current_operation = TCS_STATE_TO_HA.get(EVO_AUTO) + else: + current_operation = TCS_STATE_TO_HA.get(system_mode) + else: + current_operation = ZONE_STATE_TO_HA.get(setpoint_mode) + + return current_operation + + @property + def is_on(self) -> bool: + """Return True if the evohome Zone is off. + + A Zone is considered off if its target temp is set to its minimum, and + it is not following its schedule (i.e. not in 'FollowSchedule' mode). + """ + is_off = \ + self.target_temperature == self.min_temp and \ + self._status['setpointStatus']['setpointMode'] == EVO_PERMOVER + return not is_off + + def _set_temperature(self, temperature, until=None): + """Set the new target temperature of a Zone. + + temperature is required, until can be: + - strftime('%Y-%m-%dT%H:%M:%SZ') for TemporaryOverride, or + - None for PermanentOverride (i.e. indefinitely) + """ + try: + self._obj.set_temperature(temperature, until) + except HTTPError as err: + self._handle_exception("HTTPError", str(err)) # noqa: E501; pylint: disable=no-member + + def set_temperature(self, **kwargs): + """Set new target temperature, indefinitely.""" + self._set_temperature(kwargs['temperature'], until=None) + + def turn_on(self): + """Turn the evohome Zone on. + + This is achieved by setting the Zone to its 'FollowSchedule' mode. + """ + self._set_operation_mode(EVO_FOLLOW) + + def turn_off(self): + """Turn the evohome Zone off. + + This is achieved by setting the Zone to its minimum temperature, + indefinitely (i.e. 'PermanentOverride' mode). + """ + self._set_temperature(self.min_temp, until=None) + + def set_operation_mode(self, operation_mode): + """Set an operating mode for a Zone. + + Currently limited to 'Auto' & 'Manual'. If 'Off' is needed, it can be + enabled via turn_off method. + + NB: evohome Zones do not have an operating mode as understood by HA. + Instead they usually 'inherit' an operating mode from their controller. + + More correctly, these Zones are in a follow mode, 'FollowSchedule', + where their setpoint temperatures are a function of their schedule, and + the Controller's operating_mode, e.g. Economy mode is their scheduled + setpoint less (usually) 3C. + + Thus, you cannot set a Zone to Away mode, but the location (i.e. the + Controller) is set to Away and each Zones's setpoints are adjusted + accordingly to some lower temperature. + + However, Zones can override these setpoints, either for a specified + period of time, 'TemporaryOverride', after which they will revert back + to 'FollowSchedule' mode, or indefinitely, 'PermanentOverride'. + """ + self._set_operation_mode(HA_STATE_TO_ZONE.get(operation_mode)) + + def _set_operation_mode(self, operation_mode): + if operation_mode == EVO_FOLLOW: + try: + self._obj.cancel_temp_override(self._obj) + except HTTPError as err: + self._handle_exception("HTTPError", str(err)) # noqa: E501; pylint: disable=no-member + + elif operation_mode == EVO_TEMPOVER: + _LOGGER.error( + "_set_operation_mode(op_mode=%s): mode not yet implemented", + operation_mode + ) + + elif operation_mode == EVO_PERMOVER: + self._set_temperature(self.target_temperature, until=None) + + else: + _LOGGER.error( + "_set_operation_mode(op_mode=%s): mode not valid", + operation_mode + ) + + @property + def should_poll(self) -> bool: + """Return False as evohome child devices should never be polled. + + The evohome Controller will inform its children when to update(). + """ + return False + + def update(self): + """Process the evohome Zone's state data.""" + evo_data = self.hass.data[DATA_EVOHOME] + + for _zone in evo_data['status']['zones']: + if _zone['zoneId'] == self._id: + self._status = _zone + break + + self._available = True + + +class EvoController(EvoClimateDevice): + """Base for a Honeywell evohome hub/Controller device. + + The Controller (aka TCS, temperature control system) is the parent of all + the child (CH/DHW) devices. It is also a Climate device. + """ + + def __init__(self, evo_data, client, obj_ref): + """Initialize the evohome Controller (hub).""" + super().__init__(evo_data, client, obj_ref) + + self._id = obj_ref.systemId + self._name = '_{}'.format(obj_ref.location.name) + self._icon = "mdi:thermostat" + self._type = EVO_PARENT + + self._config = evo_data['config'][GWS][0][TCS][0] + self._status = evo_data['status'] + self._timers['statusUpdated'] = datetime.min + + self._operation_list = TCS_OP_LIST + self._supported_features = \ + SUPPORT_OPERATION_MODE | \ + SUPPORT_AWAY_MODE + + @property + def device_state_attributes(self): + """Return the device state attributes of the evohome Controller. + + This is state data that is not available otherwise, due to the + restrictions placed upon ClimateDevice properties, etc. by HA. + """ + status = dict(self._status) + + if 'zones' in status: + del status['zones'] + if 'dhw' in status: + del status['dhw'] + + return {'status': status} + + @property + def current_operation(self): + """Return the current operating mode of the evohome Controller.""" + return TCS_STATE_TO_HA.get(self._status['systemModeStatus']['mode']) + + @property + def min_temp(self): + """Return the minimum target temperature of a evohome Controller. + + Although evohome Controllers do not have a minimum target temp, one is + expected by the HA schema; the default for an evohome HR92 is used. + """ + return 5 + + @property + def max_temp(self): + """Return the minimum target temperature of a evohome Controller. + + Although evohome Controllers do not have a maximum target temp, one is + expected by the HA schema; the default for an evohome HR92 is used. + """ + return 35 @property def target_temperature(self): - """Return the average target temperature of the Heating/DHW zones.""" + """Return the average target temperature of the Heating/DHW zones. + + Although evohome Controllers do not have a target temp, one is + expected by the HA schema. + """ temps = [zone['setpointStatus']['targetHeatTemperature'] for zone in self._status['zones']] @@ -211,7 +482,11 @@ def target_temperature(self): @property def current_temperature(self): - """Return the average current temperature of the Heating/DHW zones.""" + """Return the average current temperature of the Heating/DHW zones. + + Although evohome Controllers do not have a target temp, one is + expected by the HA schema. + """ tmp_list = [x for x in self._status['zones'] if x['temperatureStatus']['isAvailable'] is True] temps = [zone['temperatureStatus']['temperature'] for zone in tmp_list] @@ -220,54 +495,36 @@ def current_temperature(self): return avg_temp @property - def temperature_unit(self): - """Return the temperature unit to use in the frontend UI.""" - return TEMP_CELSIUS - - @property - def precision(self): - """Return the temperature precision to use in the frontend UI.""" - return PRECISION_TENTHS - - @property - def min_temp(self): - """Return the minimum target temp (setpoint) of a evohome entity.""" - return MIN_TEMP - - @property - def max_temp(self): - """Return the maximum target temp (setpoint) of a evohome entity.""" - return MAX_TEMP - - @property - def is_on(self): - """Return true as evohome controllers are always on. + def is_on(self) -> bool: + """Return True as evohome Controllers are always on. - Operating modes can include 'HeatingOff', but (for example) DHW would - remain on. + For example, evohome Controllers have a 'HeatingOff' mode, but even + then the DHW would remain on. """ return True @property - def is_away_mode_on(self): - """Return true if away mode is on.""" + def is_away_mode_on(self) -> bool: + """Return True if away mode is on.""" return self._status['systemModeStatus']['mode'] == EVO_AWAY def turn_away_mode_on(self): - """Turn away mode on.""" + """Turn away mode on. + + The evohome Controller will not remember is previous operating mode. + """ self._set_operation_mode(EVO_AWAY) def turn_away_mode_off(self): - """Turn away mode off.""" + """Turn away mode off. + + The evohome Controller can not recall its previous operating mode (as + intimated by the HA schema), so this method is achieved by setting the + Controller's mode back to Auto. + """ self._set_operation_mode(EVO_AUTO) def _set_operation_mode(self, operation_mode): - # Set new target operation mode for the TCS. - _LOGGER.debug( - "_set_operation_mode(): API call [1 request(s)]: " - "tcs._set_status(%s)...", - operation_mode - ) try: self._obj._set_status(operation_mode) # noqa: E501; pylint: disable=protected-access except HTTPError as err: @@ -279,93 +536,45 @@ def set_operation_mode(self, operation_mode): Currently limited to 'Auto', 'AutoWithEco' & 'HeatingOff'. If 'Away' mode is needed, it can be enabled via turn_away_mode_on method. """ - self._set_operation_mode(HA_STATE_TO_EVO.get(operation_mode)) + self._set_operation_mode(HA_STATE_TO_TCS.get(operation_mode)) - def _update_state_data(self, evo_data): - client = evo_data['client'] - loc_idx = evo_data['params'][CONF_LOCATION_IDX] - - _LOGGER.debug( - "_update_state_data(): API call [1 request(s)]: " - "client.locations[loc_idx].status()..." - ) - - try: - evo_data['status'].update( - client.locations[loc_idx].status()[GWS][0][TCS][0]) - except HTTPError as err: # check if we've exceeded the api rate limit - self._handle_requests_exceptions(err) - else: - evo_data['timers']['statusUpdated'] = datetime.now() - - _LOGGER.debug( - "_update_state_data(): evo_data['status'] = %s", - evo_data['status'] - ) + @property + def should_poll(self) -> bool: + """Return True as the evohome Controller should always be polled.""" + return True def update(self): - """Get the latest state data of the installation. + """Get the latest state data of the entire evohome Location. - This includes state data for the Controller and its child devices, such - as the operating_mode of the Controller and the current_temperature - of its children. - - This is not asyncio-friendly due to the underlying client api. + This includes state data for the Controller and all its child devices, + such as the operating mode of the Controller and the current temp of + its children (e.g. Zones, DHW controller). """ - evo_data = self.hass.data[DATA_EVOHOME] - + # should the latest evohome state data be retreived this cycle? timeout = datetime.now() + timedelta(seconds=55) expired = timeout > self._timers['statusUpdated'] + \ - timedelta(seconds=evo_data['params'][CONF_SCAN_INTERVAL]) + self._params[CONF_SCAN_INTERVAL] if not expired: return - was_available = self._available or \ - self._timers['statusUpdated'] == datetime.min - - self._update_state_data(evo_data) - self._status = evo_data['status'] - - if _LOGGER.isEnabledFor(logging.DEBUG): - tmp_dict = dict(self._status) - if 'zones' in tmp_dict: - tmp_dict['zones'] = '...' - if 'dhw' in tmp_dict: - tmp_dict['dhw'] = '...' - - _LOGGER.debug( - "update(%s), self._status = %s", - self._id + " [" + self._name + "]", - tmp_dict - ) - - no_recent_updates = self._timers['statusUpdated'] < datetime.now() - \ - timedelta(seconds=self._params[CONF_SCAN_INTERVAL] * 3.1) - - if no_recent_updates: - self._available = False - debug_code = EVO_DEBUG_NO_RECENT_UPDATES - - elif not self._status: - # unavailable because no status (but how? other than at startup?) - self._available = False - debug_code = EVO_DEBUG_NO_STATUS + # Retreive the latest state data via the client api + loc_idx = self._params[CONF_LOCATION_IDX] + try: + self._status.update( + self._client.locations[loc_idx].status()[GWS][0][TCS][0]) + except HTTPError as err: # check if we've exceeded the api rate limit + self._handle_requests_exceptions(err) else: + self._timers['statusUpdated'] = datetime.now() self._available = True - if not self._available and was_available: - # only warn if available went from True to False - _LOGGER.warning( - "The entity, %s, has become unavailable, debug code is: %s", - self._id + " [" + self._name + "]", - debug_code - ) + _LOGGER.debug( + "_update_state_data(): self._status = %s", + self._status + ) - elif self._available and not was_available: - # this isn't the first re-available (e.g. _after_ STARTUP) - _LOGGER.debug( - "The entity, %s, has become available", - self._id + " [" + self._name + "]" - ) + # inform the child devices that state data has been updated + pkt = {'sender': 'controller', 'signal': 'refresh', 'to': EVO_CHILD} + dispatcher_send(self.hass, DISPATCHER_EVOHOME, pkt) diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py index c445a49507300..e0f104a84b195 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -20,7 +20,7 @@ CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE, CONF_REGION) -REQUIREMENTS = ['evohomeclient==0.2.7', 'somecomfort==0.5.2'] +REQUIREMENTS = ['evohomeclient==0.2.8', 'somecomfort==0.5.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/evohome.py b/homeassistant/components/evohome.py index 397d3b9f6c09c..40ba5b9b70ff0 100644 --- a/homeassistant/components/evohome.py +++ b/homeassistant/components/evohome.py @@ -1,4 +1,4 @@ -"""Support for Honeywell evohome (EMEA/EU-based systems only). +"""Support for (EMEA/EU-based) Honeywell evohome systems. Support for a temperature control system (TCS, controller) with 0+ heating zones (e.g. TRVs, relays) and, optionally, a DHW controller. @@ -8,46 +8,48 @@ """ # Glossary: -# TCS - temperature control system (a.k.a. Controller, Parent), which can -# have up to 13 Children: -# 0-12 Heating zones (a.k.a. Zone), and -# 0-1 DHW controller, (a.k.a. Boiler) +# TCS - temperature control system (a.k.a. Controller, Parent), which can +# have up to 13 Children: +# 0-12 Heating zones (a.k.a. Zone), and +# 0-1 DHW controller, (a.k.a. Boiler) +# The TCS & Zones are implemented as Climate devices, Boiler as a WaterHeater +from datetime import timedelta import logging from requests.exceptions import HTTPError import voluptuous as vol from homeassistant.const import ( - CONF_USERNAME, - CONF_PASSWORD, - CONF_SCAN_INTERVAL, - HTTP_BAD_REQUEST + CONF_SCAN_INTERVAL, CONF_USERNAME, CONF_PASSWORD, + EVENT_HOMEASSISTANT_START, + HTTP_BAD_REQUEST, HTTP_SERVICE_UNAVAILABLE, HTTP_TOO_MANY_REQUESTS ) - +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_send -REQUIREMENTS = ['evohomeclient==0.2.7'] -# If ever > 0.2.7, re-check the work-around wrapper is still required when -# instantiating the client, below. +REQUIREMENTS = ['evohomeclient==0.2.8'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'evohome' DATA_EVOHOME = 'data_' + DOMAIN +DISPATCHER_EVOHOME = 'dispatcher_' + DOMAIN CONF_LOCATION_IDX = 'location_idx' -MAX_TEMP = 28 -MIN_TEMP = 5 -SCAN_INTERVAL_DEFAULT = 180 -SCAN_INTERVAL_MAX = 300 +SCAN_INTERVAL_DEFAULT = timedelta(seconds=300) +SCAN_INTERVAL_MINIMUM = timedelta(seconds=180) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_LOCATION_IDX, default=0): cv.positive_int, + vol.Optional(CONF_LOCATION_IDX, default=0): + cv.positive_int, + vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL_DEFAULT): + vol.All(cv.time_period, vol.Range(min=SCAN_INTERVAL_MINIMUM)), }), }, extra=vol.ALLOW_EXTRA) @@ -55,91 +57,107 @@ GWS = 'gateways' TCS = 'temperatureControlSystems' +# bit masks for dispatcher packets +EVO_PARENT = 0x01 +EVO_CHILD = 0x02 + -def setup(hass, config): - """Create a Honeywell (EMEA/EU) evohome CH/DHW system. +def setup(hass, hass_config): + """Create a (EMEA/EU-based) Honeywell evohome system. - One controller with 0+ heating zones (e.g. TRVs, relays) and, optionally, a - DHW controller. Does not work for US-based systems. + Currently, only the Controller and the Zones are implemented here. """ evo_data = hass.data[DATA_EVOHOME] = {} evo_data['timers'] = {} - evo_data['params'] = dict(config[DOMAIN]) - evo_data['params'][CONF_SCAN_INTERVAL] = SCAN_INTERVAL_DEFAULT + # use a copy, since scan_interval is rounded up to nearest 60s + evo_data['params'] = dict(hass_config[DOMAIN]) + scan_interval = evo_data['params'][CONF_SCAN_INTERVAL] + scan_interval = timedelta( + minutes=(scan_interval.total_seconds() + 59) // 60) from evohomeclient2 import EvohomeClient - _LOGGER.debug("setup(): API call [4 request(s)]: client.__init__()...") - try: - # There's a bug in evohomeclient2 v0.2.7: the client.__init__() sets - # the root loglevel when EvohomeClient(debug=?), so remember it now... - log_level = logging.getLogger().getEffectiveLevel() - client = EvohomeClient( evo_data['params'][CONF_USERNAME], evo_data['params'][CONF_PASSWORD], debug=False ) - # ...then restore it to what it was before instantiating the client - logging.getLogger().setLevel(log_level) except HTTPError as err: if err.response.status_code == HTTP_BAD_REQUEST: _LOGGER.error( - "Failed to establish a connection with evohome web servers, " + "setup(): Failed to connect with the vendor's web servers. " "Check your username (%s), and password are correct." "Unable to continue. Resolve any errors and restart HA.", evo_data['params'][CONF_USERNAME] ) - return False # unable to continue - raise # we dont handle any other HTTPErrors + elif err.response.status_code == HTTP_SERVICE_UNAVAILABLE: + _LOGGER.error( + "setup(): Failed to connect with the vendor's web servers. " + "The server is not contactable. Unable to continue. " + "Resolve any errors and restart HA." + ) + + elif err.response.status_code == HTTP_TOO_MANY_REQUESTS: + _LOGGER.error( + "setup(): Failed to connect with the vendor's web servers. " + "You have exceeded the api rate limit. Unable to continue. " + "Wait a while (say 10 minutes) and restart HA." + ) + + else: + raise # we dont expect/handle any other HTTPErrors - finally: # Redact username, password as no longer needed. + return False # unable to continue + + finally: # Redact username, password as no longer needed evo_data['params'][CONF_USERNAME] = 'REDACTED' evo_data['params'][CONF_PASSWORD] = 'REDACTED' evo_data['client'] = client + evo_data['status'] = {} - # Redact any installation data we'll never need. - if client.installation_info[0]['locationInfo']['locationId'] != 'REDACTED': - for loc in client.installation_info: - loc['locationInfo']['streetAddress'] = 'REDACTED' - loc['locationInfo']['city'] = 'REDACTED' - loc['locationInfo']['locationOwner'] = 'REDACTED' - loc[GWS][0]['gatewayInfo'] = 'REDACTED' + # Redact any installation data we'll never need + for loc in client.installation_info: + loc['locationInfo']['locationId'] = 'REDACTED' + loc['locationInfo']['locationOwner'] = 'REDACTED' + loc['locationInfo']['streetAddress'] = 'REDACTED' + loc['locationInfo']['city'] = 'REDACTED' + loc[GWS][0]['gatewayInfo'] = 'REDACTED' - # Pull down the installation configuration. + # Pull down the installation configuration loc_idx = evo_data['params'][CONF_LOCATION_IDX] try: evo_data['config'] = client.installation_info[loc_idx] - except IndexError: _LOGGER.warning( - "setup(): Parameter '%s' = %s , is outside its range (0-%s)", + "setup(): Parameter '%s'=%s, is outside its range (0-%s)", CONF_LOCATION_IDX, loc_idx, len(client.installation_info) - 1 ) - return False # unable to continue - evo_data['status'] = {} - if _LOGGER.isEnabledFor(logging.DEBUG): tmp_loc = dict(evo_data['config']) tmp_loc['locationInfo']['postcode'] = 'REDACTED' - tmp_tcs = tmp_loc[GWS][0][TCS][0] - if 'zones' in tmp_tcs: - tmp_tcs['zones'] = '...' - if 'dhw' in tmp_tcs: - tmp_tcs['dhw'] = '...' + if 'dhw' in tmp_loc[GWS][0][TCS][0]: # if this location has DHW... + tmp_loc[GWS][0][TCS][0]['dhw'] = '...' + + _LOGGER.debug("setup(): evo_data['config']=%s", tmp_loc) + + load_platform(hass, 'climate', DOMAIN, {}, hass_config) - _LOGGER.debug("setup(), location = %s", tmp_loc) + @callback + def _first_update(event): + # When HA has started, the hub knows to retreive it's first update + pkt = {'sender': 'setup()', 'signal': 'refresh', 'to': EVO_PARENT} + async_dispatcher_send(hass, DISPATCHER_EVOHOME, pkt) - load_platform(hass, 'climate', DOMAIN, {}, config) + hass.bus.listen(EVENT_HOMEASSISTANT_START, _first_update) return True diff --git a/requirements_all.txt b/requirements_all.txt index 59a29eb88b342..292cac63ee7d8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -358,7 +358,7 @@ eternalegypt==0.0.5 # homeassistant.components.evohome # homeassistant.components.climate.honeywell -evohomeclient==0.2.7 +evohomeclient==0.2.8 # homeassistant.components.image_processing.dlib_face_detect # homeassistant.components.image_processing.dlib_face_identify diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d204cfa7da924..c37429958b9c0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -63,7 +63,7 @@ ephem==3.7.6.0 # homeassistant.components.evohome # homeassistant.components.climate.honeywell -evohomeclient==0.2.7 +evohomeclient==0.2.8 # homeassistant.components.feedreader feedparser==5.2.1 From 013e181497d8de59b554226342b05f208adcbc6c Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Tue, 27 Nov 2018 22:55:15 +1100 Subject: [PATCH 082/325] U.S. Geological Survey Earthquake Hazards Program Feed platform (#18207) * new platform for usgs earthquake hazards program feed * lint and pylint issues * fixed config access * shortened names of platform, classes, etc. * refactored tests * fixed hound * regenerated requirements * refactored tests * fixed hound --- .../geo_location/usgs_earthquakes_feed.py | 268 ++++++++++++++++++ requirements_all.txt | 1 + requirements_test_all.txt | 1 + .../test_usgs_earthquakes_feed.py | 194 +++++++++++++ 4 files changed, 464 insertions(+) create mode 100644 homeassistant/components/geo_location/usgs_earthquakes_feed.py create mode 100644 tests/components/geo_location/test_usgs_earthquakes_feed.py diff --git a/homeassistant/components/geo_location/usgs_earthquakes_feed.py b/homeassistant/components/geo_location/usgs_earthquakes_feed.py new file mode 100644 index 0000000000000..f835fecfeb4d8 --- /dev/null +++ b/homeassistant/components/geo_location/usgs_earthquakes_feed.py @@ -0,0 +1,268 @@ +""" +U.S. Geological Survey Earthquake Hazards Program Feed platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/geo_location/usgs_earthquakes_feed/ +""" +from datetime import timedelta +import logging +from typing import Optional + +import voluptuous as vol + +from homeassistant.components.geo_location import ( + PLATFORM_SCHEMA, GeoLocationEvent) +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_RADIUS, CONF_SCAN_INTERVAL, + EVENT_HOMEASSISTANT_START, CONF_LATITUDE, CONF_LONGITUDE) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, dispatcher_send) +from homeassistant.helpers.event import track_time_interval + +REQUIREMENTS = ['geojson_client==0.3'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_ALERT = 'alert' +ATTR_EXTERNAL_ID = 'external_id' +ATTR_MAGNITUDE = 'magnitude' +ATTR_PLACE = 'place' +ATTR_STATUS = 'status' +ATTR_TIME = 'time' +ATTR_TYPE = 'type' +ATTR_UPDATED = 'updated' + +CONF_FEED_TYPE = 'feed_type' +CONF_MINIMUM_MAGNITUDE = 'minimum_magnitude' + +DEFAULT_MINIMUM_MAGNITUDE = 0.0 +DEFAULT_RADIUS_IN_KM = 50.0 +DEFAULT_UNIT_OF_MEASUREMENT = 'km' + +SCAN_INTERVAL = timedelta(minutes=5) + +SIGNAL_DELETE_ENTITY = 'usgs_earthquakes_feed_delete_{}' +SIGNAL_UPDATE_ENTITY = 'usgs_earthquakes_feed_update_{}' + +SOURCE = 'usgs_earthquakes_feed' + +VALID_FEED_TYPES = [ + 'past_hour_significant_earthquakes', + 'past_hour_m45_earthquakes', + 'past_hour_m25_earthquakes', + 'past_hour_m10_earthquakes', + 'past_hour_all_earthquakes', + 'past_day_significant_earthquakes', + 'past_day_m45_earthquakes', + 'past_day_m25_earthquakes', + 'past_day_m10_earthquakes', + 'past_day_all_earthquakes', + 'past_week_significant_earthquakes', + 'past_week_m45_earthquakes', + 'past_week_m25_earthquakes', + 'past_week_m10_earthquakes', + 'past_week_all_earthquakes', + 'past_month_significant_earthquakes', + 'past_month_m45_earthquakes', + 'past_month_m25_earthquakes', + 'past_month_m10_earthquakes', + 'past_month_all_earthquakes', +] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_FEED_TYPE): vol.In(VALID_FEED_TYPES), + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float), + vol.Optional(CONF_MINIMUM_MAGNITUDE, default=DEFAULT_MINIMUM_MAGNITUDE): + vol.All(vol.Coerce(float), vol.Range(min=0)) +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the USGS Earthquake Hazards Program Feed platform.""" + scan_interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + feed_type = config[CONF_FEED_TYPE] + coordinates = (config.get(CONF_LATITUDE, hass.config.latitude), + config.get(CONF_LONGITUDE, hass.config.longitude)) + radius_in_km = config[CONF_RADIUS] + minimum_magnitude = config[CONF_MINIMUM_MAGNITUDE] + # Initialize the entity manager. + feed = UsgsEarthquakesFeedEntityManager( + hass, add_entities, scan_interval, coordinates, feed_type, + radius_in_km, minimum_magnitude) + + def start_feed_manager(event): + """Start feed manager.""" + feed.startup() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager) + + +class UsgsEarthquakesFeedEntityManager: + """Feed Entity Manager for USGS Earthquake Hazards Program feed.""" + + def __init__(self, hass, add_entities, scan_interval, coordinates, + feed_type, radius_in_km, minimum_magnitude): + """Initialize the Feed Entity Manager.""" + from geojson_client.usgs_earthquake_hazards_program_feed \ + import UsgsEarthquakeHazardsProgramFeedManager + + self._hass = hass + self._feed_manager = UsgsEarthquakeHazardsProgramFeedManager( + self._generate_entity, self._update_entity, self._remove_entity, + coordinates, feed_type, filter_radius=radius_in_km, + filter_minimum_magnitude=minimum_magnitude) + self._add_entities = add_entities + self._scan_interval = scan_interval + + def startup(self): + """Start up this manager.""" + self._feed_manager.update() + self._init_regular_updates() + + def _init_regular_updates(self): + """Schedule regular updates at the specified interval.""" + track_time_interval( + self._hass, lambda now: self._feed_manager.update(), + self._scan_interval) + + def get_entry(self, external_id): + """Get feed entry by external id.""" + return self._feed_manager.feed_entries.get(external_id) + + def _generate_entity(self, external_id): + """Generate new entity.""" + new_entity = UsgsEarthquakesEvent(self, external_id) + # Add new entities to HA. + self._add_entities([new_entity], True) + + def _update_entity(self, external_id): + """Update entity.""" + dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + + def _remove_entity(self, external_id): + """Remove entity.""" + dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) + + +class UsgsEarthquakesEvent(GeoLocationEvent): + """This represents an external event with USGS Earthquake data.""" + + def __init__(self, feed_manager, external_id): + """Initialize entity with data from feed entry.""" + self._feed_manager = feed_manager + self._external_id = external_id + self._name = None + self._distance = None + self._latitude = None + self._longitude = None + self._attribution = None + self._place = None + self._magnitude = None + self._time = None + self._updated = None + self._status = None + self._type = None + self._alert = None + self._remove_signal_delete = None + self._remove_signal_update = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self._remove_signal_delete = async_dispatcher_connect( + self.hass, SIGNAL_DELETE_ENTITY.format(self._external_id), + self._delete_callback) + self._remove_signal_update = async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ENTITY.format(self._external_id), + self._update_callback) + + @callback + def _delete_callback(self): + """Remove this entity.""" + self._remove_signal_delete() + self._remove_signal_update() + self.hass.async_create_task(self.async_remove()) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + @property + def should_poll(self): + """No polling needed for USGS Earthquake events.""" + return False + + async def async_update(self): + """Update this entity from the data held in the feed manager.""" + _LOGGER.debug("Updating %s", self._external_id) + feed_entry = self._feed_manager.get_entry(self._external_id) + if feed_entry: + self._update_from_feed(feed_entry) + + def _update_from_feed(self, feed_entry): + """Update the internal state from the provided feed entry.""" + self._name = feed_entry.title + self._distance = feed_entry.distance_to_home + self._latitude = feed_entry.coordinates[0] + self._longitude = feed_entry.coordinates[1] + self._attribution = feed_entry.attribution + self._place = feed_entry.place + self._magnitude = feed_entry.magnitude + self._time = feed_entry.time + self._updated = feed_entry.updated + self._status = feed_entry.status + self._type = feed_entry.type + self._alert = feed_entry.alert + + @property + def source(self) -> str: + """Return source value of this external event.""" + return SOURCE + + @property + def name(self) -> Optional[str]: + """Return the name of the entity.""" + return self._name + + @property + def distance(self) -> Optional[float]: + """Return distance value of this external event.""" + return self._distance + + @property + def latitude(self) -> Optional[float]: + """Return latitude value of this external event.""" + return self._latitude + + @property + def longitude(self) -> Optional[float]: + """Return longitude value of this external event.""" + return self._longitude + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return DEFAULT_UNIT_OF_MEASUREMENT + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = {} + for key, value in ( + (ATTR_EXTERNAL_ID, self._external_id), + (ATTR_PLACE, self._place), + (ATTR_MAGNITUDE, self._magnitude), + (ATTR_TIME, self._time), + (ATTR_UPDATED, self._updated), + (ATTR_STATUS, self._status), + (ATTR_TYPE, self._type), + (ATTR_ALERT, self._alert), + (ATTR_ATTRIBUTION, self._attribution), + ): + if value or isinstance(value, bool): + attributes[key] = value + return attributes diff --git a/requirements_all.txt b/requirements_all.txt index 292cac63ee7d8..354b7ca908ac4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -416,6 +416,7 @@ geizhals==0.0.7 # homeassistant.components.geo_location.geo_json_events # homeassistant.components.geo_location.nsw_rural_fire_service_feed +# homeassistant.components.geo_location.usgs_earthquakes_feed geojson_client==0.3 # homeassistant.components.sensor.geo_rss_events diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c37429958b9c0..7a4768e35e792 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -76,6 +76,7 @@ gTTS-token==1.1.2 # homeassistant.components.geo_location.geo_json_events # homeassistant.components.geo_location.nsw_rural_fire_service_feed +# homeassistant.components.geo_location.usgs_earthquakes_feed geojson_client==0.3 # homeassistant.components.sensor.geo_rss_events diff --git a/tests/components/geo_location/test_usgs_earthquakes_feed.py b/tests/components/geo_location/test_usgs_earthquakes_feed.py new file mode 100644 index 0000000000000..f0383c221c48e --- /dev/null +++ b/tests/components/geo_location/test_usgs_earthquakes_feed.py @@ -0,0 +1,194 @@ +"""The tests for the USGS Earthquake Hazards Program Feed platform.""" +import datetime +from unittest.mock import patch, MagicMock, call + +from homeassistant.components import geo_location +from homeassistant.components.geo_location import ATTR_SOURCE +from homeassistant.components.geo_location\ + .usgs_earthquakes_feed import \ + ATTR_ALERT, ATTR_EXTERNAL_ID, SCAN_INTERVAL, ATTR_PLACE, \ + ATTR_MAGNITUDE, ATTR_STATUS, ATTR_TYPE, \ + ATTR_TIME, ATTR_UPDATED, CONF_FEED_TYPE +from homeassistant.const import EVENT_HOMEASSISTANT_START, \ + CONF_RADIUS, ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_FRIENDLY_NAME, \ + ATTR_UNIT_OF_MEASUREMENT, ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.setup import async_setup_component +from tests.common import assert_setup_component, async_fire_time_changed +import homeassistant.util.dt as dt_util + +CONFIG = { + geo_location.DOMAIN: [ + { + 'platform': 'usgs_earthquakes_feed', + CONF_FEED_TYPE: 'past_hour_m25_earthquakes', + CONF_RADIUS: 200 + } + ] +} + +CONFIG_WITH_CUSTOM_LOCATION = { + geo_location.DOMAIN: [ + { + 'platform': 'usgs_earthquakes_feed', + CONF_FEED_TYPE: 'past_hour_m25_earthquakes', + CONF_RADIUS: 200, + CONF_LATITUDE: 15.1, + CONF_LONGITUDE: 25.2 + } + ] +} + + +def _generate_mock_feed_entry(external_id, title, distance_to_home, + coordinates, place=None, + attribution=None, time=None, updated=None, + magnitude=None, status=None, + entry_type=None, alert=None): + """Construct a mock feed entry for testing purposes.""" + feed_entry = MagicMock() + feed_entry.external_id = external_id + feed_entry.title = title + feed_entry.distance_to_home = distance_to_home + feed_entry.coordinates = coordinates + feed_entry.place = place + feed_entry.attribution = attribution + feed_entry.time = time + feed_entry.updated = updated + feed_entry.magnitude = magnitude + feed_entry.status = status + feed_entry.type = entry_type + feed_entry.alert = alert + return feed_entry + + +async def test_setup(hass): + """Test the general setup of the platform.""" + # Set up some mock feed entries for this test. + mock_entry_1 = _generate_mock_feed_entry( + '1234', 'Title 1', 15.5, (-31.0, 150.0), + place='Location 1', attribution='Attribution 1', + time=datetime.datetime(2018, 9, 22, 8, 0, + tzinfo=datetime.timezone.utc), + updated=datetime.datetime(2018, 9, 22, 9, 0, + tzinfo=datetime.timezone.utc), + magnitude=5.7, status='Status 1', entry_type='Type 1', + alert='Alert 1') + mock_entry_2 = _generate_mock_feed_entry( + '2345', 'Title 2', 20.5, (-31.1, 150.1)) + mock_entry_3 = _generate_mock_feed_entry( + '3456', 'Title 3', 25.5, (-31.2, 150.2)) + mock_entry_4 = _generate_mock_feed_entry( + '4567', 'Title 4', 12.5, (-31.3, 150.3)) + + # Patching 'utcnow' to gain more control over the timed update. + utcnow = dt_util.utcnow() + with patch('homeassistant.util.dt.utcnow', return_value=utcnow), \ + patch('geojson_client.usgs_earthquake_hazards_program_feed.' + 'UsgsEarthquakeHazardsProgramFeed') as mock_feed: + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1, + mock_entry_2, + mock_entry_3] + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG) + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 3 + + state = hass.states.get("geo_location.title_1") + assert state is not None + assert state.name == "Title 1" + assert state.attributes == { + ATTR_EXTERNAL_ID: "1234", ATTR_LATITUDE: -31.0, + ATTR_LONGITUDE: 150.0, ATTR_FRIENDLY_NAME: "Title 1", + ATTR_PLACE: "Location 1", + ATTR_ATTRIBUTION: "Attribution 1", + ATTR_TIME: + datetime.datetime( + 2018, 9, 22, 8, 0, tzinfo=datetime.timezone.utc), + ATTR_UPDATED: + datetime.datetime( + 2018, 9, 22, 9, 0, tzinfo=datetime.timezone.utc), + ATTR_STATUS: 'Status 1', ATTR_TYPE: 'Type 1', + ATTR_ALERT: 'Alert 1', ATTR_MAGNITUDE: 5.7, + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'usgs_earthquakes_feed'} + assert round(abs(float(state.state)-15.5), 7) == 0 + + state = hass.states.get("geo_location.title_2") + assert state is not None + assert state.name == "Title 2" + assert state.attributes == { + ATTR_EXTERNAL_ID: "2345", ATTR_LATITUDE: -31.1, + ATTR_LONGITUDE: 150.1, ATTR_FRIENDLY_NAME: "Title 2", + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'usgs_earthquakes_feed'} + assert round(abs(float(state.state)-20.5), 7) == 0 + + state = hass.states.get("geo_location.title_3") + assert state is not None + assert state.name == "Title 3" + assert state.attributes == { + ATTR_EXTERNAL_ID: "3456", ATTR_LATITUDE: -31.2, + ATTR_LONGITUDE: 150.2, ATTR_FRIENDLY_NAME: "Title 3", + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'usgs_earthquakes_feed'} + assert round(abs(float(state.state)-25.5), 7) == 0 + + # Simulate an update - one existing, one new entry, + # one outdated entry + mock_feed.return_value.update.return_value = 'OK', [ + mock_entry_1, mock_entry_4, mock_entry_3] + async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 3 + + # Simulate an update - empty data, but successful update, + # so no changes to entities. + mock_feed.return_value.update.return_value = 'OK_NO_DATA', None + async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 3 + + # Simulate an update - empty data, removes all entities + mock_feed.return_value.update.return_value = 'ERROR', None + async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 0 + + +async def test_setup_with_custom_location(hass): + """Test the setup with a custom location.""" + # Set up some mock feed entries for this test. + mock_entry_1 = _generate_mock_feed_entry( + '1234', 'Title 1', 20.5, (-31.1, 150.1)) + + with patch('geojson_client.usgs_earthquake_hazards_program_feed.' + 'UsgsEarthquakeHazardsProgramFeed') as mock_feed: + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1] + + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG_WITH_CUSTOM_LOCATION) + + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 1 + + assert mock_feed.call_args == call( + (15.1, 25.2), 'past_hour_m25_earthquakes', + filter_minimum_magnitude=0.0, filter_radius=200.0) From 61e0e1115641e192ad52b28757c6aac7b358df2b Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Tue, 27 Nov 2018 23:12:29 +1100 Subject: [PATCH 083/325] Geo Location platform code clean up (#18717) * code cleanup to make use of new externalised feed manager * fixed lint * revert change, keep asynctest * using asynctest * changed unit test from mocking to inspecting dispatcher signals * code clean-up --- .../geo_location/geo_json_events.py | 103 ++--- .../nsw_rural_fire_service_feed.py | 113 ++--- .../geo_location/test_geo_json_events.py | 415 +++++++++--------- .../test_nsw_rural_fire_service_feed.py | 244 +++++----- 4 files changed, 428 insertions(+), 447 deletions(-) diff --git a/homeassistant/components/geo_location/geo_json_events.py b/homeassistant/components/geo_location/geo_json_events.py index 74d1b036f6c1e..4d8c3b68edd7c 100644 --- a/homeassistant/components/geo_location/geo_json_events.py +++ b/homeassistant/components/geo_location/geo_json_events.py @@ -13,7 +13,8 @@ from homeassistant.components.geo_location import ( PLATFORM_SCHEMA, GeoLocationEvent) from homeassistant.const import ( - CONF_RADIUS, CONF_SCAN_INTERVAL, CONF_URL, EVENT_HOMEASSISTANT_START) + CONF_RADIUS, CONF_SCAN_INTERVAL, CONF_URL, EVENT_HOMEASSISTANT_START, + CONF_LATITUDE, CONF_LONGITUDE) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( @@ -38,6 +39,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float), }) @@ -46,10 +49,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the GeoJSON Events platform.""" url = config[CONF_URL] scan_interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + coordinates = (config.get(CONF_LATITUDE, hass.config.latitude), + config.get(CONF_LONGITUDE, hass.config.longitude)) radius_in_km = config[CONF_RADIUS] # Initialize the entity manager. - feed = GeoJsonFeedManager(hass, add_entities, scan_interval, url, - radius_in_km) + feed = GeoJsonFeedEntityManager( + hass, add_entities, scan_interval, coordinates, url, radius_in_km) def start_feed_manager(event): """Start feed manager.""" @@ -58,87 +63,49 @@ def start_feed_manager(event): hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager) -class GeoJsonFeedManager: - """Feed Manager for GeoJSON feeds.""" +class GeoJsonFeedEntityManager: + """Feed Entity Manager for GeoJSON feeds.""" - def __init__(self, hass, add_entities, scan_interval, url, radius_in_km): + def __init__(self, hass, add_entities, scan_interval, coordinates, url, + radius_in_km): """Initialize the GeoJSON Feed Manager.""" - from geojson_client.generic_feed import GenericFeed + from geojson_client.generic_feed import GenericFeedManager self._hass = hass - self._feed = GenericFeed( - (hass.config.latitude, hass.config.longitude), - filter_radius=radius_in_km, url=url) + self._feed_manager = GenericFeedManager( + self._generate_entity, self._update_entity, self._remove_entity, + coordinates, url, filter_radius=radius_in_km) self._add_entities = add_entities self._scan_interval = scan_interval - self.feed_entries = {} - self._managed_external_ids = set() def startup(self): """Start up this manager.""" - self._update() + self._feed_manager.update() self._init_regular_updates() def _init_regular_updates(self): """Schedule regular updates at the specified interval.""" track_time_interval( - self._hass, lambda now: self._update(), self._scan_interval) - - def _update(self): - """Update the feed and then update connected entities.""" - import geojson_client - - status, feed_entries = self._feed.update() - if status == geojson_client.UPDATE_OK: - _LOGGER.debug("Data retrieved %s", feed_entries) - # Keep a copy of all feed entries for future lookups by entities. - self.feed_entries = {entry.external_id: entry - for entry in feed_entries} - # For entity management the external ids from the feed are used. - feed_external_ids = set(self.feed_entries) - remove_external_ids = self._managed_external_ids.difference( - feed_external_ids) - self._remove_entities(remove_external_ids) - update_external_ids = self._managed_external_ids.intersection( - feed_external_ids) - self._update_entities(update_external_ids) - create_external_ids = feed_external_ids.difference( - self._managed_external_ids) - self._generate_new_entities(create_external_ids) - elif status == geojson_client.UPDATE_OK_NO_DATA: - _LOGGER.debug( - "Update successful, but no data received from %s", self._feed) - else: - _LOGGER.warning( - "Update not successful, no data received from %s", self._feed) - # Remove all entities. - self._remove_entities(self._managed_external_ids.copy()) - - def _generate_new_entities(self, external_ids): - """Generate new entities for events.""" - new_entities = [] - for external_id in external_ids: - new_entity = GeoJsonLocationEvent(self, external_id) - _LOGGER.debug("New entity added %s", external_id) - new_entities.append(new_entity) - self._managed_external_ids.add(external_id) + self._hass, lambda now: self._feed_manager.update(), + self._scan_interval) + + def get_entry(self, external_id): + """Get feed entry by external id.""" + return self._feed_manager.feed_entries.get(external_id) + + def _generate_entity(self, external_id): + """Generate new entity.""" + new_entity = GeoJsonLocationEvent(self, external_id) # Add new entities to HA. - self._add_entities(new_entities, True) + self._add_entities([new_entity], True) - def _update_entities(self, external_ids): - """Update entities.""" - for external_id in external_ids: - _LOGGER.debug("Existing entity found %s", external_id) - dispatcher_send( - self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + def _update_entity(self, external_id): + """Update entity.""" + dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) - def _remove_entities(self, external_ids): - """Remove entities.""" - for external_id in external_ids: - _LOGGER.debug("Entity not current anymore %s", external_id) - self._managed_external_ids.remove(external_id) - dispatcher_send( - self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) + def _remove_entity(self, external_id): + """Remove entity.""" + dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) class GeoJsonLocationEvent(GeoLocationEvent): @@ -184,7 +151,7 @@ def should_poll(self): async def async_update(self): """Update this entity from the data held in the feed manager.""" _LOGGER.debug("Updating %s", self._external_id) - feed_entry = self._feed_manager.feed_entries.get(self._external_id) + feed_entry = self._feed_manager.get_entry(self._external_id) if feed_entry: self._update_from_feed(feed_entry) diff --git a/homeassistant/components/geo_location/nsw_rural_fire_service_feed.py b/homeassistant/components/geo_location/nsw_rural_fire_service_feed.py index 1d2a7fadaff49..5681e4a53ace3 100644 --- a/homeassistant/components/geo_location/nsw_rural_fire_service_feed.py +++ b/homeassistant/components/geo_location/nsw_rural_fire_service_feed.py @@ -14,7 +14,7 @@ PLATFORM_SCHEMA, GeoLocationEvent) from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_LOCATION, CONF_RADIUS, CONF_SCAN_INTERVAL, - EVENT_HOMEASSISTANT_START) + EVENT_HOMEASSISTANT_START, CONF_LATITUDE, CONF_LONGITUDE) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( @@ -57,18 +57,23 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_CATEGORIES, default=[]): vol.All(cv.ensure_list, [vol.In(VALID_CATEGORIES)]), + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float), }) def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the GeoJSON Events platform.""" + """Set up the NSW Rural Fire Service Feed platform.""" scan_interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + coordinates = (config.get(CONF_LATITUDE, hass.config.latitude), + config.get(CONF_LONGITUDE, hass.config.longitude)) radius_in_km = config[CONF_RADIUS] categories = config.get(CONF_CATEGORIES) # Initialize the entity manager. - feed = NswRuralFireServiceFeedManager( - hass, add_entities, scan_interval, radius_in_km, categories) + feed = NswRuralFireServiceFeedEntityManager( + hass, add_entities, scan_interval, coordinates, radius_in_km, + categories) def start_feed_manager(event): """Start feed manager.""" @@ -77,93 +82,55 @@ def start_feed_manager(event): hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager) -class NswRuralFireServiceFeedManager: - """Feed Manager for NSW Rural Fire Service GeoJSON feed.""" +class NswRuralFireServiceFeedEntityManager: + """Feed Entity Manager for NSW Rural Fire Service GeoJSON feed.""" - def __init__(self, hass, add_entities, scan_interval, radius_in_km, - categories): - """Initialize the GeoJSON Feed Manager.""" + def __init__(self, hass, add_entities, scan_interval, coordinates, + radius_in_km, categories): + """Initialize the Feed Entity Manager.""" from geojson_client.nsw_rural_fire_service_feed \ - import NswRuralFireServiceFeed + import NswRuralFireServiceFeedManager self._hass = hass - self._feed = NswRuralFireServiceFeed( - (hass.config.latitude, hass.config.longitude), - filter_radius=radius_in_km, filter_categories=categories) + self._feed_manager = NswRuralFireServiceFeedManager( + self._generate_entity, self._update_entity, self._remove_entity, + coordinates, filter_radius=radius_in_km, + filter_categories=categories) self._add_entities = add_entities self._scan_interval = scan_interval - self.feed_entries = {} - self._managed_external_ids = set() def startup(self): """Start up this manager.""" - self._update() + self._feed_manager.update() self._init_regular_updates() def _init_regular_updates(self): """Schedule regular updates at the specified interval.""" track_time_interval( - self._hass, lambda now: self._update(), self._scan_interval) - - def _update(self): - """Update the feed and then update connected entities.""" - import geojson_client - - status, feed_entries = self._feed.update() - if status == geojson_client.UPDATE_OK: - _LOGGER.debug("Data retrieved %s", feed_entries) - # Keep a copy of all feed entries for future lookups by entities. - self.feed_entries = {entry.external_id: entry - for entry in feed_entries} - # For entity management the external ids from the feed are used. - feed_external_ids = set(self.feed_entries) - remove_external_ids = self._managed_external_ids.difference( - feed_external_ids) - self._remove_entities(remove_external_ids) - update_external_ids = self._managed_external_ids.intersection( - feed_external_ids) - self._update_entities(update_external_ids) - create_external_ids = feed_external_ids.difference( - self._managed_external_ids) - self._generate_new_entities(create_external_ids) - elif status == geojson_client.UPDATE_OK_NO_DATA: - _LOGGER.debug( - "Update successful, but no data received from %s", self._feed) - else: - _LOGGER.warning( - "Update not successful, no data received from %s", self._feed) - # Remove all entities. - self._remove_entities(self._managed_external_ids.copy()) - - def _generate_new_entities(self, external_ids): - """Generate new entities for events.""" - new_entities = [] - for external_id in external_ids: - new_entity = NswRuralFireServiceLocationEvent(self, external_id) - _LOGGER.debug("New entity added %s", external_id) - new_entities.append(new_entity) - self._managed_external_ids.add(external_id) + self._hass, lambda now: self._feed_manager.update(), + self._scan_interval) + + def get_entry(self, external_id): + """Get feed entry by external id.""" + return self._feed_manager.feed_entries.get(external_id) + + def _generate_entity(self, external_id): + """Generate new entity.""" + new_entity = NswRuralFireServiceLocationEvent(self, external_id) # Add new entities to HA. - self._add_entities(new_entities, True) + self._add_entities([new_entity], True) - def _update_entities(self, external_ids): - """Update entities.""" - for external_id in external_ids: - _LOGGER.debug("Existing entity found %s", external_id) - dispatcher_send( - self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + def _update_entity(self, external_id): + """Update entity.""" + dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) - def _remove_entities(self, external_ids): - """Remove entities.""" - for external_id in external_ids: - _LOGGER.debug("Entity not current anymore %s", external_id) - self._managed_external_ids.remove(external_id) - dispatcher_send( - self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) + def _remove_entity(self, external_id): + """Remove entity.""" + dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) class NswRuralFireServiceLocationEvent(GeoLocationEvent): - """This represents an external event with GeoJSON data.""" + """This represents an external event with NSW Rural Fire Service data.""" def __init__(self, feed_manager, external_id): """Initialize entity with data from feed entry.""" @@ -209,13 +176,13 @@ def _update_callback(self): @property def should_poll(self): - """No polling needed for GeoJSON location events.""" + """No polling needed for NSW Rural Fire Service location events.""" return False async def async_update(self): """Update this entity from the data held in the feed manager.""" _LOGGER.debug("Updating %s", self._external_id) - feed_entry = self._feed_manager.feed_entries.get(self._external_id) + feed_entry = self._feed_manager.get_entry(self._external_id) if feed_entry: self._update_from_feed(feed_entry) diff --git a/tests/components/geo_location/test_geo_json_events.py b/tests/components/geo_location/test_geo_json_events.py index f476598adc9c4..46d1ed630c4ca 100644 --- a/tests/components/geo_location/test_geo_json_events.py +++ b/tests/components/geo_location/test_geo_json_events.py @@ -1,19 +1,16 @@ """The tests for the geojson platform.""" -import unittest -from unittest import mock -from unittest.mock import patch, MagicMock +from asynctest.mock import patch, MagicMock, call -import homeassistant from homeassistant.components import geo_location from homeassistant.components.geo_location import ATTR_SOURCE from homeassistant.components.geo_location.geo_json_events import \ - SCAN_INTERVAL, ATTR_EXTERNAL_ID + SCAN_INTERVAL, ATTR_EXTERNAL_ID, SIGNAL_DELETE_ENTITY, SIGNAL_UPDATE_ENTITY from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_START, \ CONF_RADIUS, ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_FRIENDLY_NAME, \ - ATTR_UNIT_OF_MEASUREMENT -from homeassistant.setup import setup_component -from tests.common import get_test_home_assistant, assert_setup_component, \ - fire_time_changed + ATTR_UNIT_OF_MEASUREMENT, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.helpers.dispatcher import DATA_DISPATCHER +from homeassistant.setup import async_setup_component +from tests.common import assert_setup_component, async_fire_time_changed import homeassistant.util.dt as dt_util URL = 'http://geo.json.local/geo_json_events.json' @@ -27,200 +24,218 @@ ] } +CONFIG_WITH_CUSTOM_LOCATION = { + geo_location.DOMAIN: [ + { + 'platform': 'geo_json_events', + CONF_URL: URL, + CONF_RADIUS: 200, + CONF_LATITUDE: 15.1, + CONF_LONGITUDE: 25.2 + } + ] +} -class TestGeoJsonPlatform(unittest.TestCase): - """Test the geojson platform.""" - - def setUp(self): - """Initialize values for this testcase class.""" - self.hass = get_test_home_assistant() - - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() - - @staticmethod - def _generate_mock_feed_entry(external_id, title, distance_to_home, - coordinates): - """Construct a mock feed entry for testing purposes.""" - feed_entry = MagicMock() - feed_entry.external_id = external_id - feed_entry.title = title - feed_entry.distance_to_home = distance_to_home - feed_entry.coordinates = coordinates - return feed_entry - - @mock.patch('geojson_client.generic_feed.GenericFeed') - def test_setup(self, mock_feed): - """Test the general setup of the platform.""" - # Set up some mock feed entries for this test. - mock_entry_1 = self._generate_mock_feed_entry('1234', 'Title 1', 15.5, - (-31.0, 150.0)) - mock_entry_2 = self._generate_mock_feed_entry('2345', 'Title 2', 20.5, - (-31.1, 150.1)) - mock_entry_3 = self._generate_mock_feed_entry('3456', 'Title 3', 25.5, - (-31.2, 150.2)) - mock_entry_4 = self._generate_mock_feed_entry('4567', 'Title 4', 12.5, - (-31.3, 150.3)) + +def _generate_mock_feed_entry(external_id, title, distance_to_home, + coordinates): + """Construct a mock feed entry for testing purposes.""" + feed_entry = MagicMock() + feed_entry.external_id = external_id + feed_entry.title = title + feed_entry.distance_to_home = distance_to_home + feed_entry.coordinates = coordinates + return feed_entry + + +async def test_setup(hass): + """Test the general setup of the platform.""" + # Set up some mock feed entries for this test. + mock_entry_1 = _generate_mock_feed_entry( + '1234', 'Title 1', 15.5, (-31.0, 150.0)) + mock_entry_2 = _generate_mock_feed_entry( + '2345', 'Title 2', 20.5, (-31.1, 150.1)) + mock_entry_3 = _generate_mock_feed_entry( + '3456', 'Title 3', 25.5, (-31.2, 150.2)) + mock_entry_4 = _generate_mock_feed_entry( + '4567', 'Title 4', 12.5, (-31.3, 150.3)) + + # Patching 'utcnow' to gain more control over the timed update. + utcnow = dt_util.utcnow() + with patch('homeassistant.util.dt.utcnow', return_value=utcnow), \ + patch('geojson_client.generic_feed.GenericFeed') as mock_feed: mock_feed.return_value.update.return_value = 'OK', [mock_entry_1, mock_entry_2, mock_entry_3] - - utcnow = dt_util.utcnow() - # Patching 'utcnow' to gain more control over the timed update. - with patch('homeassistant.util.dt.utcnow', return_value=utcnow): - with assert_setup_component(1, geo_location.DOMAIN): - assert setup_component(self.hass, geo_location.DOMAIN, CONFIG) - # Artificially trigger update. - self.hass.bus.fire(EVENT_HOMEASSISTANT_START) - # Collect events. - self.hass.block_till_done() - - all_states = self.hass.states.all() - assert len(all_states) == 3 - - state = self.hass.states.get("geo_location.title_1") - assert state is not None - assert state.name == "Title 1" - assert state.attributes == { - ATTR_EXTERNAL_ID: "1234", ATTR_LATITUDE: -31.0, - ATTR_LONGITUDE: 150.0, ATTR_FRIENDLY_NAME: "Title 1", - ATTR_UNIT_OF_MEASUREMENT: "km", - ATTR_SOURCE: 'geo_json_events'} - assert round(abs(float(state.state)-15.5), 7) == 0 - - state = self.hass.states.get("geo_location.title_2") - assert state is not None - assert state.name == "Title 2" - assert state.attributes == { - ATTR_EXTERNAL_ID: "2345", ATTR_LATITUDE: -31.1, - ATTR_LONGITUDE: 150.1, ATTR_FRIENDLY_NAME: "Title 2", - ATTR_UNIT_OF_MEASUREMENT: "km", - ATTR_SOURCE: 'geo_json_events'} - assert round(abs(float(state.state)-20.5), 7) == 0 - - state = self.hass.states.get("geo_location.title_3") - assert state is not None - assert state.name == "Title 3" - assert state.attributes == { - ATTR_EXTERNAL_ID: "3456", ATTR_LATITUDE: -31.2, - ATTR_LONGITUDE: 150.2, ATTR_FRIENDLY_NAME: "Title 3", - ATTR_UNIT_OF_MEASUREMENT: "km", - ATTR_SOURCE: 'geo_json_events'} - assert round(abs(float(state.state)-25.5), 7) == 0 - - # Simulate an update - one existing, one new entry, - # one outdated entry - mock_feed.return_value.update.return_value = 'OK', [ - mock_entry_1, mock_entry_4, mock_entry_3] - fire_time_changed(self.hass, utcnow + SCAN_INTERVAL) - self.hass.block_till_done() - - all_states = self.hass.states.all() - assert len(all_states) == 3 - - # Simulate an update - empty data, but successful update, - # so no changes to entities. - mock_feed.return_value.update.return_value = 'OK_NO_DATA', None - # mock_restdata.return_value.data = None - fire_time_changed(self.hass, utcnow + - 2 * SCAN_INTERVAL) - self.hass.block_till_done() - - all_states = self.hass.states.all() - assert len(all_states) == 3 - - # Simulate an update - empty data, removes all entities - mock_feed.return_value.update.return_value = 'ERROR', None - fire_time_changed(self.hass, utcnow + - 2 * SCAN_INTERVAL) - self.hass.block_till_done() - - all_states = self.hass.states.all() - assert len(all_states) == 0 - - @mock.patch('geojson_client.generic_feed.GenericFeed') - def test_setup_race_condition(self, mock_feed): - """Test a particular race condition experienced.""" - # 1. Feed returns 1 entry -> Feed manager creates 1 entity. - # 2. Feed returns error -> Feed manager removes 1 entity. - # However, this stayed on and kept listening for dispatcher signals. - # 3. Feed returns 1 entry -> Feed manager creates 1 entity. - # 4. Feed returns 1 entry -> Feed manager updates 1 entity. - # Internally, the previous entity is updating itself, too. - # 5. Feed returns error -> Feed manager removes 1 entity. - # There are now 2 entities trying to remove themselves from HA, but - # the second attempt fails of course. - - # Set up some mock feed entries for this test. - mock_entry_1 = self._generate_mock_feed_entry('1234', 'Title 1', 15.5, - (-31.0, 150.0)) + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG) + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 3 + + state = hass.states.get("geo_location.title_1") + assert state is not None + assert state.name == "Title 1" + assert state.attributes == { + ATTR_EXTERNAL_ID: "1234", ATTR_LATITUDE: -31.0, + ATTR_LONGITUDE: 150.0, ATTR_FRIENDLY_NAME: "Title 1", + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'geo_json_events'} + assert round(abs(float(state.state)-15.5), 7) == 0 + + state = hass.states.get("geo_location.title_2") + assert state is not None + assert state.name == "Title 2" + assert state.attributes == { + ATTR_EXTERNAL_ID: "2345", ATTR_LATITUDE: -31.1, + ATTR_LONGITUDE: 150.1, ATTR_FRIENDLY_NAME: "Title 2", + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'geo_json_events'} + assert round(abs(float(state.state)-20.5), 7) == 0 + + state = hass.states.get("geo_location.title_3") + assert state is not None + assert state.name == "Title 3" + assert state.attributes == { + ATTR_EXTERNAL_ID: "3456", ATTR_LATITUDE: -31.2, + ATTR_LONGITUDE: 150.2, ATTR_FRIENDLY_NAME: "Title 3", + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'geo_json_events'} + assert round(abs(float(state.state)-25.5), 7) == 0 + + # Simulate an update - one existing, one new entry, + # one outdated entry + mock_feed.return_value.update.return_value = 'OK', [ + mock_entry_1, mock_entry_4, mock_entry_3] + async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 3 + + # Simulate an update - empty data, but successful update, + # so no changes to entities. + mock_feed.return_value.update.return_value = 'OK_NO_DATA', None + async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 3 + + # Simulate an update - empty data, removes all entities + mock_feed.return_value.update.return_value = 'ERROR', None + async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 0 + + +async def test_setup_with_custom_location(hass): + """Test the setup with a custom location.""" + # Set up some mock feed entries for this test. + mock_entry_1 = _generate_mock_feed_entry( + '1234', 'Title 1', 2000.5, (-31.1, 150.1)) + + with patch('geojson_client.generic_feed.GenericFeed') as mock_feed: mock_feed.return_value.update.return_value = 'OK', [mock_entry_1] - utcnow = dt_util.utcnow() - # Patching 'utcnow' to gain more control over the timed update. - with patch('homeassistant.util.dt.utcnow', return_value=utcnow): - with assert_setup_component(1, geo_location.DOMAIN): - assert setup_component(self.hass, geo_location.DOMAIN, CONFIG) - - # This gives us the ability to assert the '_delete_callback' - # has been called while still executing it. - original_delete_callback = homeassistant.components\ - .geo_location.geo_json_events.GeoJsonLocationEvent\ - ._delete_callback - - def mock_delete_callback(entity): - original_delete_callback(entity) - - with patch('homeassistant.components.geo_location' - '.geo_json_events.GeoJsonLocationEvent' - '._delete_callback', - side_effect=mock_delete_callback, - autospec=True) as mocked_delete_callback: - - # Artificially trigger update. - self.hass.bus.fire(EVENT_HOMEASSISTANT_START) - # Collect events. - self.hass.block_till_done() - - all_states = self.hass.states.all() - assert len(all_states) == 1 - - # Simulate an update - empty data, removes all entities - mock_feed.return_value.update.return_value = 'ERROR', None - fire_time_changed(self.hass, utcnow + SCAN_INTERVAL) - self.hass.block_till_done() - - assert mocked_delete_callback.call_count == 1 - all_states = self.hass.states.all() - assert len(all_states) == 0 - - # Simulate an update - 1 entry - mock_feed.return_value.update.return_value = 'OK', [ - mock_entry_1] - fire_time_changed(self.hass, utcnow + 2 * SCAN_INTERVAL) - self.hass.block_till_done() - - all_states = self.hass.states.all() - assert len(all_states) == 1 - - # Simulate an update - 1 entry - mock_feed.return_value.update.return_value = 'OK', [ - mock_entry_1] - fire_time_changed(self.hass, utcnow + 3 * SCAN_INTERVAL) - self.hass.block_till_done() - - all_states = self.hass.states.all() - assert len(all_states) == 1 - - # Reset mocked method for the next test. - mocked_delete_callback.reset_mock() - - # Simulate an update - empty data, removes all entities - mock_feed.return_value.update.return_value = 'ERROR', None - fire_time_changed(self.hass, utcnow + 4 * SCAN_INTERVAL) - self.hass.block_till_done() - - assert mocked_delete_callback.call_count == 1 - all_states = self.hass.states.all() - assert len(all_states) == 0 + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG_WITH_CUSTOM_LOCATION) + + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 1 + + assert mock_feed.call_args == call( + (15.1, 25.2), URL, filter_radius=200.0) + + +async def test_setup_race_condition(hass): + """Test a particular race condition experienced.""" + # 1. Feed returns 1 entry -> Feed manager creates 1 entity. + # 2. Feed returns error -> Feed manager removes 1 entity. + # However, this stayed on and kept listening for dispatcher signals. + # 3. Feed returns 1 entry -> Feed manager creates 1 entity. + # 4. Feed returns 1 entry -> Feed manager updates 1 entity. + # Internally, the previous entity is updating itself, too. + # 5. Feed returns error -> Feed manager removes 1 entity. + # There are now 2 entities trying to remove themselves from HA, but + # the second attempt fails of course. + + # Set up some mock feed entries for this test. + mock_entry_1 = _generate_mock_feed_entry( + '1234', 'Title 1', 15.5, (-31.0, 150.0)) + delete_signal = SIGNAL_DELETE_ENTITY.format('1234') + update_signal = SIGNAL_UPDATE_ENTITY.format('1234') + + # Patching 'utcnow' to gain more control over the timed update. + utcnow = dt_util.utcnow() + with patch('homeassistant.util.dt.utcnow', return_value=utcnow), \ + patch('geojson_client.generic_feed.GenericFeed') as mock_feed: + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG) + + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1] + + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 1 + assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 1 + assert len(hass.data[DATA_DISPATCHER][update_signal]) == 1 + + # Simulate an update - empty data, removes all entities + mock_feed.return_value.update.return_value = 'ERROR', None + async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 0 + assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 0 + assert len(hass.data[DATA_DISPATCHER][update_signal]) == 0 + + # Simulate an update - 1 entry + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1] + async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 1 + assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 1 + assert len(hass.data[DATA_DISPATCHER][update_signal]) == 1 + + # Simulate an update - 1 entry + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1] + async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 1 + assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 1 + assert len(hass.data[DATA_DISPATCHER][update_signal]) == 1 + + # Simulate an update - empty data, removes all entities + mock_feed.return_value.update.return_value = 'ERROR', None + async_fire_time_changed(hass, utcnow + 4 * SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 0 + # Ensure that delete and update signal targets are now empty. + assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 0 + assert len(hass.data[DATA_DISPATCHER][update_signal]) == 0 diff --git a/tests/components/geo_location/test_nsw_rural_fire_service_feed.py b/tests/components/geo_location/test_nsw_rural_fire_service_feed.py index 75397d27383fe..3254fd570ceec 100644 --- a/tests/components/geo_location/test_nsw_rural_fire_service_feed.py +++ b/tests/components/geo_location/test_nsw_rural_fire_service_feed.py @@ -1,6 +1,6 @@ """The tests for the geojson platform.""" import datetime -from asynctest.mock import patch, MagicMock +from asynctest.mock import patch, MagicMock, call from homeassistant.components import geo_location from homeassistant.components.geo_location import ATTR_SOURCE @@ -8,24 +8,33 @@ ATTR_EXTERNAL_ID, SCAN_INTERVAL, ATTR_CATEGORY, ATTR_FIRE, ATTR_LOCATION, \ ATTR_COUNCIL_AREA, ATTR_STATUS, ATTR_TYPE, ATTR_SIZE, \ ATTR_RESPONSIBLE_AGENCY, ATTR_PUBLICATION_DATE -from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_START, \ - CONF_RADIUS, ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_FRIENDLY_NAME, \ - ATTR_UNIT_OF_MEASUREMENT, ATTR_ATTRIBUTION +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_FRIENDLY_NAME, \ + ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_UNIT_OF_MEASUREMENT, CONF_LATITUDE, \ + CONF_LONGITUDE, CONF_RADIUS, EVENT_HOMEASSISTANT_START from homeassistant.setup import async_setup_component from tests.common import assert_setup_component, async_fire_time_changed import homeassistant.util.dt as dt_util -URL = 'http://geo.json.local/geo_json_events.json' CONFIG = { geo_location.DOMAIN: [ { 'platform': 'nsw_rural_fire_service_feed', - CONF_URL: URL, CONF_RADIUS: 200 } ] } +CONFIG_WITH_CUSTOM_LOCATION = { + geo_location.DOMAIN: [ + { + 'platform': 'nsw_rural_fire_service_feed', + CONF_RADIUS: 200, + CONF_LATITUDE: 15.1, + CONF_LONGITUDE: 25.2 + } + ] +} + def _generate_mock_feed_entry(external_id, title, distance_to_home, coordinates, category=None, location=None, @@ -55,107 +64,130 @@ def _generate_mock_feed_entry(external_id, title, distance_to_home, async def test_setup(hass): """Test the general setup of the platform.""" # Set up some mock feed entries for this test. - with patch('geojson_client.nsw_rural_fire_service_feed.' - 'NswRuralFireServiceFeed') as mock_feed: - mock_entry_1 = _generate_mock_feed_entry( - '1234', 'Title 1', 15.5, (-31.0, 150.0), category='Category 1', - location='Location 1', attribution='Attribution 1', - publication_date=datetime.datetime(2018, 9, 22, 8, 0, - tzinfo=datetime.timezone.utc), - council_area='Council Area 1', status='Status 1', - entry_type='Type 1', size='Size 1', responsible_agency='Agency 1') - mock_entry_2 = _generate_mock_feed_entry('2345', 'Title 2', 20.5, - (-31.1, 150.1), - fire=False) - mock_entry_3 = _generate_mock_feed_entry('3456', 'Title 3', 25.5, - (-31.2, 150.2)) - mock_entry_4 = _generate_mock_feed_entry('4567', 'Title 4', 12.5, - (-31.3, 150.3)) + mock_entry_1 = _generate_mock_feed_entry( + '1234', 'Title 1', 15.5, (-31.0, 150.0), category='Category 1', + location='Location 1', attribution='Attribution 1', + publication_date=datetime.datetime(2018, 9, 22, 8, 0, + tzinfo=datetime.timezone.utc), + council_area='Council Area 1', status='Status 1', + entry_type='Type 1', size='Size 1', responsible_agency='Agency 1') + mock_entry_2 = _generate_mock_feed_entry('2345', 'Title 2', 20.5, + (-31.1, 150.1), + fire=False) + mock_entry_3 = _generate_mock_feed_entry('3456', 'Title 3', 25.5, + (-31.2, 150.2)) + mock_entry_4 = _generate_mock_feed_entry('4567', 'Title 4', 12.5, + (-31.3, 150.3)) + + utcnow = dt_util.utcnow() + # Patching 'utcnow' to gain more control over the timed update. + with patch('homeassistant.util.dt.utcnow', return_value=utcnow), \ + patch('geojson_client.nsw_rural_fire_service_feed.' + 'NswRuralFireServiceFeed') as mock_feed: mock_feed.return_value.update.return_value = 'OK', [mock_entry_1, mock_entry_2, mock_entry_3] + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG) + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 3 + + state = hass.states.get("geo_location.title_1") + assert state is not None + assert state.name == "Title 1" + assert state.attributes == { + ATTR_EXTERNAL_ID: "1234", ATTR_LATITUDE: -31.0, + ATTR_LONGITUDE: 150.0, ATTR_FRIENDLY_NAME: "Title 1", + ATTR_CATEGORY: "Category 1", ATTR_LOCATION: "Location 1", + ATTR_ATTRIBUTION: "Attribution 1", + ATTR_PUBLICATION_DATE: + datetime.datetime(2018, 9, 22, 8, 0, + tzinfo=datetime.timezone.utc), + ATTR_FIRE: True, + ATTR_COUNCIL_AREA: 'Council Area 1', + ATTR_STATUS: 'Status 1', ATTR_TYPE: 'Type 1', + ATTR_SIZE: 'Size 1', ATTR_RESPONSIBLE_AGENCY: 'Agency 1', + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'nsw_rural_fire_service_feed'} + assert round(abs(float(state.state)-15.5), 7) == 0 + + state = hass.states.get("geo_location.title_2") + assert state is not None + assert state.name == "Title 2" + assert state.attributes == { + ATTR_EXTERNAL_ID: "2345", ATTR_LATITUDE: -31.1, + ATTR_LONGITUDE: 150.1, ATTR_FRIENDLY_NAME: "Title 2", + ATTR_FIRE: False, + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'nsw_rural_fire_service_feed'} + assert round(abs(float(state.state)-20.5), 7) == 0 + + state = hass.states.get("geo_location.title_3") + assert state is not None + assert state.name == "Title 3" + assert state.attributes == { + ATTR_EXTERNAL_ID: "3456", ATTR_LATITUDE: -31.2, + ATTR_LONGITUDE: 150.2, ATTR_FRIENDLY_NAME: "Title 3", + ATTR_FIRE: True, + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'nsw_rural_fire_service_feed'} + assert round(abs(float(state.state)-25.5), 7) == 0 + + # Simulate an update - one existing, one new entry, + # one outdated entry + mock_feed.return_value.update.return_value = 'OK', [ + mock_entry_1, mock_entry_4, mock_entry_3] + async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 3 + + # Simulate an update - empty data, but successful update, + # so no changes to entities. + mock_feed.return_value.update.return_value = 'OK_NO_DATA', None + async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 3 + + # Simulate an update - empty data, removes all entities + mock_feed.return_value.update.return_value = 'ERROR', None + async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 0 + + +async def test_setup_with_custom_location(hass): + """Test the setup with a custom location.""" + # Set up some mock feed entries for this test. + mock_entry_1 = _generate_mock_feed_entry( + '1234', 'Title 1', 20.5, (-31.1, 150.1)) + + with patch('geojson_client.nsw_rural_fire_service_feed.' + 'NswRuralFireServiceFeed') as mock_feed: + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1] + + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG_WITH_CUSTOM_LOCATION) + + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 1 - utcnow = dt_util.utcnow() - # Patching 'utcnow' to gain more control over the timed update. - with patch('homeassistant.util.dt.utcnow', return_value=utcnow): - with assert_setup_component(1, geo_location.DOMAIN): - assert await async_setup_component( - hass, geo_location.DOMAIN, CONFIG) - # Artificially trigger update. - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - # Collect events. - await hass.async_block_till_done() - - all_states = hass.states.async_all() - assert len(all_states) == 3 - - state = hass.states.get("geo_location.title_1") - assert state is not None - assert state.name == "Title 1" - assert state.attributes == { - ATTR_EXTERNAL_ID: "1234", ATTR_LATITUDE: -31.0, - ATTR_LONGITUDE: 150.0, ATTR_FRIENDLY_NAME: "Title 1", - ATTR_CATEGORY: "Category 1", ATTR_LOCATION: "Location 1", - ATTR_ATTRIBUTION: "Attribution 1", - ATTR_PUBLICATION_DATE: - datetime.datetime(2018, 9, 22, 8, 0, - tzinfo=datetime.timezone.utc), - ATTR_FIRE: True, - ATTR_COUNCIL_AREA: 'Council Area 1', - ATTR_STATUS: 'Status 1', ATTR_TYPE: 'Type 1', - ATTR_SIZE: 'Size 1', ATTR_RESPONSIBLE_AGENCY: 'Agency 1', - ATTR_UNIT_OF_MEASUREMENT: "km", - ATTR_SOURCE: 'nsw_rural_fire_service_feed'} - assert round(abs(float(state.state)-15.5), 7) == 0 - - state = hass.states.get("geo_location.title_2") - assert state is not None - assert state.name == "Title 2" - assert state.attributes == { - ATTR_EXTERNAL_ID: "2345", ATTR_LATITUDE: -31.1, - ATTR_LONGITUDE: 150.1, ATTR_FRIENDLY_NAME: "Title 2", - ATTR_FIRE: False, - ATTR_UNIT_OF_MEASUREMENT: "km", - ATTR_SOURCE: 'nsw_rural_fire_service_feed'} - assert round(abs(float(state.state)-20.5), 7) == 0 - - state = hass.states.get("geo_location.title_3") - assert state is not None - assert state.name == "Title 3" - assert state.attributes == { - ATTR_EXTERNAL_ID: "3456", ATTR_LATITUDE: -31.2, - ATTR_LONGITUDE: 150.2, ATTR_FRIENDLY_NAME: "Title 3", - ATTR_FIRE: True, - ATTR_UNIT_OF_MEASUREMENT: "km", - ATTR_SOURCE: 'nsw_rural_fire_service_feed'} - assert round(abs(float(state.state)-25.5), 7) == 0 - - # Simulate an update - one existing, one new entry, - # one outdated entry - mock_feed.return_value.update.return_value = 'OK', [ - mock_entry_1, mock_entry_4, mock_entry_3] - async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) - await hass.async_block_till_done() - - all_states = hass.states.async_all() - assert len(all_states) == 3 - - # Simulate an update - empty data, but successful update, - # so no changes to entities. - mock_feed.return_value.update.return_value = 'OK_NO_DATA', None - # mock_restdata.return_value.data = None - async_fire_time_changed(hass, utcnow + - 2 * SCAN_INTERVAL) - await hass.async_block_till_done() - - all_states = hass.states.async_all() - assert len(all_states) == 3 - - # Simulate an update - empty data, removes all entities - mock_feed.return_value.update.return_value = 'ERROR', None - async_fire_time_changed(hass, utcnow + - 2 * SCAN_INTERVAL) - await hass.async_block_till_done() - - all_states = hass.states.async_all() - assert len(all_states) == 0 + assert mock_feed.call_args == call( + (15.1, 25.2), filter_categories=[], filter_radius=200.0) From 1cbe080df92babd0e423e37690d45314d7568b7b Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 27 Nov 2018 13:21:42 +0100 Subject: [PATCH 084/325] Fix remaining issues (#18416) --- homeassistant/components/light/niko_home_control.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/light/niko_home_control.py b/homeassistant/components/light/niko_home_control.py index 3146954ed628e..6b58ced59897f 100644 --- a/homeassistant/components/light/niko_home_control.py +++ b/homeassistant/components/light/niko_home_control.py @@ -5,7 +5,6 @@ https://home-assistant.io/components/light.niko_home_control/ """ import logging -import socket import voluptuous as vol @@ -24,11 +23,11 @@ }) -def setup_platform(hass, config, add_devices, discovery_info=None): +def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Niko Home Control light platform.""" import nikohomecontrol - host = config.get(CONF_HOST) + host = config[CONF_HOST] try: hub = nikohomecontrol.Hub({ @@ -37,11 +36,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): 'timeout': 20000, 'events': True }) - except socket.error as err: + except OSError as err: _LOGGER.error("Unable to access %s (%s)", host, err) raise PlatformNotReady - add_devices( + add_entities( [NikoHomeControlLight(light, hub) for light in hub.list_actions()], True) @@ -76,12 +75,10 @@ def turn_on(self, **kwargs): """Instruct the light to turn on.""" self._light.brightness = kwargs.get(ATTR_BRIGHTNESS, 255) self._light.turn_on() - self._state = True def turn_off(self, **kwargs): """Instruct the light to turn off.""" self._light.turn_off() - self._state = False def update(self): """Fetch new state data for this light.""" From c1ed2f17ac41d065cc05ada1876d284ad1dca342 Mon Sep 17 00:00:00 2001 From: Robert Dunmire III Date: Tue, 27 Nov 2018 07:26:52 -0500 Subject: [PATCH 085/325] Update librouteros and re-connect to api if connection is lost (#18421) * Reconnect when connection is lost * Fix tabs * add librouteros.exceptions * add logger * fix line too long * added import librouteros * Update librouteros version * Update mikrotik.py * Update mikrotik.py * Fix trailing whitespace * Update mikrotik.py * Update mikrotik.py --- homeassistant/components/device_tracker/mikrotik.py | 12 +++++++++--- requirements_all.txt | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/device_tracker/mikrotik.py b/homeassistant/components/device_tracker/mikrotik.py index 587872db8396a..cddcd1f26eefb 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_SSL, CONF_METHOD) -REQUIREMENTS = ['librouteros==2.1.1'] +REQUIREMENTS = ['librouteros==2.2.0'] _LOGGER = logging.getLogger(__name__) @@ -144,12 +144,18 @@ def connect_to_device(self): librouteros.exceptions.MultiTrapError, librouteros.exceptions.ConnectionError) as api_error: _LOGGER.error("Connection error: %s", api_error) - return self.connected def scan_devices(self): """Scan for new devices and return a list with found device MACs.""" - self._update_info() + import librouteros + try: + self._update_info() + except (librouteros.exceptions.TrapError, + librouteros.exceptions.MultiTrapError, + librouteros.exceptions.ConnectionError) as api_error: + _LOGGER.error("Connection error: %s", api_error) + self.connect_to_device() return [device for device in self.last_results] def get_device_name(self, device): diff --git a/requirements_all.txt b/requirements_all.txt index 354b7ca908ac4..294f1cb1ebb64 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -570,7 +570,7 @@ libpurecoollink==0.4.2 libpyfoscam==1.0 # homeassistant.components.device_tracker.mikrotik -librouteros==2.1.1 +librouteros==2.2.0 # homeassistant.components.media_player.soundtouch libsoundtouch==0.7.2 From 16e3ff2fecf84659c552fc4009760c735b11a93e Mon Sep 17 00:00:00 2001 From: emontnemery Date: Tue, 27 Nov 2018 14:00:05 +0100 Subject: [PATCH 086/325] Mqtt light refactor (#18227) * Rename mqtt light files * Refactor mqtt light * Remove outdated testcase * Add backwards compatibility for MQTT discovered MQTT lights. Refactor according to review comments. --- .../components/light/mqtt/__init__.py | 72 ++++++++++++ .../light/{mqtt.py => mqtt/schema_basic.py} | 40 ++----- .../{mqtt_json.py => mqtt/schema_json.py} | 31 ++--- .../schema_template.py} | 11 +- homeassistant/components/mqtt/discovery.py | 63 +++++----- tests/components/light/test_mqtt.py | 20 +++- tests/components/light/test_mqtt_json.py | 61 +++++++--- tests/components/light/test_mqtt_template.py | 108 ++++++++++++++++-- tests/scripts/test_check_config.py | 39 ------- 9 files changed, 287 insertions(+), 158 deletions(-) create mode 100644 homeassistant/components/light/mqtt/__init__.py rename homeassistant/components/light/{mqtt.py => mqtt/schema_basic.py} (94%) rename homeassistant/components/light/{mqtt_json.py => mqtt/schema_json.py} (94%) rename homeassistant/components/light/{mqtt_template.py => mqtt/schema_template.py} (97%) diff --git a/homeassistant/components/light/mqtt/__init__.py b/homeassistant/components/light/mqtt/__init__.py new file mode 100644 index 0000000000000..93f32cd27918e --- /dev/null +++ b/homeassistant/components/light/mqtt/__init__.py @@ -0,0 +1,72 @@ +""" +Support for MQTT lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.mqtt/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components import light +from homeassistant.components.mqtt import ATTR_DISCOVERY_HASH +from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import HomeAssistantType, ConfigType + +from . import schema_basic +from . import schema_json +from . import schema_template + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['mqtt'] + +CONF_SCHEMA = 'schema' + + +def validate_mqtt_light(value): + """Validate MQTT light schema.""" + schemas = { + 'basic': schema_basic.PLATFORM_SCHEMA_BASIC, + 'json': schema_json.PLATFORM_SCHEMA_JSON, + 'template': schema_template.PLATFORM_SCHEMA_TEMPLATE, + } + return schemas[value[CONF_SCHEMA]](value) + + +PLATFORM_SCHEMA = vol.All(vol.Schema({ + vol.Optional(CONF_SCHEMA, default='basic'): vol.All( + vol.Lower, vol.Any('basic', 'json', 'template')) +}, extra=vol.ALLOW_EXTRA), validate_mqtt_light) + + +async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_entities, discovery_info=None): + """Set up MQTT light through configuration.yaml.""" + await _async_setup_entity(hass, config, async_add_entities) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up MQTT light dynamically through MQTT discovery.""" + async def async_discover(discovery_payload): + """Discover and add a MQTT light.""" + config = PLATFORM_SCHEMA(discovery_payload) + await _async_setup_entity(hass, config, async_add_entities, + discovery_payload[ATTR_DISCOVERY_HASH]) + + async_dispatcher_connect( + hass, MQTT_DISCOVERY_NEW.format(light.DOMAIN, 'mqtt'), + async_discover) + + +async def _async_setup_entity(hass, config, async_add_entities, + discovery_hash=None): + """Set up a MQTT Light.""" + setup_entity = { + 'basic': schema_basic.async_setup_entity_basic, + 'json': schema_json.async_setup_entity_json, + 'template': schema_template.async_setup_entity_template, + } + await setup_entity[config['schema']]( + hass, config, async_add_entities, discovery_hash) diff --git a/homeassistant/components/light/mqtt.py b/homeassistant/components/light/mqtt/schema_basic.py similarity index 94% rename from homeassistant/components/light/mqtt.py rename to homeassistant/components/light/mqtt/schema_basic.py index 92030c8617a37..6c7b0e75301ae 100644 --- a/homeassistant/components/light/mqtt.py +++ b/homeassistant/components/light/mqtt/schema_basic.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.core import callback -from homeassistant.components import mqtt, light +from homeassistant.components import mqtt from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_WHITE_VALUE, Light, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, @@ -19,13 +19,10 @@ CONF_OPTIMISTIC, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, STATE_ON, CONF_RGB, CONF_STATE, CONF_VALUE_TEMPLATE, CONF_WHITE_VALUE, CONF_XY) from homeassistant.components.mqtt import ( - ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_COMMAND_TOPIC, - CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, - CONF_STATE_TOPIC, MqttAvailability, MqttDiscoveryUpdate) -from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW + CONF_AVAILABILITY_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, + CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, + MqttAvailability, MqttDiscoveryUpdate) from homeassistant.helpers.restore_state import async_get_last_state -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.typing import HomeAssistantType, ConfigType import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util @@ -72,7 +69,7 @@ VALUES_ON_COMMAND_TYPE = ['first', 'last', 'brightness'] -PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ +PLATFORM_SCHEMA_BASIC = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_BRIGHTNESS_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Optional(CONF_BRIGHTNESS_SCALE, default=DEFAULT_BRIGHTNESS_SCALE): vol.All(vol.Coerce(int), vol.Range(min=1)), @@ -111,27 +108,8 @@ }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) -async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, - async_add_entities, discovery_info=None): - """Set up MQTT light through configuration.yaml.""" - await _async_setup_entity(hass, config, async_add_entities) - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up MQTT light dynamically through MQTT discovery.""" - async def async_discover(discovery_payload): - """Discover and add a MQTT light.""" - config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(hass, config, async_add_entities, - discovery_payload[ATTR_DISCOVERY_HASH]) - - async_dispatcher_connect( - hass, MQTT_DISCOVERY_NEW.format(light.DOMAIN, 'mqtt'), - async_discover) - - -async def _async_setup_entity(hass, config, async_add_entities, - discovery_hash=None): +async def async_setup_entity_basic(hass, config, async_add_entities, + discovery_hash=None): """Set up a MQTT Light.""" config.setdefault( CONF_STATE_VALUE_TEMPLATE, config.get(CONF_VALUE_TEMPLATE)) @@ -688,7 +666,7 @@ async def async_turn_on(self, **kwargs): should_update = True if self._optimistic: - # Optimistically assume that switch has changed state. + # Optimistically assume that the light has changed state. self._state = True should_update = True @@ -705,6 +683,6 @@ async def async_turn_off(self, **kwargs): self._qos, self._retain) if self._optimistic: - # Optimistically assume that switch has changed state. + # Optimistically assume that the light has changed state. self._state = False self.async_schedule_update_ha_state() diff --git a/homeassistant/components/light/mqtt_json.py b/homeassistant/components/light/mqtt/schema_json.py similarity index 94% rename from homeassistant/components/light/mqtt_json.py rename to homeassistant/components/light/mqtt/schema_json.py index 1ed43a6385a65..43e0f655f0b3c 100644 --- a/homeassistant/components/light/mqtt_json.py +++ b/homeassistant/components/light/mqtt/schema_json.py @@ -14,14 +14,12 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_WHITE_VALUE, FLASH_LONG, FLASH_SHORT, - PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, - SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_TRANSITION, SUPPORT_WHITE_VALUE, - Light) -from homeassistant.components.light.mqtt import CONF_BRIGHTNESS_SCALE + SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, + SUPPORT_FLASH, SUPPORT_TRANSITION, SUPPORT_WHITE_VALUE, Light) from homeassistant.components.mqtt import ( - ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_COMMAND_TOPIC, - CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, - CONF_STATE_TOPIC, MqttAvailability, MqttDiscoveryUpdate) + CONF_AVAILABILITY_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, + CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, + MqttAvailability, MqttDiscoveryUpdate) from homeassistant.const import ( CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_EFFECT, CONF_NAME, CONF_OPTIMISTIC, CONF_RGB, CONF_WHITE_VALUE, CONF_XY, STATE_ON) @@ -31,6 +29,8 @@ from homeassistant.helpers.typing import ConfigType, HomeAssistantType import homeassistant.util.color as color_util +from .schema_basic import CONF_BRIGHTNESS_SCALE + _LOGGER = logging.getLogger(__name__) DOMAIN = 'mqtt_json' @@ -58,7 +58,7 @@ CONF_UNIQUE_ID = 'unique_id' # Stealing some of these from the base MQTT configs. -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ +PLATFORM_SCHEMA_JSON = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_BRIGHTNESS, default=DEFAULT_BRIGHTNESS): cv.boolean, vol.Optional(CONF_BRIGHTNESS_SCALE, default=DEFAULT_BRIGHTNESS_SCALE): vol.All(vol.Coerce(int), vol.Range(min=1)), @@ -84,17 +84,10 @@ }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) -async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, - async_add_entities, discovery_info=None): +async def async_setup_entity_json(hass: HomeAssistantType, config: ConfigType, + async_add_entities, discovery_hash): """Set up a MQTT JSON Light.""" - if discovery_info is not None: - config = PLATFORM_SCHEMA(discovery_info) - - discovery_hash = None - if discovery_info is not None and ATTR_DISCOVERY_HASH in discovery_info: - discovery_hash = discovery_info[ATTR_DISCOVERY_HASH] - - async_add_entities([MqttJson( + async_add_entities([MqttLightJson( config.get(CONF_NAME), config.get(CONF_UNIQUE_ID), config.get(CONF_EFFECT_LIST), @@ -128,7 +121,7 @@ async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, )]) -class MqttJson(MqttAvailability, MqttDiscoveryUpdate, Light): +class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, Light): """Representation of a MQTT JSON light.""" def __init__(self, name, unique_id, effect_list, topic, qos, retain, diff --git a/homeassistant/components/light/mqtt_template.py b/homeassistant/components/light/mqtt/schema_template.py similarity index 97% rename from homeassistant/components/light/mqtt_template.py rename to homeassistant/components/light/mqtt/schema_template.py index 72cfd6b678c25..082e4674cb9f7 100644 --- a/homeassistant/components/light/mqtt_template.py +++ b/homeassistant/components/light/mqtt/schema_template.py @@ -11,7 +11,7 @@ from homeassistant.components import mqtt from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, - ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_WHITE_VALUE, Light, PLATFORM_SCHEMA, + ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_WHITE_VALUE, Light, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_COLOR, SUPPORT_TRANSITION, SUPPORT_WHITE_VALUE) from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, STATE_ON, STATE_OFF @@ -44,7 +44,7 @@ CONF_STATE_TEMPLATE = 'state_template' CONF_WHITE_VALUE_TEMPLATE = 'white_value_template' -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ +PLATFORM_SCHEMA_TEMPLATE = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_BLUE_TEMPLATE): cv.template, vol.Optional(CONF_BRIGHTNESS_TEMPLATE): cv.template, vol.Optional(CONF_COLOR_TEMP_TEMPLATE): cv.template, @@ -66,12 +66,9 @@ }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +async def async_setup_entity_template(hass, config, async_add_entities, + discovery_hash): """Set up a MQTT Template light.""" - if discovery_info is not None: - config = PLATFORM_SCHEMA(discovery_info) - async_add_entities([MqttTemplate( hass, config.get(CONF_NAME), diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index d91ab6ee445e9..9ea3151c65c1b 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -27,32 +27,26 @@ 'light', 'sensor', 'switch', 'lock', 'climate', 'alarm_control_panel'] -ALLOWED_PLATFORMS = { - 'binary_sensor': ['mqtt'], - 'camera': ['mqtt'], - 'cover': ['mqtt'], - 'fan': ['mqtt'], - 'light': ['mqtt', 'mqtt_json', 'mqtt_template'], - 'lock': ['mqtt'], - 'sensor': ['mqtt'], - 'switch': ['mqtt'], - 'climate': ['mqtt'], - 'alarm_control_panel': ['mqtt'], -} +CONFIG_ENTRY_COMPONENTS = [ + 'binary_sensor', + 'camera', + 'cover', + 'light', + 'lock', + 'sensor', + 'switch', + 'climate', + 'alarm_control_panel', + 'fan', +] -CONFIG_ENTRY_PLATFORMS = { - 'binary_sensor': ['mqtt'], - 'camera': ['mqtt'], - 'cover': ['mqtt'], - 'light': ['mqtt'], - 'lock': ['mqtt'], - 'sensor': ['mqtt'], - 'switch': ['mqtt'], - 'climate': ['mqtt'], - 'alarm_control_panel': ['mqtt'], - 'fan': ['mqtt'], +DEPRECATED_PLATFORM_TO_SCHEMA = { + 'mqtt': 'basic', + 'mqtt_json': 'json', + 'mqtt_template': 'template', } + ALREADY_DISCOVERED = 'mqtt_discovered_components' DATA_CONFIG_ENTRY_LOCK = 'mqtt_config_entry_lock' CONFIG_ENTRY_IS_SETUP = 'mqtt_config_entry_is_setup' @@ -216,12 +210,15 @@ async def async_device_message_received(topic, payload, qos): discovery_hash = (component, discovery_id) if payload: - platform = payload.get(CONF_PLATFORM, 'mqtt') - if platform not in ALLOWED_PLATFORMS.get(component, []): - _LOGGER.warning("Platform %s (component %s) is not allowed", - platform, component) - return - payload[CONF_PLATFORM] = platform + if CONF_PLATFORM in payload: + platform = payload[CONF_PLATFORM] + if platform in DEPRECATED_PLATFORM_TO_SCHEMA: + schema = DEPRECATED_PLATFORM_TO_SCHEMA[platform] + payload['schema'] = schema + _LOGGER.warning('"platform": "%s" is deprecated, ' + 'replace with "schema":"%s"', + platform, schema) + payload[CONF_PLATFORM] = 'mqtt' if CONF_STATE_TOPIC not in payload: payload[CONF_STATE_TOPIC] = '{}/{}/{}{}/state'.format( @@ -244,12 +241,12 @@ async def async_device_message_received(topic, payload, qos): _LOGGER.info("Found new component: %s %s", component, discovery_id) hass.data[ALREADY_DISCOVERED][discovery_hash] = None - if platform not in CONFIG_ENTRY_PLATFORMS.get(component, []): + if component not in CONFIG_ENTRY_COMPONENTS: await async_load_platform( - hass, component, platform, payload, hass_config) + hass, component, 'mqtt', payload, hass_config) return - config_entries_key = '{}.{}'.format(component, platform) + config_entries_key = '{}.{}'.format(component, 'mqtt') async with hass.data[DATA_CONFIG_ENTRY_LOCK]: if config_entries_key not in hass.data[CONFIG_ENTRY_IS_SETUP]: await hass.config_entries.async_forward_entry_setup( @@ -257,7 +254,7 @@ async def async_device_message_received(topic, payload, qos): hass.data[CONFIG_ENTRY_IS_SETUP].add(config_entries_key) async_dispatcher_send(hass, MQTT_DISCOVERY_NEW.format( - component, platform), payload) + component, 'mqtt'), payload) hass.data[DATA_CONFIG_ENTRY_LOCK] = asyncio.Lock() hass.data[CONFIG_ENTRY_IS_SETUP] = set() diff --git a/tests/components/light/test_mqtt.py b/tests/components/light/test_mqtt.py index f09f3726252af..c56835afc9fc6 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -585,7 +585,8 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): 'effect': 'random', 'color_temp': 100, 'white_value': 50}) - with patch('homeassistant.components.light.mqtt.async_get_last_state', + with patch('homeassistant.components.light.mqtt.schema_basic' + '.async_get_last_state', return_value=mock_coro(fake_state)): with assert_setup_component(1, light.DOMAIN): assert await async_setup_component(hass, light.DOMAIN, config) @@ -1063,3 +1064,20 @@ async def test_discovery_removal_light(hass, mqtt_mock, caplog): state = hass.states.get('light.beer') assert state is None + + +async def test_discovery_deprecated(hass, mqtt_mock, caplog): + """Test removal of discovered mqtt_json lights.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {'mqtt': {}}, entry) + data = ( + '{ "name": "Beer",' + ' "platform": "mqtt",' + ' "command_topic": "test_topic"}' + ) + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data) + await hass.async_block_till_done() + state = hass.states.get('light.beer') + assert state is not None + assert state.name == 'Beer' diff --git a/tests/components/light/test_mqtt_json.py b/tests/components/light/test_mqtt_json.py index 03a3927472a33..e509cd5718cf7 100644 --- a/tests/components/light/test_mqtt_json.py +++ b/tests/components/light/test_mqtt_json.py @@ -93,18 +93,19 @@ from homeassistant.const import ( STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE, ATTR_SUPPORTED_FEATURES) -import homeassistant.components.light as light +from homeassistant.components import light, mqtt from homeassistant.components.mqtt.discovery import async_start import homeassistant.core as ha -from tests.common import mock_coro, async_fire_mqtt_message +from tests.common import mock_coro, async_fire_mqtt_message, MockConfigEntry async def test_fail_setup_if_no_command_topic(hass, mqtt_mock): """Test if setup fails with no command topic.""" assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_json', + 'platform': 'mqtt', + 'schema': 'json', 'name': 'test', } }) @@ -116,7 +117,8 @@ async def test_no_color_brightness_color_temp_white_val_if_no_topics( """Test for no RGB, brightness, color temp, effect, white val or XY.""" assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_json', + 'platform': 'mqtt', + 'schema': 'json', 'name': 'test', 'state_topic': 'test_light_rgb', 'command_topic': 'test_light_rgb/set', @@ -152,7 +154,8 @@ async def test_controlling_state_via_topic(hass, mqtt_mock): """Test the controlling of the state via topic.""" assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_json', + 'platform': 'mqtt', + 'schema': 'json', 'name': 'test', 'state_topic': 'test_light_rgb', 'command_topic': 'test_light_rgb/set', @@ -276,12 +279,13 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): 'color_temp': 100, 'white_value': 50}) - with patch('homeassistant.components.light.mqtt_json' + with patch('homeassistant.components.light.mqtt.schema_json' '.async_get_last_state', return_value=mock_coro(fake_state)): assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_json', + 'platform': 'mqtt', + 'schema': 'json', 'name': 'test', 'command_topic': 'test_light_rgb/set', 'brightness': True, @@ -308,7 +312,8 @@ async def test_sending_hs_color(hass, mqtt_mock): """Test light.turn_on with hs color sends hs color parameters.""" assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_json', + 'platform': 'mqtt', + 'schema': 'json', 'name': 'test', 'command_topic': 'test_light_rgb/set', 'hs': True, @@ -323,7 +328,8 @@ async def test_flash_short_and_long(hass, mqtt_mock): """Test for flash length being sent when included.""" assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_json', + 'platform': 'mqtt', + 'schema': 'json', 'name': 'test', 'state_topic': 'test_light_rgb', 'command_topic': 'test_light_rgb/set', @@ -342,7 +348,8 @@ async def test_transition(hass, mqtt_mock): """Test for transition time being sent when included.""" assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_json', + 'platform': 'mqtt', + 'schema': 'json', 'name': 'test', 'state_topic': 'test_light_rgb', 'command_topic': 'test_light_rgb/set', @@ -359,7 +366,8 @@ async def test_brightness_scale(hass, mqtt_mock): """Test for brightness scaling.""" assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_json', + 'platform': 'mqtt', + 'schema': 'json', 'name': 'test', 'state_topic': 'test_light_bright_scale', 'command_topic': 'test_light_bright_scale/set', @@ -395,7 +403,8 @@ async def test_invalid_color_brightness_and_white_values(hass, mqtt_mock): """Test that invalid color/brightness/white values are ignored.""" assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_json', + 'platform': 'mqtt', + 'schema': 'json', 'name': 'test', 'state_topic': 'test_light_rgb', 'command_topic': 'test_light_rgb/set', @@ -466,7 +475,8 @@ async def test_default_availability_payload(hass, mqtt_mock): """Test availability by default payload with defined topic.""" assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_json', + 'platform': 'mqtt', + 'schema': 'json', 'name': 'test', 'state_topic': 'test_light_rgb', 'command_topic': 'test_light_rgb/set', @@ -495,7 +505,8 @@ async def test_custom_availability_payload(hass, mqtt_mock): """Test availability by custom payload with defined topic.""" assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_json', + 'platform': 'mqtt', + 'schema': 'json', 'name': 'test', 'state_topic': 'test_light_rgb', 'command_topic': 'test_light_rgb/set', @@ -524,10 +535,11 @@ async def test_custom_availability_payload(hass, mqtt_mock): async def test_discovery_removal(hass, mqtt_mock, caplog): """Test removal of discovered mqtt_json lights.""" - await async_start(hass, 'homeassistant', {'mqtt': {}}) + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {'mqtt': {}}, entry) data = ( '{ "name": "Beer",' - ' "platform": "mqtt_json",' + ' "schema": "json",' ' "command_topic": "test_topic" }' ) async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', @@ -542,3 +554,20 @@ async def test_discovery_removal(hass, mqtt_mock, caplog): await hass.async_block_till_done() state = hass.states.get('light.beer') assert state is None + + +async def test_discovery_deprecated(hass, mqtt_mock, caplog): + """Test removal of discovered mqtt_json lights.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {'mqtt': {}}, entry) + data = ( + '{ "name": "Beer",' + ' "platform": "mqtt_json",' + ' "command_topic": "test_topic"}' + ) + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data) + await hass.async_block_till_done() + state = hass.states.get('light.beer') + assert state is not None + assert state.name == 'Beer' diff --git a/tests/components/light/test_mqtt_template.py b/tests/components/light/test_mqtt_template.py index 6bc0b4536eaf7..0d26d6edb120f 100644 --- a/tests/components/light/test_mqtt_template.py +++ b/tests/components/light/test_mqtt_template.py @@ -31,11 +31,13 @@ from homeassistant.setup import async_setup_component from homeassistant.const import ( STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE) -import homeassistant.components.light as light +from homeassistant.components import light, mqtt +from homeassistant.components.mqtt.discovery import async_start import homeassistant.core as ha from tests.common import ( - async_fire_mqtt_message, assert_setup_component, mock_coro) + async_fire_mqtt_message, assert_setup_component, mock_coro, + MockConfigEntry) async def test_setup_fails(hass, mqtt_mock): @@ -43,19 +45,56 @@ async def test_setup_fails(hass, mqtt_mock): with assert_setup_component(0, light.DOMAIN): assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_template', + 'platform': 'mqtt', + 'schema': 'template', 'name': 'test', } }) assert hass.states.get('light.test') is None + with assert_setup_component(0, light.DOMAIN): + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt', + 'schema': 'template', + 'name': 'test', + 'command_topic': 'test_topic', + } + }) + assert hass.states.get('light.test') is None + + with assert_setup_component(0, light.DOMAIN): + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt', + 'schema': 'template', + 'name': 'test', + 'command_topic': 'test_topic', + 'command_on_template': 'on', + } + }) + assert hass.states.get('light.test') is None + + with assert_setup_component(0, light.DOMAIN): + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt', + 'schema': 'template', + 'name': 'test', + 'command_topic': 'test_topic', + 'command_off_template': 'off', + } + }) + assert hass.states.get('light.test') is None + async def test_state_change_via_topic(hass, mqtt_mock): """Test state change via topic.""" with assert_setup_component(1, light.DOMAIN): assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_template', + 'platform': 'mqtt', + 'schema': 'template', 'name': 'test', 'state_topic': 'test_light_rgb', 'command_topic': 'test_light_rgb/set', @@ -96,7 +135,8 @@ async def test_state_brightness_color_effect_temp_white_change_via_topic( with assert_setup_component(1, light.DOMAIN): assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_template', + 'platform': 'mqtt', + 'schema': 'template', 'name': 'test', 'effect_list': ['rainbow', 'colorloop'], 'state_topic': 'test_light_rgb', @@ -205,13 +245,14 @@ async def test_optimistic(hass, mqtt_mock): 'color_temp': 100, 'white_value': 50}) - with patch('homeassistant.components.light.mqtt_template' + with patch('homeassistant.components.light.mqtt.schema_template' '.async_get_last_state', return_value=mock_coro(fake_state)): with assert_setup_component(1, light.DOMAIN): assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_template', + 'platform': 'mqtt', + 'schema': 'template', 'name': 'test', 'command_topic': 'test_light_rgb/set', 'command_on_template': 'on,' @@ -243,7 +284,8 @@ async def test_flash(hass, mqtt_mock): with assert_setup_component(1, light.DOMAIN): assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_template', + 'platform': 'mqtt', + 'schema': 'template', 'name': 'test', 'command_topic': 'test_light_rgb/set', 'command_on_template': 'on,{{ flash }}', @@ -261,7 +303,8 @@ async def test_transition(hass, mqtt_mock): with assert_setup_component(1, light.DOMAIN): assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_template', + 'platform': 'mqtt', + 'schema': 'template', 'name': 'test', 'command_topic': 'test_light_rgb/set', 'command_on_template': 'on,{{ transition }}', @@ -278,7 +321,8 @@ async def test_invalid_values(hass, mqtt_mock): with assert_setup_component(1, light.DOMAIN): assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_template', + 'platform': 'mqtt', + 'schema': 'template', 'name': 'test', 'effect_list': ['rainbow', 'colorloop'], 'state_topic': 'test_light_rgb', @@ -380,7 +424,8 @@ async def test_default_availability_payload(hass, mqtt_mock): """Test availability by default payload with defined topic.""" assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_template', + 'platform': 'mqtt', + 'schema': 'template', 'name': 'test', 'command_topic': 'test_light_rgb/set', 'command_on_template': 'on,{{ transition }}', @@ -410,7 +455,8 @@ async def test_custom_availability_payload(hass, mqtt_mock): """Test availability by custom payload with defined topic.""" assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_template', + 'platform': 'mqtt', + 'schema': 'template', 'name': 'test', 'command_topic': 'test_light_rgb/set', 'command_on_template': 'on,{{ transition }}', @@ -436,3 +482,41 @@ async def test_custom_availability_payload(hass, mqtt_mock): state = hass.states.get('light.test') assert STATE_UNAVAILABLE == state.state + + +async def test_discovery(hass, mqtt_mock, caplog): + """Test removal of discovered mqtt_json lights.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {'mqtt': {}}, entry) + data = ( + '{ "name": "Beer",' + ' "schema": "template",' + ' "command_topic": "test_topic",' + ' "command_on_template": "on",' + ' "command_off_template": "off"}' + ) + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data) + await hass.async_block_till_done() + state = hass.states.get('light.beer') + assert state is not None + assert state.name == 'Beer' + + +async def test_discovery_deprecated(hass, mqtt_mock, caplog): + """Test removal of discovered mqtt_json lights.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {'mqtt': {}}, entry) + data = ( + '{ "name": "Beer",' + ' "platform": "mqtt_template",' + ' "command_topic": "test_topic",' + ' "command_on_template": "on",' + ' "command_off_template": "off"}' + ) + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data) + await hass.async_block_till_done() + state = hass.states.get('light.beer') + assert state is not None + assert state.name == 'Beer' diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 28438a5e4b362..217f26e71f71b 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -79,45 +79,6 @@ def test_config_platform_valid(self, isfile_patch): assert res['secrets'] == {} assert len(res['yaml_files']) == 1 - @patch('os.path.isfile', return_value=True) - def test_config_component_platform_fail_validation(self, isfile_patch): - """Test errors if component & platform not found.""" - files = { - YAML_CONFIG_FILE: BASE_CONFIG + 'http:\n password: err123', - } - with patch_yaml_files(files): - res = check_config.check(get_test_config_dir()) - assert res['components'].keys() == {'homeassistant'} - assert res['except'].keys() == {'http'} - assert res['except']['http'][1] == {'http': {'password': 'err123'}} - assert res['secret_cache'] == {} - assert res['secrets'] == {} - assert len(res['yaml_files']) == 1 - - files = { - YAML_CONFIG_FILE: (BASE_CONFIG + 'mqtt:\n\n' - 'light:\n platform: mqtt_json'), - } - with patch_yaml_files(files): - res = check_config.check(get_test_config_dir()) - assert res['components'].keys() == { - 'homeassistant', 'light', 'mqtt'} - assert res['components']['light'] == [] - assert res['components']['mqtt'] == { - 'keepalive': 60, - 'port': 1883, - 'protocol': '3.1.1', - 'discovery': False, - 'discovery_prefix': 'homeassistant', - 'tls_version': 'auto', - } - assert res['except'].keys() == {'light.mqtt_json'} - assert res['except']['light.mqtt_json'][1] == { - 'platform': 'mqtt_json'} - assert res['secret_cache'] == {} - assert res['secrets'] == {} - assert len(res['yaml_files']) == 1 - @patch('os.path.isfile', return_value=True) def test_component_platform_not_found(self, isfile_patch): """Test errors if component or platform not found.""" From 9d1b94c24ae053954150bb6a9d209e61dee9eefb Mon Sep 17 00:00:00 2001 From: Luis Martinez de Bartolome Izquierdo Date: Tue, 27 Nov 2018 14:01:34 +0100 Subject: [PATCH 087/325] Supports the new Netatmo Home Coach (#18308) * Supports the new Netatmo Home Coach * unused import * Missing docstring * Fixed pylint * pydocs * doc style --- homeassistant/components/netatmo.py | 4 ++-- homeassistant/components/sensor/netatmo.py | 23 ++++++++++++++++------ requirements_all.txt | 2 +- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/netatmo.py b/homeassistant/components/netatmo.py index d8924c6c30124..b5b349d5073f7 100644 --- a/homeassistant/components/netatmo.py +++ b/homeassistant/components/netatmo.py @@ -16,7 +16,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -REQUIREMENTS = ['pyatmo==1.2'] +REQUIREMENTS = ['pyatmo==1.3'] _LOGGER = logging.getLogger(__name__) @@ -52,7 +52,7 @@ def setup(hass, config): config[DOMAIN][CONF_USERNAME], config[DOMAIN][CONF_PASSWORD], 'read_station read_camera access_camera ' 'read_thermostat write_thermostat ' - 'read_presence access_presence') + 'read_presence access_presence read_homecoach') except HTTPError: _LOGGER.error("Unable to connect to Netatmo API") return False diff --git a/homeassistant/components/sensor/netatmo.py b/homeassistant/components/sensor/netatmo.py index f709e0169cf53..2abaa801d6890 100644 --- a/homeassistant/components/sensor/netatmo.py +++ b/homeassistant/components/sensor/netatmo.py @@ -304,6 +304,20 @@ def get_module_names(self): self.update() return self.data.keys() + def _detect_platform_type(self): + """Return the XXXData object corresponding to the specified platform. + + The return can be a WeatherStationData or a HomeCoachData. + """ + import pyatmo + for data_class in [pyatmo.WeatherStationData, pyatmo.HomeCoachData]: + try: + station_data = data_class(self.auth) + _LOGGER.debug("%s detected!", str(data_class.__name__)) + return station_data + except TypeError: + continue + def update(self): """Call the Netatmo API to update the data. @@ -316,12 +330,9 @@ def update(self): return try: - import pyatmo - try: - self.station_data = pyatmo.WeatherStationData(self.auth) - except TypeError: - _LOGGER.error("Failed to connect to NetAtmo") - return # finally statement will be executed + self.station_data = self._detect_platform_type() + if not self.station_data: + raise Exception("No Weather nor HomeCoach devices found") if self.station is not None: self.data = self.station_data.lastData( diff --git a/requirements_all.txt b/requirements_all.txt index 294f1cb1ebb64..5b0a4d75550b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -849,7 +849,7 @@ pyalarmdotcom==0.3.2 pyarlo==0.2.2 # homeassistant.components.netatmo -pyatmo==1.2 +pyatmo==1.3 # homeassistant.components.apple_tv pyatv==0.3.10 From 87507c4b6f0bc79435e3359b32d733b85abd18b7 Mon Sep 17 00:00:00 2001 From: kennedyshead Date: Tue, 27 Nov 2018 14:20:25 +0100 Subject: [PATCH 088/325] fix aioasuswrt sometimes return empty lists (#18742) * aioasuswrt sometimes return empty lists * Bumping aioasuswrt to 1.1.12 --- homeassistant/components/asuswrt.py | 2 +- homeassistant/components/sensor/asuswrt.py | 8 ++++---- requirements_all.txt | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/asuswrt.py b/homeassistant/components/asuswrt.py index c653c1d03fd0e..d72c8d77a2bbd 100644 --- a/homeassistant/components/asuswrt.py +++ b/homeassistant/components/asuswrt.py @@ -14,7 +14,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform -REQUIREMENTS = ['aioasuswrt==1.1.11'] +REQUIREMENTS = ['aioasuswrt==1.1.12'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/asuswrt.py b/homeassistant/components/sensor/asuswrt.py index 4ca088fb1e2c5..876f0dfd55949 100644 --- a/homeassistant/components/sensor/asuswrt.py +++ b/homeassistant/components/sensor/asuswrt.py @@ -68,7 +68,7 @@ def unit_of_measurement(self): async def async_update(self): """Fetch new state data for the sensor.""" await super().async_update() - if self._speed is not None: + if self._speed: self._state = round(self._speed[0] / 125000, 2) @@ -86,7 +86,7 @@ def unit_of_measurement(self): async def async_update(self): """Fetch new state data for the sensor.""" await super().async_update() - if self._speed is not None: + if self._speed: self._state = round(self._speed[1] / 125000, 2) @@ -104,7 +104,7 @@ def unit_of_measurement(self): async def async_update(self): """Fetch new state data for the sensor.""" await super().async_update() - if self._rates is not None: + if self._rates: self._state = round(self._rates[0] / 1000000000, 1) @@ -122,5 +122,5 @@ def unit_of_measurement(self): async def async_update(self): """Fetch new state data for the sensor.""" await super().async_update() - if self._rates is not None: + if self._rates: self._state = round(self._rates[1] / 1000000000, 1) diff --git a/requirements_all.txt b/requirements_all.txt index 5b0a4d75550b3..5f19495c25994 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -86,7 +86,7 @@ abodepy==0.14.0 afsapi==0.0.4 # homeassistant.components.asuswrt -aioasuswrt==1.1.11 +aioasuswrt==1.1.12 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.5 From 4d5338a1b0ef7ec3e3d2204eb7e379d90d891272 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Tue, 27 Nov 2018 05:57:42 -0800 Subject: [PATCH 089/325] Fix google assistant request sync service call (#17415) * Update __init__.py * Add optional agent_user_id field to request_sync service * Update services.yaml --- homeassistant/components/google_assistant/__init__.py | 6 +++--- homeassistant/components/google_assistant/services.yaml | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index f444974bc8d80..bf0c72ec1c8e0 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -33,8 +33,6 @@ DEPENDENCIES = ['http'] -DEFAULT_AGENT_USER_ID = 'home-assistant' - ENTITY_SCHEMA = vol.Schema({ vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_EXPOSE): cv.boolean, @@ -70,10 +68,12 @@ async def request_sync_service_handler(call: ServiceCall): websession = async_get_clientsession(hass) try: with async_timeout.timeout(5, loop=hass.loop): + agent_user_id = call.data.get('agent_user_id') or \ + call.context.user_id res = await websession.post( REQUEST_SYNC_BASE_URL, params={'key': api_key}, - json={'agent_user_id': call.context.user_id}) + json={'agent_user_id': agent_user_id}) _LOGGER.info("Submitted request_sync request to Google") res.raise_for_status() except aiohttp.ClientResponseError: diff --git a/homeassistant/components/google_assistant/services.yaml b/homeassistant/components/google_assistant/services.yaml index 6019b75bd9830..7d3af71ac2bba 100644 --- a/homeassistant/components/google_assistant/services.yaml +++ b/homeassistant/components/google_assistant/services.yaml @@ -1,2 +1,5 @@ request_sync: - description: Send a request_sync command to Google. \ No newline at end of file + description: Send a request_sync command to Google. + fields: + agent_user_id: + description: Optional. Only needed for automations. Specific Home Assistant user id to sync with Google Assistant. Do not need when you call this service through Home Assistant front end or API. Used in automation script or other place where context.user_id is missing. From 392898e69456172d7fc6eb3261ee063a19db4014 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Tue, 27 Nov 2018 14:59:25 +0100 Subject: [PATCH 090/325] Updated codeowners (#18746) --- CODEOWNERS | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CODEOWNERS b/CODEOWNERS index dabc3bbd4db9d..85f8d996fac0c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -160,6 +160,8 @@ homeassistant/components/*/broadlink.py @danielhiversen homeassistant/components/counter/* @fabaff # D +homeassistant/components/daikin.py @fredrike @rofrantz +homeassistant/components/*/daikin.py @fredrike @rofrantz homeassistant/components/*/deconz.py @kane610 homeassistant/components/digital_ocean.py @fabaff homeassistant/components/*/digital_ocean.py @fabaff @@ -204,6 +206,10 @@ homeassistant/components/*/mystrom.py @fabaff homeassistant/components/openuv/* @bachya homeassistant/components/*/openuv.py @bachya +# P +homeassistant/components/point/* @fredrike +homeassistant/components/*/point.py @fredrike + # Q homeassistant/components/qwikswitch.py @kellerza homeassistant/components/*/qwikswitch.py @kellerza From eb2e2a116e64e6d80a097128a5f11cba5b8d6161 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Tue, 27 Nov 2018 15:35:51 +0100 Subject: [PATCH 091/325] Add unique_id for tellduslive (#18744) --- homeassistant/components/sensor/tellduslive.py | 5 +++++ homeassistant/components/tellduslive.py | 10 +++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/tellduslive.py b/homeassistant/components/sensor/tellduslive.py index 4676e08a24741..9bd5a1d841319 100644 --- a/homeassistant/components/sensor/tellduslive.py +++ b/homeassistant/components/sensor/tellduslive.py @@ -127,3 +127,8 @@ def device_class(self): """Return the device class.""" return SENSOR_TYPES[self._type][3] \ if self._type in SENSOR_TYPES else None + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return "-".join(self._id[0:2]) diff --git a/homeassistant/components/tellduslive.py b/homeassistant/components/tellduslive.py index c2b7ba9ba0f53..a6ba248b99b4e 100644 --- a/homeassistant/components/tellduslive.py +++ b/homeassistant/components/tellduslive.py @@ -9,12 +9,11 @@ import voluptuous as vol +from homeassistant.components.discovery import SERVICE_TELLDUSLIVE from homeassistant.const import ( - ATTR_BATTERY_LEVEL, DEVICE_DEFAULT_NAME, - CONF_TOKEN, CONF_HOST, + ATTR_BATTERY_LEVEL, CONF_HOST, CONF_TOKEN, DEVICE_DEFAULT_NAME, EVENT_HOMEASSISTANT_START) from homeassistant.helpers import discovery -from homeassistant.components.discovery import SERVICE_TELLDUSLIVE import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_point_in_utc_time @@ -360,3 +359,8 @@ def _last_updated(self): """Return the last update of a device.""" return str(datetime.fromtimestamp(self.device.lastUpdated)) \ if self.device.lastUpdated else None + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._id From 5d5c78b3741b8867928ecff2a1bd6c095fcb9457 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Tue, 27 Nov 2018 15:36:55 +0100 Subject: [PATCH 092/325] Add unique_id for Daikin entities (#18747) --- homeassistant/components/climate/daikin.py | 5 +++++ homeassistant/components/daikin.py | 5 +++++ homeassistant/components/sensor/daikin.py | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/homeassistant/components/climate/daikin.py b/homeassistant/components/climate/daikin.py index 4a5c325889371..38c78bfdb3d69 100644 --- a/homeassistant/components/climate/daikin.py +++ b/homeassistant/components/climate/daikin.py @@ -192,6 +192,11 @@ def name(self): """Return the name of the thermostat, if any.""" return self._api.name + @property + def unique_id(self): + """Return a unique ID.""" + return self._api.mac + @property def temperature_unit(self): """Return the unit of measurement which this thermostat uses.""" diff --git a/homeassistant/components/daikin.py b/homeassistant/components/daikin.py index 4fcd33bee26da..e2e4572939d24 100644 --- a/homeassistant/components/daikin.py +++ b/homeassistant/components/daikin.py @@ -132,3 +132,8 @@ def update(self, **kwargs): _LOGGER.warning( "Connection failed for %s", self.ip_address ) + + @property + def mac(self): + """Return mac-address of device.""" + return self.device.values.get('mac') diff --git a/homeassistant/components/sensor/daikin.py b/homeassistant/components/sensor/daikin.py index 3445eb531aa23..eae0c6e96147b 100644 --- a/homeassistant/components/sensor/daikin.py +++ b/homeassistant/components/sensor/daikin.py @@ -71,6 +71,11 @@ def __init__(self, api, monitored_state, units: UnitSystem, if self._sensor[CONF_TYPE] == SENSOR_TYPE_TEMPERATURE: self._unit_of_measurement = units.temperature_unit + @property + def unique_id(self): + """Return a unique ID.""" + return "{}-{}".format(self._api.mac, self._device_attribute) + def get(self, key): """Retrieve device settings from API library cache.""" value = None From 7b3b7d2eecec6940c6087b26ac6e2d9ad6001b67 Mon Sep 17 00:00:00 2001 From: Luis Martinez de Bartolome Izquierdo Date: Tue, 27 Nov 2018 15:44:09 +0100 Subject: [PATCH 093/325] Wunderlist component (#18339) * Wunderlist component * Check credentials * Dont print credentials * Update __init__.py --- .../components/wunderlist/__init__.py | 91 +++++++++++++++++++ .../components/wunderlist/services.yaml | 15 +++ requirements_all.txt | 3 + 3 files changed, 109 insertions(+) create mode 100644 homeassistant/components/wunderlist/__init__.py create mode 100644 homeassistant/components/wunderlist/services.yaml diff --git a/homeassistant/components/wunderlist/__init__.py b/homeassistant/components/wunderlist/__init__.py new file mode 100644 index 0000000000000..f64d97dfc0d5c --- /dev/null +++ b/homeassistant/components/wunderlist/__init__.py @@ -0,0 +1,91 @@ +""" +Component to interact with Wunderlist. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/wunderlist/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_NAME, CONF_ACCESS_TOKEN) + +REQUIREMENTS = ['wunderpy2==0.1.6'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'wunderlist' +CONF_CLIENT_ID = 'client_id' +CONF_LIST_NAME = 'list_name' +CONF_STARRED = 'starred' + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string + }) +}, extra=vol.ALLOW_EXTRA) + + +SERVICE_CREATE_TASK = 'create_task' + +SERVICE_SCHEMA_CREATE_TASK = vol.Schema({ + vol.Required(CONF_LIST_NAME): cv.string, + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_STARRED): cv.boolean +}) + + +def setup(hass, config): + """Set up the Wunderlist component.""" + conf = config[DOMAIN] + client_id = conf.get(CONF_CLIENT_ID) + access_token = conf.get(CONF_ACCESS_TOKEN) + data = Wunderlist(access_token, client_id) + if not data.check_credentials(): + _LOGGER.error("Invalid credentials") + return False + + hass.services.register(DOMAIN, 'create_task', data.create_task) + return True + + +class Wunderlist: + """Representation of an interface to Wunderlist.""" + + def __init__(self, access_token, client_id): + """Create new instance of Wunderlist component.""" + import wunderpy2 + + api = wunderpy2.WunderApi() + self._client = api.get_client(access_token, client_id) + + _LOGGER.debug("Instance created") + + def check_credentials(self): + """Check if the provided credentials are valid.""" + try: + self._client.get_lists() + return True + except ValueError: + return False + + def create_task(self, call): + """Create a new task on a list of Wunderlist.""" + list_name = call.data.get(CONF_LIST_NAME) + task_title = call.data.get(CONF_NAME) + starred = call.data.get(CONF_STARRED) + list_id = self._list_by_name(list_name) + self._client.create_task(list_id, task_title, starred=starred) + return True + + def _list_by_name(self, name): + """Return a list ID by name.""" + lists = self._client.get_lists() + tmp = [l for l in lists if l["title"] == name] + if tmp: + return tmp[0]["id"] + return None diff --git a/homeassistant/components/wunderlist/services.yaml b/homeassistant/components/wunderlist/services.yaml new file mode 100644 index 0000000000000..a3b097c5d3536 --- /dev/null +++ b/homeassistant/components/wunderlist/services.yaml @@ -0,0 +1,15 @@ +# Describes the format for available Wunderlist + +create_task: + description: > + Create a new task in Wunderlist. + fields: + list_name: + description: name of the new list where the task will be created + example: 'Shopping list' + name: + description: name of the new task + example: 'Buy 5 bottles of beer' + starred: + description: Create the task as starred [Optional] + example: true diff --git a/requirements_all.txt b/requirements_all.txt index 5f19495c25994..d86d02c8bbace 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1617,6 +1617,9 @@ websockets==6.0 # homeassistant.components.wirelesstag wirelesstagpy==0.4.0 +# homeassistant.components.wunderlist +wunderpy2==0.1.6 + # homeassistant.components.zigbee xbee-helper==0.0.7 From 2f07e92cc2537fd2e5cfb486ee75caa3644fa566 Mon Sep 17 00:00:00 2001 From: Austin Date: Tue, 27 Nov 2018 15:53:28 +0000 Subject: [PATCH 094/325] Fix decora_wifi residences (#17228) * Fix decora multiple residences * Fix typo * Update decora_wifi.py --- homeassistant/components/light/decora_wifi.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/decora_wifi.py b/homeassistant/components/light/decora_wifi.py index da7ccfb2db258..b9c575dbd5a1b 100644 --- a/homeassistant/components/light/decora_wifi.py +++ b/homeassistant/components/light/decora_wifi.py @@ -40,6 +40,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): from decora_wifi import DecoraWiFiSession from decora_wifi.models.person import Person from decora_wifi.models.residential_account import ResidentialAccount + from decora_wifi.models.residence import Residence email = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) @@ -60,8 +61,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): perms = session.user.get_residential_permissions() all_switches = [] for permission in perms: - acct = ResidentialAccount(session, permission.residentialAccountId) - for residence in acct.get_residences(): + if permission.residentialAccountId is not None: + acct = ResidentialAccount( + session, permission.residentialAccountId) + for residence in acct.get_residences(): + for switch in residence.get_iot_switches(): + all_switches.append(switch) + elif permission.residenceId is not None: + residence = Residence(session, permission.residenceId) for switch in residence.get_iot_switches(): all_switches.append(switch) From 02309cc318b02bc75b15fd2b3944f0e956a33307 Mon Sep 17 00:00:00 2001 From: Bryan York Date: Tue, 27 Nov 2018 08:11:55 -0800 Subject: [PATCH 095/325] Enable Google Assistant OnOffTrait for climate devices that support them (#18544) * Enable Google Assistant OnOffTrait for climate devices that support them This commit enables the OnOffTrait for climate devices that have the SUPPORT_ON_OFF feature. I have tested this locally with a Sensibo device which supports ON_OFF and a nest device that does not. * Update trait.py * Add tests for onoff_climate * Add OnOff trait to climate.heatpump * Add on status to heatpump in google_assistant tests --- .../components/google_assistant/trait.py | 2 + tests/components/google_assistant/__init__.py | 5 ++- .../google_assistant/test_google_assistant.py | 2 + .../components/google_assistant/test_trait.py | 41 +++++++++++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index d32dd91a3c1c8..e0d12e00e305f 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -197,6 +197,8 @@ class OnOffTrait(_Trait): @staticmethod def supported(domain, features): """Test if state is supported.""" + if domain == climate.DOMAIN: + return features & climate.SUPPORT_ON_OFF != 0 return domain in ( group.DOMAIN, input_boolean.DOMAIN, diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 1568919a9b495..c8748ade00e57 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -225,7 +225,10 @@ 'name': { 'name': 'HeatPump' }, - 'traits': ['action.devices.traits.TemperatureSetting'], + 'traits': [ + 'action.devices.traits.OnOff', + 'action.devices.traits.TemperatureSetting' + ], 'type': 'action.devices.types.THERMOSTAT', 'willReportState': False }, { diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 047fad3574cec..89e9090da98a7 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -204,6 +204,7 @@ def test_query_climate_request(hass_fixture, assistant_client, auth_header): devices = body['payload']['devices'] assert len(devices) == 3 assert devices['climate.heatpump'] == { + 'on': True, 'online': True, 'thermostatTemperatureSetpoint': 20.0, 'thermostatTemperatureAmbient': 25.0, @@ -260,6 +261,7 @@ def test_query_climate_request_f(hass_fixture, assistant_client, auth_header): devices = body['payload']['devices'] assert len(devices) == 3 assert devices['climate.heatpump'] == { + 'on': True, 'online': True, 'thermostatTemperatureSetpoint': -6.7, 'thermostatTemperatureAmbient': -3.9, diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 42af1230eed79..ef6ed7a4b8f13 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -388,6 +388,47 @@ async def test_onoff_media_player(hass): } +async def test_onoff_climate(hass): + """Test OnOff trait support for climate domain.""" + assert trait.OnOffTrait.supported(climate.DOMAIN, climate.SUPPORT_ON_OFF) + + trt_on = trait.OnOffTrait(hass, State('climate.bla', STATE_ON), + BASIC_CONFIG) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == { + 'on': True + } + + trt_off = trait.OnOffTrait(hass, State('climate.bla', STATE_OFF), + BASIC_CONFIG) + + assert trt_off.query_attributes() == { + 'on': False + } + + on_calls = async_mock_service(hass, climate.DOMAIN, SERVICE_TURN_ON) + await trt_on.execute(trait.COMMAND_ONOFF, { + 'on': True + }) + assert len(on_calls) == 1 + assert on_calls[0].data == { + ATTR_ENTITY_ID: 'climate.bla', + } + + off_calls = async_mock_service(hass, climate.DOMAIN, + SERVICE_TURN_OFF) + + await trt_on.execute(trait.COMMAND_ONOFF, { + 'on': False + }) + assert len(off_calls) == 1 + assert off_calls[0].data == { + ATTR_ENTITY_ID: 'climate.bla', + } + + async def test_dock_vacuum(hass): """Test dock trait support for vacuum domain.""" assert trait.DockTrait.supported(vacuum.DOMAIN, 0) From dd8544fdf88b166a109c2710b92d3a647fcf6624 Mon Sep 17 00:00:00 2001 From: Anton Johansson Date: Tue, 27 Nov 2018 19:09:25 +0100 Subject: [PATCH 096/325] Fix typo in log (#18751) --- homeassistant/components/zwave/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index dd0b36020a405..6d96192f075ea 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -420,7 +420,7 @@ def add_node_secure(service): def remove_node(service): """Switch into exclusion mode.""" - _LOGGER.info("Z-Wwave remove_node have been initialized") + _LOGGER.info("Z-Wave remove_node have been initialized") network.controller.remove_node() def cancel_command(service): From 093fa6f5e91c4ef1fd527a71a7b8f585f88ee462 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 27 Nov 2018 11:40:49 -0700 Subject: [PATCH 097/325] Bumped simplisafe-python to 3.1.14 (#18752) --- homeassistant/components/simplisafe/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index aaa8e3a19f979..7f1f8f539eba5 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -23,7 +23,7 @@ from .config_flow import configured_instances from .const import DATA_CLIENT, DEFAULT_SCAN_INTERVAL, DOMAIN, TOPIC_UPDATE -REQUIREMENTS = ['simplisafe-python==3.1.13'] +REQUIREMENTS = ['simplisafe-python==3.1.14'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index d86d02c8bbace..4b3277dee27be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1413,7 +1413,7 @@ shodan==1.10.4 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==3.1.13 +simplisafe-python==3.1.14 # homeassistant.components.sisyphus sisyphus-control==2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a4768e35e792..8ea99fdeaedcb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -230,7 +230,7 @@ ring_doorbell==0.2.2 rxv==0.5.1 # homeassistant.components.simplisafe -simplisafe-python==3.1.13 +simplisafe-python==3.1.14 # homeassistant.components.sleepiq sleepyq==0.6 From 3a8303137a6151c74fc6f8236f7f7f3f9766b2f2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 25 Nov 2018 18:04:48 +0100 Subject: [PATCH 098/325] Add permission checks to Rest API (#18639) * Add permission checks to Rest API * Clean up unnecessary method * Remove all the tuple stuff from entity check * Simplify perms * Correct param name for owner permission * Hass.io make/update user to be admin * Types --- homeassistant/auth/__init__.py | 17 +++- homeassistant/auth/auth_store.py | 27 +++++++ homeassistant/auth/models.py | 16 +++- homeassistant/auth/permissions/__init__.py | 61 +++++---------- homeassistant/auth/permissions/entities.py | 40 +++++----- homeassistant/components/api.py | 27 ++++++- homeassistant/components/hassio/__init__.py | 9 ++- homeassistant/components/http/view.py | 10 ++- homeassistant/helpers/service.py | 10 +-- tests/auth/permissions/test_entities.py | 50 ++++++------ tests/auth/permissions/test_init.py | 34 -------- tests/common.py | 7 +- tests/components/conftest.py | 5 +- tests/components/hassio/test_init.py | 28 +++++++ tests/components/test_api.py | 86 +++++++++++++++++++-- 15 files changed, 282 insertions(+), 145 deletions(-) delete mode 100644 tests/auth/permissions/test_init.py diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index e69dec37df28d..7d8ef13d2bb92 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -132,13 +132,15 @@ async def async_get_user_by_credentials( return None - async def async_create_system_user(self, name: str) -> models.User: + async def async_create_system_user( + self, name: str, + group_ids: Optional[List[str]] = None) -> models.User: """Create a system user.""" user = await self._store.async_create_user( name=name, system_generated=True, is_active=True, - group_ids=[], + group_ids=group_ids or [], ) self.hass.bus.async_fire(EVENT_USER_ADDED, { @@ -217,6 +219,17 @@ async def async_remove_user(self, user: models.User) -> None: 'user_id': user.id }) + async def async_update_user(self, user: models.User, + name: Optional[str] = None, + group_ids: Optional[List[str]] = None) -> None: + """Update a user.""" + kwargs = {} # type: Dict[str,Any] + if name is not None: + kwargs['name'] = name + if group_ids is not None: + kwargs['group_ids'] = group_ids + await self._store.async_update_user(user, **kwargs) + async def async_activate_user(self, user: models.User) -> None: """Activate a user.""" await self._store.async_activate_user(user) diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 867d5357a583a..cf82c40a4d374 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -133,6 +133,33 @@ async def async_remove_user(self, user: models.User) -> None: self._users.pop(user.id) self._async_schedule_save() + async def async_update_user( + self, user: models.User, name: Optional[str] = None, + is_active: Optional[bool] = None, + group_ids: Optional[List[str]] = None) -> None: + """Update a user.""" + assert self._groups is not None + + if group_ids is not None: + groups = [] + for grid in group_ids: + group = self._groups.get(grid) + if group is None: + raise ValueError("Invalid group specified.") + groups.append(group) + + user.groups = groups + user.invalidate_permission_cache() + + for attr_name, value in ( + ('name', name), + ('is_active', is_active), + ): + if value is not None: + setattr(user, attr_name, value) + + self._async_schedule_save() + async def async_activate_user(self, user: models.User) -> None: """Activate a user.""" user.is_active = True diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index cefaabe752140..4b192c35898e1 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -8,6 +8,7 @@ from homeassistant.util import dt as dt_util from . import permissions as perm_mdl +from .const import GROUP_ID_ADMIN from .util import generate_secret TOKEN_TYPE_NORMAL = 'normal' @@ -48,7 +49,7 @@ class User: ) # type: Dict[str, RefreshToken] _permissions = attr.ib( - type=perm_mdl.PolicyPermissions, + type=Optional[perm_mdl.PolicyPermissions], init=False, cmp=False, default=None, @@ -69,6 +70,19 @@ def permissions(self) -> perm_mdl.AbstractPermissions: return self._permissions + @property + def is_admin(self) -> bool: + """Return if user is part of the admin group.""" + if self.is_owner: + return True + + return self.is_active and any( + gr.id == GROUP_ID_ADMIN for gr in self.groups) + + def invalidate_permission_cache(self) -> None: + """Invalidate permission cache.""" + self._permissions = None + @attr.s(slots=True) class RefreshToken: diff --git a/homeassistant/auth/permissions/__init__.py b/homeassistant/auth/permissions/__init__.py index fd3cf81f02958..9113f2b03a956 100644 --- a/homeassistant/auth/permissions/__init__.py +++ b/homeassistant/auth/permissions/__init__.py @@ -5,10 +5,8 @@ import voluptuous as vol -from homeassistant.core import State - from .const import CAT_ENTITIES -from .types import CategoryType, PolicyType +from .types import PolicyType from .entities import ENTITY_POLICY_SCHEMA, compile_entities from .merge import merge_policies # noqa @@ -22,14 +20,21 @@ class AbstractPermissions: """Default permissions class.""" - def check_entity(self, entity_id: str, key: str) -> bool: - """Test if we can access entity.""" - raise NotImplementedError + _cached_entity_func = None - def filter_states(self, states: List[State]) -> List[State]: - """Filter a list of states for what the user is allowed to see.""" + def _entity_func(self) -> Callable[[str, str], bool]: + """Return a function that can test entity access.""" raise NotImplementedError + def check_entity(self, entity_id: str, key: str) -> bool: + """Check if we can access entity.""" + entity_func = self._cached_entity_func + + if entity_func is None: + entity_func = self._cached_entity_func = self._entity_func() + + return entity_func(entity_id, key) + class PolicyPermissions(AbstractPermissions): """Handle permissions.""" @@ -37,34 +42,10 @@ class PolicyPermissions(AbstractPermissions): def __init__(self, policy: PolicyType) -> None: """Initialize the permission class.""" self._policy = policy - self._compiled = {} # type: Dict[str, Callable[..., bool]] - - def check_entity(self, entity_id: str, key: str) -> bool: - """Test if we can access entity.""" - func = self._policy_func(CAT_ENTITIES, compile_entities) - return func(entity_id, (key,)) - - def filter_states(self, states: List[State]) -> List[State]: - """Filter a list of states for what the user is allowed to see.""" - func = self._policy_func(CAT_ENTITIES, compile_entities) - keys = ('read',) - return [entity for entity in states if func(entity.entity_id, keys)] - - def _policy_func(self, category: str, - compile_func: Callable[[CategoryType], Callable]) \ - -> Callable[..., bool]: - """Get a policy function.""" - func = self._compiled.get(category) - - if func: - return func - func = self._compiled[category] = compile_func( - self._policy.get(category)) - - _LOGGER.debug("Compiled %s func: %s", category, func) - - return func + def _entity_func(self) -> Callable[[str, str], bool]: + """Return a function that can test entity access.""" + return compile_entities(self._policy.get(CAT_ENTITIES)) def __eq__(self, other: Any) -> bool: """Equals check.""" @@ -78,13 +59,9 @@ class _OwnerPermissions(AbstractPermissions): # pylint: disable=no-self-use - def check_entity(self, entity_id: str, key: str) -> bool: - """Test if we can access entity.""" - return True - - def filter_states(self, states: List[State]) -> List[State]: - """Filter a list of states for what the user is allowed to see.""" - return states + def _entity_func(self) -> Callable[[str, str], bool]: + """Return a function that can test entity access.""" + return lambda entity_id, key: True OwnerPermissions = _OwnerPermissions() # pylint: disable=invalid-name diff --git a/homeassistant/auth/permissions/entities.py b/homeassistant/auth/permissions/entities.py index 89b9398628c0c..74a43246fd179 100644 --- a/homeassistant/auth/permissions/entities.py +++ b/homeassistant/auth/permissions/entities.py @@ -28,28 +28,28 @@ })) -def _entity_allowed(schema: ValueType, keys: Tuple[str]) \ +def _entity_allowed(schema: ValueType, key: str) \ -> Union[bool, None]: """Test if an entity is allowed based on the keys.""" if schema is None or isinstance(schema, bool): return schema assert isinstance(schema, dict) - return schema.get(keys[0]) + return schema.get(key) def compile_entities(policy: CategoryType) \ - -> Callable[[str, Tuple[str]], bool]: + -> Callable[[str, str], bool]: """Compile policy into a function that tests policy.""" # None, Empty Dict, False if not policy: - def apply_policy_deny_all(entity_id: str, keys: Tuple[str]) -> bool: + def apply_policy_deny_all(entity_id: str, key: str) -> bool: """Decline all.""" return False return apply_policy_deny_all if policy is True: - def apply_policy_allow_all(entity_id: str, keys: Tuple[str]) -> bool: + def apply_policy_allow_all(entity_id: str, key: str) -> bool: """Approve all.""" return True @@ -61,7 +61,7 @@ def apply_policy_allow_all(entity_id: str, keys: Tuple[str]) -> bool: entity_ids = policy.get(ENTITY_ENTITY_IDS) all_entities = policy.get(SUBCAT_ALL) - funcs = [] # type: List[Callable[[str, Tuple[str]], Union[None, bool]]] + funcs = [] # type: List[Callable[[str, str], Union[None, bool]]] # The order of these functions matter. The more precise are at the top. # If a function returns None, they cannot handle it. @@ -70,23 +70,23 @@ def apply_policy_allow_all(entity_id: str, keys: Tuple[str]) -> bool: # Setting entity_ids to a boolean is final decision for permissions # So return right away. if isinstance(entity_ids, bool): - def allowed_entity_id_bool(entity_id: str, keys: Tuple[str]) -> bool: + def allowed_entity_id_bool(entity_id: str, key: str) -> bool: """Test if allowed entity_id.""" return entity_ids # type: ignore return allowed_entity_id_bool if entity_ids is not None: - def allowed_entity_id_dict(entity_id: str, keys: Tuple[str]) \ + def allowed_entity_id_dict(entity_id: str, key: str) \ -> Union[None, bool]: """Test if allowed entity_id.""" return _entity_allowed( - entity_ids.get(entity_id), keys) # type: ignore + entity_ids.get(entity_id), key) # type: ignore funcs.append(allowed_entity_id_dict) if isinstance(domains, bool): - def allowed_domain_bool(entity_id: str, keys: Tuple[str]) \ + def allowed_domain_bool(entity_id: str, key: str) \ -> Union[None, bool]: """Test if allowed domain.""" return domains @@ -94,31 +94,31 @@ def allowed_domain_bool(entity_id: str, keys: Tuple[str]) \ funcs.append(allowed_domain_bool) elif domains is not None: - def allowed_domain_dict(entity_id: str, keys: Tuple[str]) \ + def allowed_domain_dict(entity_id: str, key: str) \ -> Union[None, bool]: """Test if allowed domain.""" domain = entity_id.split(".", 1)[0] - return _entity_allowed(domains.get(domain), keys) # type: ignore + return _entity_allowed(domains.get(domain), key) # type: ignore funcs.append(allowed_domain_dict) if isinstance(all_entities, bool): - def allowed_all_entities_bool(entity_id: str, keys: Tuple[str]) \ + def allowed_all_entities_bool(entity_id: str, key: str) \ -> Union[None, bool]: """Test if allowed domain.""" return all_entities funcs.append(allowed_all_entities_bool) elif all_entities is not None: - def allowed_all_entities_dict(entity_id: str, keys: Tuple[str]) \ + def allowed_all_entities_dict(entity_id: str, key: str) \ -> Union[None, bool]: """Test if allowed domain.""" - return _entity_allowed(all_entities, keys) + return _entity_allowed(all_entities, key) funcs.append(allowed_all_entities_dict) # Can happen if no valid subcategories specified if not funcs: - def apply_policy_deny_all_2(entity_id: str, keys: Tuple[str]) -> bool: + def apply_policy_deny_all_2(entity_id: str, key: str) -> bool: """Decline all.""" return False @@ -128,16 +128,16 @@ def apply_policy_deny_all_2(entity_id: str, keys: Tuple[str]) -> bool: func = funcs[0] @wraps(func) - def apply_policy_func(entity_id: str, keys: Tuple[str]) -> bool: + def apply_policy_func(entity_id: str, key: str) -> bool: """Apply a single policy function.""" - return func(entity_id, keys) is True + return func(entity_id, key) is True return apply_policy_func - def apply_policy_funcs(entity_id: str, keys: Tuple[str]) -> bool: + def apply_policy_funcs(entity_id: str, key: str) -> bool: """Apply several policy functions.""" for func in funcs: - result = func(entity_id, keys) + result = func(entity_id, key) if result is not None: return result return False diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index cbe404537ebd5..b001bcd043725 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -20,7 +20,8 @@ URL_API_SERVICES, URL_API_STATES, URL_API_STATES_ENTITY, URL_API_STREAM, URL_API_TEMPLATE, __version__) import homeassistant.core as ha -from homeassistant.exceptions import TemplateError +from homeassistant.auth.permissions.const import POLICY_READ +from homeassistant.exceptions import TemplateError, Unauthorized from homeassistant.helpers import template from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.helpers.state import AsyncTrackStates @@ -81,6 +82,8 @@ class APIEventStream(HomeAssistantView): async def get(self, request): """Provide a streaming interface for the event bus.""" + if not request['hass_user'].is_admin: + raise Unauthorized() hass = request.app['hass'] stop_obj = object() to_write = asyncio.Queue(loop=hass.loop) @@ -185,7 +188,13 @@ class APIStatesView(HomeAssistantView): @ha.callback def get(self, request): """Get current states.""" - return self.json(request.app['hass'].states.async_all()) + user = request['hass_user'] + entity_perm = user.permissions.check_entity + states = [ + state for state in request.app['hass'].states.async_all() + if entity_perm(state.entity_id, 'read') + ] + return self.json(states) class APIEntityStateView(HomeAssistantView): @@ -197,6 +206,10 @@ class APIEntityStateView(HomeAssistantView): @ha.callback def get(self, request, entity_id): """Retrieve state of entity.""" + user = request['hass_user'] + if not user.permissions.check_entity(entity_id, POLICY_READ): + raise Unauthorized(entity_id=entity_id) + state = request.app['hass'].states.get(entity_id) if state: return self.json(state) @@ -204,6 +217,8 @@ def get(self, request, entity_id): async def post(self, request, entity_id): """Update state of entity.""" + if not request['hass_user'].is_admin: + raise Unauthorized(entity_id=entity_id) hass = request.app['hass'] try: data = await request.json() @@ -236,6 +251,8 @@ async def post(self, request, entity_id): @ha.callback def delete(self, request, entity_id): """Remove entity.""" + if not request['hass_user'].is_admin: + raise Unauthorized(entity_id=entity_id) if request.app['hass'].states.async_remove(entity_id): return self.json_message("Entity removed.") return self.json_message("Entity not found.", HTTP_NOT_FOUND) @@ -261,6 +278,8 @@ class APIEventView(HomeAssistantView): async def post(self, request, event_type): """Fire events.""" + if not request['hass_user'].is_admin: + raise Unauthorized() body = await request.text() try: event_data = json.loads(body) if body else None @@ -346,6 +365,8 @@ class APITemplateView(HomeAssistantView): async def post(self, request): """Render a template.""" + if not request['hass_user'].is_admin: + raise Unauthorized() try: data = await request.json() tpl = template.Template(data['template'], request.app['hass']) @@ -363,6 +384,8 @@ class APIErrorLog(HomeAssistantView): async def get(self, request): """Retrieve API error log.""" + if not request['hass_user'].is_admin: + raise Unauthorized() return web.FileResponse(request.app['hass'].data[DATA_LOGGING]) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 4c13cb799a63c..6bfcaaa5d85ab 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -10,6 +10,7 @@ import voluptuous as vol +from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components import SERVICE_CHECK_CONFIG from homeassistant.const import ( ATTR_NAME, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP) @@ -181,8 +182,14 @@ async def async_setup(hass, config): if user and user.refresh_tokens: refresh_token = list(user.refresh_tokens.values())[0] + # Migrate old hass.io users to be admin. + if not user.is_admin: + await hass.auth.async_update_user( + user, group_ids=[GROUP_ID_ADMIN]) + if refresh_token is None: - user = await hass.auth.async_create_system_user('Hass.io') + user = await hass.auth.async_create_system_user( + 'Hass.io', [GROUP_ID_ADMIN]) refresh_token = await hass.auth.async_create_refresh_token(user) data['hassio_user'] = user.id await store.async_save(data) diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index b3b2587fc458e..30d4ed0ab8da7 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -14,6 +14,7 @@ from homeassistant.components.http.ban import process_success_login from homeassistant.core import Context, is_callback from homeassistant.const import CONTENT_TYPE_JSON +from homeassistant import exceptions from homeassistant.helpers.json import JSONEncoder from .const import KEY_AUTHENTICATED, KEY_REAL_IP @@ -107,10 +108,13 @@ async def handle(request): _LOGGER.info('Serving %s to %s (auth: %s)', request.path, request.get(KEY_REAL_IP), authenticated) - result = handler(request, **request.match_info) + try: + result = handler(request, **request.match_info) - if asyncio.iscoroutine(result): - result = await result + if asyncio.iscoroutine(result): + result = await result + except exceptions.Unauthorized: + raise HTTPUnauthorized() if isinstance(result, web.StreamResponse): # The method handler returned a ready-made Response, how nice of it diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 5e0d9c7e88afc..e8068f5728649 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -192,9 +192,9 @@ async def entity_service_call(hass, platforms, func, call): user = await hass.auth.async_get_user(call.context.user_id) if user is None: raise UnknownUser(context=call.context) - perms = user.permissions + entity_perms = user.permissions.check_entity else: - perms = None + entity_perms = None # Are we trying to target all entities target_all_entities = ATTR_ENTITY_ID not in call.data @@ -218,7 +218,7 @@ async def entity_service_call(hass, platforms, func, call): # the service on. platforms_entities = [] - if perms is None: + if entity_perms is None: for platform in platforms: if target_all_entities: platforms_entities.append(list(platform.entities.values())) @@ -234,7 +234,7 @@ async def entity_service_call(hass, platforms, func, call): for platform in platforms: platforms_entities.append([ entity for entity in platform.entities.values() - if perms.check_entity(entity.entity_id, POLICY_CONTROL)]) + if entity_perms(entity.entity_id, POLICY_CONTROL)]) else: for platform in platforms: @@ -243,7 +243,7 @@ async def entity_service_call(hass, platforms, func, call): if entity.entity_id not in entity_ids: continue - if not perms.check_entity(entity.entity_id, POLICY_CONTROL): + if not entity_perms(entity.entity_id, POLICY_CONTROL): raise Unauthorized( context=call.context, entity_id=entity.entity_id, diff --git a/tests/auth/permissions/test_entities.py b/tests/auth/permissions/test_entities.py index 33c164d12b4bf..40de5ca73343b 100644 --- a/tests/auth/permissions/test_entities.py +++ b/tests/auth/permissions/test_entities.py @@ -10,7 +10,7 @@ def test_entities_none(): """Test entity ID policy.""" policy = None compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is False + assert compiled('light.kitchen', 'read') is False def test_entities_empty(): @@ -18,7 +18,7 @@ def test_entities_empty(): policy = {} ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is False + assert compiled('light.kitchen', 'read') is False def test_entities_false(): @@ -33,7 +33,7 @@ def test_entities_true(): policy = True ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True def test_entities_domains_true(): @@ -43,7 +43,7 @@ def test_entities_domains_true(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True def test_entities_domains_domain_true(): @@ -55,8 +55,8 @@ def test_entities_domains_domain_true(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('switch.kitchen', ('read',)) is False + assert compiled('light.kitchen', 'read') is True + assert compiled('switch.kitchen', 'read') is False def test_entities_domains_domain_false(): @@ -77,7 +77,7 @@ def test_entities_entity_ids_true(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True def test_entities_entity_ids_false(): @@ -98,8 +98,8 @@ def test_entities_entity_ids_entity_id_true(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('switch.kitchen', ('read',)) is False + assert compiled('light.kitchen', 'read') is True + assert compiled('switch.kitchen', 'read') is False def test_entities_entity_ids_entity_id_false(): @@ -124,9 +124,9 @@ def test_entities_control_only(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('light.kitchen', ('control',)) is False - assert compiled('light.kitchen', ('edit',)) is False + assert compiled('light.kitchen', 'read') is True + assert compiled('light.kitchen', 'control') is False + assert compiled('light.kitchen', 'edit') is False def test_entities_read_control(): @@ -141,9 +141,9 @@ def test_entities_read_control(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('light.kitchen', ('control',)) is True - assert compiled('light.kitchen', ('edit',)) is False + assert compiled('light.kitchen', 'read') is True + assert compiled('light.kitchen', 'control') is True + assert compiled('light.kitchen', 'edit') is False def test_entities_all_allow(): @@ -153,9 +153,9 @@ def test_entities_all_allow(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('light.kitchen', ('control',)) is True - assert compiled('switch.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True + assert compiled('light.kitchen', 'control') is True + assert compiled('switch.kitchen', 'read') is True def test_entities_all_read(): @@ -167,9 +167,9 @@ def test_entities_all_read(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('light.kitchen', ('control',)) is False - assert compiled('switch.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True + assert compiled('light.kitchen', 'control') is False + assert compiled('switch.kitchen', 'read') is True def test_entities_all_control(): @@ -181,7 +181,7 @@ def test_entities_all_control(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is False - assert compiled('light.kitchen', ('control',)) is True - assert compiled('switch.kitchen', ('read',)) is False - assert compiled('switch.kitchen', ('control',)) is True + assert compiled('light.kitchen', 'read') is False + assert compiled('light.kitchen', 'control') is True + assert compiled('switch.kitchen', 'read') is False + assert compiled('switch.kitchen', 'control') is True diff --git a/tests/auth/permissions/test_init.py b/tests/auth/permissions/test_init.py deleted file mode 100644 index fdc5440a9d5b2..0000000000000 --- a/tests/auth/permissions/test_init.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Tests for the auth permission system.""" -from homeassistant.core import State -from homeassistant.auth import permissions - - -def test_policy_perm_filter_states(): - """Test filtering entitites.""" - states = [ - State('light.kitchen', 'on'), - State('light.living_room', 'off'), - State('light.balcony', 'on'), - ] - perm = permissions.PolicyPermissions({ - 'entities': { - 'entity_ids': { - 'light.kitchen': True, - 'light.balcony': True, - } - } - }) - filtered = perm.filter_states(states) - assert len(filtered) == 2 - assert filtered == [states[0], states[2]] - - -def test_owner_permissions(): - """Test owner permissions access all.""" - assert permissions.OwnerPermissions.check_entity('light.kitchen', 'write') - states = [ - State('light.kitchen', 'on'), - State('light.living_room', 'off'), - State('light.balcony', 'on'), - ] - assert permissions.OwnerPermissions.filter_states(states) == states diff --git a/tests/common.py b/tests/common.py index c6a75fcb63d8e..d5056e220f015 100644 --- a/tests/common.py +++ b/tests/common.py @@ -14,7 +14,8 @@ from homeassistant import auth, core as ha, config_entries from homeassistant.auth import ( - models as auth_models, auth_store, providers as auth_providers) + models as auth_models, auth_store, providers as auth_providers, + permissions as auth_permissions) from homeassistant.auth.permissions import system_policies from homeassistant.setup import setup_component, async_setup_component from homeassistant.config import async_process_component_config @@ -400,6 +401,10 @@ def add_to_auth_manager(self, auth_mgr): auth_mgr._store._users[self.id] = self return self + def mock_policy(self, policy): + """Mock a policy for a user.""" + self._permissions = auth_permissions.PolicyPermissions(policy) + async def register_auth_provider(hass, config): """Register an auth provider.""" diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 2568a1092448e..97f2044baea84 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -72,11 +72,10 @@ async def create_client(hass, access_token=None): @pytest.fixture -def hass_access_token(hass): +def hass_access_token(hass, hass_admin_user): """Return an access token to access Home Assistant.""" - user = MockUser().add_to_hass(hass) refresh_token = hass.loop.run_until_complete( - hass.auth.async_create_refresh_token(user, CLIENT_ID)) + hass.auth.async_create_refresh_token(hass_admin_user, CLIENT_ID)) yield hass.auth.async_create_access_token(refresh_token) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 4fd59dd3f7aae..51fca931faaaf 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -5,6 +5,7 @@ import pytest +from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.setup import async_setup_component from homeassistant.components.hassio import ( STORAGE_KEY, async_check_config) @@ -106,6 +107,8 @@ async def test_setup_api_push_api_data_default(hass, aioclient_mock, ) assert hassio_user is not None assert hassio_user.system_generated + assert len(hassio_user.groups) == 1 + assert hassio_user.groups[0].id == GROUP_ID_ADMIN for token in hassio_user.refresh_tokens.values(): if token.token == refresh_token: break @@ -113,6 +116,31 @@ async def test_setup_api_push_api_data_default(hass, aioclient_mock, assert False, 'refresh token not found' +async def test_setup_adds_admin_group_to_user(hass, aioclient_mock, + hass_storage): + """Test setup with API push default data.""" + # Create user without admin + user = await hass.auth.async_create_system_user('Hass.io') + assert not user.is_admin + await hass.auth.async_create_refresh_token(user) + + hass_storage[STORAGE_KEY] = { + 'data': {'hassio_user': user.id}, + 'key': STORAGE_KEY, + 'version': 1 + } + + with patch.dict(os.environ, MOCK_ENVIRON), \ + patch('homeassistant.auth.AuthManager.active', return_value=True): + result = await async_setup_component(hass, 'hassio', { + 'http': {}, + 'hassio': {} + }) + assert result + + assert user.is_admin + + async def test_setup_api_push_api_data_no_auth(hass, aioclient_mock, hass_storage): """Test setup with API push default data.""" diff --git a/tests/components/test_api.py b/tests/components/test_api.py index 6f6b4e93068e2..3ebfa05a3d39c 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -16,10 +16,12 @@ @pytest.fixture -def mock_api_client(hass, aiohttp_client): - """Start the Hass HTTP component.""" +def mock_api_client(hass, aiohttp_client, hass_access_token): + """Start the Hass HTTP component and return admin API client.""" hass.loop.run_until_complete(async_setup_component(hass, 'api', {})) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + return hass.loop.run_until_complete(aiohttp_client(hass.http.app, headers={ + 'Authorization': 'Bearer {}'.format(hass_access_token) + })) @asyncio.coroutine @@ -405,7 +407,8 @@ def _listen_count(hass): return sum(hass.bus.async_listeners().values()) -async def test_api_error_log(hass, aiohttp_client): +async def test_api_error_log(hass, aiohttp_client, hass_access_token, + hass_admin_user): """Test if we can fetch the error log.""" hass.data[DATA_LOGGING] = '/some/path' await async_setup_component(hass, 'api', { @@ -416,7 +419,7 @@ async def test_api_error_log(hass, aiohttp_client): client = await aiohttp_client(hass.http.app) resp = await client.get(const.URL_API_ERROR_LOG) - # Verufy auth required + # Verify auth required assert resp.status == 401 with patch( @@ -424,7 +427,7 @@ async def test_api_error_log(hass, aiohttp_client): return_value=web.Response(status=200, text='Hello') ) as mock_file: resp = await client.get(const.URL_API_ERROR_LOG, headers={ - 'x-ha-access': 'yolo' + 'Authorization': 'Bearer {}'.format(hass_access_token) }) assert len(mock_file.mock_calls) == 1 @@ -432,6 +435,13 @@ async def test_api_error_log(hass, aiohttp_client): assert resp.status == 200 assert await resp.text() == 'Hello' + # Verify we require admin user + hass_admin_user.groups = [] + resp = await client.get(const.URL_API_ERROR_LOG, headers={ + 'Authorization': 'Bearer {}'.format(hass_access_token) + }) + assert resp.status == 401 + async def test_api_fire_event_context(hass, mock_api_client, hass_access_token): @@ -494,3 +504,67 @@ async def test_api_set_state_context(hass, mock_api_client, hass_access_token): state = hass.states.get('light.kitchen') assert state.context.user_id == refresh_token.user.id + + +async def test_event_stream_requires_admin(hass, mock_api_client, + hass_admin_user): + """Test user needs to be admin to access event stream.""" + hass_admin_user.groups = [] + resp = await mock_api_client.get('/api/stream') + assert resp.status == 401 + + +async def test_states_view_filters(hass, mock_api_client, hass_admin_user): + """Test filtering only visible states.""" + hass_admin_user.mock_policy({ + 'entities': { + 'entity_ids': { + 'test.entity': True + } + } + }) + hass.states.async_set('test.entity', 'hello') + hass.states.async_set('test.not_visible_entity', 'invisible') + resp = await mock_api_client.get(const.URL_API_STATES) + assert resp.status == 200 + json = await resp.json() + assert len(json) == 1 + assert json[0]['entity_id'] == 'test.entity' + + +async def test_get_entity_state_read_perm(hass, mock_api_client, + hass_admin_user): + """Test getting a state requires read permission.""" + hass_admin_user.mock_policy({}) + resp = await mock_api_client.get('/api/states/light.test') + assert resp.status == 401 + + +async def test_post_entity_state_admin(hass, mock_api_client, hass_admin_user): + """Test updating state requires admin.""" + hass_admin_user.groups = [] + resp = await mock_api_client.post('/api/states/light.test') + assert resp.status == 401 + + +async def test_delete_entity_state_admin(hass, mock_api_client, + hass_admin_user): + """Test deleting entity requires admin.""" + hass_admin_user.groups = [] + resp = await mock_api_client.delete('/api/states/light.test') + assert resp.status == 401 + + +async def test_post_event_admin(hass, mock_api_client, hass_admin_user): + """Test sending event requires admin.""" + hass_admin_user.groups = [] + resp = await mock_api_client.post('/api/events/state_changed') + assert resp.status == 401 + + +async def test_rendering_template_admin(hass, mock_api_client, + hass_admin_user): + """Test rendering a template requires admin.""" + hass_admin_user.groups = [] + resp = await mock_api_client.post('/api/template') + assert resp.status == 401 From 775c909a8c80442280f5b78efc21655bd95a672c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 27 Nov 2018 20:15:57 +0100 Subject: [PATCH 099/325] Bumped version to 0.83.0b2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 9866bb5dad95e..9fc6d61cb336f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 83 -PATCH_VERSION = '0b1' +PATCH_VERSION = '0b2' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From f3047b9c031cd5b6e373d0639d613cb5de2d5fe5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 26 Nov 2018 19:53:24 +0100 Subject: [PATCH 100/325] Fix logbook filtering entities (#18721) * Fix logbook filtering entities * Fix flaky test --- homeassistant/components/logbook.py | 6 +++--- tests/components/test_logbook.py | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index ada8bf78ab0da..c7a37411f1eaa 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -391,9 +391,9 @@ def _get_events(hass, config, start_day, end_day, entity_id=None): .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.state_id.is_(None))) \ - .filter(States.entity_id.in_(entity_ids)) + .filter(((States.last_updated == States.last_changed) & + States.entity_id.in_(entity_ids)) + | (States.state_id.is_(None))) events = execute(query) diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index 5229d34b74ca8..ae1e3d1d51aba 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -62,6 +62,12 @@ def event_listener(event): # Our service call will unblock when the event listeners have been # scheduled. This means that they may not have been processed yet. self.hass.block_till_done() + self.hass.data[recorder.DATA_INSTANCE].block_till_done() + + events = list(logbook._get_events( + self.hass, {}, dt_util.utcnow() - timedelta(hours=1), + dt_util.utcnow() + timedelta(hours=1))) + assert len(events) == 2 assert 1 == len(calls) last_call = calls[-1] From 43676fcaf402994f18735bbd71a62fca54567594 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Tue, 27 Nov 2018 12:41:25 -0700 Subject: [PATCH 101/325] Moved stop method and registering STOP_EVENT outside of init (#18582) * Moved stop method and registering outside of init Moved the cleanup to a seperate method and perform registering for the event in setup. * Removed use of global variable Removed use of global variable. * Removed API_SESSIONS Removed unused declaration API_SESSIONS. --- homeassistant/components/august.py | 33 ++++++++++++++---------------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/august.py b/homeassistant/components/august.py index 1f12abd3d4e7c..2073f680e00bd 100644 --- a/homeassistant/components/august.py +++ b/homeassistant/components/august.py @@ -11,7 +11,6 @@ from requests import RequestException import homeassistant.helpers.config_validation as cv -from homeassistant.core import callback from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, CONF_TIMEOUT, EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers import discovery @@ -141,11 +140,11 @@ def setup(hass, config): from requests import Session conf = config[DOMAIN] + api_http_session = None try: api_http_session = Session() except RequestException as ex: _LOGGER.warning("Creating HTTP session failed with: %s", str(ex)) - api_http_session = None api = Api(timeout=conf.get(CONF_TIMEOUT), http_session=api_http_session) @@ -157,6 +156,20 @@ def setup(hass, config): install_id=conf.get(CONF_INSTALL_ID), access_token_cache_file=hass.config.path(AUGUST_CONFIG_FILE)) + def close_http_session(event): + """Close API sessions used to connect to August.""" + _LOGGER.debug("Closing August HTTP sessions") + if api_http_session: + try: + api_http_session.close() + except RequestException: + pass + + _LOGGER.debug("August HTTP session closed.") + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, close_http_session) + _LOGGER.debug("Registered for HASS stop event") + return setup_august(hass, config, api, authenticator) @@ -178,22 +191,6 @@ def __init__(self, hass, api, access_token): self._door_state_by_id = {} self._activities_by_id = {} - @callback - def august_api_stop(event): - """Close the API HTTP session.""" - _LOGGER.debug("Closing August HTTP session") - - try: - self._api.http_session.close() - self._api.http_session = None - except RequestException: - pass - _LOGGER.debug("August HTTP session closed.") - - self._hass.bus.listen_once( - EVENT_HOMEASSISTANT_STOP, august_api_stop) - _LOGGER.debug("Registered for HASS stop event") - @property def house_ids(self): """Return a list of house_ids.""" From 052d305243b893c4f797fc242f28ed5044c631c3 Mon Sep 17 00:00:00 2001 From: damarco Date: Tue, 27 Nov 2018 21:21:25 +0100 Subject: [PATCH 102/325] Add config entry for ZHA (#18352) * Add support for zha config entries * Add support for zha config entries * Fix node_config retrieval * Dynamically load discovered entities * Restore device config support * Refactor loading of entities * Remove device registry support * Send discovery_info directly * Clean up discovery_info in hass.data * Update tests * Clean up rebase * Simplify config flow * Address comments * Fix config path and zigpy check timeout * Remove device entities when unloading config entry --- homeassistant/components/binary_sensor/zha.py | 61 ++++-- homeassistant/components/fan/zha.py | 37 +++- homeassistant/components/light/zha.py | 73 +++++--- homeassistant/components/sensor/zha.py | 38 +++- homeassistant/components/switch/zha.py | 52 ++++-- .../components/zha/.translations/en.json | 21 +++ homeassistant/components/zha/__init__.py | 176 +++++++++++++----- homeassistant/components/zha/config_flow.py | 57 ++++++ homeassistant/components/zha/const.py | 47 +++++ homeassistant/components/zha/helpers.py | 40 ++-- homeassistant/components/zha/strings.json | 21 +++ homeassistant/config_entries.py | 1 + tests/components/zha/__init__.py | 1 + tests/components/zha/test_config_flow.py | 77 ++++++++ 14 files changed, 567 insertions(+), 135 deletions(-) create mode 100644 homeassistant/components/zha/.translations/en.json create mode 100644 homeassistant/components/zha/config_flow.py create mode 100644 homeassistant/components/zha/strings.json create mode 100644 tests/components/zha/__init__.py create mode 100644 tests/components/zha/test_config_flow.py diff --git a/homeassistant/components/binary_sensor/zha.py b/homeassistant/components/binary_sensor/zha.py index c1ced3766c99e..087e7963c000f 100644 --- a/homeassistant/components/binary_sensor/zha.py +++ b/homeassistant/components/binary_sensor/zha.py @@ -9,6 +9,10 @@ from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice from homeassistant.components.zha.entities import ZhaEntity from homeassistant.components.zha import helpers +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.components.zha.const import ( + ZHA_DISCOVERY_NEW, DATA_ZHA, DATA_ZHA_DISPATCHERS +) _LOGGER = logging.getLogger(__name__) @@ -27,23 +31,43 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Zigbee Home Automation binary sensors.""" - discovery_info = helpers.get_discovery_info(hass, discovery_info) - if discovery_info is None: - return + """Old way of setting up Zigbee Home Automation binary sensors.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Zigbee Home Automation binary sensor from config entry.""" + async def async_discover(discovery_info): + await _async_setup_entities(hass, config_entry, async_add_entities, + [discovery_info]) + + unsub = async_dispatcher_connect( + hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover) + hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + + binary_sensors = hass.data.get(DATA_ZHA, {}).get(DOMAIN) + if binary_sensors is not None: + await _async_setup_entities(hass, config_entry, async_add_entities, + binary_sensors.values()) + del hass.data[DATA_ZHA][DOMAIN] - from zigpy.zcl.clusters.general import OnOff - from zigpy.zcl.clusters.security import IasZone - if IasZone.cluster_id in discovery_info['in_clusters']: - await _async_setup_iaszone(hass, config, async_add_entities, - discovery_info) - elif OnOff.cluster_id in discovery_info['out_clusters']: - await _async_setup_remote(hass, config, async_add_entities, - discovery_info) +async def _async_setup_entities(hass, config_entry, async_add_entities, + discovery_infos): + """Set up the ZHA binary sensors.""" + entities = [] + for discovery_info in discovery_infos: + from zigpy.zcl.clusters.general import OnOff + from zigpy.zcl.clusters.security import IasZone + if IasZone.cluster_id in discovery_info['in_clusters']: + entities.append(await _async_setup_iaszone(discovery_info)) + elif OnOff.cluster_id in discovery_info['out_clusters']: + entities.append(await _async_setup_remote(discovery_info)) + + async_add_entities(entities, update_before_add=True) -async def _async_setup_iaszone(hass, config, async_add_entities, - discovery_info): + +async def _async_setup_iaszone(discovery_info): device_class = None from zigpy.zcl.clusters.security import IasZone cluster = discovery_info['in_clusters'][IasZone.cluster_id] @@ -59,13 +83,10 @@ async def _async_setup_iaszone(hass, config, async_add_entities, # If we fail to read from the device, use a non-specific class pass - sensor = BinarySensor(device_class, **discovery_info) - async_add_entities([sensor], update_before_add=True) - + return BinarySensor(device_class, **discovery_info) -async def _async_setup_remote(hass, config, async_add_entities, - discovery_info): +async def _async_setup_remote(discovery_info): remote = Remote(**discovery_info) if discovery_info['new_join']: @@ -84,7 +105,7 @@ async def _async_setup_remote(hass, config, async_add_entities, reportable_change=1 ) - async_add_entities([remote], update_before_add=True) + return remote class BinarySensor(ZhaEntity, BinarySensorDevice): diff --git a/homeassistant/components/fan/zha.py b/homeassistant/components/fan/zha.py index d948ba2ff5b7d..4f8254672a850 100644 --- a/homeassistant/components/fan/zha.py +++ b/homeassistant/components/fan/zha.py @@ -7,6 +7,10 @@ import logging from homeassistant.components.zha.entities import ZhaEntity from homeassistant.components.zha import helpers +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.components.zha.const import ( + ZHA_DISCOVERY_NEW, DATA_ZHA, DATA_ZHA_DISPATCHERS +) from homeassistant.components.fan import ( DOMAIN, FanEntity, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, SUPPORT_SET_SPEED) @@ -40,12 +44,35 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Zigbee Home Automation fans.""" - discovery_info = helpers.get_discovery_info(hass, discovery_info) - if discovery_info is None: - return + """Old way of setting up Zigbee Home Automation fans.""" + pass - async_add_entities([ZhaFan(**discovery_info)], update_before_add=True) + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Zigbee Home Automation fan from config entry.""" + async def async_discover(discovery_info): + await _async_setup_entities(hass, config_entry, async_add_entities, + [discovery_info]) + + unsub = async_dispatcher_connect( + hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover) + hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + + fans = hass.data.get(DATA_ZHA, {}).get(DOMAIN) + if fans is not None: + await _async_setup_entities(hass, config_entry, async_add_entities, + fans.values()) + del hass.data[DATA_ZHA][DOMAIN] + + +async def _async_setup_entities(hass, config_entry, async_add_entities, + discovery_infos): + """Set up the ZHA fans.""" + entities = [] + for discovery_info in discovery_infos: + entities.append(ZhaFan(**discovery_info)) + + async_add_entities(entities, update_before_add=True) class ZhaFan(ZhaEntity, FanEntity): diff --git a/homeassistant/components/light/zha.py b/homeassistant/components/light/zha.py index 20c9faf2514c5..67b65edb0a64c 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -8,6 +8,10 @@ from homeassistant.components import light from homeassistant.components.zha.entities import ZhaEntity from homeassistant.components.zha import helpers +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.components.zha.const import ( + ZHA_DISCOVERY_NEW, DATA_ZHA, DATA_ZHA_DISPATCHERS +) import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) @@ -24,27 +28,54 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Zigbee Home Automation lights.""" - discovery_info = helpers.get_discovery_info(hass, discovery_info) - if discovery_info is None: - return - - endpoint = discovery_info['endpoint'] - if hasattr(endpoint, 'light_color'): - caps = await helpers.safe_read( - endpoint.light_color, ['color_capabilities']) - discovery_info['color_capabilities'] = caps.get('color_capabilities') - if discovery_info['color_capabilities'] is None: - # ZCL Version 4 devices don't support the color_capabilities - # attribute. In this version XY support is mandatory, but we need - # to probe to determine if the device supports color temperature. - discovery_info['color_capabilities'] = CAPABILITIES_COLOR_XY - result = await helpers.safe_read( - endpoint.light_color, ['color_temperature']) - if result.get('color_temperature') is not UNSUPPORTED_ATTRIBUTE: - discovery_info['color_capabilities'] |= CAPABILITIES_COLOR_TEMP - - async_add_entities([Light(**discovery_info)], update_before_add=True) + """Old way of setting up Zigbee Home Automation lights.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Zigbee Home Automation light from config entry.""" + async def async_discover(discovery_info): + await _async_setup_entities(hass, config_entry, async_add_entities, + [discovery_info]) + + unsub = async_dispatcher_connect( + hass, ZHA_DISCOVERY_NEW.format(light.DOMAIN), async_discover) + hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + + lights = hass.data.get(DATA_ZHA, {}).get(light.DOMAIN) + if lights is not None: + await _async_setup_entities(hass, config_entry, async_add_entities, + lights.values()) + del hass.data[DATA_ZHA][light.DOMAIN] + + +async def _async_setup_entities(hass, config_entry, async_add_entities, + discovery_infos): + """Set up the ZHA lights.""" + entities = [] + for discovery_info in discovery_infos: + endpoint = discovery_info['endpoint'] + if hasattr(endpoint, 'light_color'): + caps = await helpers.safe_read( + endpoint.light_color, ['color_capabilities']) + discovery_info['color_capabilities'] = caps.get( + 'color_capabilities') + if discovery_info['color_capabilities'] is None: + # ZCL Version 4 devices don't support the color_capabilities + # attribute. In this version XY support is mandatory, but we + # need to probe to determine if the device supports color + # temperature. + discovery_info['color_capabilities'] = \ + CAPABILITIES_COLOR_XY + result = await helpers.safe_read( + endpoint.light_color, ['color_temperature']) + if (result.get('color_temperature') is not + UNSUPPORTED_ATTRIBUTE): + discovery_info['color_capabilities'] |= \ + CAPABILITIES_COLOR_TEMP + entities.append(Light(**discovery_info)) + + async_add_entities(entities, update_before_add=True) class Light(ZhaEntity, light.Light): diff --git a/homeassistant/components/sensor/zha.py b/homeassistant/components/sensor/zha.py index 993b247a4394c..97432b2512f2b 100644 --- a/homeassistant/components/sensor/zha.py +++ b/homeassistant/components/sensor/zha.py @@ -9,6 +9,10 @@ from homeassistant.components.sensor import DOMAIN from homeassistant.components.zha.entities import ZhaEntity from homeassistant.components.zha import helpers +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.components.zha.const import ( + ZHA_DISCOVERY_NEW, DATA_ZHA, DATA_ZHA_DISPATCHERS +) from homeassistant.const import TEMP_CELSIUS from homeassistant.util.temperature import convert as convert_temperature @@ -19,13 +23,35 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up Zigbee Home Automation sensors.""" - discovery_info = helpers.get_discovery_info(hass, discovery_info) - if discovery_info is None: - return + """Old way of setting up Zigbee Home Automation sensors.""" + pass - sensor = await make_sensor(discovery_info) - async_add_entities([sensor], update_before_add=True) + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Zigbee Home Automation sensor from config entry.""" + async def async_discover(discovery_info): + await _async_setup_entities(hass, config_entry, async_add_entities, + [discovery_info]) + + unsub = async_dispatcher_connect( + hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover) + hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + + sensors = hass.data.get(DATA_ZHA, {}).get(DOMAIN) + if sensors is not None: + await _async_setup_entities(hass, config_entry, async_add_entities, + sensors.values()) + del hass.data[DATA_ZHA][DOMAIN] + + +async def _async_setup_entities(hass, config_entry, async_add_entities, + discovery_infos): + """Set up the ZHA sensors.""" + entities = [] + for discovery_info in discovery_infos: + entities.append(await make_sensor(discovery_info)) + + async_add_entities(entities, update_before_add=True) async def make_sensor(discovery_info): diff --git a/homeassistant/components/switch/zha.py b/homeassistant/components/switch/zha.py index b184d7baa5ccb..d34ca5e71bafb 100644 --- a/homeassistant/components/switch/zha.py +++ b/homeassistant/components/switch/zha.py @@ -6,9 +6,13 @@ """ import logging +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.components.switch import DOMAIN, SwitchDevice from homeassistant.components.zha.entities import ZhaEntity from homeassistant.components.zha import helpers +from homeassistant.components.zha.const import ( + ZHA_DISCOVERY_NEW, DATA_ZHA, DATA_ZHA_DISPATCHERS +) _LOGGER = logging.getLogger(__name__) @@ -17,24 +21,44 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Zigbee Home Automation switches.""" - from zigpy.zcl.clusters.general import OnOff + """Old way of setting up Zigbee Home Automation switches.""" + pass + - discovery_info = helpers.get_discovery_info(hass, discovery_info) - if discovery_info is None: - return +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Zigbee Home Automation switch from config entry.""" + async def async_discover(discovery_info): + await _async_setup_entities(hass, config_entry, async_add_entities, + [discovery_info]) - switch = Switch(**discovery_info) + unsub = async_dispatcher_connect( + hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover) + hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) - if discovery_info['new_join']: - in_clusters = discovery_info['in_clusters'] - cluster = in_clusters[OnOff.cluster_id] - await helpers.configure_reporting( - switch.entity_id, cluster, switch.value_attribute, - min_report=0, max_report=600, reportable_change=1 - ) + switches = hass.data.get(DATA_ZHA, {}).get(DOMAIN) + if switches is not None: + await _async_setup_entities(hass, config_entry, async_add_entities, + switches.values()) + del hass.data[DATA_ZHA][DOMAIN] - async_add_entities([switch], update_before_add=True) + +async def _async_setup_entities(hass, config_entry, async_add_entities, + discovery_infos): + """Set up the ZHA switches.""" + from zigpy.zcl.clusters.general import OnOff + entities = [] + for discovery_info in discovery_infos: + switch = Switch(**discovery_info) + if discovery_info['new_join']: + in_clusters = discovery_info['in_clusters'] + cluster = in_clusters[OnOff.cluster_id] + await helpers.configure_reporting( + switch.entity_id, cluster, switch.value_attribute, + min_report=0, max_report=600, reportable_change=1 + ) + entities.append(switch) + + async_add_entities(entities, update_before_add=True) class Switch(ZhaEntity, SwitchDevice): diff --git a/homeassistant/components/zha/.translations/en.json b/homeassistant/components/zha/.translations/en.json new file mode 100644 index 0000000000000..b6d7948c0b3a1 --- /dev/null +++ b/homeassistant/components/zha/.translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "title": "ZHA", + "step": { + "user": { + "title": "ZHA", + "description": "", + "data": { + "usb_path": "USB Device Path", + "radio_type": "Radio Type" + } + } + }, + "abort": { + "single_instance_allowed": "Only a single configuration of ZHA is allowed." + }, + "error": { + "cannot_connect": "Unable to connect to ZHA device." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index e54b7f7f65793..0fc2b978fbbe6 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -5,51 +5,47 @@ https://home-assistant.io/components/zha/ """ import collections -import enum import logging +import os import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant import const as ha_const -from homeassistant.helpers import discovery from homeassistant.helpers.entity_component import EntityComponent from homeassistant.components.zha.entities import ZhaDeviceEntity +from homeassistant import config_entries, const as ha_const +from homeassistant.helpers.dispatcher import async_dispatcher_send from . import const as zha_const +# Loading the config flow file will register the flow +from . import config_flow # noqa # pylint: disable=unused-import +from .const import ( + DOMAIN, COMPONENTS, CONF_BAUDRATE, CONF_DATABASE, CONF_RADIO_TYPE, + CONF_USB_PATH, CONF_DEVICE_CONFIG, ZHA_DISCOVERY_NEW, DATA_ZHA, + DATA_ZHA_CONFIG, DATA_ZHA_BRIDGE_ID, DATA_ZHA_RADIO, DATA_ZHA_DISPATCHERS, + DATA_ZHA_CORE_COMPONENT, DEFAULT_RADIO_TYPE, DEFAULT_DATABASE_NAME, + DEFAULT_BAUDRATE, RadioType +) + REQUIREMENTS = [ 'bellows==0.7.0', 'zigpy==0.2.0', 'zigpy-xbee==0.1.1', ] -DOMAIN = 'zha' - - -class RadioType(enum.Enum): - """Possible options for radio type in config.""" - - ezsp = 'ezsp' - xbee = 'xbee' - - -CONF_BAUDRATE = 'baudrate' -CONF_DATABASE = 'database_path' -CONF_DEVICE_CONFIG = 'device_config' -CONF_RADIO_TYPE = 'radio_type' -CONF_USB_PATH = 'usb_path' -DATA_DEVICE_CONFIG = 'zha_device_config' - DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({ vol.Optional(ha_const.CONF_TYPE): cv.string, }) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Optional(CONF_RADIO_TYPE, default='ezsp'): cv.enum(RadioType), + vol.Optional( + CONF_RADIO_TYPE, + default=DEFAULT_RADIO_TYPE + ): cv.enum(RadioType), CONF_USB_PATH: cv.string, - vol.Optional(CONF_BAUDRATE, default=57600): cv.positive_int, - CONF_DATABASE: cv.string, + vol.Optional(CONF_BAUDRATE, default=DEFAULT_BAUDRATE): cv.positive_int, + vol.Optional(CONF_DATABASE): cv.string, vol.Optional(CONF_DEVICE_CONFIG, default={}): vol.Schema({cv.string: DEVICE_CONFIG_SCHEMA_ENTRY}), }) @@ -73,8 +69,6 @@ class RadioType(enum.Enum): # Zigbee definitions CENTICELSIUS = 'C-100' -# Key in hass.data dict containing discovery info -DISCOVERY_KEY = 'zha_discovery_info' # Internal definitions APPLICATION_CONTROLLER = None @@ -82,27 +76,58 @@ class RadioType(enum.Enum): async def async_setup(hass, config): + """Set up ZHA from config.""" + hass.data[DATA_ZHA] = {} + + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + hass.data[DATA_ZHA][DATA_ZHA_CONFIG] = conf + + if not hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, + context={'source': config_entries.SOURCE_IMPORT}, + data={ + CONF_USB_PATH: conf[CONF_USB_PATH], + CONF_RADIO_TYPE: conf.get(CONF_RADIO_TYPE).value + } + )) + return True + + +async def async_setup_entry(hass, config_entry): """Set up ZHA. Will automatically load components to support devices found on the network. """ global APPLICATION_CONTROLLER - usb_path = config[DOMAIN].get(CONF_USB_PATH) - baudrate = config[DOMAIN].get(CONF_BAUDRATE) - radio_type = config[DOMAIN].get(CONF_RADIO_TYPE) - if radio_type == RadioType.ezsp: + hass.data[DATA_ZHA] = hass.data.get(DATA_ZHA, {}) + hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS] = [] + + config = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}) + + usb_path = config_entry.data.get(CONF_USB_PATH) + baudrate = config.get(CONF_BAUDRATE, DEFAULT_BAUDRATE) + radio_type = config_entry.data.get(CONF_RADIO_TYPE) + if radio_type == RadioType.ezsp.name: import bellows.ezsp from bellows.zigbee.application import ControllerApplication radio = bellows.ezsp.EZSP() - elif radio_type == RadioType.xbee: + elif radio_type == RadioType.xbee.name: import zigpy_xbee.api from zigpy_xbee.zigbee.application import ControllerApplication radio = zigpy_xbee.api.XBee() await radio.connect(usb_path, baudrate) + hass.data[DATA_ZHA][DATA_ZHA_RADIO] = radio - database = config[DOMAIN].get(CONF_DATABASE) + if CONF_DATABASE in config: + database = config[CONF_DATABASE] + else: + database = os.path.join(hass.config.config_dir, DEFAULT_DATABASE_NAME) APPLICATION_CONTROLLER = ControllerApplication(radio, database) listener = ApplicationListener(hass, config) APPLICATION_CONTROLLER.add_listener(listener) @@ -112,6 +137,14 @@ async def async_setup(hass, config): hass.async_create_task( listener.async_device_initialized(device, False)) + hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(APPLICATION_CONTROLLER.ieee) + + for component in COMPONENTS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup( + config_entry, component) + ) + async def permit(service): """Allow devices to join this network.""" duration = service.data.get(ATTR_DURATION) @@ -132,6 +165,37 @@ async def remove(service): hass.services.async_register(DOMAIN, SERVICE_REMOVE, remove, schema=SERVICE_SCHEMAS[SERVICE_REMOVE]) + def zha_shutdown(event): + """Close radio.""" + hass.data[DATA_ZHA][DATA_ZHA_RADIO].close() + + hass.bus.async_listen_once(ha_const.EVENT_HOMEASSISTANT_STOP, zha_shutdown) + return True + + +async def async_unload_entry(hass, config_entry): + """Unload ZHA config entry.""" + hass.services.async_remove(DOMAIN, SERVICE_PERMIT) + hass.services.async_remove(DOMAIN, SERVICE_REMOVE) + + dispatchers = hass.data[DATA_ZHA].get(DATA_ZHA_DISPATCHERS, []) + for unsub_dispatcher in dispatchers: + unsub_dispatcher() + + for component in COMPONENTS: + await hass.config_entries.async_forward_entry_unload( + config_entry, component) + + # clean up device entities + component = hass.data[DATA_ZHA][DATA_ZHA_CORE_COMPONENT] + entity_ids = [entity.entity_id for entity in component.entities] + for entity_id in entity_ids: + await component.async_remove_entity(entity_id) + + _LOGGER.debug("Closing zha radio") + hass.data[DATA_ZHA][DATA_ZHA_RADIO].close() + + del hass.data[DATA_ZHA] return True @@ -144,9 +208,14 @@ def __init__(self, hass, config): self._config = config self._component = EntityComponent(_LOGGER, DOMAIN, hass) self._device_registry = collections.defaultdict(list) - hass.data[DISCOVERY_KEY] = hass.data.get(DISCOVERY_KEY, {}) zha_const.populate_data() + for component in COMPONENTS: + hass.data[DATA_ZHA][component] = ( + hass.data[DATA_ZHA].get(component, {}) + ) + hass.data[DATA_ZHA][DATA_ZHA_CORE_COMPONENT] = self._component + def device_joined(self, device): """Handle device joined. @@ -193,8 +262,11 @@ async def async_device_initialized(self, device, join): component = None profile_clusters = ([], []) device_key = "{}-{}".format(device.ieee, endpoint_id) - node_config = self._config[DOMAIN][CONF_DEVICE_CONFIG].get( - device_key, {}) + node_config = {} + if CONF_DEVICE_CONFIG in self._config: + node_config = self._config[CONF_DEVICE_CONFIG].get( + device_key, {} + ) if endpoint.profile_id in zigpy.profiles.PROFILES: profile = zigpy.profiles.PROFILES[endpoint.profile_id] @@ -226,15 +298,17 @@ async def async_device_initialized(self, device, join): 'new_join': join, 'unique_id': device_key, } - self._hass.data[DISCOVERY_KEY][device_key] = discovery_info - - await discovery.async_load_platform( - self._hass, - component, - DOMAIN, - {'discovery_key': device_key}, - self._config, - ) + + if join: + async_dispatcher_send( + self._hass, + ZHA_DISCOVERY_NEW.format(component), + discovery_info + ) + else: + self._hass.data[DATA_ZHA][component][device_key] = ( + discovery_info + ) for cluster in endpoint.in_clusters.values(): await self._attempt_single_cluster_device( @@ -309,12 +383,12 @@ async def _attempt_single_cluster_device(self, endpoint, cluster, discovery_info[discovery_attr] = {cluster.cluster_id: cluster} if sub_component: discovery_info.update({'sub_component': sub_component}) - self._hass.data[DISCOVERY_KEY][cluster_key] = discovery_info - await discovery.async_load_platform( - self._hass, - component, - DOMAIN, - {'discovery_key': cluster_key}, - self._config, - ) + if is_new_join: + async_dispatcher_send( + self._hass, + ZHA_DISCOVERY_NEW.format(component), + discovery_info + ) + else: + self._hass.data[DATA_ZHA][component][cluster_key] = discovery_info diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py new file mode 100644 index 0000000000000..fa45194ea3fe7 --- /dev/null +++ b/homeassistant/components/zha/config_flow.py @@ -0,0 +1,57 @@ +"""Config flow for ZHA.""" +import os +from collections import OrderedDict + +import voluptuous as vol + +from homeassistant import config_entries +from .helpers import check_zigpy_connection +from .const import ( + DOMAIN, CONF_RADIO_TYPE, CONF_USB_PATH, DEFAULT_DATABASE_NAME, RadioType +) + + +@config_entries.HANDLERS.register(DOMAIN) +class ZhaFlowHandler(config_entries.ConfigFlow): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + async def async_step_user(self, user_input=None): + """Handle a zha config flow start.""" + if self._async_current_entries(): + return self.async_abort(reason='single_instance_allowed') + + errors = {} + + fields = OrderedDict() + fields[vol.Required(CONF_USB_PATH)] = str + fields[vol.Optional(CONF_RADIO_TYPE, default='ezsp')] = vol.In( + RadioType.list() + ) + + if user_input is not None: + database = os.path.join(self.hass.config.config_dir, + DEFAULT_DATABASE_NAME) + test = await check_zigpy_connection(user_input[CONF_USB_PATH], + user_input[CONF_RADIO_TYPE], + database) + if test: + return self.async_create_entry( + title=user_input[CONF_USB_PATH], data=user_input) + errors['base'] = 'cannot_connect' + + return self.async_show_form( + step_id='user', data_schema=vol.Schema(fields), errors=errors + ) + + async def async_step_import(self, import_info): + """Handle a zha config import.""" + if self._async_current_entries(): + return self.async_abort(reason='single_instance_allowed') + + return self.async_create_entry( + title=import_info[CONF_USB_PATH], + data=import_info + ) diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py index 2a7e35ff517df..9efa847b50cde 100644 --- a/homeassistant/components/zha/const.py +++ b/homeassistant/components/zha/const.py @@ -1,4 +1,51 @@ """All constants related to the ZHA component.""" +import enum + +DOMAIN = 'zha' + +BAUD_RATES = [ + 2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 256000 +] + +DATA_ZHA = 'zha' +DATA_ZHA_CONFIG = 'config' +DATA_ZHA_BRIDGE_ID = 'zha_bridge_id' +DATA_ZHA_RADIO = 'zha_radio' +DATA_ZHA_DISPATCHERS = 'zha_dispatchers' +DATA_ZHA_CORE_COMPONENT = 'zha_core_component' +ZHA_DISCOVERY_NEW = 'zha_discovery_new_{}' + +COMPONENTS = [ + 'binary_sensor', + 'fan', + 'light', + 'sensor', + 'switch', +] + +CONF_BAUDRATE = 'baudrate' +CONF_DATABASE = 'database_path' +CONF_DEVICE_CONFIG = 'device_config' +CONF_RADIO_TYPE = 'radio_type' +CONF_USB_PATH = 'usb_path' +DATA_DEVICE_CONFIG = 'zha_device_config' + +DEFAULT_RADIO_TYPE = 'ezsp' +DEFAULT_BAUDRATE = 57600 +DEFAULT_DATABASE_NAME = 'zigbee.db' + + +class RadioType(enum.Enum): + """Possible options for radio type.""" + + ezsp = 'ezsp' + xbee = 'xbee' + + @classmethod + def list(cls): + """Return list of enum's values.""" + return [e.value for e in RadioType] + DISCOVERY_KEY = 'zha_discovery_info' DEVICE_CLASS = {} diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index 9d07f546b7f61..f3e1a27dca27a 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -5,28 +5,12 @@ https://home-assistant.io/components/zha/ """ import logging +import asyncio +from .const import RadioType, DEFAULT_BAUDRATE _LOGGER = logging.getLogger(__name__) -def get_discovery_info(hass, discovery_info): - """Get the full discovery info for a device. - - Some of the info that needs to be passed to platforms is not JSON - serializable, so it cannot be put in the discovery_info dictionary. This - component places that info we need to pass to the platform in hass.data, - and this function is a helper for platforms to retrieve the complete - discovery info. - """ - if discovery_info is None: - return - - import homeassistant.components.zha.const as zha_const - discovery_key = discovery_info.get('discovery_key', None) - all_discovery_info = hass.data.get(zha_const.DISCOVERY_KEY, {}) - return all_discovery_info.get(discovery_key, None) - - async def safe_read(cluster, attributes, allow_cache=True, only_cache=False): """Swallow all exceptions from network read. @@ -82,3 +66,23 @@ async def configure_reporting(entity_id, cluster, attr, skip_bind=False, "%s: failed to set reporting for '%s' attr on '%s' cluster: %s", entity_id, attr_name, cluster_name, str(ex) ) + + +async def check_zigpy_connection(usb_path, radio_type, database_path): + """Test zigpy radio connection.""" + if radio_type == RadioType.ezsp.name: + import bellows.ezsp + from bellows.zigbee.application import ControllerApplication + radio = bellows.ezsp.EZSP() + elif radio_type == RadioType.xbee.name: + import zigpy_xbee.api + from zigpy_xbee.zigbee.application import ControllerApplication + radio = zigpy_xbee.api.XBee() + try: + await radio.connect(usb_path, DEFAULT_BAUDRATE) + controller = ControllerApplication(radio, database_path) + await asyncio.wait_for(controller.startup(auto_form=True), timeout=30) + radio.close() + except Exception: # pylint: disable=broad-except + return False + return True diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json new file mode 100644 index 0000000000000..b6d7948c0b3a1 --- /dev/null +++ b/homeassistant/components/zha/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "title": "ZHA", + "step": { + "user": { + "title": "ZHA", + "description": "", + "data": { + "usb_path": "USB Device Path", + "radio_type": "Radio Type" + } + } + }, + "abort": { + "single_instance_allowed": "Only a single configuration of ZHA is allowed." + }, + "error": { + "cannot_connect": "Unable to connect to ZHA device." + } + } +} \ No newline at end of file diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 42bc8b089da65..acfa10acdefd8 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -158,6 +158,7 @@ async def async_step_discovery(info): 'twilio', 'unifi', 'upnp', + 'zha', 'zone', 'zwave' ] diff --git a/tests/components/zha/__init__.py b/tests/components/zha/__init__.py new file mode 100644 index 0000000000000..23d26b50312df --- /dev/null +++ b/tests/components/zha/__init__.py @@ -0,0 +1 @@ +"""Tests for the ZHA component.""" diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py new file mode 100644 index 0000000000000..e46f1849fa128 --- /dev/null +++ b/tests/components/zha/test_config_flow.py @@ -0,0 +1,77 @@ +"""Tests for ZHA config flow.""" +from asynctest import patch +from homeassistant.components.zha import config_flow +from homeassistant.components.zha.const import DOMAIN +from tests.common import MockConfigEntry + + +async def test_user_flow(hass): + """Test that config flow works.""" + flow = config_flow.ZhaFlowHandler() + flow.hass = hass + + with patch('homeassistant.components.zha.config_flow' + '.check_zigpy_connection', return_value=False): + result = await flow.async_step_user( + user_input={'usb_path': '/dev/ttyUSB1', 'radio_type': 'ezsp'}) + + assert result['errors'] == {'base': 'cannot_connect'} + + with patch('homeassistant.components.zha.config_flow' + '.check_zigpy_connection', return_value=True): + result = await flow.async_step_user( + user_input={'usb_path': '/dev/ttyUSB1', 'radio_type': 'ezsp'}) + + assert result['type'] == 'create_entry' + assert result['title'] == '/dev/ttyUSB1' + assert result['data'] == { + 'usb_path': '/dev/ttyUSB1', + 'radio_type': 'ezsp' + } + + +async def test_user_flow_existing_config_entry(hass): + """Test if config entry already exists.""" + MockConfigEntry(domain=DOMAIN, data={ + 'usb_path': '/dev/ttyUSB1' + }).add_to_hass(hass) + flow = config_flow.ZhaFlowHandler() + flow.hass = hass + + result = await flow.async_step_user() + + assert result['type'] == 'abort' + + +async def test_import_flow(hass): + """Test import from configuration.yaml .""" + flow = config_flow.ZhaFlowHandler() + flow.hass = hass + + result = await flow.async_step_import({ + 'usb_path': '/dev/ttyUSB1', + 'radio_type': 'xbee', + }) + + assert result['type'] == 'create_entry' + assert result['title'] == '/dev/ttyUSB1' + assert result['data'] == { + 'usb_path': '/dev/ttyUSB1', + 'radio_type': 'xbee' + } + + +async def test_import_flow_existing_config_entry(hass): + """Test import from configuration.yaml .""" + MockConfigEntry(domain=DOMAIN, data={ + 'usb_path': '/dev/ttyUSB1' + }).add_to_hass(hass) + flow = config_flow.ZhaFlowHandler() + flow.hass = hass + + result = await flow.async_step_import({ + 'usb_path': '/dev/ttyUSB1', + 'radio_type': 'xbee', + }) + + assert result['type'] == 'abort' From fc8b1f4968608fe85b808cd6cabb0f58f0337ddd Mon Sep 17 00:00:00 2001 From: majuss Date: Wed, 28 Nov 2018 02:21:27 +0000 Subject: [PATCH 103/325] Update lupupy version to 0.0.13 (#18754) * lupupy version push --- homeassistant/components/lupusec.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lupusec.py b/homeassistant/components/lupusec.py index 162b49ef9b236..17b04fce8675e 100644 --- a/homeassistant/components/lupusec.py +++ b/homeassistant/components/lupusec.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['lupupy==0.0.10'] +REQUIREMENTS = ['lupupy==0.0.13'] DOMAIN = 'lupusec' diff --git a/requirements_all.txt b/requirements_all.txt index 4b3277dee27be..8c78eaef2b5eb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -604,7 +604,7 @@ logi_circle==0.1.7 luftdaten==0.3.4 # homeassistant.components.lupusec -lupupy==0.0.10 +lupupy==0.0.13 # homeassistant.components.light.lw12wifi lw12==0.9.2 From a039c3209bcc43fb5c2ada21bd1ce0648f517810 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Wed, 28 Nov 2018 10:36:29 +0100 Subject: [PATCH 104/325] Replace token in camera.push with webhook (#18380) * replace token with webhook * missing PR 18206 aditions * remove unused property * increase robustness * lint * address review comments * id -> name --- homeassistant/components/camera/push.py | 112 +++++++++++------------- tests/components/camera/test_push.py | 77 ++++------------ 2 files changed, 69 insertions(+), 120 deletions(-) diff --git a/homeassistant/components/camera/push.py b/homeassistant/components/camera/push.py index c9deca1309d69..36c4a3109baba 100644 --- a/homeassistant/components/camera/push.py +++ b/homeassistant/components/camera/push.py @@ -5,35 +5,33 @@ https://home-assistant.io/components/camera.push/ """ import logging +import asyncio from collections import deque from datetime import timedelta import voluptuous as vol +import aiohttp +import async_timeout from homeassistant.components.camera import Camera, PLATFORM_SCHEMA,\ - STATE_IDLE, STATE_RECORDING + STATE_IDLE, STATE_RECORDING, DOMAIN from homeassistant.core import callback -from homeassistant.components.http.view import KEY_AUTHENTICATED,\ - HomeAssistantView -from homeassistant.const import CONF_NAME, CONF_TIMEOUT,\ - HTTP_NOT_FOUND, HTTP_UNAUTHORIZED, HTTP_BAD_REQUEST +from homeassistant.const import CONF_NAME, CONF_TIMEOUT, CONF_WEBHOOK_ID from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_point_in_utc_time import homeassistant.util.dt as dt_util -_LOGGER = logging.getLogger(__name__) +DEPENDENCIES = ['webhook'] -DEPENDENCIES = ['http'] +_LOGGER = logging.getLogger(__name__) CONF_BUFFER_SIZE = 'buffer' CONF_IMAGE_FIELD = 'field' -CONF_TOKEN = 'token' DEFAULT_NAME = "Push Camera" ATTR_FILENAME = 'filename' ATTR_LAST_TRIP = 'last_trip' -ATTR_TOKEN = 'token' PUSH_CAMERA_DATA = 'push_camera' @@ -43,7 +41,7 @@ vol.Optional(CONF_TIMEOUT, default=timedelta(seconds=5)): vol.All( cv.time_period, cv.positive_timedelta), vol.Optional(CONF_IMAGE_FIELD, default='image'): cv.string, - vol.Optional(CONF_TOKEN): vol.All(cv.string, vol.Length(min=8)), + vol.Required(CONF_WEBHOOK_ID): cv.string, }) @@ -53,69 +51,43 @@ async def async_setup_platform(hass, config, async_add_entities, if PUSH_CAMERA_DATA not in hass.data: hass.data[PUSH_CAMERA_DATA] = {} - cameras = [PushCamera(config[CONF_NAME], + webhook_id = config.get(CONF_WEBHOOK_ID) + + cameras = [PushCamera(hass, + config[CONF_NAME], config[CONF_BUFFER_SIZE], config[CONF_TIMEOUT], - config.get(CONF_TOKEN))] - - hass.http.register_view(CameraPushReceiver(hass, - config[CONF_IMAGE_FIELD])) + config[CONF_IMAGE_FIELD], + webhook_id)] async_add_entities(cameras) -class CameraPushReceiver(HomeAssistantView): - """Handle pushes from remote camera.""" +async def handle_webhook(hass, webhook_id, request): + """Handle incoming webhook POST with image files.""" + try: + with async_timeout.timeout(5, loop=hass.loop): + data = dict(await request.post()) + except (asyncio.TimeoutError, aiohttp.web.HTTPException) as error: + _LOGGER.error("Could not get information from POST <%s>", error) + return - url = "/api/camera_push/{entity_id}" - name = 'api:camera_push:camera_entity' - requires_auth = False + camera = hass.data[PUSH_CAMERA_DATA][webhook_id] - def __init__(self, hass, image_field): - """Initialize CameraPushReceiver with camera entity.""" - self._cameras = hass.data[PUSH_CAMERA_DATA] - self._image = image_field + if camera.image_field not in data: + _LOGGER.warning("Webhook call without POST parameter <%s>", + camera.image_field) + return - async def post(self, request, entity_id): - """Accept the POST from Camera.""" - _camera = self._cameras.get(entity_id) - - if _camera is None: - _LOGGER.error("Unknown %s", entity_id) - status = HTTP_NOT_FOUND if request[KEY_AUTHENTICATED]\ - else HTTP_UNAUTHORIZED - return self.json_message('Unknown {}'.format(entity_id), - status) - - # Supports HA authentication and token based - # when token has been configured - authenticated = (request[KEY_AUTHENTICATED] or - (_camera.token is not None and - request.query.get('token') == _camera.token)) - - if not authenticated: - return self.json_message( - 'Invalid authorization credentials for {}'.format(entity_id), - HTTP_UNAUTHORIZED) - - try: - data = await request.post() - _LOGGER.debug("Received Camera push: %s", data[self._image]) - await _camera.update_image(data[self._image].file.read(), - data[self._image].filename) - except ValueError as value_error: - _LOGGER.error("Unknown value %s", value_error) - return self.json_message('Invalid POST', HTTP_BAD_REQUEST) - except KeyError as key_error: - _LOGGER.error('In your POST message %s', key_error) - return self.json_message('{} missing'.format(self._image), - HTTP_BAD_REQUEST) + await camera.update_image(data[camera.image_field].file.read(), + data[camera.image_field].filename) class PushCamera(Camera): """The representation of a Push camera.""" - def __init__(self, name, buffer_size, timeout, token): + def __init__(self, hass, name, buffer_size, timeout, image_field, + webhook_id): """Initialize push camera component.""" super().__init__() self._name = name @@ -126,11 +98,28 @@ def __init__(self, name, buffer_size, timeout, token): self._timeout = timeout self.queue = deque([], buffer_size) self._current_image = None - self.token = token + self._image_field = image_field + self.webhook_id = webhook_id + self.webhook_url = \ + hass.components.webhook.async_generate_url(webhook_id) async def async_added_to_hass(self): """Call when entity is added to hass.""" - self.hass.data[PUSH_CAMERA_DATA][self.entity_id] = self + self.hass.data[PUSH_CAMERA_DATA][self.webhook_id] = self + + try: + self.hass.components.webhook.async_register(DOMAIN, + self.name, + self.webhook_id, + handle_webhook) + except ValueError: + _LOGGER.error("In <%s>, webhook_id <%s> already used", + self.name, self.webhook_id) + + @property + def image_field(self): + """HTTP field containing the image file.""" + return self._image_field @property def state(self): @@ -189,6 +178,5 @@ def device_state_attributes(self): name: value for name, value in ( (ATTR_LAST_TRIP, self._last_trip), (ATTR_FILENAME, self._filename), - (ATTR_TOKEN, self.token), ) if value is not None } diff --git a/tests/components/camera/test_push.py b/tests/components/camera/test_push.py index 6d9688c10e62a..be1d24ce34fbb 100644 --- a/tests/components/camera/test_push.py +++ b/tests/components/camera/test_push.py @@ -4,90 +4,51 @@ from datetime import timedelta from homeassistant import core as ha +from homeassistant.components import webhook from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from homeassistant.components.http.auth import setup_auth async def test_bad_posting(aioclient_mock, hass, aiohttp_client): """Test that posting to wrong api endpoint fails.""" + await async_setup_component(hass, webhook.DOMAIN, {}) await async_setup_component(hass, 'camera', { 'camera': { 'platform': 'push', 'name': 'config_test', - 'token': '12345678' - }}) - client = await aiohttp_client(hass.http.app) - - # missing file - resp = await client.post('/api/camera_push/camera.config_test') - assert resp.status == 400 - - # wrong entity - files = {'image': io.BytesIO(b'fake')} - resp = await client.post('/api/camera_push/camera.wrong', data=files) - assert resp.status == 404 - - -async def test_cases_with_no_auth(aioclient_mock, hass, aiohttp_client): - """Test cases where aiohttp_client is not auth.""" - await async_setup_component(hass, 'camera', { - 'camera': { - 'platform': 'push', - 'name': 'config_test', - 'token': '12345678' + 'webhook_id': 'camera.config_test' }}) + await hass.async_block_till_done() + assert hass.states.get('camera.config_test') is not None - setup_auth(hass.http.app, [], True, api_password=None) client = await aiohttp_client(hass.http.app) - # wrong token + # wrong webhook files = {'image': io.BytesIO(b'fake')} - resp = await client.post('/api/camera_push/camera.config_test?token=1234', - data=files) - assert resp.status == 401 - - # right token - files = {'image': io.BytesIO(b'fake')} - resp = await client.post( - '/api/camera_push/camera.config_test?token=12345678', - data=files) - assert resp.status == 200 - - -async def test_no_auth_no_token(aioclient_mock, hass, aiohttp_client): - """Test cases where aiohttp_client is not auth.""" - await async_setup_component(hass, 'camera', { - 'camera': { - 'platform': 'push', - 'name': 'config_test', - }}) + resp = await client.post('/api/webhood/camera.wrong', data=files) + assert resp.status == 404 - setup_auth(hass.http.app, [], True, api_password=None) - client = await aiohttp_client(hass.http.app) + # missing file + camera_state = hass.states.get('camera.config_test') + assert camera_state.state == 'idle' - # no token - files = {'image': io.BytesIO(b'fake')} - resp = await client.post('/api/camera_push/camera.config_test', - data=files) - assert resp.status == 401 + resp = await client.post('/api/webhook/camera.config_test') + assert resp.status == 200 # webhooks always return 200 - # fake token - files = {'image': io.BytesIO(b'fake')} - resp = await client.post( - '/api/camera_push/camera.config_test?token=12345678', - data=files) - assert resp.status == 401 + camera_state = hass.states.get('camera.config_test') + assert camera_state.state == 'idle' # no file supplied we are still idle async def test_posting_url(hass, aiohttp_client): """Test that posting to api endpoint works.""" + await async_setup_component(hass, webhook.DOMAIN, {}) await async_setup_component(hass, 'camera', { 'camera': { 'platform': 'push', 'name': 'config_test', - 'token': '12345678' + 'webhook_id': 'camera.config_test' }}) + await hass.async_block_till_done() client = await aiohttp_client(hass.http.app) files = {'image': io.BytesIO(b'fake')} @@ -98,7 +59,7 @@ async def test_posting_url(hass, aiohttp_client): # post image resp = await client.post( - '/api/camera_push/camera.config_test?token=12345678', + '/api/webhook/camera.config_test', data=files) assert resp.status == 200 From 5c3a4e3d10c5b0bfc0d5a10bfb64a4bfcc7aa62f Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Wed, 28 Nov 2018 07:16:43 -0500 Subject: [PATCH 105/325] Restore states through a JSON store instead of recorder (#17270) * Restore states through a JSON store * Accept entity_id directly in restore state helper * Keep states stored between runs for a limited time * Remove warning --- .../components/alarm_control_panel/manual.py | 6 +- .../components/alarm_control_panel/mqtt.py | 3 +- .../components/automation/__init__.py | 8 +- .../components/binary_sensor/mqtt.py | 3 +- .../components/climate/generic_thermostat.py | 7 +- homeassistant/components/climate/mqtt.py | 3 +- homeassistant/components/counter/__init__.py | 8 +- homeassistant/components/cover/mqtt.py | 3 +- .../components/device_tracker/__init__.py | 8 +- homeassistant/components/fan/mqtt.py | 3 +- homeassistant/components/history.py | 14 - homeassistant/components/input_boolean.py | 7 +- homeassistant/components/input_datetime.py | 8 +- homeassistant/components/input_number.py | 8 +- homeassistant/components/input_select.py | 8 +- homeassistant/components/input_text.py | 8 +- .../components/light/limitlessled.py | 7 +- .../components/light/mqtt/schema_basic.py | 9 +- .../components/light/mqtt/schema_json.py | 10 +- .../components/light/mqtt/schema_template.py | 6 +- homeassistant/components/lock/mqtt.py | 3 +- homeassistant/components/mqtt/__init__.py | 3 + homeassistant/components/recorder/__init__.py | 7 - homeassistant/components/sensor/fastdotcom.py | 8 +- homeassistant/components/sensor/mqtt.py | 3 +- homeassistant/components/sensor/speedtest.py | 8 +- homeassistant/components/switch/mqtt.py | 11 +- homeassistant/components/switch/pilight.py | 7 +- homeassistant/components/timer/__init__.py | 7 +- homeassistant/helpers/entity.py | 11 +- homeassistant/helpers/entity_platform.py | 3 +- homeassistant/helpers/restore_state.py | 247 ++++++++++----- homeassistant/helpers/storage.py | 21 +- homeassistant/util/json.py | 7 +- tests/common.py | 31 +- tests/components/emulated_hue/test_upnp.py | 32 +- tests/components/light/test_mqtt.py | 2 +- tests/components/light/test_mqtt_json.py | 2 +- tests/components/light/test_mqtt_template.py | 2 +- tests/components/recorder/test_migrate.py | 17 +- tests/components/switch/test_mqtt.py | 3 +- tests/components/test_history.py | 1 - tests/components/test_logbook.py | 2 - tests/helpers/test_restore_state.py | 291 +++++++++--------- tests/helpers/test_storage.py | 18 +- tests/util/test_json.py | 21 +- 46 files changed, 488 insertions(+), 417 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/manual.py b/homeassistant/components/alarm_control_panel/manual.py index 362923a4ce21d..0a79d74d68607 100644 --- a/homeassistant/components/alarm_control_panel/manual.py +++ b/homeassistant/components/alarm_control_panel/manual.py @@ -21,7 +21,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import track_point_in_time import homeassistant.util.dt as dt_util -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity _LOGGER = logging.getLogger(__name__) @@ -116,7 +116,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): )]) -class ManualAlarm(alarm.AlarmControlPanel): +class ManualAlarm(alarm.AlarmControlPanel, RestoreEntity): """ Representation of an alarm status. @@ -310,7 +310,7 @@ def device_state_attributes(self): async def async_added_to_hass(self): """Run when entity about to be added to hass.""" - state = await async_get_last_state(self.hass, self.entity_id) + state = await self.async_get_last_state() if state: self._state = state.state self._state_ts = state.last_updated diff --git a/homeassistant/components/alarm_control_panel/mqtt.py b/homeassistant/components/alarm_control_panel/mqtt.py index 1b9bb020eada6..5f0793ae58ce1 100644 --- a/homeassistant/components/alarm_control_panel/mqtt.py +++ b/homeassistant/components/alarm_control_panel/mqtt.py @@ -108,8 +108,7 @@ def __init__(self, config, discovery_hash): async def async_added_to_hass(self): """Subscribe mqtt events.""" - await MqttAvailability.async_added_to_hass(self) - await MqttDiscoveryUpdate.async_added_to_hass(self) + await super().async_added_to_hass() await self._subscribe_topics() async def discovery_update(self, discovery_payload): diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index f8563071fbc52..f44d044ecfaf5 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -21,7 +21,7 @@ from homeassistant.helpers import extract_domain_configs, script, condition from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.dt import utcnow import homeassistant.helpers.config_validation as cv @@ -182,7 +182,7 @@ async def reload_service_handler(service_call): return True -class AutomationEntity(ToggleEntity): +class AutomationEntity(ToggleEntity, RestoreEntity): """Entity to show status of entity.""" def __init__(self, automation_id, name, async_attach_triggers, cond_func, @@ -227,12 +227,13 @@ def is_on(self) -> bool: async def async_added_to_hass(self) -> None: """Startup with initial state or previous state.""" + await super().async_added_to_hass() if self._initial_state is not None: enable_automation = self._initial_state _LOGGER.debug("Automation %s initial state %s from config " "initial_state", self.entity_id, enable_automation) else: - state = await async_get_last_state(self.hass, self.entity_id) + state = await self.async_get_last_state() if state: enable_automation = state.state == STATE_ON self._last_triggered = state.attributes.get('last_triggered') @@ -291,6 +292,7 @@ async def async_trigger(self, variables, skip_condition=False, async def async_will_remove_from_hass(self): """Remove listeners when removing automation from HASS.""" + await super().async_will_remove_from_hass() await self.async_turn_off() async def async_enable(self): diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index 4d7e2c07eba65..acbad0d041903 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -102,8 +102,7 @@ def __init__(self, config, discovery_hash): async def async_added_to_hass(self): """Subscribe mqtt events.""" - await MqttAvailability.async_added_to_hass(self) - await MqttDiscoveryUpdate.async_added_to_hass(self) + await super().async_added_to_hass() await self._subscribe_topics() async def discovery_update(self, discovery_payload): diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index 212c4265d8a79..ffab50c989d70 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -23,7 +23,7 @@ from homeassistant.helpers.event import ( async_track_state_change, async_track_time_interval) import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity _LOGGER = logging.getLogger(__name__) @@ -96,7 +96,7 @@ async def async_setup_platform(hass, config, async_add_entities, precision)]) -class GenericThermostat(ClimateDevice): +class GenericThermostat(ClimateDevice, RestoreEntity): """Representation of a Generic Thermostat device.""" def __init__(self, hass, name, heater_entity_id, sensor_entity_id, @@ -155,8 +155,9 @@ def __init__(self, hass, name, heater_entity_id, sensor_entity_id, async def async_added_to_hass(self): """Run when entity about to be added.""" + await super().async_added_to_hass() # Check If we have an old state - old_state = await async_get_last_state(self.hass, self.entity_id) + old_state = await self.async_get_last_state() if old_state is not None: # If we have no initial temperature, restore if self._target_temp is None: diff --git a/homeassistant/components/climate/mqtt.py b/homeassistant/components/climate/mqtt.py index 7436ffc41ea1e..bccf282f055da 100644 --- a/homeassistant/components/climate/mqtt.py +++ b/homeassistant/components/climate/mqtt.py @@ -221,8 +221,7 @@ def __init__(self, hass, config, discovery_hash): async def async_added_to_hass(self): """Handle being added to home assistant.""" - await MqttAvailability.async_added_to_hass(self) - await MqttDiscoveryUpdate.async_added_to_hass(self) + await super().async_added_to_hass() await self._subscribe_topics() async def discovery_update(self, discovery_payload): diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index d67c93c0d6ef9..228870489a2d6 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -10,9 +10,8 @@ from homeassistant.const import ATTR_ENTITY_ID, CONF_ICON, CONF_NAME import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity _LOGGER = logging.getLogger(__name__) @@ -86,7 +85,7 @@ async def async_setup(hass, config): return True -class Counter(Entity): +class Counter(RestoreEntity): """Representation of a counter.""" def __init__(self, object_id, name, initial, restore, step, icon): @@ -128,10 +127,11 @@ def state_attributes(self): async def async_added_to_hass(self): """Call when entity about to be added to Home Assistant.""" + await super().async_added_to_hass() # __init__ will set self._state to self._initial, only override # if needed. if self._restore: - state = await async_get_last_state(self.hass, self.entity_id) + state = await self.async_get_last_state() if state is not None: self._state = int(state.state) diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index 92394fc026bb8..94e2b948c4898 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -205,8 +205,7 @@ def __init__(self, config, discovery_hash): async def async_added_to_hass(self): """Subscribe MQTT events.""" - await MqttAvailability.async_added_to_hass(self) - await MqttDiscoveryUpdate.async_added_to_hass(self) + await super().async_added_to_hass() await self._subscribe_topics() async def discovery_update(self, discovery_payload): diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index a43a7c93bdc75..35ecaf716168f 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -22,9 +22,8 @@ from homeassistant.config import load_yaml_config_file, async_log_exception from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_per_platform, discovery -from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import GPSType, ConfigType, HomeAssistantType import homeassistant.helpers.config_validation as cv from homeassistant import util @@ -396,7 +395,7 @@ async def async_init_single_device(dev): await asyncio.wait(tasks, loop=self.hass.loop) -class Device(Entity): +class Device(RestoreEntity): """Represent a tracked device.""" host_name = None # type: str @@ -564,7 +563,8 @@ async def async_update(self): async def async_added_to_hass(self): """Add an entity.""" - state = await async_get_last_state(self.hass, self.entity_id) + await super().async_added_to_hass() + state = await self.async_get_last_state() if not state: return self._state = state.state diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/fan/mqtt.py index 505a6e90720aa..75be8e0277c8a 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/fan/mqtt.py @@ -151,8 +151,7 @@ def __init__(self, config, discovery_hash): async def async_added_to_hass(self): """Subscribe to MQTT events.""" - await MqttAvailability.async_added_to_hass(self) - await MqttDiscoveryUpdate.async_added_to_hass(self) + await super().async_added_to_hass() await self._subscribe_topics() async def discovery_update(self, discovery_payload): diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index 21d4cdc6e5608..1773a55b3f1bb 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -38,20 +38,6 @@ IGNORE_DOMAINS = ('zone', 'scene',) -def last_recorder_run(hass): - """Retrieve the last closed recorder run from the database.""" - from homeassistant.components.recorder.models import RecorderRuns - - with session_scope(hass=hass) as session: - res = (session.query(RecorderRuns) - .filter(RecorderRuns.end.isnot(None)) - .order_by(RecorderRuns.end.desc()).first()) - if res is None: - return None - session.expunge(res) - return res - - def get_significant_states(hass, start_time, end_time=None, entity_ids=None, filters=None, include_start_time_state=True): """ diff --git a/homeassistant/components/input_boolean.py b/homeassistant/components/input_boolean.py index 18c9808c6d20a..541e38202fc14 100644 --- a/homeassistant/components/input_boolean.py +++ b/homeassistant/components/input_boolean.py @@ -15,7 +15,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity DOMAIN = 'input_boolean' @@ -84,7 +84,7 @@ async def async_setup(hass, config): return True -class InputBoolean(ToggleEntity): +class InputBoolean(ToggleEntity, RestoreEntity): """Representation of a boolean input.""" def __init__(self, object_id, name, initial, icon): @@ -117,10 +117,11 @@ def is_on(self): async def async_added_to_hass(self): """Call when entity about to be added to hass.""" # If not None, we got an initial value. + await super().async_added_to_hass() if self._state is not None: return - state = await async_get_last_state(self.hass, self.entity_id) + state = await self.async_get_last_state() self._state = state and state.state == STATE_ON async def async_turn_on(self, **kwargs): diff --git a/homeassistant/components/input_datetime.py b/homeassistant/components/input_datetime.py index df35ae53ba9fc..6ac9a24d0444a 100644 --- a/homeassistant/components/input_datetime.py +++ b/homeassistant/components/input_datetime.py @@ -11,9 +11,8 @@ from homeassistant.const import ATTR_ENTITY_ID, CONF_ICON, CONF_NAME import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import dt as dt_util @@ -97,7 +96,7 @@ async def async_set_datetime_service(entity, call): return True -class InputDatetime(Entity): +class InputDatetime(RestoreEntity): """Representation of a datetime input.""" def __init__(self, object_id, name, has_date, has_time, icon, initial): @@ -112,6 +111,7 @@ def __init__(self, object_id, name, has_date, has_time, icon, initial): async def async_added_to_hass(self): """Run when entity about to be added.""" + await super().async_added_to_hass() restore_val = None # Priority 1: Initial State @@ -120,7 +120,7 @@ async def async_added_to_hass(self): # Priority 2: Old state if restore_val is None: - old_state = await async_get_last_state(self.hass, self.entity_id) + old_state = await self.async_get_last_state() if old_state is not None: restore_val = old_state.state diff --git a/homeassistant/components/input_number.py b/homeassistant/components/input_number.py index f52b9add82162..b6c6eab3cf5a0 100644 --- a/homeassistant/components/input_number.py +++ b/homeassistant/components/input_number.py @@ -11,9 +11,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_NAME, CONF_MODE) -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity _LOGGER = logging.getLogger(__name__) @@ -123,7 +122,7 @@ async def async_setup(hass, config): return True -class InputNumber(Entity): +class InputNumber(RestoreEntity): """Representation of a slider.""" def __init__(self, object_id, name, initial, minimum, maximum, step, icon, @@ -178,10 +177,11 @@ def state_attributes(self): async def async_added_to_hass(self): """Run when entity about to be added to hass.""" + await super().async_added_to_hass() if self._current_value is not None: return - state = await async_get_last_state(self.hass, self.entity_id) + state = await self.async_get_last_state() value = state and float(state.state) # Check against None because value can be 0 diff --git a/homeassistant/components/input_select.py b/homeassistant/components/input_select.py index b8398e1be3d81..cc9a73bf91549 100644 --- a/homeassistant/components/input_select.py +++ b/homeassistant/components/input_select.py @@ -10,9 +10,8 @@ from homeassistant.const import ATTR_ENTITY_ID, CONF_ICON, CONF_NAME import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity _LOGGER = logging.getLogger(__name__) @@ -116,7 +115,7 @@ async def async_setup(hass, config): return True -class InputSelect(Entity): +class InputSelect(RestoreEntity): """Representation of a select input.""" def __init__(self, object_id, name, initial, options, icon): @@ -129,10 +128,11 @@ def __init__(self, object_id, name, initial, options, icon): async def async_added_to_hass(self): """Run when entity about to be added.""" + await super().async_added_to_hass() if self._current_option is not None: return - state = await async_get_last_state(self.hass, self.entity_id) + state = await self.async_get_last_state() if not state or state.state not in self._options: self._current_option = self._options[0] else: diff --git a/homeassistant/components/input_text.py b/homeassistant/components/input_text.py index 956d9a6466d7c..8ac64b398f4bc 100644 --- a/homeassistant/components/input_text.py +++ b/homeassistant/components/input_text.py @@ -11,9 +11,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_NAME, CONF_MODE) -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity _LOGGER = logging.getLogger(__name__) @@ -104,7 +103,7 @@ async def async_setup(hass, config): return True -class InputText(Entity): +class InputText(RestoreEntity): """Represent a text box.""" def __init__(self, object_id, name, initial, minimum, maximum, icon, @@ -157,10 +156,11 @@ def state_attributes(self): async def async_added_to_hass(self): """Run when entity about to be added to hass.""" + await super().async_added_to_hass() if self._current_value is not None: return - state = await async_get_last_state(self.hass, self.entity_id) + state = await self.async_get_last_state() value = state and state.state # Check against None because value can be 0 diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index 2e2971cfdc267..3a0225d8d650d 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -18,7 +18,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util.color import ( color_temperature_mired_to_kelvin, color_hs_to_RGB) -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity REQUIREMENTS = ['limitlessled==1.1.3'] @@ -157,7 +157,7 @@ def wrapper(self, **kwargs): return decorator -class LimitlessLEDGroup(Light): +class LimitlessLEDGroup(Light, RestoreEntity): """Representation of a LimitessLED group.""" def __init__(self, group, config): @@ -189,7 +189,8 @@ def __init__(self, group, config): async def async_added_to_hass(self): """Handle entity about to be added to hass event.""" - last_state = await async_get_last_state(self.hass, self.entity_id) + await super().async_added_to_hass() + last_state = await self.async_get_last_state() if last_state: self._is_on = (last_state.state == STATE_ON) self._brightness = last_state.attributes.get('brightness') diff --git a/homeassistant/components/light/mqtt/schema_basic.py b/homeassistant/components/light/mqtt/schema_basic.py index 6c7b0e75301ae..6a151092ef0e1 100644 --- a/homeassistant/components/light/mqtt/schema_basic.py +++ b/homeassistant/components/light/mqtt/schema_basic.py @@ -22,7 +22,7 @@ CONF_AVAILABILITY_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, MqttAvailability, MqttDiscoveryUpdate) -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util @@ -166,7 +166,7 @@ async def async_setup_entity_basic(hass, config, async_add_entities, )]) -class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): +class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): """Representation of a MQTT light.""" def __init__(self, name, unique_id, effect_list, topic, templates, @@ -237,8 +237,7 @@ def __init__(self, name, unique_id, effect_list, topic, templates, async def async_added_to_hass(self): """Subscribe to MQTT events.""" - await MqttAvailability.async_added_to_hass(self) - await MqttDiscoveryUpdate.async_added_to_hass(self) + await super().async_added_to_hass() templates = {} for key, tpl in list(self._templates.items()): @@ -248,7 +247,7 @@ async def async_added_to_hass(self): tpl.hass = self.hass templates[key] = tpl.async_render_with_possible_json_value - last_state = await async_get_last_state(self.hass, self.entity_id) + last_state = await self.async_get_last_state() @callback def state_received(topic, payload, qos): diff --git a/homeassistant/components/light/mqtt/schema_json.py b/homeassistant/components/light/mqtt/schema_json.py index 43e0f655f0b3c..55df6cbfd5ee7 100644 --- a/homeassistant/components/light/mqtt/schema_json.py +++ b/homeassistant/components/light/mqtt/schema_json.py @@ -25,7 +25,7 @@ CONF_RGB, CONF_WHITE_VALUE, CONF_XY, STATE_ON) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, HomeAssistantType import homeassistant.util.color as color_util @@ -121,7 +121,8 @@ async def async_setup_entity_json(hass: HomeAssistantType, config: ConfigType, )]) -class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, Light): +class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, Light, + RestoreEntity): """Representation of a MQTT JSON light.""" def __init__(self, name, unique_id, effect_list, topic, qos, retain, @@ -183,10 +184,9 @@ def __init__(self, name, unique_id, effect_list, topic, qos, retain, async def async_added_to_hass(self): """Subscribe to MQTT events.""" - await MqttAvailability.async_added_to_hass(self) - await MqttDiscoveryUpdate.async_added_to_hass(self) + await super().async_added_to_hass() - last_state = await async_get_last_state(self.hass, self.entity_id) + last_state = await self.async_get_last_state() @callback def state_received(topic, payload, qos): diff --git a/homeassistant/components/light/mqtt/schema_template.py b/homeassistant/components/light/mqtt/schema_template.py index 082e4674cb9f7..81ef3e901dd31 100644 --- a/homeassistant/components/light/mqtt/schema_template.py +++ b/homeassistant/components/light/mqtt/schema_template.py @@ -21,7 +21,7 @@ MqttAvailability) import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity _LOGGER = logging.getLogger(__name__) @@ -102,7 +102,7 @@ async def async_setup_entity_template(hass, config, async_add_entities, )]) -class MqttTemplate(MqttAvailability, Light): +class MqttTemplate(MqttAvailability, Light, RestoreEntity): """Representation of a MQTT Template light.""" def __init__(self, hass, name, effect_list, topics, templates, optimistic, @@ -153,7 +153,7 @@ async def async_added_to_hass(self): """Subscribe to MQTT events.""" await super().async_added_to_hass() - last_state = await async_get_last_state(self.hass, self.entity_id) + last_state = await self.async_get_last_state() @callback def state_received(topic, payload, qos): diff --git a/homeassistant/components/lock/mqtt.py b/homeassistant/components/lock/mqtt.py index b62382e6dd10f..28849c8815906 100644 --- a/homeassistant/components/lock/mqtt.py +++ b/homeassistant/components/lock/mqtt.py @@ -111,8 +111,7 @@ def __init__(self, name, state_topic, command_topic, qos, retain, async def async_added_to_hass(self): """Subscribe to MQTT events.""" - await MqttAvailability.async_added_to_hass(self) - await MqttDiscoveryUpdate.async_added_to_hass(self) + await super().async_added_to_hass() @callback def message_received(topic, payload, qos): diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 72684c7ec13f6..7ff32a7914270 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -840,6 +840,7 @@ async def async_added_to_hass(self) -> None: This method must be run in the event loop and returns a coroutine. """ + await super().async_added_to_hass() await self._availability_subscribe_topics() async def availability_discovery_update(self, config: dict): @@ -900,6 +901,8 @@ def __init__(self, discovery_hash, discovery_update=None) -> None: async def async_added_to_hass(self) -> None: """Subscribe to discovery updates.""" + await super().async_added_to_hass() + from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.components.mqtt.discovery import ( ALREADY_DISCOVERED, MQTT_DISCOVERY_UPDATED) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index ddb508d128225..c53fa051a2707 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -28,7 +28,6 @@ from homeassistant.helpers.entityfilter import generate_filter from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util -from homeassistant.loader import bind_hass from . import migration, purge from .const import DATA_INSTANCE @@ -83,12 +82,6 @@ }, extra=vol.ALLOW_EXTRA) -@bind_hass -async def wait_connection_ready(hass): - """Wait till the connection is ready.""" - return await hass.data[DATA_INSTANCE].async_db_ready - - def run_information(hass, point_in_time: Optional[datetime] = None): """Return information about current run. diff --git a/homeassistant/components/sensor/fastdotcom.py b/homeassistant/components/sensor/fastdotcom.py index 761dc7c6a00e7..8e975c4857423 100644 --- a/homeassistant/components/sensor/fastdotcom.py +++ b/homeassistant/components/sensor/fastdotcom.py @@ -10,9 +10,8 @@ from homeassistant.components.sensor import DOMAIN, PLATFORM_SCHEMA import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_time_change -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.util.dt as dt_util REQUIREMENTS = ['fastdotcom==0.0.3'] @@ -51,7 +50,7 @@ def update(call=None): hass.services.register(DOMAIN, 'update_fastdotcom', update) -class SpeedtestSensor(Entity): +class SpeedtestSensor(RestoreEntity): """Implementation of a FAst.com sensor.""" def __init__(self, speedtest_data): @@ -86,7 +85,8 @@ def update(self): async def async_added_to_hass(self): """Handle entity which will be added.""" - state = await async_get_last_state(self.hass, self.entity_id) + await super().async_added_to_hass() + state = await self.async_get_last_state() if not state: return self._state = state.state diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/sensor/mqtt.py index 68f49961cf960..bd97cc0e90ddb 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -119,8 +119,7 @@ def __init__(self, config, discovery_hash): async def async_added_to_hass(self): """Subscribe to MQTT events.""" - await MqttAvailability.async_added_to_hass(self) - await MqttDiscoveryUpdate.async_added_to_hass(self) + await super().async_added_to_hass() await self._subscribe_topics() async def discovery_update(self, discovery_payload): diff --git a/homeassistant/components/sensor/speedtest.py b/homeassistant/components/sensor/speedtest.py index a08eec56e1758..f834b51b064ae 100644 --- a/homeassistant/components/sensor/speedtest.py +++ b/homeassistant/components/sensor/speedtest.py @@ -11,9 +11,8 @@ 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.helpers.entity import Entity from homeassistant.helpers.event import track_time_change -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.util.dt as dt_util REQUIREMENTS = ['speedtest-cli==2.0.2'] @@ -76,7 +75,7 @@ def update(call=None): hass.services.register(DOMAIN, 'update_speedtest', update) -class SpeedtestSensor(Entity): +class SpeedtestSensor(RestoreEntity): """Implementation of a speedtest.net sensor.""" def __init__(self, speedtest_data, sensor_type): @@ -137,7 +136,8 @@ def update(self): async def async_added_to_hass(self): """Handle all entity which are about to be added.""" - state = await async_get_last_state(self.hass, self.entity_id) + await super().async_added_to_hass() + state = await self.async_get_last_state() if not state: return self._state = state.state diff --git a/homeassistant/components/switch/mqtt.py b/homeassistant/components/switch/mqtt.py index ad2b963629ec8..250fe36b7003d 100644 --- a/homeassistant/components/switch/mqtt.py +++ b/homeassistant/components/switch/mqtt.py @@ -24,7 +24,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import HomeAssistantType, ConfigType -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity _LOGGER = logging.getLogger(__name__) @@ -102,8 +102,9 @@ async def _async_setup_entity(hass, config, async_add_entities, async_add_entities([newswitch]) +# pylint: disable=too-many-ancestors class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, - SwitchDevice): + SwitchDevice, RestoreEntity): """Representation of a switch that can be toggled using MQTT.""" def __init__(self, name, icon, @@ -136,8 +137,7 @@ def __init__(self, name, icon, async def async_added_to_hass(self): """Subscribe to MQTT events.""" - await MqttAvailability.async_added_to_hass(self) - await MqttDiscoveryUpdate.async_added_to_hass(self) + await super().async_added_to_hass() @callback def state_message_received(topic, payload, qos): @@ -161,8 +161,7 @@ def state_message_received(topic, payload, qos): self._qos) if self._optimistic: - last_state = await async_get_last_state(self.hass, - self.entity_id) + last_state = await self.async_get_last_state() if last_state: self._state = last_state.state == STATE_ON diff --git a/homeassistant/components/switch/pilight.py b/homeassistant/components/switch/pilight.py index 16dfc075409ef..3bbe2e6911018 100644 --- a/homeassistant/components/switch/pilight.py +++ b/homeassistant/components/switch/pilight.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import (CONF_NAME, CONF_ID, CONF_SWITCHES, CONF_STATE, CONF_PROTOCOL, STATE_ON) -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity _LOGGER = logging.getLogger(__name__) @@ -97,7 +97,7 @@ def run(self, switch, turn_on): switch.set_state(turn_on=turn_on, send_code=self.echo) -class PilightSwitch(SwitchDevice): +class PilightSwitch(SwitchDevice, RestoreEntity): """Representation of a Pilight switch.""" def __init__(self, hass, name, code_on, code_off, code_on_receive, @@ -123,7 +123,8 @@ def __init__(self, hass, name, code_on, code_off, code_on_receive, async def async_added_to_hass(self): """Call when entity about to be added to hass.""" - state = await async_get_last_state(self._hass, self.entity_id) + await super().async_added_to_hass() + state = await self.async_get_last_state() if state: self._state = state.state == STATE_ON diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index c29df9db8588c..3f758edea863c 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -12,9 +12,9 @@ import homeassistant.util.dt as dt_util import homeassistant.helpers.config_validation as cv from homeassistant.const import (ATTR_ENTITY_ID, CONF_ICON, CONF_NAME) -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.restore_state import RestoreEntity _LOGGER = logging.getLogger(__name__) @@ -97,7 +97,7 @@ async def async_setup(hass, config): return True -class Timer(Entity): +class Timer(RestoreEntity): """Representation of a timer.""" def __init__(self, hass, object_id, name, icon, duration): @@ -146,8 +146,7 @@ async def async_added_to_hass(self): if self._state is not None: return - restore_state = self._hass.helpers.restore_state - state = await restore_state.async_get_last_state(self.entity_id) + state = await self.async_get_last_state() self._state = state and state.state == state async def async_start(self, duration): diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 687ed0b6f8b14..2d4ad68dbbed9 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -363,10 +363,7 @@ def async_on_remove(self, func): async def async_remove(self): """Remove entity from Home Assistant.""" - will_remove = getattr(self, 'async_will_remove_from_hass', None) - - if will_remove: - await will_remove() # pylint: disable=not-callable + await self.async_will_remove_from_hass() if self._on_remove is not None: while self._on_remove: @@ -390,6 +387,12 @@ async def readd(): self.hass.async_create_task(readd()) + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + def __eq__(self, other): """Return the comparison.""" if not isinstance(other, self.__class__): diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index ec7b557934228..ece0fbd071a36 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -346,8 +346,7 @@ async def _async_add_entity(self, entity, update_before_add, self.entities[entity_id] = entity entity.async_on_remove(lambda: self.entities.pop(entity_id)) - if hasattr(entity, 'async_added_to_hass'): - await entity.async_added_to_hass() + await entity.async_added_to_hass() await entity.async_update_ha_state() diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index eb88a3db369a9..51f1bd76c2ab8 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -2,97 +2,174 @@ import asyncio import logging from datetime import timedelta +from typing import Any, Dict, List, Set, Optional # noqa pylint_disable=unused-import -import async_timeout - -from homeassistant.core import HomeAssistant, CoreState, callback -from homeassistant.const import EVENT_HOMEASSISTANT_START -from homeassistant.loader import bind_hass -from homeassistant.components.history import get_states, last_recorder_run -from homeassistant.components.recorder import ( - wait_connection_ready, DOMAIN as _RECORDER) +from homeassistant.core import HomeAssistant, callback, State, CoreState +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.storage import Store # noqa pylint_disable=unused-import + +DATA_RESTORE_STATE_TASK = 'restore_state_task' -RECORDER_TIMEOUT = 10 -DATA_RESTORE_CACHE = 'restore_state_cache' -_LOCK = 'restore_lock' _LOGGER = logging.getLogger(__name__) +STORAGE_KEY = 'core.restore_state' +STORAGE_VERSION = 1 + +# How long between periodically saving the current states to disk +STATE_DUMP_INTERVAL = timedelta(minutes=15) + +# How long should a saved state be preserved if the entity no longer exists +STATE_EXPIRATION = timedelta(days=7) + + +class RestoreStateData(): + """Helper class for managing the helper saved data.""" + + @classmethod + async def async_get_instance( + cls, hass: HomeAssistant) -> 'RestoreStateData': + """Get the singleton instance of this data helper.""" + task = hass.data.get(DATA_RESTORE_STATE_TASK) + + if task is None: + async def load_instance(hass: HomeAssistant) -> 'RestoreStateData': + """Set up the restore state helper.""" + data = cls(hass) + + try: + states = await data.store.async_load() + except HomeAssistantError as exc: + _LOGGER.error("Error loading last states", exc_info=exc) + states = None + + if states is None: + _LOGGER.debug('Not creating cache - no saved states found') + data.last_states = {} + else: + data.last_states = { + state['entity_id']: State.from_dict(state) + for state in states} + _LOGGER.debug( + 'Created cache with %s', list(data.last_states)) + + if hass.state == CoreState.running: + data.async_setup_dump() + else: + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, data.async_setup_dump) + + return data + + task = hass.data[DATA_RESTORE_STATE_TASK] = hass.async_create_task( + load_instance(hass)) + + return await task + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the restore state data class.""" + self.hass = hass # type: HomeAssistant + self.store = Store(hass, STORAGE_VERSION, STORAGE_KEY, + encoder=JSONEncoder) # type: Store + self.last_states = {} # type: Dict[str, State] + self.entity_ids = set() # type: Set[str] + + def async_get_states(self) -> List[State]: + """Get the set of states which should be stored. + + This includes the states of all registered entities, as well as the + stored states from the previous run, which have not been created as + entities on this run, and have not expired. + """ + all_states = self.hass.states.async_all() + current_entity_ids = set(state.entity_id for state in all_states) + + # Start with the currently registered states + states = [state for state in all_states + if state.entity_id in self.entity_ids] + + expiration_time = dt_util.utcnow() - STATE_EXPIRATION + + for entity_id, state in self.last_states.items(): + # Don't save old states that have entities in the current run + if entity_id in current_entity_ids: + continue + + # Don't save old states that have expired + if state.last_updated < expiration_time: + continue + + states.append(state) + + return states + + async def async_dump_states(self) -> None: + """Save the current state machine to storage.""" + _LOGGER.debug("Dumping states") + try: + await self.store.async_save([ + state.as_dict() for state in self.async_get_states()]) + except HomeAssistantError as exc: + _LOGGER.error("Error saving current states", exc_info=exc) -def _load_restore_cache(hass: HomeAssistant): - """Load the restore cache to be used by other components.""" @callback - def remove_cache(event): - """Remove the states cache.""" - hass.data.pop(DATA_RESTORE_CACHE, None) - - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, remove_cache) - - last_run = last_recorder_run(hass) - - if last_run is None or last_run.end is None: - _LOGGER.debug('Not creating cache - no suitable last run found: %s', - last_run) - hass.data[DATA_RESTORE_CACHE] = {} - return - - last_end_time = last_run.end - timedelta(seconds=1) - # Unfortunately the recorder_run model do not return offset-aware time - last_end_time = last_end_time.replace(tzinfo=dt_util.UTC) - _LOGGER.debug("Last run: %s - %s", last_run.start, last_end_time) - - states = get_states(hass, last_end_time, run=last_run) - - # Cache the states - hass.data[DATA_RESTORE_CACHE] = { - state.entity_id: state for state in states} - _LOGGER.debug('Created cache with %s', list(hass.data[DATA_RESTORE_CACHE])) - - -@bind_hass -async def async_get_last_state(hass, entity_id: str): - """Restore state.""" - if DATA_RESTORE_CACHE in hass.data: - return hass.data[DATA_RESTORE_CACHE].get(entity_id) - - if _RECORDER not in hass.config.components: - return None + def async_setup_dump(self, *args: Any) -> None: + """Set up the restore state listeners.""" + # Dump the initial states now. This helps minimize the risk of having + # old states loaded by overwritting the last states once home assistant + # has started and the old states have been read. + self.hass.async_create_task(self.async_dump_states()) + + # Dump states periodically + async_track_time_interval( + self.hass, lambda *_: self.hass.async_create_task( + self.async_dump_states()), STATE_DUMP_INTERVAL) + + # Dump states when stopping hass + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, lambda *_: self.hass.async_create_task( + self.async_dump_states())) - if hass.state not in (CoreState.starting, CoreState.not_running): - _LOGGER.debug("Cache for %s can only be loaded during startup, not %s", - entity_id, hass.state) - return None - - try: - with async_timeout.timeout(RECORDER_TIMEOUT, loop=hass.loop): - connected = await wait_connection_ready(hass) - except asyncio.TimeoutError: - return None - - if not connected: - return None - - if _LOCK not in hass.data: - hass.data[_LOCK] = asyncio.Lock(loop=hass.loop) - - async with hass.data[_LOCK]: - if DATA_RESTORE_CACHE not in hass.data: - await hass.async_add_job( - _load_restore_cache, hass) - - return hass.data.get(DATA_RESTORE_CACHE, {}).get(entity_id) - - -async def async_restore_state(entity, extract_info): - """Call entity.async_restore_state with cached info.""" - if entity.hass.state not in (CoreState.starting, CoreState.not_running): - _LOGGER.debug("Not restoring state for %s: Hass is not starting: %s", - entity.entity_id, entity.hass.state) - return - - state = await async_get_last_state(entity.hass, entity.entity_id) - - if not state: - return + @callback + def async_register_entity(self, entity_id: str) -> None: + """Store this entity's state when hass is shutdown.""" + self.entity_ids.add(entity_id) - await entity.async_restore_state(**extract_info(state)) + @callback + def async_unregister_entity(self, entity_id: str) -> None: + """Unregister this entity from saving state.""" + self.entity_ids.remove(entity_id) + + +class RestoreEntity(Entity): + """Mixin class for restoring previous entity state.""" + + async def async_added_to_hass(self) -> None: + """Register this entity as a restorable entity.""" + _, data = await asyncio.gather( + super().async_added_to_hass(), + RestoreStateData.async_get_instance(self.hass), + ) + data.async_register_entity(self.entity_id) + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + _, data = await asyncio.gather( + super().async_will_remove_from_hass(), + RestoreStateData.async_get_instance(self.hass), + ) + data.async_unregister_entity(self.entity_id) + + async def async_get_last_state(self) -> Optional[State]: + """Get the entity state from the previous run.""" + if self.hass is None or self.entity_id is None: + # Return None if this entity isn't added to hass yet + _LOGGER.warning("Cannot get last state. Entity not added to hass") + return None + data = await RestoreStateData.async_get_instance(self.hass) + return data.last_states.get(self.entity_id) diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index cfe73d6d1476a..5fbb770045867 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -1,13 +1,14 @@ """Helper to help store data.""" import asyncio +from json import JSONEncoder import logging import os -from typing import Dict, Optional, Callable, Any +from typing import Dict, List, Optional, Callable, Union from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import callback from homeassistant.loader import bind_hass -from homeassistant.util import json +from homeassistant.util import json as json_util from homeassistant.helpers.event import async_call_later STORAGE_DIR = '.storage' @@ -16,7 +17,7 @@ @bind_hass async def async_migrator(hass, old_path, store, *, - old_conf_load_func=json.load_json, + old_conf_load_func=json_util.load_json, old_conf_migrate_func=None): """Migrate old data to a store and then load data. @@ -46,7 +47,8 @@ def load_old_config(): class Store: """Class to help storing data.""" - def __init__(self, hass, version: int, key: str, private: bool = False): + def __init__(self, hass, version: int, key: str, private: bool = False, *, + encoder: JSONEncoder = None): """Initialize storage class.""" self.version = version self.key = key @@ -57,13 +59,14 @@ def __init__(self, hass, version: int, key: str, private: bool = False): self._unsub_stop_listener = None self._write_lock = asyncio.Lock(loop=hass.loop) self._load_task = None + self._encoder = encoder @property def path(self): """Return the config path.""" return self.hass.config.path(STORAGE_DIR, self.key) - async def async_load(self) -> Optional[Dict[str, Any]]: + async def async_load(self) -> Optional[Union[Dict, List]]: """Load data. If the expected version does not match the given version, the migrate @@ -88,7 +91,7 @@ async def _async_load(self): data['data'] = data.pop('data_func')() else: data = await self.hass.async_add_executor_job( - json.load_json, self.path) + json_util.load_json, self.path) if data == {}: return None @@ -103,7 +106,7 @@ async def _async_load(self): self._load_task = None return stored - async def async_save(self, data): + async def async_save(self, data: Union[Dict, List]) -> None: """Save data.""" self._data = { 'version': self.version, @@ -178,7 +181,7 @@ async def _async_handle_write_data(self, *_args): try: await self.hass.async_add_executor_job( self._write_data, self.path, data) - except (json.SerializationError, json.WriteError) as err: + except (json_util.SerializationError, json_util.WriteError) as err: _LOGGER.error('Error writing config for %s: %s', self.key, err) def _write_data(self, path: str, data: Dict): @@ -187,7 +190,7 @@ def _write_data(self, path: str, data: Dict): os.makedirs(os.path.dirname(path)) _LOGGER.debug('Writing data for %s', self.key) - json.save_json(path, data, self._private) + json_util.save_json(path, data, self._private, encoder=self._encoder) async def _async_migrate_func(self, old_version, old_data): """Migrate to the new version.""" diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index b002c8e314722..8ca1c702b6c68 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -1,6 +1,6 @@ """JSON utility functions.""" import logging -from typing import Union, List, Dict +from typing import Union, List, Dict, Optional import json import os @@ -41,7 +41,8 @@ def load_json(filename: str, default: Union[List, Dict, None] = None) \ def save_json(filename: str, data: Union[List, Dict], - private: bool = False) -> None: + private: bool = False, *, + encoder: Optional[json.JSONEncoder] = None) -> None: """Save JSON data to a file. Returns True on success. @@ -49,7 +50,7 @@ def save_json(filename: str, data: Union[List, Dict], tmp_filename = "" tmp_path = os.path.split(filename)[0] try: - json_data = json.dumps(data, sort_keys=True, indent=4) + json_data = json.dumps(data, sort_keys=True, indent=4, cls=encoder) # Modern versions of Python tempfile create this file with mode 0o600 with tempfile.NamedTemporaryFile(mode="w", encoding='utf-8', dir=tmp_path, delete=False) as fdesc: diff --git a/tests/common.py b/tests/common.py index d5056e220f015..86bc0643d657b 100644 --- a/tests/common.py +++ b/tests/common.py @@ -114,8 +114,7 @@ def stop_hass(): # pylint: disable=protected-access -@asyncio.coroutine -def async_test_home_assistant(loop): +async def async_test_home_assistant(loop): """Return a Home Assistant object pointing at test config dir.""" hass = ha.HomeAssistant(loop) hass.config.async_load = Mock() @@ -168,13 +167,12 @@ def async_create_task(coroutine): # Mock async_start orig_start = hass.async_start - @asyncio.coroutine - def mock_async_start(): + async def mock_async_start(): """Start the mocking.""" # We only mock time during tests and we want to track tasks with patch('homeassistant.core._async_create_timer'), \ patch.object(hass, 'async_stop_track_tasks'): - yield from orig_start() + await orig_start() hass.async_start = mock_async_start @@ -715,14 +713,20 @@ def init_recorder_component(hass, add_config=None): def mock_restore_cache(hass, states): """Mock the DATA_RESTORE_CACHE.""" - key = restore_state.DATA_RESTORE_CACHE - hass.data[key] = { + key = restore_state.DATA_RESTORE_STATE_TASK + data = restore_state.RestoreStateData(hass) + + data.last_states = { state.entity_id: state for state in states} - _LOGGER.debug('Restore cache: %s', hass.data[key]) - assert len(hass.data[key]) == len(states), \ + _LOGGER.debug('Restore cache: %s', data.last_states) + assert len(data.last_states) == len(states), \ "Duplicate entity_id? {}".format(states) - hass.state = ha.CoreState.starting - mock_component(hass, recorder.DOMAIN) + + async def get_restore_state_data() -> restore_state.RestoreStateData: + return data + + # Patch the singleton task in hass.data to return our new RestoreStateData + hass.data[key] = hass.async_create_task(get_restore_state_data()) class MockDependency: @@ -846,9 +850,10 @@ async def mock_async_load(store): def mock_write_data(store, path, data_to_write): """Mock version of write data.""" - # To ensure that the data can be serialized _LOGGER.info('Writing data to %s: %s', store.key, data_to_write) - data[store.key] = json.loads(json.dumps(data_to_write)) + # To ensure that the data can be serialized + data[store.key] = json.loads(json.dumps( + data_to_write, cls=store._encoder)) with patch('homeassistant.helpers.storage.Store._async_load', side_effect=mock_async_load, autospec=True), \ diff --git a/tests/components/emulated_hue/test_upnp.py b/tests/components/emulated_hue/test_upnp.py index 9c549f00ee801..0a82dc3513d29 100644 --- a/tests/components/emulated_hue/test_upnp.py +++ b/tests/components/emulated_hue/test_upnp.py @@ -6,10 +6,8 @@ import requests from aiohttp.hdrs import CONTENT_TYPE -from homeassistant import setup, const, core -import homeassistant.components as core_components +from homeassistant import setup, const from homeassistant.components import emulated_hue, http -from homeassistant.util.async_ import run_coroutine_threadsafe from tests.common import get_test_instance_port, get_test_home_assistant @@ -20,29 +18,6 @@ JSON_HEADERS = {CONTENT_TYPE: const.CONTENT_TYPE_JSON} -def setup_hass_instance(emulated_hue_config): - """Set up the Home Assistant instance to test.""" - hass = get_test_home_assistant() - - # We need to do this to get access to homeassistant/turn_(on,off) - run_coroutine_threadsafe( - core_components.async_setup(hass, {core.DOMAIN: {}}), hass.loop - ).result() - - setup.setup_component( - hass, http.DOMAIN, - {http.DOMAIN: {http.CONF_SERVER_PORT: HTTP_SERVER_PORT}}) - - setup.setup_component(hass, emulated_hue.DOMAIN, emulated_hue_config) - - return hass - - -def start_hass_instance(hass): - """Start the Home Assistant instance to test.""" - hass.start() - - class TestEmulatedHue(unittest.TestCase): """Test the emulated Hue component.""" @@ -53,11 +28,6 @@ def setUpClass(cls): """Set up the class.""" cls.hass = hass = get_test_home_assistant() - # We need to do this to get access to homeassistant/turn_(on,off) - run_coroutine_threadsafe( - core_components.async_setup(hass, {core.DOMAIN: {}}), hass.loop - ).result() - setup.setup_component( hass, http.DOMAIN, {http.DOMAIN: {http.CONF_SERVER_PORT: HTTP_SERVER_PORT}}) diff --git a/tests/components/light/test_mqtt.py b/tests/components/light/test_mqtt.py index c56835afc9fc6..3b4ff586c94bc 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -585,7 +585,7 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): 'effect': 'random', 'color_temp': 100, 'white_value': 50}) - with patch('homeassistant.components.light.mqtt.schema_basic' + with patch('homeassistant.helpers.restore_state.RestoreEntity' '.async_get_last_state', return_value=mock_coro(fake_state)): with assert_setup_component(1, light.DOMAIN): diff --git a/tests/components/light/test_mqtt_json.py b/tests/components/light/test_mqtt_json.py index e509cd5718cf7..ae34cb6d82790 100644 --- a/tests/components/light/test_mqtt_json.py +++ b/tests/components/light/test_mqtt_json.py @@ -279,7 +279,7 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): 'color_temp': 100, 'white_value': 50}) - with patch('homeassistant.components.light.mqtt.schema_json' + with patch('homeassistant.helpers.restore_state.RestoreEntity' '.async_get_last_state', return_value=mock_coro(fake_state)): assert await async_setup_component(hass, light.DOMAIN, { diff --git a/tests/components/light/test_mqtt_template.py b/tests/components/light/test_mqtt_template.py index 0d26d6edb120f..56030da43f222 100644 --- a/tests/components/light/test_mqtt_template.py +++ b/tests/components/light/test_mqtt_template.py @@ -245,7 +245,7 @@ async def test_optimistic(hass, mqtt_mock): 'color_temp': 100, 'white_value': 50}) - with patch('homeassistant.components.light.mqtt.schema_template' + with patch('homeassistant.helpers.restore_state.RestoreEntity' '.async_get_last_state', return_value=mock_coro(fake_state)): with assert_setup_component(1, light.DOMAIN): diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index 93da4ec109bd8..d008f868466d8 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -1,6 +1,5 @@ """The tests for the Recorder component.""" # pylint: disable=protected-access -import asyncio from unittest.mock import patch, call import pytest @@ -9,7 +8,7 @@ from homeassistant.bootstrap import async_setup_component from homeassistant.components.recorder import ( - wait_connection_ready, migration, const, models) + migration, const, models) from tests.components.recorder import models_original @@ -23,26 +22,24 @@ def create_engine_test(*args, **kwargs): return engine -@asyncio.coroutine -def test_schema_update_calls(hass): +async def test_schema_update_calls(hass): """Test that schema migrations occur in correct order.""" with patch('sqlalchemy.create_engine', new=create_engine_test), \ patch('homeassistant.components.recorder.migration._apply_update') as \ update: - yield from async_setup_component(hass, 'recorder', { + await async_setup_component(hass, 'recorder', { 'recorder': { 'db_url': 'sqlite://' } }) - yield from wait_connection_ready(hass) + await hass.async_block_till_done() update.assert_has_calls([ call(hass.data[const.DATA_INSTANCE].engine, version+1, 0) for version in range(0, models.SCHEMA_VERSION)]) -@asyncio.coroutine -def test_schema_migrate(hass): +async def test_schema_migrate(hass): """Test the full schema migration logic. We're just testing that the logic can execute successfully here without @@ -52,12 +49,12 @@ def test_schema_migrate(hass): with patch('sqlalchemy.create_engine', new=create_engine_test), \ patch('homeassistant.components.recorder.Recorder._setup_run') as \ setup_run: - yield from async_setup_component(hass, 'recorder', { + await async_setup_component(hass, 'recorder', { 'recorder': { 'db_url': 'sqlite://' } }) - yield from wait_connection_ready(hass) + await hass.async_block_till_done() assert setup_run.called diff --git a/tests/components/switch/test_mqtt.py b/tests/components/switch/test_mqtt.py index 4099a5b7951de..5cfefd7a0c82a 100644 --- a/tests/components/switch/test_mqtt.py +++ b/tests/components/switch/test_mqtt.py @@ -57,7 +57,8 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mock_publish): """Test the sending MQTT commands in optimistic mode.""" fake_state = ha.State('switch.test', 'on') - with patch('homeassistant.components.switch.mqtt.async_get_last_state', + with patch('homeassistant.helpers.restore_state.RestoreEntity' + '.async_get_last_state', return_value=mock_coro(fake_state)): assert await async_setup_component(hass, switch.DOMAIN, { switch.DOMAIN: { diff --git a/tests/components/test_history.py b/tests/components/test_history.py index 641dff3b4e630..0c9062414e769 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -519,7 +519,6 @@ async def test_fetch_period_api(hass, hass_client): """Test the fetch period view for history.""" await hass.async_add_job(init_recorder_component, hass) await async_setup_component(hass, 'history', {}) - await hass.components.recorder.wait_connection_ready() await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) client = await hass_client() response = await client.get( diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index ae1e3d1d51aba..4619dc7ec2ea4 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -575,7 +575,6 @@ async def test_logbook_view(hass, aiohttp_client): """Test the logbook view.""" await hass.async_add_job(init_recorder_component, hass) await async_setup_component(hass, 'logbook', {}) - await hass.components.recorder.wait_connection_ready() await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) client = await aiohttp_client(hass.http.app) response = await client.get( @@ -587,7 +586,6 @@ async def test_logbook_view_period_entity(hass, aiohttp_client): """Test the logbook view with period and entity.""" await hass.async_add_job(init_recorder_component, hass) await async_setup_component(hass, 'logbook', {}) - await hass.components.recorder.wait_connection_ready() await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) entity_id_test = 'switch.test' diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index 15dda24a529cc..1ac48264d45b0 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -1,60 +1,52 @@ """The tests for the Restore component.""" -import asyncio -from datetime import timedelta -from unittest.mock import patch, MagicMock +from datetime import datetime -from homeassistant.setup import setup_component from homeassistant.const import EVENT_HOMEASSISTANT_START -from homeassistant.core import CoreState, split_entity_id, State -import homeassistant.util.dt as dt_util -from homeassistant.components import input_boolean, recorder +from homeassistant.core import CoreState, State +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import Entity from homeassistant.helpers.restore_state import ( - async_get_last_state, DATA_RESTORE_CACHE) -from homeassistant.components.recorder.models import RecorderRuns, States + RestoreStateData, RestoreEntity, DATA_RESTORE_STATE_TASK) +from homeassistant.util import dt as dt_util -from tests.common import ( - get_test_home_assistant, mock_coro, init_recorder_component, - mock_component) +from asynctest import patch +from tests.common import mock_coro -@asyncio.coroutine -def test_caching_data(hass): - """Test that we cache data.""" - mock_component(hass, 'recorder') - hass.state = CoreState.starting +async def test_caching_data(hass): + """Test that we cache data.""" states = [ State('input_boolean.b0', 'on'), State('input_boolean.b1', 'on'), State('input_boolean.b2', 'on'), ] - with patch('homeassistant.helpers.restore_state.last_recorder_run', - return_value=MagicMock(end=dt_util.utcnow())), \ - patch('homeassistant.helpers.restore_state.get_states', - return_value=states), \ - patch('homeassistant.helpers.restore_state.wait_connection_ready', - return_value=mock_coro(True)): - state = yield from async_get_last_state(hass, 'input_boolean.b1') + data = await RestoreStateData.async_get_instance(hass) + await data.store.async_save([state.as_dict() for state in states]) - assert DATA_RESTORE_CACHE in hass.data - assert hass.data[DATA_RESTORE_CACHE] == {st.entity_id: st for st in states} + # Emulate a fresh load + hass.data[DATA_RESTORE_STATE_TASK] = None + + entity = RestoreEntity() + entity.hass = hass + entity.entity_id = 'input_boolean.b1' + + # Mock that only b1 is present this run + with patch('homeassistant.helpers.restore_state.Store.async_save' + ) as mock_write_data: + state = await entity.async_get_last_state() assert state is not None assert state.entity_id == 'input_boolean.b1' assert state.state == 'on' - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - - yield from hass.async_block_till_done() + assert mock_write_data.called - assert DATA_RESTORE_CACHE not in hass.data - -@asyncio.coroutine -def test_hass_running(hass): - """Test that cache cannot be accessed while hass is running.""" - mock_component(hass, 'recorder') +async def test_hass_starting(hass): + """Test that we cache data.""" + hass.state = CoreState.starting states = [ State('input_boolean.b0', 'on'), @@ -62,129 +54,144 @@ def test_hass_running(hass): State('input_boolean.b2', 'on'), ] - with patch('homeassistant.helpers.restore_state.last_recorder_run', - return_value=MagicMock(end=dt_util.utcnow())), \ - patch('homeassistant.helpers.restore_state.get_states', - return_value=states), \ - patch('homeassistant.helpers.restore_state.wait_connection_ready', - return_value=mock_coro(True)): - state = yield from async_get_last_state(hass, 'input_boolean.b1') - assert state is None + data = await RestoreStateData.async_get_instance(hass) + await data.store.async_save([state.as_dict() for state in states]) + # Emulate a fresh load + hass.data[DATA_RESTORE_STATE_TASK] = None -@asyncio.coroutine -def test_not_connected(hass): - """Test that cache cannot be accessed if db connection times out.""" - mock_component(hass, 'recorder') - hass.state = CoreState.starting + entity = RestoreEntity() + entity.hass = hass + entity.entity_id = 'input_boolean.b1' - states = [State('input_boolean.b1', 'on')] - - with patch('homeassistant.helpers.restore_state.last_recorder_run', - return_value=MagicMock(end=dt_util.utcnow())), \ - patch('homeassistant.helpers.restore_state.get_states', - return_value=states), \ - patch('homeassistant.helpers.restore_state.wait_connection_ready', - return_value=mock_coro(False)): - state = yield from async_get_last_state(hass, 'input_boolean.b1') - assert state is None - - -@asyncio.coroutine -def test_no_last_run_found(hass): - """Test that cache cannot be accessed if no last run found.""" - mock_component(hass, 'recorder') - hass.state = CoreState.starting + # Mock that only b1 is present this run + states = [ + State('input_boolean.b1', 'on'), + ] + with patch('homeassistant.helpers.restore_state.Store.async_save' + ) as mock_write_data, patch.object( + hass.states, 'async_all', return_value=states): + state = await entity.async_get_last_state() - states = [State('input_boolean.b1', 'on')] + assert state is not None + assert state.entity_id == 'input_boolean.b1' + assert state.state == 'on' - with patch('homeassistant.helpers.restore_state.last_recorder_run', - return_value=None), \ - patch('homeassistant.helpers.restore_state.get_states', - return_value=states), \ - patch('homeassistant.helpers.restore_state.wait_connection_ready', - return_value=mock_coro(True)): - state = yield from async_get_last_state(hass, 'input_boolean.b1') - assert state is None + # Assert that no data was written yet, since hass is still starting. + assert not mock_write_data.called + # Finish hass startup + with patch('homeassistant.helpers.restore_state.Store.async_save' + ) as mock_write_data: + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() -@asyncio.coroutine -def test_cache_timeout(hass): - """Test that cache timeout returns none.""" - mock_component(hass, 'recorder') - hass.state = CoreState.starting + # Assert that this session states were written + assert mock_write_data.called - states = [State('input_boolean.b1', 'on')] - @asyncio.coroutine - def timeout_coro(): - raise asyncio.TimeoutError() +async def test_dump_data(hass): + """Test that we cache data.""" + states = [ + State('input_boolean.b0', 'on'), + State('input_boolean.b1', 'on'), + State('input_boolean.b2', 'on'), + ] - with patch('homeassistant.helpers.restore_state.last_recorder_run', - return_value=MagicMock(end=dt_util.utcnow())), \ - patch('homeassistant.helpers.restore_state.get_states', - return_value=states), \ - patch('homeassistant.helpers.restore_state.wait_connection_ready', - return_value=timeout_coro()): - state = yield from async_get_last_state(hass, 'input_boolean.b1') - assert state is None + entity = Entity() + entity.hass = hass + entity.entity_id = 'input_boolean.b0' + await entity.async_added_to_hass() + + entity = RestoreEntity() + entity.hass = hass + entity.entity_id = 'input_boolean.b1' + await entity.async_added_to_hass() + + data = await RestoreStateData.async_get_instance(hass) + data.last_states = { + 'input_boolean.b0': State('input_boolean.b0', 'off'), + 'input_boolean.b1': State('input_boolean.b1', 'off'), + 'input_boolean.b2': State('input_boolean.b2', 'off'), + 'input_boolean.b3': State('input_boolean.b3', 'off'), + 'input_boolean.b4': State( + 'input_boolean.b4', 'off', last_updated=datetime( + 1985, 10, 26, 1, 22, tzinfo=dt_util.UTC)), + } + + with patch('homeassistant.helpers.restore_state.Store.async_save' + ) as mock_write_data, patch.object( + hass.states, 'async_all', return_value=states): + await data.async_dump_states() + + assert mock_write_data.called + args = mock_write_data.mock_calls[0][1] + written_states = args[0] + + # b0 should not be written, since it didn't extend RestoreEntity + # b1 should be written, since it is present in the current run + # b2 should not be written, since it is not registered with the helper + # b3 should be written, since it is still not expired + # b4 should not be written, since it is now expired + assert len(written_states) == 2 + assert written_states[0]['entity_id'] == 'input_boolean.b1' + assert written_states[0]['state'] == 'on' + assert written_states[1]['entity_id'] == 'input_boolean.b3' + assert written_states[1]['state'] == 'off' + + # Test that removed entities are not persisted + await entity.async_will_remove_from_hass() + + with patch('homeassistant.helpers.restore_state.Store.async_save' + ) as mock_write_data, patch.object( + hass.states, 'async_all', return_value=states): + await data.async_dump_states() + + assert mock_write_data.called + args = mock_write_data.mock_calls[0][1] + written_states = args[0] + assert len(written_states) == 1 + assert written_states[0]['entity_id'] == 'input_boolean.b3' + assert written_states[0]['state'] == 'off' + + +async def test_dump_error(hass): + """Test that we cache data.""" + states = [ + State('input_boolean.b0', 'on'), + State('input_boolean.b1', 'on'), + State('input_boolean.b2', 'on'), + ] + entity = Entity() + entity.hass = hass + entity.entity_id = 'input_boolean.b0' + await entity.async_added_to_hass() -def _add_data_in_last_run(hass, entities): - """Add test data in the last recorder_run.""" - # pylint: disable=protected-access - t_now = dt_util.utcnow() - timedelta(minutes=10) - t_min_1 = t_now - timedelta(minutes=20) - t_min_2 = t_now - timedelta(minutes=30) - - with recorder.session_scope(hass=hass) as session: - session.add(RecorderRuns( - start=t_min_2, - end=t_now, - created=t_min_2 - )) - - for entity_id, state in entities.items(): - session.add(States( - entity_id=entity_id, - domain=split_entity_id(entity_id)[0], - state=state, - attributes='{}', - last_changed=t_min_1, - last_updated=t_min_1, - created=t_min_1)) - - -def test_filling_the_cache(): - """Test filling the cache from the DB.""" - test_entity_id1 = 'input_boolean.b1' - test_entity_id2 = 'input_boolean.b2' - - hass = get_test_home_assistant() - hass.state = CoreState.starting + entity = RestoreEntity() + entity.hass = hass + entity.entity_id = 'input_boolean.b1' + await entity.async_added_to_hass() - init_recorder_component(hass) + data = await RestoreStateData.async_get_instance(hass) - _add_data_in_last_run(hass, { - test_entity_id1: 'on', - test_entity_id2: 'off', - }) + with patch('homeassistant.helpers.restore_state.Store.async_save', + return_value=mock_coro(exception=HomeAssistantError) + ) as mock_write_data, patch.object( + hass.states, 'async_all', return_value=states): + await data.async_dump_states() - hass.block_till_done() - setup_component(hass, input_boolean.DOMAIN, { - input_boolean.DOMAIN: { - 'b1': None, - 'b2': None, - }}) + assert mock_write_data.called - hass.start() - state = hass.states.get('input_boolean.b1') - assert state - assert state.state == 'on' +async def test_load_error(hass): + """Test that we cache data.""" + entity = RestoreEntity() + entity.hass = hass + entity.entity_id = 'input_boolean.b1' - state = hass.states.get('input_boolean.b2') - assert state - assert state.state == 'off' + with patch('homeassistant.helpers.storage.Store.async_load', + return_value=mock_coro(exception=HomeAssistantError)): + state = await entity.async_get_last_state() - hass.stop() + assert state is None diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py index 38b8a7cd38039..7c713082372fd 100644 --- a/tests/helpers/test_storage.py +++ b/tests/helpers/test_storage.py @@ -1,7 +1,8 @@ """Tests for the storage helper.""" import asyncio from datetime import timedelta -from unittest.mock import patch +import json +from unittest.mock import patch, Mock import pytest @@ -31,6 +32,21 @@ async def test_loading(hass, store): assert data == MOCK_DATA +async def test_custom_encoder(hass): + """Test we can save and load data.""" + class JSONEncoder(json.JSONEncoder): + """Mock JSON encoder.""" + + def default(self, o): + """Mock JSON encode method.""" + return "9" + + store = storage.Store(hass, MOCK_VERSION, MOCK_KEY, encoder=JSONEncoder) + await store.async_save(Mock()) + data = await store.async_load() + assert data == "9" + + async def test_loading_non_existing(hass, store): """Test we can save and load data.""" with patch('homeassistant.util.json.open', side_effect=FileNotFoundError): diff --git a/tests/util/test_json.py b/tests/util/test_json.py index 414a9f400aa01..a7df74d9225a8 100644 --- a/tests/util/test_json.py +++ b/tests/util/test_json.py @@ -1,14 +1,17 @@ """Test Home Assistant json utility functions.""" +from json import JSONEncoder import os import unittest import sys from tempfile import mkdtemp -from homeassistant.util.json import (SerializationError, - load_json, save_json) +from homeassistant.util.json import ( + SerializationError, load_json, save_json) from homeassistant.exceptions import HomeAssistantError import pytest +from unittest.mock import Mock + # Test data that can be saved as JSON TEST_JSON_A = {"a": 1, "B": "two"} TEST_JSON_B = {"a": "one", "B": 2} @@ -74,3 +77,17 @@ def test_load_bad_data(self): fh.write(TEST_BAD_SERIALIED) with pytest.raises(HomeAssistantError): load_json(fname) + + def test_custom_encoder(self): + """Test serializing with a custom encoder.""" + class MockJSONEncoder(JSONEncoder): + """Mock JSON encoder.""" + + def default(self, o): + """Mock JSON encode method.""" + return "9" + + fname = self._path_for("test6") + save_json(fname, Mock(), encoder=MockJSONEncoder) + data = load_json(fname) + self.assertEqual(data, "9") From a2386f871dc0563b7fde66797a001a2a7e84e8ec Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 28 Nov 2018 13:25:23 +0100 Subject: [PATCH 106/325] Forbid float NaN in JSON (#18757) --- homeassistant/components/http/view.py | 5 +++-- homeassistant/components/websocket_api/http.py | 10 +++++++--- tests/components/http/test_view.py | 4 ++-- tests/components/websocket_api/test_commands.py | 16 ++++++++++++++++ 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index 30d4ed0ab8da7..c8f5d788dd281 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -45,8 +45,9 @@ def json(self, result, status_code=200, headers=None): """Return a JSON response.""" try: msg = json.dumps( - result, sort_keys=True, cls=JSONEncoder).encode('UTF-8') - except TypeError as err: + result, sort_keys=True, cls=JSONEncoder, allow_nan=False + ).encode('UTF-8') + except (ValueError, TypeError) as err: _LOGGER.error('Unable to serialize to JSON: %s\n%s', err, result) raise HTTPInternalServerError response = web.Response( diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 13be503a0095a..42c2c0a5751e3 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -13,11 +13,12 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.helpers.json import JSONEncoder -from .const import MAX_PENDING_MSG, CANCELLATION_ERRORS, URL +from .const import MAX_PENDING_MSG, CANCELLATION_ERRORS, URL, ERR_UNKNOWN_ERROR from .auth import AuthPhase, auth_required_message from .error import Disconnect +from .messages import error_message -JSON_DUMP = partial(json.dumps, cls=JSONEncoder) +JSON_DUMP = partial(json.dumps, cls=JSONEncoder, allow_nan=False) class WebsocketAPIView(HomeAssistantView): @@ -58,9 +59,12 @@ async def _writer(self): self._logger.debug("Sending %s", message) try: await self.wsock.send_json(message, dumps=JSON_DUMP) - except TypeError as err: + except (ValueError, TypeError) as err: self._logger.error('Unable to serialize to JSON: %s\n%s', err, message) + await self.wsock.send_json(error_message( + message['id'], ERR_UNKNOWN_ERROR, + 'Invalid JSON in response')) @callback def _send_message(self, message): diff --git a/tests/components/http/test_view.py b/tests/components/http/test_view.py index ac0e23edd64fc..ed97af9c76442 100644 --- a/tests/components/http/test_view.py +++ b/tests/components/http/test_view.py @@ -10,6 +10,6 @@ async def test_invalid_json(caplog): view = HomeAssistantView() with pytest.raises(HTTPInternalServerError): - view.json(object) + view.json(float("NaN")) - assert str(object) in caplog.text + assert str(float("NaN")) in caplog.text diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index b83d4051356d6..dc9d0318fd142 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -300,3 +300,19 @@ async def test_states_filters_visible(hass, hass_admin_user, websocket_client): assert len(msg['result']) == 1 assert msg['result'][0]['entity_id'] == 'test.entity' + + +async def test_get_states_not_allows_nan(hass, websocket_client): + """Test get_states command not allows NaN floats.""" + hass.states.async_set('greeting.hello', 'world', { + 'hello': float("NaN") + }) + + await websocket_client.send_json({ + 'id': 5, + 'type': commands.TYPE_GET_STATES, + }) + + msg = await websocket_client.receive_json() + assert not msg['success'] + assert msg['error']['code'] == const.ERR_UNKNOWN_ERROR From 623cec206b1c46cf8e0285d54d2c9dc1430b64f6 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 28 Nov 2018 13:38:26 +0100 Subject: [PATCH 107/325] Upgrade Adafruit-DHT to 1.4.0 (fixes #15847) (#18614) --- homeassistant/components/sensor/dht.py | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/dht.py b/homeassistant/components/sensor/dht.py index a3af5631a9c0d..04c084784c73f 100644 --- a/homeassistant/components/sensor/dht.py +++ b/homeassistant/components/sensor/dht.py @@ -17,7 +17,7 @@ from homeassistant.util import Throttle from homeassistant.util.temperature import celsius_to_fahrenheit -REQUIREMENTS = ['Adafruit-DHT==1.3.4'] +REQUIREMENTS = ['Adafruit-DHT==1.4.0'] _LOGGER = logging.getLogger(__name__) @@ -57,9 +57,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): SENSOR_TYPES[SENSOR_TEMPERATURE][1] = hass.config.units.temperature_unit available_sensors = { + "AM2302": Adafruit_DHT.AM2302, "DHT11": Adafruit_DHT.DHT11, "DHT22": Adafruit_DHT.DHT22, - "AM2302": Adafruit_DHT.AM2302 } sensor = available_sensors.get(config.get(CONF_SENSOR)) pin = config.get(CONF_PIN) diff --git a/requirements_all.txt b/requirements_all.txt index 8c78eaef2b5eb..ff5779299d389 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -20,7 +20,7 @@ voluptuous-serialize==2.0.0 --only-binary=all nuimo==0.1.0 # homeassistant.components.sensor.dht -# Adafruit-DHT==1.3.4 +# Adafruit-DHT==1.4.0 # homeassistant.components.sensor.sht31 Adafruit-GPIO==1.0.3 From 0bdf96d94c9908ca7020f39bc990b05ab87e7586 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Wed, 28 Nov 2018 08:14:37 -0700 Subject: [PATCH 108/325] Add block after setting up component (#18756) Added a block_till_done after setting up component and before starting HASS. --- tests/components/sensor/test_statistics.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/sensor/test_statistics.py b/tests/components/sensor/test_statistics.py index 5d1137c35e695..8552ed9efad91 100644 --- a/tests/components/sensor/test_statistics.py +++ b/tests/components/sensor/test_statistics.py @@ -305,6 +305,7 @@ def mock_purge(self): 'max_age': {'hours': max_age} } }) + self.hass.block_till_done() self.hass.start() self.hass.block_till_done() From e06fa0d2d0752b6e30f7916b49e29605a3bf7df6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 28 Nov 2018 22:17:37 +0100 Subject: [PATCH 109/325] Default to on if logged in (#18766) --- homeassistant/components/cloud/__init__.py | 2 +- homeassistant/components/cloud/prefs.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 183dddf2c52a6..fed812138d628 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -253,7 +253,7 @@ def load_config(): info = await self.hass.async_add_job(load_config) - await self.prefs.async_initialize(not info) + await self.prefs.async_initialize(bool(info)) if info is None: return diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index b2ed83fc6b219..c4aa43c91d264 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -28,6 +28,7 @@ async def async_initialize(self, logged_in): PREF_GOOGLE_ALLOW_UNLOCK: False, PREF_CLOUDHOOKS: {} } + await self._store.async_save(prefs) self._prefs = prefs From 48e28843e6b689cda98ed6a80c4d8de20c77682b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 28 Nov 2018 22:20:13 +0100 Subject: [PATCH 110/325] OwnTracks Config Entry (#18759) * OwnTracks Config Entry * Fix test * Fix headers * Lint * Username for android only * Update translations * Tweak translation * Create config entry if not there * Update reqs * Types * Lint --- .../components/device_tracker/__init__.py | 11 + .../components/device_tracker/owntracks.py | 158 +------------ .../device_tracker/owntracks_http.py | 82 ------- .../owntracks/.translations/en.json | 17 ++ .../components/owntracks/__init__.py | 219 ++++++++++++++++++ .../components/owntracks/config_flow.py | 79 +++++++ .../components/owntracks/strings.json | 17 ++ homeassistant/config_entries.py | 1 + homeassistant/setup.py | 34 ++- requirements_all.txt | 3 +- .../device_tracker/test_owntracks.py | 154 ++++++------ tests/components/owntracks/__init__.py | 1 + .../components/owntracks/test_config_flow.py | 1 + .../test_init.py} | 97 +++++--- tests/test_setup.py | 35 ++- 15 files changed, 554 insertions(+), 355 deletions(-) delete mode 100644 homeassistant/components/device_tracker/owntracks_http.py create mode 100644 homeassistant/components/owntracks/.translations/en.json create mode 100644 homeassistant/components/owntracks/__init__.py create mode 100644 homeassistant/components/owntracks/config_flow.py create mode 100644 homeassistant/components/owntracks/strings.json create mode 100644 tests/components/owntracks/__init__.py create mode 100644 tests/components/owntracks/test_config_flow.py rename tests/components/{device_tracker/test_owntracks_http.py => owntracks/test_init.py} (51%) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 35ecaf716168f..16d9022c98fa1 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -181,6 +181,9 @@ async def async_setup_platform(p_type, p_config, disc_info=None): setup = await hass.async_add_job( platform.setup_scanner, hass, p_config, tracker.see, disc_info) + elif hasattr(platform, 'async_setup_entry'): + setup = await platform.async_setup_entry( + hass, p_config, tracker.async_see) else: raise HomeAssistantError("Invalid device_tracker platform.") @@ -196,6 +199,8 @@ async def async_setup_platform(p_type, p_config, disc_info=None): except Exception: # pylint: disable=broad-except _LOGGER.exception("Error setting up platform %s", p_type) + hass.data[DOMAIN] = async_setup_platform + setup_tasks = [async_setup_platform(p_type, p_config) for p_type, p_config in config_per_platform(config, DOMAIN)] if setup_tasks: @@ -229,6 +234,12 @@ async def async_see_service(call): return True +async def async_setup_entry(hass, entry): + """Set up an entry.""" + await hass.data[DOMAIN](entry.domain, entry) + return True + + class DeviceTracker: """Representation of a device tracker.""" diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 10f71450f69ab..ae2b9d6146b29 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -7,55 +7,29 @@ import base64 import json import logging -from collections import defaultdict -import voluptuous as vol - -from homeassistant.components import mqtt -import homeassistant.helpers.config_validation as cv from homeassistant.components import zone as zone_comp from homeassistant.components.device_tracker import ( - PLATFORM_SCHEMA, ATTR_SOURCE_TYPE, SOURCE_TYPE_BLUETOOTH_LE, - SOURCE_TYPE_GPS + ATTR_SOURCE_TYPE, SOURCE_TYPE_BLUETOOTH_LE, SOURCE_TYPE_GPS ) +from homeassistant.components.owntracks import DOMAIN as OT_DOMAIN from homeassistant.const import STATE_HOME -from homeassistant.core import callback from homeassistant.util import slugify, decorator -REQUIREMENTS = ['libnacl==1.6.1'] + +DEPENDENCIES = ['owntracks'] _LOGGER = logging.getLogger(__name__) HANDLERS = decorator.Registry() -BEACON_DEV_ID = 'beacon' - -CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' -CONF_SECRET = 'secret' -CONF_WAYPOINT_IMPORT = 'waypoints' -CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist' -CONF_MQTT_TOPIC = 'mqtt_topic' -CONF_REGION_MAPPING = 'region_mapping' -CONF_EVENTS_ONLY = 'events_only' - -DEPENDENCIES = ['mqtt'] - -DEFAULT_OWNTRACKS_TOPIC = 'owntracks/#' -REGION_MAPPING = {} -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), - vol.Optional(CONF_WAYPOINT_IMPORT, default=True): cv.boolean, - vol.Optional(CONF_EVENTS_ONLY, default=False): cv.boolean, - vol.Optional(CONF_MQTT_TOPIC, default=DEFAULT_OWNTRACKS_TOPIC): - mqtt.valid_subscribe_topic, - vol.Optional(CONF_WAYPOINT_WHITELIST): vol.All( - cv.ensure_list, [cv.string]), - vol.Optional(CONF_SECRET): vol.Any( - vol.Schema({vol.Optional(cv.string): cv.string}), - cv.string), - vol.Optional(CONF_REGION_MAPPING, default=REGION_MAPPING): dict -}) +async def async_setup_entry(hass, entry, async_see): + """Set up OwnTracks based off an entry.""" + hass.data[OT_DOMAIN]['context'].async_see = async_see + hass.helpers.dispatcher.async_dispatcher_connect( + OT_DOMAIN, async_handle_message) + return True def get_cipher(): @@ -72,29 +46,6 @@ def decrypt(ciphertext, key): return (KEYLEN, decrypt) -async def async_setup_scanner(hass, config, async_see, discovery_info=None): - """Set up an OwnTracks tracker.""" - context = context_from_config(async_see, config) - - async def async_handle_mqtt_message(topic, payload, qos): - """Handle incoming OwnTracks message.""" - try: - message = json.loads(payload) - except ValueError: - # If invalid JSON - _LOGGER.error("Unable to parse payload as JSON: %s", payload) - return - - message['topic'] = topic - - await async_handle_message(hass, context, message) - - await mqtt.async_subscribe( - hass, context.mqtt_topic, async_handle_mqtt_message, 1) - - return True - - def _parse_topic(topic, subscribe_topic): """Parse an MQTT topic {sub_topic}/user/dev, return (user, dev) tuple. @@ -202,93 +153,6 @@ def _decrypt_payload(secret, topic, ciphertext): return None -def context_from_config(async_see, config): - """Create an async context from Home Assistant config.""" - max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) - waypoint_import = config.get(CONF_WAYPOINT_IMPORT) - waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) - secret = config.get(CONF_SECRET) - region_mapping = config.get(CONF_REGION_MAPPING) - events_only = config.get(CONF_EVENTS_ONLY) - mqtt_topic = config.get(CONF_MQTT_TOPIC) - - return OwnTracksContext(async_see, secret, max_gps_accuracy, - waypoint_import, waypoint_whitelist, - region_mapping, events_only, mqtt_topic) - - -class OwnTracksContext: - """Hold the current OwnTracks context.""" - - def __init__(self, async_see, secret, max_gps_accuracy, import_waypoints, - waypoint_whitelist, region_mapping, events_only, mqtt_topic): - """Initialize an OwnTracks context.""" - self.async_see = async_see - self.secret = secret - self.max_gps_accuracy = max_gps_accuracy - self.mobile_beacons_active = defaultdict(set) - self.regions_entered = defaultdict(list) - self.import_waypoints = import_waypoints - self.waypoint_whitelist = waypoint_whitelist - self.region_mapping = region_mapping - self.events_only = events_only - self.mqtt_topic = mqtt_topic - - @callback - def async_valid_accuracy(self, message): - """Check if we should ignore this message.""" - acc = message.get('acc') - - if acc is None: - return False - - try: - acc = float(acc) - except ValueError: - return False - - if acc == 0: - _LOGGER.warning( - "Ignoring %s update because GPS accuracy is zero: %s", - message['_type'], message) - return False - - if self.max_gps_accuracy is not None and \ - acc > self.max_gps_accuracy: - _LOGGER.info("Ignoring %s update because expected GPS " - "accuracy %s is not met: %s", - message['_type'], self.max_gps_accuracy, - message) - return False - - return True - - async def async_see_beacons(self, hass, dev_id, kwargs_param): - """Set active beacons to the current location.""" - kwargs = kwargs_param.copy() - - # Mobile beacons should always be set to the location of the - # tracking device. I get the device state and make the necessary - # changes to kwargs. - device_tracker_state = hass.states.get( - "device_tracker.{}".format(dev_id)) - - if device_tracker_state is not None: - acc = device_tracker_state.attributes.get("gps_accuracy") - lat = device_tracker_state.attributes.get("latitude") - lon = device_tracker_state.attributes.get("longitude") - kwargs['gps_accuracy'] = acc - kwargs['gps'] = (lat, lon) - - # the battery state applies to the tracking device, not the beacon - # kwargs location is the beacon's configured lat/lon - kwargs.pop('battery', None) - for beacon in self.mobile_beacons_active[dev_id]: - kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) - kwargs['host_name'] = beacon - await self.async_see(**kwargs) - - @HANDLERS.register('location') async def async_handle_location_message(hass, context, message): """Handle a location message.""" @@ -485,6 +349,8 @@ async def async_handle_message(hass, context, message): """Handle an OwnTracks message.""" msgtype = message.get('_type') + _LOGGER.debug("Received %s", message) + handler = HANDLERS.get(msgtype, async_handle_unsupported_msg) await handler(hass, context, message) diff --git a/homeassistant/components/device_tracker/owntracks_http.py b/homeassistant/components/device_tracker/owntracks_http.py deleted file mode 100644 index b9f379e753433..0000000000000 --- a/homeassistant/components/device_tracker/owntracks_http.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -Device tracker platform that adds support for OwnTracks over HTTP. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.owntracks_http/ -""" -import json -import logging -import re - -from aiohttp.web import Response -import voluptuous as vol - -# pylint: disable=unused-import -from homeassistant.components.device_tracker.owntracks import ( # NOQA - PLATFORM_SCHEMA, REQUIREMENTS, async_handle_message, context_from_config) -from homeassistant.const import CONF_WEBHOOK_ID -import homeassistant.helpers.config_validation as cv - -DEPENDENCIES = ['webhook'] - -_LOGGER = logging.getLogger(__name__) - -EVENT_RECEIVED = 'owntracks_http_webhook_received' -EVENT_RESPONSE = 'owntracks_http_webhook_response_' - -DOMAIN = 'device_tracker.owntracks_http' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_WEBHOOK_ID): cv.string -}) - - -async def async_setup_scanner(hass, config, async_see, discovery_info=None): - """Set up OwnTracks HTTP component.""" - context = context_from_config(async_see, config) - - subscription = context.mqtt_topic - topic = re.sub('/#$', '', subscription) - - async def handle_webhook(hass, webhook_id, request): - """Handle webhook callback.""" - headers = request.headers - data = dict() - - if 'X-Limit-U' in headers: - data['user'] = headers['X-Limit-U'] - elif 'u' in request.query: - data['user'] = request.query['u'] - else: - return Response( - body=json.dumps({'error': 'You need to supply username.'}), - content_type="application/json" - ) - - if 'X-Limit-D' in headers: - data['device'] = headers['X-Limit-D'] - elif 'd' in request.query: - data['device'] = request.query['d'] - else: - return Response( - body=json.dumps({'error': 'You need to supply device name.'}), - content_type="application/json" - ) - - message = await request.json() - - message['topic'] = '{}/{}/{}'.format(topic, data['user'], - data['device']) - - try: - await async_handle_message(hass, context, message) - return Response(body=json.dumps([]), status=200, - content_type="application/json") - except ValueError: - _LOGGER.error("Received invalid JSON") - return None - - hass.components.webhook.async_register( - 'owntracks', 'OwnTracks', config['webhook_id'], handle_webhook) - - return True diff --git a/homeassistant/components/owntracks/.translations/en.json b/homeassistant/components/owntracks/.translations/en.json new file mode 100644 index 0000000000000..a34077a0a8329 --- /dev/null +++ b/homeassistant/components/owntracks/.translations/en.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "Only a single instance is necessary." + }, + "create_entry": { + "default": "\n\nOn Android, open [the OwnTracks app]({android_url}), go to preferences -> connection. Change the following settings:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\nOn iOS, open [the OwnTracks app]({ios_url}), tap (i) icon in top left -> settings. Change the following settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\nSee [the documentation]({docs_url}) for more information." + }, + "step": { + "user": { + "description": "Are you sure you want to set up OwnTracks?", + "title": "Set up OwnTracks" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py new file mode 100644 index 0000000000000..a5da7f5fc483d --- /dev/null +++ b/homeassistant/components/owntracks/__init__.py @@ -0,0 +1,219 @@ +"""Component for OwnTracks.""" +from collections import defaultdict +import json +import logging +import re + +from aiohttp.web import json_response +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.core import callback +from homeassistant.components import mqtt +from homeassistant.setup import async_when_setup +import homeassistant.helpers.config_validation as cv + +from .config_flow import CONF_SECRET + +DOMAIN = "owntracks" +REQUIREMENTS = ['libnacl==1.6.1'] +DEPENDENCIES = ['device_tracker', 'webhook'] + +CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' +CONF_WAYPOINT_IMPORT = 'waypoints' +CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist' +CONF_MQTT_TOPIC = 'mqtt_topic' +CONF_REGION_MAPPING = 'region_mapping' +CONF_EVENTS_ONLY = 'events_only' +BEACON_DEV_ID = 'beacon' + +DEFAULT_OWNTRACKS_TOPIC = 'owntracks/#' + +CONFIG_SCHEMA = vol.Schema({ + vol.Optional(DOMAIN, default={}): { + vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), + vol.Optional(CONF_WAYPOINT_IMPORT, default=True): cv.boolean, + vol.Optional(CONF_EVENTS_ONLY, default=False): cv.boolean, + vol.Optional(CONF_MQTT_TOPIC, default=DEFAULT_OWNTRACKS_TOPIC): + mqtt.valid_subscribe_topic, + vol.Optional(CONF_WAYPOINT_WHITELIST): vol.All( + cv.ensure_list, [cv.string]), + vol.Optional(CONF_SECRET): vol.Any( + vol.Schema({vol.Optional(cv.string): cv.string}), + cv.string), + vol.Optional(CONF_REGION_MAPPING, default={}): dict, + vol.Optional(CONF_WEBHOOK_ID): cv.string, + } +}, extra=vol.ALLOW_EXTRA) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass, config): + """Initialize OwnTracks component.""" + hass.data[DOMAIN] = { + 'config': config[DOMAIN] + } + if not hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, + data={} + )) + + return True + + +async def async_setup_entry(hass, entry): + """Set up OwnTracks entry.""" + config = hass.data[DOMAIN]['config'] + max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) + waypoint_import = config.get(CONF_WAYPOINT_IMPORT) + waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) + secret = config.get(CONF_SECRET) or entry.data[CONF_SECRET] + region_mapping = config.get(CONF_REGION_MAPPING) + events_only = config.get(CONF_EVENTS_ONLY) + mqtt_topic = config.get(CONF_MQTT_TOPIC) + + context = OwnTracksContext(hass, secret, max_gps_accuracy, + waypoint_import, waypoint_whitelist, + region_mapping, events_only, mqtt_topic) + + webhook_id = config.get(CONF_WEBHOOK_ID) or entry.data[CONF_WEBHOOK_ID] + + hass.data[DOMAIN]['context'] = context + + async_when_setup(hass, 'mqtt', async_connect_mqtt) + + hass.components.webhook.async_register( + DOMAIN, 'OwnTracks', webhook_id, handle_webhook) + + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + entry, 'device_tracker')) + + return True + + +async def async_connect_mqtt(hass, component): + """Subscribe to MQTT topic.""" + context = hass.data[DOMAIN]['context'] + + async def async_handle_mqtt_message(topic, payload, qos): + """Handle incoming OwnTracks message.""" + try: + message = json.loads(payload) + except ValueError: + # If invalid JSON + _LOGGER.error("Unable to parse payload as JSON: %s", payload) + return + + message['topic'] = topic + hass.helpers.dispatcher.async_dispatcher_send( + DOMAIN, hass, context, message) + + await hass.components.mqtt.async_subscribe( + context.mqtt_topic, async_handle_mqtt_message, 1) + + return True + + +async def handle_webhook(hass, webhook_id, request): + """Handle webhook callback.""" + context = hass.data[DOMAIN]['context'] + message = await request.json() + + # Android doesn't populate topic + if 'topic' not in message: + headers = request.headers + user = headers.get('X-Limit-U') + device = headers.get('X-Limit-D', user) + + if user is None: + _LOGGER.warning('Set a username in Connection -> Identification') + return json_response( + {'error': 'You need to supply username.'}, + status=400 + ) + + topic_base = re.sub('/#$', '', context.mqtt_topic) + message['topic'] = '{}/{}/{}'.format(topic_base, user, device) + + hass.helpers.dispatcher.async_dispatcher_send( + DOMAIN, hass, context, message) + return json_response([]) + + +class OwnTracksContext: + """Hold the current OwnTracks context.""" + + def __init__(self, hass, secret, max_gps_accuracy, import_waypoints, + waypoint_whitelist, region_mapping, events_only, mqtt_topic): + """Initialize an OwnTracks context.""" + self.hass = hass + self.secret = secret + self.max_gps_accuracy = max_gps_accuracy + self.mobile_beacons_active = defaultdict(set) + self.regions_entered = defaultdict(list) + self.import_waypoints = import_waypoints + self.waypoint_whitelist = waypoint_whitelist + self.region_mapping = region_mapping + self.events_only = events_only + self.mqtt_topic = mqtt_topic + + @callback + def async_valid_accuracy(self, message): + """Check if we should ignore this message.""" + acc = message.get('acc') + + if acc is None: + return False + + try: + acc = float(acc) + except ValueError: + return False + + if acc == 0: + _LOGGER.warning( + "Ignoring %s update because GPS accuracy is zero: %s", + message['_type'], message) + return False + + if self.max_gps_accuracy is not None and \ + acc > self.max_gps_accuracy: + _LOGGER.info("Ignoring %s update because expected GPS " + "accuracy %s is not met: %s", + message['_type'], self.max_gps_accuracy, + message) + return False + + return True + + async def async_see(self, **data): + """Send a see message to the device tracker.""" + await self.hass.components.device_tracker.async_see(**data) + + async def async_see_beacons(self, hass, dev_id, kwargs_param): + """Set active beacons to the current location.""" + kwargs = kwargs_param.copy() + + # Mobile beacons should always be set to the location of the + # tracking device. I get the device state and make the necessary + # changes to kwargs. + device_tracker_state = hass.states.get( + "device_tracker.{}".format(dev_id)) + + if device_tracker_state is not None: + acc = device_tracker_state.attributes.get("gps_accuracy") + lat = device_tracker_state.attributes.get("latitude") + lon = device_tracker_state.attributes.get("longitude") + kwargs['gps_accuracy'] = acc + kwargs['gps'] = (lat, lon) + + # the battery state applies to the tracking device, not the beacon + # kwargs location is the beacon's configured lat/lon + kwargs.pop('battery', None) + for beacon in self.mobile_beacons_active[dev_id]: + kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) + kwargs['host_name'] = beacon + await self.async_see(**kwargs) diff --git a/homeassistant/components/owntracks/config_flow.py b/homeassistant/components/owntracks/config_flow.py new file mode 100644 index 0000000000000..8836294642833 --- /dev/null +++ b/homeassistant/components/owntracks/config_flow.py @@ -0,0 +1,79 @@ +"""Config flow for OwnTracks.""" +from homeassistant import config_entries +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.auth.util import generate_secret + +CONF_SECRET = 'secret' + + +def supports_encryption(): + """Test if we support encryption.""" + try: + # pylint: disable=unused-variable + import libnacl # noqa + return True + except OSError: + return False + + +@config_entries.HANDLERS.register('owntracks') +class OwnTracksFlow(config_entries.ConfigFlow): + """Set up OwnTracks.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle a user initiated set up flow to create OwnTracks webhook.""" + if self._async_current_entries(): + return self.async_abort(reason='one_instance_allowed') + + if user_input is None: + return self.async_show_form( + step_id='user', + ) + + webhook_id = self.hass.components.webhook.async_generate_id() + webhook_url = \ + self.hass.components.webhook.async_generate_url(webhook_id) + + secret = generate_secret(16) + + if supports_encryption(): + secret_desc = ( + "The encryption key is {secret} " + "(on Android under preferences -> advanced)") + else: + secret_desc = ( + "Encryption is not supported because libsodium is not " + "installed.") + + return self.async_create_entry( + title="OwnTracks", + data={ + CONF_WEBHOOK_ID: webhook_id, + CONF_SECRET: secret + }, + description_placeholders={ + 'secret': secret_desc, + 'webhook_url': webhook_url, + 'android_url': + 'https://play.google.com/store/apps/details?' + 'id=org.owntracks.android', + 'ios_url': + 'https://itunes.apple.com/us/app/owntracks/id692424691?mt=8', + 'docs_url': + 'https://www.home-assistant.io/components/owntracks/' + } + ) + + async def async_step_import(self, user_input): + """Import a config flow from configuration.""" + webhook_id = self.hass.components.webhook.async_generate_id() + secret = generate_secret(16) + return self.async_create_entry( + title="OwnTracks", + data={ + CONF_WEBHOOK_ID: webhook_id, + CONF_SECRET: secret + } + ) diff --git a/homeassistant/components/owntracks/strings.json b/homeassistant/components/owntracks/strings.json new file mode 100644 index 0000000000000..fcf7305d714c9 --- /dev/null +++ b/homeassistant/components/owntracks/strings.json @@ -0,0 +1,17 @@ +{ + "config": { + "title": "OwnTracks", + "step": { + "user": { + "title": "Set up OwnTracks", + "description": "Are you sure you want to set up OwnTracks?" + } + }, + "abort": { + "one_instance_allowed": "Only a single instance is necessary." + }, + "create_entry": { + "default": "\n\nOn Android, open [the OwnTracks app]({android_url}), go to preferences -> connection. Change the following settings:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\nOn iOS, open [the OwnTracks app]({ios_url}), tap (i) icon in top left -> settings. Change the following settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\nSee [the documentation]({docs_url}) for more information." + } + } +} diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index acfa10acdefd8..5c6ced5756f78 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -149,6 +149,7 @@ async def async_step_discovery(info): 'mqtt', 'nest', 'openuv', + 'owntracks', 'point', 'rainmachine', 'simplisafe', diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 057843834c051..cc7c4284f9c96 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -4,7 +4,7 @@ from timeit import default_timer as timer from types import ModuleType -from typing import Optional, Dict, List +from typing import Awaitable, Callable, Optional, Dict, List from homeassistant import requirements, core, loader, config as conf_util from homeassistant.config import async_notify_setup_error @@ -248,3 +248,35 @@ async def async_process_deps_reqs( raise HomeAssistantError("Could not install all requirements.") processed.add(name) + + +@core.callback +def async_when_setup( + hass: core.HomeAssistant, component: str, + when_setup_cb: Callable[ + [core.HomeAssistant, str], Awaitable[None]]) -> None: + """Call a method when a component is setup.""" + async def when_setup() -> None: + """Call the callback.""" + try: + await when_setup_cb(hass, component) + except Exception: # pylint: disable=broad-except + _LOGGER.exception('Error handling when_setup callback for %s', + component) + + # Running it in a new task so that it always runs after + if component in hass.config.components: + hass.async_create_task(when_setup()) + return + + unsub = None + + async def loaded_event(event: core.Event) -> None: + """Call the callback.""" + if event.data[ATTR_COMPONENT] != component: + return + + unsub() # type: ignore + await when_setup() + + unsub = hass.bus.async_listen(EVENT_COMPONENT_LOADED, loaded_event) diff --git a/requirements_all.txt b/requirements_all.txt index ff5779299d389..9f094a387fe04 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -559,8 +559,7 @@ konnected==0.1.4 # homeassistant.components.eufy lakeside==0.10 -# homeassistant.components.device_tracker.owntracks -# homeassistant.components.device_tracker.owntracks_http +# homeassistant.components.owntracks libnacl==1.6.1 # homeassistant.components.dyson diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index 2d7397692f8e7..6f457f30ed0a6 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -4,12 +4,11 @@ import pytest from tests.common import ( - assert_setup_component, async_fire_mqtt_message, mock_coro, mock_component, - async_mock_mqtt_component) -import homeassistant.components.device_tracker.owntracks as owntracks + async_fire_mqtt_message, mock_coro, mock_component, + async_mock_mqtt_component, MockConfigEntry) +from homeassistant.components import owntracks from homeassistant.setup import async_setup_component -from homeassistant.components import device_tracker -from homeassistant.const import CONF_PLATFORM, STATE_NOT_HOME +from homeassistant.const import STATE_NOT_HOME USER = 'greg' DEVICE = 'phone' @@ -290,6 +289,25 @@ def setup_comp(hass): 'zone.outer', 'zoning', OUTER_ZONE) +async def setup_owntracks(hass, config, + ctx_cls=owntracks.OwnTracksContext): + """Set up OwnTracks.""" + await async_mock_mqtt_component(hass) + + MockConfigEntry(domain='owntracks', data={ + 'webhook_id': 'owntracks_test', + 'secret': 'abcd', + }).add_to_hass(hass) + + with patch('homeassistant.components.device_tracker.async_load_config', + return_value=mock_coro([])), \ + patch('homeassistant.components.device_tracker.' + 'load_yaml_config_file', return_value=mock_coro({})), \ + patch.object(owntracks, 'OwnTracksContext', ctx_cls): + assert await async_setup_component( + hass, 'owntracks', {'owntracks': config}) + + @pytest.fixture def context(hass, setup_comp): """Set up the mocked context.""" @@ -306,20 +324,11 @@ def store_context(*args): context = orig_context(*args) return context - with patch('homeassistant.components.device_tracker.async_load_config', - return_value=mock_coro([])), \ - patch('homeassistant.components.device_tracker.' - 'load_yaml_config_file', return_value=mock_coro({})), \ - patch.object(owntracks, 'OwnTracksContext', store_context), \ - assert_setup_component(1, device_tracker.DOMAIN): - assert hass.loop.run_until_complete(async_setup_component( - hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_MAX_GPS_ACCURACY: 200, - CONF_WAYPOINT_IMPORT: True, - CONF_WAYPOINT_WHITELIST: ['jon', 'greg'] - }})) + hass.loop.run_until_complete(setup_owntracks(hass, { + CONF_MAX_GPS_ACCURACY: 200, + CONF_WAYPOINT_IMPORT: True, + CONF_WAYPOINT_WHITELIST: ['jon', 'greg'] + }, store_context)) def get_context(): """Get the current context.""" @@ -1211,19 +1220,14 @@ async def test_waypoint_import_blacklist(hass, context): assert wayp is None -async def test_waypoint_import_no_whitelist(hass, context): +async def test_waypoint_import_no_whitelist(hass, config_context): """Test import of list of waypoints with no whitelist set.""" - async def mock_see(**kwargs): - """Fake see method for owntracks.""" - return - - test_config = { - CONF_PLATFORM: 'owntracks', + await setup_owntracks(hass, { CONF_MAX_GPS_ACCURACY: 200, CONF_WAYPOINT_IMPORT: True, CONF_MQTT_TOPIC: 'owntracks/#', - } - await owntracks.async_setup_scanner(hass, test_config, mock_see) + }) + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() await send_message(hass, WAYPOINTS_TOPIC_BLOCKED, waypoints_message) # Check if it made it into states @@ -1364,12 +1368,9 @@ def config_context(hass, setup_comp): mock_cipher) async def test_encrypted_payload(hass, config_context): """Test encrypted payload.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: TEST_SECRET_KEY, - }}) + await setup_owntracks(hass, { + CONF_SECRET: TEST_SECRET_KEY, + }) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert_location_latitude(hass, LOCATION_MESSAGE['lat']) @@ -1378,13 +1379,11 @@ async def test_encrypted_payload(hass, config_context): mock_cipher) async def test_encrypted_payload_topic_key(hass, config_context): """Test encrypted payload with a topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - LOCATION_TOPIC: TEST_SECRET_KEY, - }}}) + await setup_owntracks(hass, { + CONF_SECRET: { + LOCATION_TOPIC: TEST_SECRET_KEY, + } + }) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert_location_latitude(hass, LOCATION_MESSAGE['lat']) @@ -1394,12 +1393,10 @@ async def test_encrypted_payload_topic_key(hass, config_context): async def test_encrypted_payload_no_key(hass, config_context): """Test encrypted payload with no key, .""" assert hass.states.get(DEVICE_TRACKER_STATE) is None - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - # key missing - }}) + await setup_owntracks(hass, { + CONF_SECRET: { + } + }) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert hass.states.get(DEVICE_TRACKER_STATE) is None @@ -1408,12 +1405,9 @@ async def test_encrypted_payload_no_key(hass, config_context): mock_cipher) async def test_encrypted_payload_wrong_key(hass, config_context): """Test encrypted payload with wrong key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: 'wrong key', - }}) + await setup_owntracks(hass, { + CONF_SECRET: 'wrong key', + }) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert hass.states.get(DEVICE_TRACKER_STATE) is None @@ -1422,13 +1416,11 @@ async def test_encrypted_payload_wrong_key(hass, config_context): mock_cipher) async def test_encrypted_payload_wrong_topic_key(hass, config_context): """Test encrypted payload with wrong topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - LOCATION_TOPIC: 'wrong key' - }}}) + await setup_owntracks(hass, { + CONF_SECRET: { + LOCATION_TOPIC: 'wrong key' + }, + }) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert hass.states.get(DEVICE_TRACKER_STATE) is None @@ -1437,13 +1429,10 @@ async def test_encrypted_payload_wrong_topic_key(hass, config_context): mock_cipher) async def test_encrypted_payload_no_topic_key(hass, config_context): """Test encrypted payload with no topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - 'owntracks/{}/{}'.format(USER, 'otherdevice'): 'foobar' - }}}) + await setup_owntracks(hass, { + CONF_SECRET: { + 'owntracks/{}/{}'.format(USER, 'otherdevice'): 'foobar' + }}) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert hass.states.get(DEVICE_TRACKER_STATE) is None @@ -1456,12 +1445,9 @@ async def test_encrypted_payload_libsodium(hass, config_context): pytest.skip("libnacl/libsodium is not installed") return - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: TEST_SECRET_KEY, - }}) + await setup_owntracks(hass, { + CONF_SECRET: TEST_SECRET_KEY, + }) await send_message(hass, LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE) assert_location_latitude(hass, LOCATION_MESSAGE['lat']) @@ -1469,12 +1455,9 @@ async def test_encrypted_payload_libsodium(hass, config_context): async def test_customized_mqtt_topic(hass, config_context): """Test subscribing to a custom mqtt topic.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_MQTT_TOPIC: 'mytracks/#', - }}) + await setup_owntracks(hass, { + CONF_MQTT_TOPIC: 'mytracks/#', + }) topic = 'mytracks/{}/{}'.format(USER, DEVICE) @@ -1484,14 +1467,11 @@ async def test_customized_mqtt_topic(hass, config_context): async def test_region_mapping(hass, config_context): """Test region to zone mapping.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_REGION_MAPPING: { - 'foo': 'inner' - }, - }}) + await setup_owntracks(hass, { + CONF_REGION_MAPPING: { + 'foo': 'inner' + }, + }) hass.states.async_set( 'zone.inner', 'zoning', INNER_ZONE) diff --git a/tests/components/owntracks/__init__.py b/tests/components/owntracks/__init__.py new file mode 100644 index 0000000000000..a95431913b24d --- /dev/null +++ b/tests/components/owntracks/__init__.py @@ -0,0 +1 @@ +"""Tests for OwnTracks component.""" diff --git a/tests/components/owntracks/test_config_flow.py b/tests/components/owntracks/test_config_flow.py new file mode 100644 index 0000000000000..079fdfafea09d --- /dev/null +++ b/tests/components/owntracks/test_config_flow.py @@ -0,0 +1 @@ +"""Tests for OwnTracks config flow.""" diff --git a/tests/components/device_tracker/test_owntracks_http.py b/tests/components/owntracks/test_init.py similarity index 51% rename from tests/components/device_tracker/test_owntracks_http.py rename to tests/components/owntracks/test_init.py index a49f30c683996..ee79c8b9e10be 100644 --- a/tests/components/device_tracker/test_owntracks_http.py +++ b/tests/components/owntracks/test_init.py @@ -1,14 +1,11 @@ """Test the owntracks_http platform.""" import asyncio -from unittest.mock import patch -import os import pytest -from homeassistant.components import device_tracker from homeassistant.setup import async_setup_component -from tests.common import mock_component, mock_coro +from tests.common import mock_component, MockConfigEntry MINIMAL_LOCATION_MESSAGE = { '_type': 'location', @@ -36,38 +33,33 @@ } -@pytest.fixture(autouse=True) -def owntracks_http_cleanup(hass): - """Remove known_devices.yaml.""" - try: - os.remove(hass.config.path(device_tracker.YAML_DEVICES)) - except OSError: - pass - - @pytest.fixture def mock_client(hass, aiohttp_client): """Start the Hass HTTP component.""" mock_component(hass, 'group') mock_component(hass, 'zone') - with patch('homeassistant.components.device_tracker.async_load_config', - return_value=mock_coro([])): - hass.loop.run_until_complete( - async_setup_component(hass, 'device_tracker', { - 'device_tracker': { - 'platform': 'owntracks_http', - 'webhook_id': 'owntracks_test' - } - })) + mock_component(hass, 'device_tracker') + + MockConfigEntry(domain='owntracks', data={ + 'webhook_id': 'owntracks_test', + 'secret': 'abcd', + }).add_to_hass(hass) + hass.loop.run_until_complete(async_setup_component(hass, 'owntracks', {})) + return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) @asyncio.coroutine def test_handle_valid_message(mock_client): """Test that we forward messages correctly to OwnTracks.""" - resp = yield from mock_client.post('/api/webhook/owntracks_test?' - 'u=test&d=test', - json=LOCATION_MESSAGE) + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json=LOCATION_MESSAGE, + headers={ + 'X-Limit-u': 'Paulus', + 'X-Limit-d': 'Pixel', + } + ) assert resp.status == 200 @@ -78,9 +70,14 @@ def test_handle_valid_message(mock_client): @asyncio.coroutine def test_handle_valid_minimal_message(mock_client): """Test that we forward messages correctly to OwnTracks.""" - resp = yield from mock_client.post('/api/webhook/owntracks_test?' - 'u=test&d=test', - json=MINIMAL_LOCATION_MESSAGE) + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json=MINIMAL_LOCATION_MESSAGE, + headers={ + 'X-Limit-u': 'Paulus', + 'X-Limit-d': 'Pixel', + } + ) assert resp.status == 200 @@ -91,8 +88,14 @@ def test_handle_valid_minimal_message(mock_client): @asyncio.coroutine def test_handle_value_error(mock_client): """Test we don't disclose that this is a valid webhook.""" - resp = yield from mock_client.post('/api/webhook/owntracks_test' - '?u=test&d=test', json='') + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json='', + headers={ + 'X-Limit-u': 'Paulus', + 'X-Limit-d': 'Pixel', + } + ) assert resp.status == 200 @@ -103,10 +106,15 @@ def test_handle_value_error(mock_client): @asyncio.coroutine def test_returns_error_missing_username(mock_client): """Test that an error is returned when username is missing.""" - resp = yield from mock_client.post('/api/webhook/owntracks_test?d=test', - json=LOCATION_MESSAGE) + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json=LOCATION_MESSAGE, + headers={ + 'X-Limit-d': 'Pixel', + } + ) - assert resp.status == 200 + assert resp.status == 400 json = yield from resp.json() assert json == {'error': 'You need to supply username.'} @@ -115,10 +123,27 @@ def test_returns_error_missing_username(mock_client): @asyncio.coroutine def test_returns_error_missing_device(mock_client): """Test that an error is returned when device name is missing.""" - resp = yield from mock_client.post('/api/webhook/owntracks_test?u=test', - json=LOCATION_MESSAGE) + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json=LOCATION_MESSAGE, + headers={ + 'X-Limit-u': 'Paulus', + } + ) assert resp.status == 200 json = yield from resp.json() - assert json == {'error': 'You need to supply device name.'} + assert json == [] + + +async def test_config_flow_import(hass): + """Test that we automatically create a config flow.""" + assert not hass.config_entries.async_entries('owntracks') + assert await async_setup_component(hass, 'owntracks', { + 'owntracks': { + + } + }) + await hass.async_block_till_done() + assert hass.config_entries.async_entries('owntracks') diff --git a/tests/test_setup.py b/tests/test_setup.py index 29712f40ebc01..2e44ee539d7b6 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -9,7 +9,8 @@ import voluptuous as vol from homeassistant.core import callback -from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_COMPONENT_LOADED) import homeassistant.config as config_util from homeassistant import setup, loader import homeassistant.util.dt as dt_util @@ -459,3 +460,35 @@ def test_platform_no_warn_slow(hass): hass, 'test_component1', {}) assert result assert not mock_call.called + + +async def test_when_setup_already_loaded(hass): + """Test when setup.""" + calls = [] + + async def mock_callback(hass, component): + """Mock callback.""" + calls.append(component) + + setup.async_when_setup(hass, 'test', mock_callback) + await hass.async_block_till_done() + assert calls == [] + + hass.config.components.add('test') + hass.bus.async_fire(EVENT_COMPONENT_LOADED, { + 'component': 'test' + }) + await hass.async_block_till_done() + assert calls == ['test'] + + # Event listener should be gone + hass.bus.async_fire(EVENT_COMPONENT_LOADED, { + 'component': 'test' + }) + await hass.async_block_till_done() + assert calls == ['test'] + + # Should be called right away + setup.async_when_setup(hass, 'test', mock_callback) + await hass.async_block_till_done() + assert calls == ['test', 'test'] From 58e0ff0b1b4a4a1177a837f8b9a70831d17ff305 Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Sat, 24 Nov 2018 14:34:36 -0500 Subject: [PATCH 111/325] Async tests for owntracks device tracker (#18681) --- .../device_tracker/test_owntracks.py | 2280 +++++++++-------- 1 file changed, 1156 insertions(+), 1124 deletions(-) diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index eaf17fb53f4b5..2d7397692f8e7 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -1,17 +1,15 @@ """The tests for the Owntracks device tracker.""" -import asyncio import json -import unittest -from unittest.mock import patch +from asynctest import patch +import pytest from tests.common import ( - assert_setup_component, fire_mqtt_message, mock_coro, mock_component, - get_test_home_assistant, mock_mqtt_component) + assert_setup_component, async_fire_mqtt_message, mock_coro, mock_component, + async_mock_mqtt_component) import homeassistant.components.device_tracker.owntracks as owntracks -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component from homeassistant.components import device_tracker from homeassistant.const import CONF_PLATFORM, STATE_NOT_HOME -from homeassistant.util.async_ import run_coroutine_threadsafe USER = 'greg' DEVICE = 'phone' @@ -275,982 +273,1016 @@ def build_message(test_params, default_params): BAD_JSON_SUFFIX = '** and it ends here ^^' -# def raise_on_not_implemented(hass, context, message): -def raise_on_not_implemented(): - """Throw NotImplemented.""" - raise NotImplementedError("oopsie") - - -class BaseMQTT(unittest.TestCase): - """Base MQTT assert functions.""" - - hass = None - - def send_message(self, topic, message, corrupt=False): - """Test the sending of a message.""" - str_message = json.dumps(message) - if corrupt: - mod_message = BAD_JSON_PREFIX + str_message + BAD_JSON_SUFFIX - else: - mod_message = str_message - fire_mqtt_message(self.hass, topic, mod_message) - self.hass.block_till_done() - - def assert_location_state(self, location): - """Test the assertion of a location state.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.state == location - - def assert_location_latitude(self, latitude): - """Test the assertion of a location latitude.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.attributes.get('latitude') == latitude - - def assert_location_longitude(self, longitude): - """Test the assertion of a location longitude.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.attributes.get('longitude') == longitude - - def assert_location_accuracy(self, accuracy): - """Test the assertion of a location accuracy.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.attributes.get('gps_accuracy') == accuracy - - def assert_location_source_type(self, source_type): - """Test the assertion of source_type.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.attributes.get('source_type') == source_type - - -class TestDeviceTrackerOwnTracks(BaseMQTT): - """Test the OwnTrack sensor.""" - - # pylint: disable=invalid-name - def setup_method(self, _): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - mock_mqtt_component(self.hass) - mock_component(self.hass, 'group') - mock_component(self.hass, 'zone') - - patcher = patch('homeassistant.components.device_tracker.' - 'DeviceTracker.async_update_config') - patcher.start() - self.addCleanup(patcher.stop) - - orig_context = owntracks.OwnTracksContext - - def store_context(*args): - self.context = orig_context(*args) - return self.context - - with patch('homeassistant.components.device_tracker.async_load_config', - return_value=mock_coro([])), \ - patch('homeassistant.components.device_tracker.' - 'load_yaml_config_file', return_value=mock_coro({})), \ - patch.object(owntracks, 'OwnTracksContext', store_context), \ - assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { +@pytest.fixture +def setup_comp(hass): + """Initialize components.""" + mock_component(hass, 'group') + mock_component(hass, 'zone') + hass.loop.run_until_complete(async_mock_mqtt_component(hass)) + + hass.states.async_set( + 'zone.inner', 'zoning', INNER_ZONE) + + hass.states.async_set( + 'zone.inner_2', 'zoning', INNER_ZONE) + + hass.states.async_set( + 'zone.outer', 'zoning', OUTER_ZONE) + + +@pytest.fixture +def context(hass, setup_comp): + """Set up the mocked context.""" + patcher = patch('homeassistant.components.device_tracker.' + 'DeviceTracker.async_update_config') + patcher.start() + + orig_context = owntracks.OwnTracksContext + + context = None + + def store_context(*args): + nonlocal context + context = orig_context(*args) + return context + + with patch('homeassistant.components.device_tracker.async_load_config', + return_value=mock_coro([])), \ + patch('homeassistant.components.device_tracker.' + 'load_yaml_config_file', return_value=mock_coro({})), \ + patch.object(owntracks, 'OwnTracksContext', store_context), \ + assert_setup_component(1, device_tracker.DOMAIN): + assert hass.loop.run_until_complete(async_setup_component( + hass, device_tracker.DOMAIN, { device_tracker.DOMAIN: { CONF_PLATFORM: 'owntracks', CONF_MAX_GPS_ACCURACY: 200, CONF_WAYPOINT_IMPORT: True, CONF_WAYPOINT_WHITELIST: ['jon', 'greg'] - }}) + }})) - self.hass.states.set( - 'zone.inner', 'zoning', INNER_ZONE) - - self.hass.states.set( - 'zone.inner_2', 'zoning', INNER_ZONE) - - self.hass.states.set( - 'zone.outer', 'zoning', OUTER_ZONE) - - # Clear state between tests - # NB: state "None" is not a state that is created by Device - # so when we compare state to None in the tests this - # is really checking that it is still in its original - # test case state. See Device.async_update. - self.hass.states.set(DEVICE_TRACKER_STATE, None) - - def teardown_method(self, _): - """Stop everything that was started.""" - self.hass.stop() - - def assert_mobile_tracker_state(self, location, beacon=IBEACON_DEVICE): - """Test the assertion of a mobile beacon tracker state.""" - dev_id = MOBILE_BEACON_FMT.format(beacon) - state = self.hass.states.get(dev_id) - assert state.state == location - - def assert_mobile_tracker_latitude(self, latitude, beacon=IBEACON_DEVICE): - """Test the assertion of a mobile beacon tracker latitude.""" - dev_id = MOBILE_BEACON_FMT.format(beacon) - state = self.hass.states.get(dev_id) - assert state.attributes.get('latitude') == latitude - - def assert_mobile_tracker_accuracy(self, accuracy, beacon=IBEACON_DEVICE): - """Test the assertion of a mobile beacon tracker accuracy.""" - dev_id = MOBILE_BEACON_FMT.format(beacon) - state = self.hass.states.get(dev_id) - assert state.attributes.get('gps_accuracy') == accuracy - - def test_location_invalid_devid(self): # pylint: disable=invalid-name - """Test the update of a location.""" - self.send_message('owntracks/paulus/nexus-5x', LOCATION_MESSAGE) - state = self.hass.states.get('device_tracker.paulus_nexus5x') - assert state.state == 'outer' - - def test_location_update(self): - """Test the update of a location.""" - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_accuracy(LOCATION_MESSAGE['acc']) - self.assert_location_state('outer') - - def test_location_inaccurate_gps(self): - """Test the location for inaccurate GPS information.""" - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_INACCURATE) - - # Ignored inaccurate GPS. Location remains at previous. - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_longitude(LOCATION_MESSAGE['lon']) - - def test_location_zero_accuracy_gps(self): - """Ignore the location for zero accuracy GPS information.""" - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_ZERO_ACCURACY) - - # Ignored inaccurate GPS. Location remains at previous. - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_longitude(LOCATION_MESSAGE['lon']) - - # ------------------------------------------------------------------------ - # GPS based event entry / exit testing - - def test_event_gps_entry_exit(self): - """Test the entry event.""" - # Entering the owntracks circular region named "inner" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - - # Enter uses the zone's gps co-ords - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Updates ignored when in a zone - # note that LOCATION_MESSAGE is actually pretty far - # from INNER_ZONE and has good accuracy. I haven't - # received a transition message though so I'm still - # associated with the inner zone regardless of GPS. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - - # Exit switches back to GPS - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_accuracy(REGION_GPS_LEAVE_MESSAGE['acc']) - self.assert_location_state('outer') - - # Left clean zone state - assert not self.context.regions_entered[USER] - - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Now sending a location update moves me again. - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_accuracy(LOCATION_MESSAGE['acc']) - - def test_event_gps_with_spaces(self): - """Test the entry event.""" - message = build_message({'desc': "inner 2"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner 2') - - message = build_message({'desc': "inner 2"}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # Left clean zone state - assert not self.context.regions_entered[USER] - - def test_event_gps_entry_inaccurate(self): - """Test the event for inaccurate entry.""" - # Set location to the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_INACCURATE) - - # I enter the zone even though the message GPS was inaccurate. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - def test_event_gps_entry_exit_inaccurate(self): - """Test the event for inaccurate exit.""" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - - # Enter uses the zone's gps co-ords - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_INACCURATE) - - # Exit doesn't use inaccurate gps - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - # But does exit region correctly - assert not self.context.regions_entered[USER] - - def test_event_gps_entry_exit_zero_accuracy(self): - """Test entry/exit events with accuracy zero.""" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_ZERO) - - # Enter uses the zone's gps co-ords - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_ZERO) - - # Exit doesn't use zero gps - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - # But does exit region correctly - assert not self.context.regions_entered[USER] - - def test_event_gps_exit_outside_zone_sets_away(self): - """Test the event for exit zone.""" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_state('inner') - - # Exit message far away GPS location - message = build_message( - {'lon': 90.0, - 'lat': 90.0}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # Exit forces zone change to away - self.assert_location_state(STATE_NOT_HOME) - - def test_event_gps_entry_exit_right_order(self): - """Test the event for ordering.""" - # Enter inner zone - # Set location to the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_state('inner') - - # Enter inner2 zone - message = build_message( - {'desc': "inner_2"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner_2') - - # Exit inner_2 - should be in 'inner' - message = build_message( - {'desc': "inner_2"}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') - - # Exit inner - should be in 'outer' - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_accuracy(REGION_GPS_LEAVE_MESSAGE['acc']) - self.assert_location_state('outer') - - def test_event_gps_entry_exit_wrong_order(self): - """Test the event for wrong order.""" - # Enter inner zone - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_state('inner') - - # Enter inner2 zone - message = build_message( - {'desc': "inner_2"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner_2') - - # Exit inner - should still be in 'inner_2' - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.assert_location_state('inner_2') - - # Exit inner_2 - should be in 'outer' - message = build_message( - {'desc': "inner_2"}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_accuracy(REGION_GPS_LEAVE_MESSAGE['acc']) - self.assert_location_state('outer') - - def test_event_gps_entry_unknown_zone(self): - """Test the event for unknown zone.""" - # Just treat as location update - message = build_message( - {'desc': "unknown"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_latitude(REGION_GPS_ENTER_MESSAGE['lat']) - self.assert_location_state('inner') - - def test_event_gps_exit_unknown_zone(self): - """Test the event for unknown zone.""" - # Just treat as location update - message = build_message( - {'desc': "unknown"}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_state('outer') - - def test_event_entry_zone_loading_dash(self): - """Test the event for zone landing.""" - # Make sure the leading - is ignored - # Owntracks uses this to switch on hold - message = build_message( - {'desc': "-inner"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') - - def test_events_only_on(self): - """Test events_only config suppresses location updates.""" - # Sending a location message that is not home - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - self.assert_location_state(STATE_NOT_HOME) - - self.context.events_only = True - - # Enter and Leave messages - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER) - self.assert_location_state('outer') - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) - self.assert_location_state(STATE_NOT_HOME) - - # Sending a location message that is inside outer zone - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Ignored location update. Location remains at previous. - self.assert_location_state(STATE_NOT_HOME) - - def test_events_only_off(self): - """Test when events_only is False.""" - # Sending a location message that is not home - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - self.assert_location_state(STATE_NOT_HOME) - - self.context.events_only = False - - # Enter and Leave messages - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER) - self.assert_location_state('outer') - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) - self.assert_location_state(STATE_NOT_HOME) - - # Sending a location message that is inside outer zone - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Location update processed - self.assert_location_state('outer') - - def test_event_source_type_entry_exit(self): - """Test the entry and exit events of source type.""" - # Entering the owntracks circular region named "inner" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - - # source_type should be gps when entering using gps. - self.assert_location_source_type('gps') - - # owntracks shouldn't send beacon events with acc = 0 - self.send_message(EVENT_TOPIC, build_message( - {'acc': 1}, REGION_BEACON_ENTER_MESSAGE)) - - # We should be able to enter a beacon zone even inside a gps zone - self.assert_location_source_type('bluetooth_le') - - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - - # source_type should be gps when leaving using gps. - self.assert_location_source_type('gps') - - # owntracks shouldn't send beacon events with acc = 0 - self.send_message(EVENT_TOPIC, build_message( - {'acc': 1}, REGION_BEACON_LEAVE_MESSAGE)) - - self.assert_location_source_type('bluetooth_le') - - # Region Beacon based event entry / exit testing - - def test_event_region_entry_exit(self): - """Test the entry event.""" - # Seeing a beacon named "inner" - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - - # Enter uses the zone's gps co-ords - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Updates ignored when in a zone - # note that LOCATION_MESSAGE is actually pretty far - # from INNER_ZONE and has good accuracy. I haven't - # received a transition message though so I'm still - # associated with the inner zone regardless of GPS. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - - # Exit switches back to GPS but the beacon has no coords - # so I am still located at the center of the inner region - # until I receive a location update. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - # Left clean zone state - assert not self.context.regions_entered[USER] - - # Now sending a location update moves me again. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_accuracy(LOCATION_MESSAGE['acc']) - - def test_event_region_with_spaces(self): - """Test the entry event.""" - message = build_message({'desc': "inner 2"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner 2') - - message = build_message({'desc': "inner 2"}, - REGION_BEACON_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # Left clean zone state - assert not self.context.regions_entered[USER] - - def test_event_region_entry_exit_right_order(self): - """Test the event for ordering.""" - # Enter inner zone - # Set location to the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # See 'inner' region beacon - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.assert_location_state('inner') - - # See 'inner_2' region beacon - message = build_message( - {'desc': "inner_2"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner_2') - - # Exit inner_2 - should be in 'inner' - message = build_message( - {'desc': "inner_2"}, - REGION_BEACON_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') - - # Exit inner - should be in 'outer' - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - - # I have not had an actual location update yet and my - # coordinates are set to the center of the last region I - # entered which puts me in the inner zone. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - def test_event_region_entry_exit_wrong_order(self): - """Test the event for wrong order.""" - # Enter inner zone - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.assert_location_state('inner') - - # Enter inner2 zone - message = build_message( - {'desc': "inner_2"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner_2') - - # Exit inner - should still be in 'inner_2' - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - self.assert_location_state('inner_2') - - # Exit inner_2 - should be in 'outer' - message = build_message( - {'desc': "inner_2"}, - REGION_BEACON_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # I have not had an actual location update yet and my - # coordinates are set to the center of the last region I - # entered which puts me in the inner_2 zone. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner_2') - - def test_event_beacon_unknown_zone_no_location(self): - """Test the event for unknown zone.""" - # A beacon which does not match a HA zone is the - # definition of a mobile beacon. In this case, "unknown" - # will be turned into device_tracker.beacon_unknown and - # that will be tracked at my current location. Except - # in this case my Device hasn't had a location message - # yet so it's in an odd state where it has state.state - # None and no GPS coords so set the beacon to. - - message = build_message( - {'desc': "unknown"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # My current state is None because I haven't seen a - # location message or a GPS or Region # Beacon event - # message. None is the state the test harness set for - # the Device during test case setup. - self.assert_location_state('None') - - # home is the state of a Device constructed through - # the normal code path on it's first observation with - # the conditions I pass along. - self.assert_mobile_tracker_state('home', 'unknown') - - def test_event_beacon_unknown_zone(self): - """Test the event for unknown zone.""" - # A beacon which does not match a HA zone is the - # definition of a mobile beacon. In this case, "unknown" - # will be turned into device_tracker.beacon_unknown and - # that will be tracked at my current location. First I - # set my location so that my state is 'outer' - - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.assert_location_state('outer') - - message = build_message( - {'desc': "unknown"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # My state is still outer and now the unknown beacon - # has joined me at outer. - self.assert_location_state('outer') - self.assert_mobile_tracker_state('outer', 'unknown') - - def test_event_beacon_entry_zone_loading_dash(self): - """Test the event for beacon zone landing.""" - # Make sure the leading - is ignored - # Owntracks uses this to switch on hold - - message = build_message( - {'desc': "-inner"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') - - # ------------------------------------------------------------------------ - # Mobile Beacon based event entry / exit testing - - def test_mobile_enter_move_beacon(self): - """Test the movement of a beacon.""" - # I am in the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # I see the 'keys' beacon. I set the location of the - # beacon_keys tracker to my current device location. - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - - self.assert_mobile_tracker_latitude(LOCATION_MESSAGE['lat']) - self.assert_mobile_tracker_state('outer') - - # Location update to outside of defined zones. - # I am now 'not home' and neither are my keys. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - - self.assert_location_state(STATE_NOT_HOME) - self.assert_mobile_tracker_state(STATE_NOT_HOME) - - not_home_lat = LOCATION_MESSAGE_NOT_HOME['lat'] - self.assert_location_latitude(not_home_lat) - self.assert_mobile_tracker_latitude(not_home_lat) - - def test_mobile_enter_exit_region_beacon(self): - """Test the enter and the exit of a mobile beacon.""" - # I am in the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # I see a new mobile beacon - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.assert_mobile_tracker_latitude(OUTER_ZONE['latitude']) - self.assert_mobile_tracker_state('outer') - - # GPS enter message should move beacon - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - - self.assert_mobile_tracker_latitude(INNER_ZONE['latitude']) - self.assert_mobile_tracker_state(REGION_GPS_ENTER_MESSAGE['desc']) - - # Exit inner zone to outer zone should move beacon to - # center of outer zone - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.assert_mobile_tracker_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_mobile_tracker_state('outer') - - def test_mobile_exit_move_beacon(self): - """Test the exit move of a beacon.""" - # I am in the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # I see a new mobile beacon - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.assert_mobile_tracker_latitude(OUTER_ZONE['latitude']) - self.assert_mobile_tracker_state('outer') - - # Exit mobile beacon, should set location - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - - self.assert_mobile_tracker_latitude(OUTER_ZONE['latitude']) - self.assert_mobile_tracker_state('outer') - - # Move after exit should do nothing - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - self.assert_mobile_tracker_latitude(OUTER_ZONE['latitude']) - self.assert_mobile_tracker_state('outer') - - def test_mobile_multiple_async_enter_exit(self): - """Test the multiple entering.""" - # Test race condition - for _ in range(0, 20): - fire_mqtt_message( - self.hass, EVENT_TOPIC, - json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE)) - fire_mqtt_message( - self.hass, EVENT_TOPIC, - json.dumps(MOBILE_BEACON_LEAVE_EVENT_MESSAGE)) - - fire_mqtt_message( - self.hass, EVENT_TOPIC, - json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE)) + def get_context(): + """Get the current context.""" + return context - self.hass.block_till_done() - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - assert len(self.context.mobile_beacons_active['greg_phone']) == \ - 0 - - def test_mobile_multiple_enter_exit(self): - """Test the multiple entering.""" - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - - assert len(self.context.mobile_beacons_active['greg_phone']) == \ - 0 - - def test_complex_movement(self): - """Test a complex sequence representative of real-world use.""" - # I am in the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.assert_location_state('outer') - - # gps to inner location and event, as actually happens with OwnTracks - location_message = build_message( - {'lat': REGION_GPS_ENTER_MESSAGE['lat'], - 'lon': REGION_GPS_ENTER_MESSAGE['lon']}, - LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - - # region beacon enter inner event and location as actually happens - # with OwnTracks - location_message = build_message( - {'lat': location_message['lat'] + FIVE_M, - 'lon': location_message['lon'] + FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - - # see keys mobile beacon and location message as actually happens - location_message = build_message( - {'lat': location_message['lat'] + FIVE_M, - 'lon': location_message['lon'] + FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_mobile_tracker_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') - - # Slightly odd, I leave the location by gps before I lose - # sight of the region beacon. This is also a little odd in - # that my GPS coords are now in the 'outer' zone but I did not - # "enter" that zone when I started up so my location is not - # the center of OUTER_ZONE, but rather just my GPS location. - - # gps out of inner event and location - location_message = build_message( - {'lat': REGION_GPS_LEAVE_MESSAGE['lat'], - 'lon': REGION_GPS_LEAVE_MESSAGE['lon']}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_mobile_tracker_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_state('outer') - self.assert_mobile_tracker_state('outer') - - # region beacon leave inner - location_message = build_message( - {'lat': location_message['lat'] - FIVE_M, - 'lon': location_message['lon'] - FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(location_message['lat']) - self.assert_mobile_tracker_latitude(location_message['lat']) - self.assert_location_state('outer') - self.assert_mobile_tracker_state('outer') - - # lose keys mobile beacon - lost_keys_location_message = build_message( - {'lat': location_message['lat'] - FIVE_M, - 'lon': location_message['lon'] - FIVE_M}, - LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, lost_keys_location_message) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - self.assert_location_latitude(lost_keys_location_message['lat']) - self.assert_mobile_tracker_latitude(lost_keys_location_message['lat']) - self.assert_location_state('outer') - self.assert_mobile_tracker_state('outer') - - # gps leave outer - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) - self.assert_location_latitude(LOCATION_MESSAGE_NOT_HOME['lat']) - self.assert_mobile_tracker_latitude(lost_keys_location_message['lat']) - self.assert_location_state('not_home') - self.assert_mobile_tracker_state('outer') - - # location move not home - location_message = build_message( - {'lat': LOCATION_MESSAGE_NOT_HOME['lat'] - FIVE_M, - 'lon': LOCATION_MESSAGE_NOT_HOME['lon'] - FIVE_M}, - LOCATION_MESSAGE_NOT_HOME) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(location_message['lat']) - self.assert_mobile_tracker_latitude(lost_keys_location_message['lat']) - self.assert_location_state('not_home') - self.assert_mobile_tracker_state('outer') - - def test_complex_movement_sticky_keys_beacon(self): - """Test a complex sequence which was previously broken.""" - # I am not_home - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.assert_location_state('outer') - - # gps to inner location and event, as actually happens with OwnTracks - location_message = build_message( - {'lat': REGION_GPS_ENTER_MESSAGE['lat'], - 'lon': REGION_GPS_ENTER_MESSAGE['lon']}, - LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - - # see keys mobile beacon and location message as actually happens - location_message = build_message( - {'lat': location_message['lat'] + FIVE_M, - 'lon': location_message['lon'] + FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_mobile_tracker_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') - - # region beacon enter inner event and location as actually happens - # with OwnTracks - location_message = build_message( - {'lat': location_message['lat'] + FIVE_M, - 'lon': location_message['lon'] + FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - - # This sequence of moves would cause keys to follow - # greg_phone around even after the OwnTracks sent - # a mobile beacon 'leave' event for the keys. - # leave keys - self.send_message(LOCATION_TOPIC, location_message) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') - - # leave inner region beacon - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') - - # enter inner region beacon - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - - # enter keys - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') - - # leave keys - self.send_message(LOCATION_TOPIC, location_message) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') - - # leave inner region beacon - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') - - # GPS leave inner region, I'm in the 'outer' region now - # but on GPS coords - leave_location_message = build_message( - {'lat': REGION_GPS_LEAVE_MESSAGE['lat'], - 'lon': REGION_GPS_LEAVE_MESSAGE['lon']}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, leave_location_message) - self.assert_location_state('outer') - self.assert_mobile_tracker_state('inner') - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_mobile_tracker_latitude(INNER_ZONE['latitude']) - - def test_waypoint_import_simple(self): - """Test a simple import of list of waypoints.""" - waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC, waypoints_message) - # Check if it made it into states - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) - assert wayp is not None - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[1]) - assert wayp is not None - - def test_waypoint_import_blacklist(self): - """Test import of list of waypoints for blacklisted user.""" - waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC_BLOCKED, waypoints_message) - # Check if it made it into states - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) - assert wayp is None - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[3]) - assert wayp is None - - def test_waypoint_import_no_whitelist(self): - """Test import of list of waypoints with no whitelist set.""" - @asyncio.coroutine - def mock_see(**kwargs): - """Fake see method for owntracks.""" - return - - test_config = { - CONF_PLATFORM: 'owntracks', - CONF_MAX_GPS_ACCURACY: 200, - CONF_WAYPOINT_IMPORT: True, - CONF_MQTT_TOPIC: 'owntracks/#', - } - run_coroutine_threadsafe(owntracks.async_setup_scanner( - self.hass, test_config, mock_see), self.hass.loop).result() - waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC_BLOCKED, waypoints_message) - # Check if it made it into states - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) - assert wayp is not None - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[3]) - assert wayp is not None - - def test_waypoint_import_bad_json(self): - """Test importing a bad JSON payload.""" - waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC, waypoints_message, True) - # Check if it made it into states - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) - assert wayp is None - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[3]) - assert wayp is None - - def test_waypoint_import_existing(self): - """Test importing a zone that exists.""" - waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC, waypoints_message) - # Get the first waypoint exported - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) - # Send an update - waypoints_message = WAYPOINTS_UPDATED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC, waypoints_message) - new_wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) - assert wayp == new_wayp - - def test_single_waypoint_import(self): - """Test single waypoint message.""" - waypoint_message = WAYPOINT_MESSAGE.copy() - self.send_message(WAYPOINT_TOPIC, waypoint_message) - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) - assert wayp is not None - - def test_not_implemented_message(self): - """Handle not implemented message type.""" - patch_handler = patch('homeassistant.components.device_tracker.' - 'owntracks.async_handle_not_impl_msg', - return_value=mock_coro(False)) - patch_handler.start() - assert not self.send_message(LWT_TOPIC, LWT_MESSAGE) - patch_handler.stop() - - def test_unsupported_message(self): - """Handle not implemented message type.""" - patch_handler = patch('homeassistant.components.device_tracker.' - 'owntracks.async_handle_unsupported_msg', - return_value=mock_coro(False)) - patch_handler.start() - assert not self.send_message(BAD_TOPIC, BAD_MESSAGE) - patch_handler.stop() + yield get_context + + patcher.stop() + + +async def send_message(hass, topic, message, corrupt=False): + """Test the sending of a message.""" + str_message = json.dumps(message) + if corrupt: + mod_message = BAD_JSON_PREFIX + str_message + BAD_JSON_SUFFIX + else: + mod_message = str_message + async_fire_mqtt_message(hass, topic, mod_message) + await hass.async_block_till_done() + await hass.async_block_till_done() + + +def assert_location_state(hass, location): + """Test the assertion of a location state.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.state == location + + +def assert_location_latitude(hass, latitude): + """Test the assertion of a location latitude.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.attributes.get('latitude') == latitude + + +def assert_location_longitude(hass, longitude): + """Test the assertion of a location longitude.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.attributes.get('longitude') == longitude + + +def assert_location_accuracy(hass, accuracy): + """Test the assertion of a location accuracy.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.attributes.get('gps_accuracy') == accuracy + + +def assert_location_source_type(hass, source_type): + """Test the assertion of source_type.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.attributes.get('source_type') == source_type + + +def assert_mobile_tracker_state(hass, location, beacon=IBEACON_DEVICE): + """Test the assertion of a mobile beacon tracker state.""" + dev_id = MOBILE_BEACON_FMT.format(beacon) + state = hass.states.get(dev_id) + assert state.state == location + + +def assert_mobile_tracker_latitude(hass, latitude, beacon=IBEACON_DEVICE): + """Test the assertion of a mobile beacon tracker latitude.""" + dev_id = MOBILE_BEACON_FMT.format(beacon) + state = hass.states.get(dev_id) + assert state.attributes.get('latitude') == latitude + + +def assert_mobile_tracker_accuracy(hass, accuracy, beacon=IBEACON_DEVICE): + """Test the assertion of a mobile beacon tracker accuracy.""" + dev_id = MOBILE_BEACON_FMT.format(beacon) + state = hass.states.get(dev_id) + assert state.attributes.get('gps_accuracy') == accuracy + + +async def test_location_invalid_devid(hass, context): + """Test the update of a location.""" + await send_message(hass, 'owntracks/paulus/nexus-5x', LOCATION_MESSAGE) + state = hass.states.get('device_tracker.paulus_nexus5x') + assert state.state == 'outer' + + +async def test_location_update(hass, context): + """Test the update of a location.""" + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_accuracy(hass, LOCATION_MESSAGE['acc']) + assert_location_state(hass, 'outer') + + +async def test_location_inaccurate_gps(hass, context): + """Test the location for inaccurate GPS information.""" + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_INACCURATE) + + # Ignored inaccurate GPS. Location remains at previous. + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_longitude(hass, LOCATION_MESSAGE['lon']) + + +async def test_location_zero_accuracy_gps(hass, context): + """Ignore the location for zero accuracy GPS information.""" + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_ZERO_ACCURACY) + + # Ignored inaccurate GPS. Location remains at previous. + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_longitude(hass, LOCATION_MESSAGE['lon']) + + +# ------------------------------------------------------------------------ +# GPS based event entry / exit testing +async def test_event_gps_entry_exit(hass, context): + """Test the entry event.""" + # Entering the owntracks circular region named "inner" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + + # Enter uses the zone's gps co-ords + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Updates ignored when in a zone + # note that LOCATION_MESSAGE is actually pretty far + # from INNER_ZONE and has good accuracy. I haven't + # received a transition message though so I'm still + # associated with the inner zone regardless of GPS. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + + # Exit switches back to GPS + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_accuracy(hass, REGION_GPS_LEAVE_MESSAGE['acc']) + assert_location_state(hass, 'outer') + + # Left clean zone state + assert not context().regions_entered[USER] + + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Now sending a location update moves me again. + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_accuracy(hass, LOCATION_MESSAGE['acc']) + + +async def test_event_gps_with_spaces(hass, context): + """Test the entry event.""" + message = build_message({'desc': "inner 2"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner 2') + + message = build_message({'desc': "inner 2"}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # Left clean zone state + assert not context().regions_entered[USER] + + +async def test_event_gps_entry_inaccurate(hass, context): + """Test the event for inaccurate entry.""" + # Set location to the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_INACCURATE) + + # I enter the zone even though the message GPS was inaccurate. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + +async def test_event_gps_entry_exit_inaccurate(hass, context): + """Test the event for inaccurate exit.""" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + + # Enter uses the zone's gps co-ords + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_INACCURATE) + + # Exit doesn't use inaccurate gps + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + # But does exit region correctly + assert not context().regions_entered[USER] + + +async def test_event_gps_entry_exit_zero_accuracy(hass, context): + """Test entry/exit events with accuracy zero.""" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_ZERO) + + # Enter uses the zone's gps co-ords + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_ZERO) + + # Exit doesn't use zero gps + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + # But does exit region correctly + assert not context().regions_entered[USER] + + +async def test_event_gps_exit_outside_zone_sets_away(hass, context): + """Test the event for exit zone.""" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # Exit message far away GPS location + message = build_message( + {'lon': 90.0, + 'lat': 90.0}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # Exit forces zone change to away + assert_location_state(hass, STATE_NOT_HOME) + + +async def test_event_gps_entry_exit_right_order(hass, context): + """Test the event for ordering.""" + # Enter inner zone + # Set location to the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # Enter inner2 zone + message = build_message( + {'desc': "inner_2"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner_2') + + # Exit inner_2 - should be in 'inner' + message = build_message( + {'desc': "inner_2"}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') + + # Exit inner - should be in 'outer' + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_accuracy(hass, REGION_GPS_LEAVE_MESSAGE['acc']) + assert_location_state(hass, 'outer') + + +async def test_event_gps_entry_exit_wrong_order(hass, context): + """Test the event for wrong order.""" + # Enter inner zone + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # Enter inner2 zone + message = build_message( + {'desc': "inner_2"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner_2') + + # Exit inner - should still be in 'inner_2' + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + assert_location_state(hass, 'inner_2') + + # Exit inner_2 - should be in 'outer' + message = build_message( + {'desc': "inner_2"}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_accuracy(hass, REGION_GPS_LEAVE_MESSAGE['acc']) + assert_location_state(hass, 'outer') + + +async def test_event_gps_entry_unknown_zone(hass, context): + """Test the event for unknown zone.""" + # Just treat as location update + message = build_message( + {'desc': "unknown"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_latitude(hass, REGION_GPS_ENTER_MESSAGE['lat']) + assert_location_state(hass, 'inner') + + +async def test_event_gps_exit_unknown_zone(hass, context): + """Test the event for unknown zone.""" + # Just treat as location update + message = build_message( + {'desc': "unknown"}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_state(hass, 'outer') + + +async def test_event_entry_zone_loading_dash(hass, context): + """Test the event for zone landing.""" + # Make sure the leading - is ignored + # Owntracks uses this to switch on hold + message = build_message( + {'desc': "-inner"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') + + +async def test_events_only_on(hass, context): + """Test events_only config suppresses location updates.""" + # Sending a location message that is not home + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + assert_location_state(hass, STATE_NOT_HOME) + + context().events_only = True + + # Enter and Leave messages + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER) + assert_location_state(hass, 'outer') + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) + assert_location_state(hass, STATE_NOT_HOME) + + # Sending a location message that is inside outer zone + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Ignored location update. Location remains at previous. + assert_location_state(hass, STATE_NOT_HOME) + + +async def test_events_only_off(hass, context): + """Test when events_only is False.""" + # Sending a location message that is not home + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + assert_location_state(hass, STATE_NOT_HOME) + + context().events_only = False + + # Enter and Leave messages + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER) + assert_location_state(hass, 'outer') + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) + assert_location_state(hass, STATE_NOT_HOME) + + # Sending a location message that is inside outer zone + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Location update processed + assert_location_state(hass, 'outer') + + +async def test_event_source_type_entry_exit(hass, context): + """Test the entry and exit events of source type.""" + # Entering the owntracks circular region named "inner" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + + # source_type should be gps when entering using gps. + assert_location_source_type(hass, 'gps') + + # owntracks shouldn't send beacon events with acc = 0 + await send_message(hass, EVENT_TOPIC, build_message( + {'acc': 1}, REGION_BEACON_ENTER_MESSAGE)) + + # We should be able to enter a beacon zone even inside a gps zone + assert_location_source_type(hass, 'bluetooth_le') + + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + + # source_type should be gps when leaving using gps. + assert_location_source_type(hass, 'gps') + + # owntracks shouldn't send beacon events with acc = 0 + await send_message(hass, EVENT_TOPIC, build_message( + {'acc': 1}, REGION_BEACON_LEAVE_MESSAGE)) + + assert_location_source_type(hass, 'bluetooth_le') + + +# Region Beacon based event entry / exit testing +async def test_event_region_entry_exit(hass, context): + """Test the entry event.""" + # Seeing a beacon named "inner" + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + + # Enter uses the zone's gps co-ords + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Updates ignored when in a zone + # note that LOCATION_MESSAGE is actually pretty far + # from INNER_ZONE and has good accuracy. I haven't + # received a transition message though so I'm still + # associated with the inner zone regardless of GPS. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + + # Exit switches back to GPS but the beacon has no coords + # so I am still located at the center of the inner region + # until I receive a location update. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + # Left clean zone state + assert not context().regions_entered[USER] + + # Now sending a location update moves me again. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_accuracy(hass, LOCATION_MESSAGE['acc']) + + +async def test_event_region_with_spaces(hass, context): + """Test the entry event.""" + message = build_message({'desc': "inner 2"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner 2') + + message = build_message({'desc': "inner 2"}, + REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # Left clean zone state + assert not context().regions_entered[USER] + + +async def test_event_region_entry_exit_right_order(hass, context): + """Test the event for ordering.""" + # Enter inner zone + # Set location to the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # See 'inner' region beacon + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # See 'inner_2' region beacon + message = build_message( + {'desc': "inner_2"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner_2') + + # Exit inner_2 - should be in 'inner' + message = build_message( + {'desc': "inner_2"}, + REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') + + # Exit inner - should be in 'outer' + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + + # I have not had an actual location update yet and my + # coordinates are set to the center of the last region I + # entered which puts me in the inner zone. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + +async def test_event_region_entry_exit_wrong_order(hass, context): + """Test the event for wrong order.""" + # Enter inner zone + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # Enter inner2 zone + message = build_message( + {'desc': "inner_2"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner_2') + + # Exit inner - should still be in 'inner_2' + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + assert_location_state(hass, 'inner_2') + + # Exit inner_2 - should be in 'outer' + message = build_message( + {'desc': "inner_2"}, + REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # I have not had an actual location update yet and my + # coordinates are set to the center of the last region I + # entered which puts me in the inner_2 zone. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner_2') + + +async def test_event_beacon_unknown_zone_no_location(hass, context): + """Test the event for unknown zone.""" + # A beacon which does not match a HA zone is the + # definition of a mobile beacon. In this case, "unknown" + # will be turned into device_tracker.beacon_unknown and + # that will be tracked at my current location. Except + # in this case my Device hasn't had a location message + # yet so it's in an odd state where it has state.state + # None and no GPS coords so set the beacon to. + hass.states.async_set(DEVICE_TRACKER_STATE, None) + + message = build_message( + {'desc': "unknown"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # My current state is None because I haven't seen a + # location message or a GPS or Region # Beacon event + # message. None is the state the test harness set for + # the Device during test case setup. + assert_location_state(hass, 'None') + + # home is the state of a Device constructed through + # the normal code path on it's first observation with + # the conditions I pass along. + assert_mobile_tracker_state(hass, 'home', 'unknown') + + +async def test_event_beacon_unknown_zone(hass, context): + """Test the event for unknown zone.""" + # A beacon which does not match a HA zone is the + # definition of a mobile beacon. In this case, "unknown" + # will be turned into device_tracker.beacon_unknown and + # that will be tracked at my current location. First I + # set my location so that my state is 'outer' + + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + assert_location_state(hass, 'outer') + + message = build_message( + {'desc': "unknown"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # My state is still outer and now the unknown beacon + # has joined me at outer. + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'outer', 'unknown') + + +async def test_event_beacon_entry_zone_loading_dash(hass, context): + """Test the event for beacon zone landing.""" + # Make sure the leading - is ignored + # Owntracks uses this to switch on hold + + message = build_message( + {'desc': "-inner"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') + + +# ------------------------------------------------------------------------ +# Mobile Beacon based event entry / exit testing +async def test_mobile_enter_move_beacon(hass, context): + """Test the movement of a beacon.""" + # I am in the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # I see the 'keys' beacon. I set the location of the + # beacon_keys tracker to my current device location. + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + + assert_mobile_tracker_latitude(hass, LOCATION_MESSAGE['lat']) + assert_mobile_tracker_state(hass, 'outer') + + # Location update to outside of defined zones. + # I am now 'not home' and neither are my keys. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + + assert_location_state(hass, STATE_NOT_HOME) + assert_mobile_tracker_state(hass, STATE_NOT_HOME) + + not_home_lat = LOCATION_MESSAGE_NOT_HOME['lat'] + assert_location_latitude(hass, not_home_lat) + assert_mobile_tracker_latitude(hass, not_home_lat) + + +async def test_mobile_enter_exit_region_beacon(hass, context): + """Test the enter and the exit of a mobile beacon.""" + # I am in the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # I see a new mobile beacon + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + assert_mobile_tracker_latitude(hass, OUTER_ZONE['latitude']) + assert_mobile_tracker_state(hass, 'outer') + + # GPS enter message should move beacon + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + + assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude']) + assert_mobile_tracker_state(hass, REGION_GPS_ENTER_MESSAGE['desc']) + + # Exit inner zone to outer zone should move beacon to + # center of outer zone + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + assert_mobile_tracker_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_mobile_tracker_state(hass, 'outer') + + +async def test_mobile_exit_move_beacon(hass, context): + """Test the exit move of a beacon.""" + # I am in the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # I see a new mobile beacon + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + assert_mobile_tracker_latitude(hass, OUTER_ZONE['latitude']) + assert_mobile_tracker_state(hass, 'outer') + + # Exit mobile beacon, should set location + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + + assert_mobile_tracker_latitude(hass, OUTER_ZONE['latitude']) + assert_mobile_tracker_state(hass, 'outer') + + # Move after exit should do nothing + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + assert_mobile_tracker_latitude(hass, OUTER_ZONE['latitude']) + assert_mobile_tracker_state(hass, 'outer') + + +async def test_mobile_multiple_async_enter_exit(hass, context): + """Test the multiple entering.""" + # Test race condition + for _ in range(0, 20): + async_fire_mqtt_message( + hass, EVENT_TOPIC, + json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE)) + async_fire_mqtt_message( + hass, EVENT_TOPIC, + json.dumps(MOBILE_BEACON_LEAVE_EVENT_MESSAGE)) + + async_fire_mqtt_message( + hass, EVENT_TOPIC, + json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE)) + + await hass.async_block_till_done() + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + assert len(context().mobile_beacons_active['greg_phone']) == \ + 0 + + +async def test_mobile_multiple_enter_exit(hass, context): + """Test the multiple entering.""" + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + + assert len(context().mobile_beacons_active['greg_phone']) == \ + 0 + + +async def test_complex_movement(hass, context): + """Test a complex sequence representative of real-world use.""" + # I am in the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + assert_location_state(hass, 'outer') + + # gps to inner location and event, as actually happens with OwnTracks + location_message = build_message( + {'lat': REGION_GPS_ENTER_MESSAGE['lat'], + 'lon': REGION_GPS_ENTER_MESSAGE['lon']}, + LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + + # region beacon enter inner event and location as actually happens + # with OwnTracks + location_message = build_message( + {'lat': location_message['lat'] + FIVE_M, + 'lon': location_message['lon'] + FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + + # see keys mobile beacon and location message as actually happens + location_message = build_message( + {'lat': location_message['lat'] + FIVE_M, + 'lon': location_message['lon'] + FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') + + # Slightly odd, I leave the location by gps before I lose + # sight of the region beacon. This is also a little odd in + # that my GPS coords are now in the 'outer' zone but I did not + # "enter" that zone when I started up so my location is not + # the center of OUTER_ZONE, but rather just my GPS location. + + # gps out of inner event and location + location_message = build_message( + {'lat': REGION_GPS_LEAVE_MESSAGE['lat'], + 'lon': REGION_GPS_LEAVE_MESSAGE['lon']}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_mobile_tracker_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'outer') + + # region beacon leave inner + location_message = build_message( + {'lat': location_message['lat'] - FIVE_M, + 'lon': location_message['lon'] - FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, location_message['lat']) + assert_mobile_tracker_latitude(hass, location_message['lat']) + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'outer') + + # lose keys mobile beacon + lost_keys_location_message = build_message( + {'lat': location_message['lat'] - FIVE_M, + 'lon': location_message['lon'] - FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, lost_keys_location_message) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + assert_location_latitude(hass, lost_keys_location_message['lat']) + assert_mobile_tracker_latitude(hass, lost_keys_location_message['lat']) + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'outer') + + # gps leave outer + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) + assert_location_latitude(hass, LOCATION_MESSAGE_NOT_HOME['lat']) + assert_mobile_tracker_latitude(hass, lost_keys_location_message['lat']) + assert_location_state(hass, 'not_home') + assert_mobile_tracker_state(hass, 'outer') + + # location move not home + location_message = build_message( + {'lat': LOCATION_MESSAGE_NOT_HOME['lat'] - FIVE_M, + 'lon': LOCATION_MESSAGE_NOT_HOME['lon'] - FIVE_M}, + LOCATION_MESSAGE_NOT_HOME) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, location_message['lat']) + assert_mobile_tracker_latitude(hass, lost_keys_location_message['lat']) + assert_location_state(hass, 'not_home') + assert_mobile_tracker_state(hass, 'outer') + + +async def test_complex_movement_sticky_keys_beacon(hass, context): + """Test a complex sequence which was previously broken.""" + # I am not_home + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + assert_location_state(hass, 'outer') + + # gps to inner location and event, as actually happens with OwnTracks + location_message = build_message( + {'lat': REGION_GPS_ENTER_MESSAGE['lat'], + 'lon': REGION_GPS_ENTER_MESSAGE['lon']}, + LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + + # see keys mobile beacon and location message as actually happens + location_message = build_message( + {'lat': location_message['lat'] + FIVE_M, + 'lon': location_message['lon'] + FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') + + # region beacon enter inner event and location as actually happens + # with OwnTracks + location_message = build_message( + {'lat': location_message['lat'] + FIVE_M, + 'lon': location_message['lon'] + FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + + # This sequence of moves would cause keys to follow + # greg_phone around even after the OwnTracks sent + # a mobile beacon 'leave' event for the keys. + # leave keys + await send_message(hass, LOCATION_TOPIC, location_message) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') + + # leave inner region beacon + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') + + # enter inner region beacon + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + + # enter keys + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') + + # leave keys + await send_message(hass, LOCATION_TOPIC, location_message) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') + + # leave inner region beacon + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') + + # GPS leave inner region, I'm in the 'outer' region now + # but on GPS coords + leave_location_message = build_message( + {'lat': REGION_GPS_LEAVE_MESSAGE['lat'], + 'lon': REGION_GPS_LEAVE_MESSAGE['lon']}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, leave_location_message) + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'inner') + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude']) + + +async def test_waypoint_import_simple(hass, context): + """Test a simple import of list of waypoints.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC, waypoints_message) + # Check if it made it into states + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + assert wayp is not None + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[1]) + assert wayp is not None + + +async def test_waypoint_import_blacklist(hass, context): + """Test import of list of waypoints for blacklisted user.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC_BLOCKED, waypoints_message) + # Check if it made it into states + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[2]) + assert wayp is None + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[3]) + assert wayp is None + + +async def test_waypoint_import_no_whitelist(hass, context): + """Test import of list of waypoints with no whitelist set.""" + async def mock_see(**kwargs): + """Fake see method for owntracks.""" + return + + test_config = { + CONF_PLATFORM: 'owntracks', + CONF_MAX_GPS_ACCURACY: 200, + CONF_WAYPOINT_IMPORT: True, + CONF_MQTT_TOPIC: 'owntracks/#', + } + await owntracks.async_setup_scanner(hass, test_config, mock_see) + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC_BLOCKED, waypoints_message) + # Check if it made it into states + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[2]) + assert wayp is not None + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[3]) + assert wayp is not None + + +async def test_waypoint_import_bad_json(hass, context): + """Test importing a bad JSON payload.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC, waypoints_message, True) + # Check if it made it into states + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[2]) + assert wayp is None + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[3]) + assert wayp is None + + +async def test_waypoint_import_existing(hass, context): + """Test importing a zone that exists.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC, waypoints_message) + # Get the first waypoint exported + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + # Send an update + waypoints_message = WAYPOINTS_UPDATED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC, waypoints_message) + new_wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + assert wayp == new_wayp + + +async def test_single_waypoint_import(hass, context): + """Test single waypoint message.""" + waypoint_message = WAYPOINT_MESSAGE.copy() + await send_message(hass, WAYPOINT_TOPIC, waypoint_message) + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + assert wayp is not None + + +async def test_not_implemented_message(hass, context): + """Handle not implemented message type.""" + patch_handler = patch('homeassistant.components.device_tracker.' + 'owntracks.async_handle_not_impl_msg', + return_value=mock_coro(False)) + patch_handler.start() + assert not await send_message(hass, LWT_TOPIC, LWT_MESSAGE) + patch_handler.stop() + + +async def test_unsupported_message(hass, context): + """Handle not implemented message type.""" + patch_handler = patch('homeassistant.components.device_tracker.' + 'owntracks.async_handle_unsupported_msg', + return_value=mock_coro(False)) + patch_handler.start() + assert not await send_message(hass, BAD_TOPIC, BAD_MESSAGE) + patch_handler.stop() def generate_ciphers(secret): @@ -1310,162 +1342,162 @@ def mock_decrypt(ciphertext, key): return len(TEST_SECRET_KEY), mock_decrypt -class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): - """Test the OwnTrack sensor.""" - - # pylint: disable=invalid-name - - def setup_method(self, method): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - mock_mqtt_component(self.hass) - mock_component(self.hass, 'group') - mock_component(self.hass, 'zone') - - self.patch_load = patch( - 'homeassistant.components.device_tracker.async_load_config', - return_value=mock_coro([])) - self.patch_load.start() - - self.patch_save = patch('homeassistant.components.device_tracker.' - 'DeviceTracker.async_update_config') - self.patch_save.start() - - def teardown_method(self, method): - """Tear down resources.""" - self.patch_load.stop() - self.patch_save.stop() - self.hass.stop() - - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload(self): - """Test encrypted payload.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: TEST_SECRET_KEY, - }}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_topic_key(self): - """Test encrypted payload with a topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - LOCATION_TOPIC: TEST_SECRET_KEY, - }}}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_no_key(self): - """Test encrypted payload with no key, .""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - # key missing - }}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - assert self.hass.states.get(DEVICE_TRACKER_STATE) is None - - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_wrong_key(self): - """Test encrypted payload with wrong key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: 'wrong key', - }}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - assert self.hass.states.get(DEVICE_TRACKER_STATE) is None - - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_wrong_topic_key(self): - """Test encrypted payload with wrong topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - LOCATION_TOPIC: 'wrong key' - }}}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - assert self.hass.states.get(DEVICE_TRACKER_STATE) is None - - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_no_topic_key(self): - """Test encrypted payload with no topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - 'owntracks/{}/{}'.format(USER, 'otherdevice'): 'foobar' - }}}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - assert self.hass.states.get(DEVICE_TRACKER_STATE) is None - +@pytest.fixture +def config_context(hass, setup_comp): + """Set up the mocked context.""" + patch_load = patch( + 'homeassistant.components.device_tracker.async_load_config', + return_value=mock_coro([])) + patch_load.start() + + patch_save = patch('homeassistant.components.device_tracker.' + 'DeviceTracker.async_update_config') + patch_save.start() + + yield + + patch_load.stop() + patch_save.stop() + + +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload(hass, config_context): + """Test encrypted payload.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_SECRET: TEST_SECRET_KEY, + }}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + + +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_topic_key(hass, config_context): + """Test encrypted payload with a topic key.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_SECRET: { + LOCATION_TOPIC: TEST_SECRET_KEY, + }}}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + + +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_no_key(hass, config_context): + """Test encrypted payload with no key, .""" + assert hass.states.get(DEVICE_TRACKER_STATE) is None + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + # key missing + }}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert hass.states.get(DEVICE_TRACKER_STATE) is None + + +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_wrong_key(hass, config_context): + """Test encrypted payload with wrong key.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_SECRET: 'wrong key', + }}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert hass.states.get(DEVICE_TRACKER_STATE) is None + + +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_wrong_topic_key(hass, config_context): + """Test encrypted payload with wrong topic key.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_SECRET: { + LOCATION_TOPIC: 'wrong key' + }}}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert hass.states.get(DEVICE_TRACKER_STATE) is None + + +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_no_topic_key(hass, config_context): + """Test encrypted payload with no topic key.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_SECRET: { + 'owntracks/{}/{}'.format(USER, 'otherdevice'): 'foobar' + }}}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert hass.states.get(DEVICE_TRACKER_STATE) is None + + +async def test_encrypted_payload_libsodium(hass, config_context): + """Test sending encrypted message payload.""" try: - import libnacl + import libnacl # noqa: F401 except (ImportError, OSError): - libnacl = None + pytest.skip("libnacl/libsodium is not installed") + return + + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_SECRET: TEST_SECRET_KEY, + }}) - @unittest.skipUnless(libnacl, "libnacl/libsodium is not installed") - def test_encrypted_payload_libsodium(self): - """Test sending encrypted message payload.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: TEST_SECRET_KEY, - }}) + await send_message(hass, LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) - self.send_message(LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - def test_customized_mqtt_topic(self): - """Test subscribing to a custom mqtt topic.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_MQTT_TOPIC: 'mytracks/#', - }}) +async def test_customized_mqtt_topic(hass, config_context): + """Test subscribing to a custom mqtt topic.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_MQTT_TOPIC: 'mytracks/#', + }}) - topic = 'mytracks/{}/{}'.format(USER, DEVICE) + topic = 'mytracks/{}/{}'.format(USER, DEVICE) - self.send_message(topic, LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) + await send_message(hass, topic, LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) - def test_region_mapping(self): - """Test region to zone mapping.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_REGION_MAPPING: { - 'foo': 'inner' - }, - }}) - self.hass.states.set( - 'zone.inner', 'zoning', INNER_ZONE) +async def test_region_mapping(hass, config_context): + """Test region to zone mapping.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'owntracks', + CONF_REGION_MAPPING: { + 'foo': 'inner' + }, + }}) + + hass.states.async_set( + 'zone.inner', 'zoning', INNER_ZONE) - message = build_message({'desc': 'foo'}, REGION_GPS_ENTER_MESSAGE) - assert message['desc'] == 'foo' + message = build_message({'desc': 'foo'}, REGION_GPS_ENTER_MESSAGE) + assert message['desc'] == 'foo' - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') From f860cac4ea5ab1276dd1e228d92e80433651cc50 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 28 Nov 2018 22:20:13 +0100 Subject: [PATCH 112/325] OwnTracks Config Entry (#18759) * OwnTracks Config Entry * Fix test * Fix headers * Lint * Username for android only * Update translations * Tweak translation * Create config entry if not there * Update reqs * Types * Lint --- .../components/device_tracker/__init__.py | 11 + .../components/device_tracker/owntracks.py | 158 +------------ .../device_tracker/owntracks_http.py | 82 ------- .../owntracks/.translations/en.json | 17 ++ .../components/owntracks/__init__.py | 219 ++++++++++++++++++ .../components/owntracks/config_flow.py | 79 +++++++ .../components/owntracks/strings.json | 17 ++ homeassistant/config_entries.py | 1 + homeassistant/setup.py | 34 ++- requirements_all.txt | 3 +- .../device_tracker/test_owntracks.py | 154 ++++++------ tests/components/owntracks/__init__.py | 1 + .../components/owntracks/test_config_flow.py | 1 + .../test_init.py} | 97 +++++--- tests/test_setup.py | 35 ++- 15 files changed, 554 insertions(+), 355 deletions(-) delete mode 100644 homeassistant/components/device_tracker/owntracks_http.py create mode 100644 homeassistant/components/owntracks/.translations/en.json create mode 100644 homeassistant/components/owntracks/__init__.py create mode 100644 homeassistant/components/owntracks/config_flow.py create mode 100644 homeassistant/components/owntracks/strings.json create mode 100644 tests/components/owntracks/__init__.py create mode 100644 tests/components/owntracks/test_config_flow.py rename tests/components/{device_tracker/test_owntracks_http.py => owntracks/test_init.py} (51%) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index a43a7c93bdc75..ad792d035ccd3 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -182,6 +182,9 @@ async def async_setup_platform(p_type, p_config, disc_info=None): setup = await hass.async_add_job( platform.setup_scanner, hass, p_config, tracker.see, disc_info) + elif hasattr(platform, 'async_setup_entry'): + setup = await platform.async_setup_entry( + hass, p_config, tracker.async_see) else: raise HomeAssistantError("Invalid device_tracker platform.") @@ -197,6 +200,8 @@ async def async_setup_platform(p_type, p_config, disc_info=None): except Exception: # pylint: disable=broad-except _LOGGER.exception("Error setting up platform %s", p_type) + hass.data[DOMAIN] = async_setup_platform + setup_tasks = [async_setup_platform(p_type, p_config) for p_type, p_config in config_per_platform(config, DOMAIN)] if setup_tasks: @@ -230,6 +235,12 @@ async def async_see_service(call): return True +async def async_setup_entry(hass, entry): + """Set up an entry.""" + await hass.data[DOMAIN](entry.domain, entry) + return True + + class DeviceTracker: """Representation of a device tracker.""" diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 10f71450f69ab..ae2b9d6146b29 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -7,55 +7,29 @@ import base64 import json import logging -from collections import defaultdict -import voluptuous as vol - -from homeassistant.components import mqtt -import homeassistant.helpers.config_validation as cv from homeassistant.components import zone as zone_comp from homeassistant.components.device_tracker import ( - PLATFORM_SCHEMA, ATTR_SOURCE_TYPE, SOURCE_TYPE_BLUETOOTH_LE, - SOURCE_TYPE_GPS + ATTR_SOURCE_TYPE, SOURCE_TYPE_BLUETOOTH_LE, SOURCE_TYPE_GPS ) +from homeassistant.components.owntracks import DOMAIN as OT_DOMAIN from homeassistant.const import STATE_HOME -from homeassistant.core import callback from homeassistant.util import slugify, decorator -REQUIREMENTS = ['libnacl==1.6.1'] + +DEPENDENCIES = ['owntracks'] _LOGGER = logging.getLogger(__name__) HANDLERS = decorator.Registry() -BEACON_DEV_ID = 'beacon' - -CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' -CONF_SECRET = 'secret' -CONF_WAYPOINT_IMPORT = 'waypoints' -CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist' -CONF_MQTT_TOPIC = 'mqtt_topic' -CONF_REGION_MAPPING = 'region_mapping' -CONF_EVENTS_ONLY = 'events_only' - -DEPENDENCIES = ['mqtt'] - -DEFAULT_OWNTRACKS_TOPIC = 'owntracks/#' -REGION_MAPPING = {} -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), - vol.Optional(CONF_WAYPOINT_IMPORT, default=True): cv.boolean, - vol.Optional(CONF_EVENTS_ONLY, default=False): cv.boolean, - vol.Optional(CONF_MQTT_TOPIC, default=DEFAULT_OWNTRACKS_TOPIC): - mqtt.valid_subscribe_topic, - vol.Optional(CONF_WAYPOINT_WHITELIST): vol.All( - cv.ensure_list, [cv.string]), - vol.Optional(CONF_SECRET): vol.Any( - vol.Schema({vol.Optional(cv.string): cv.string}), - cv.string), - vol.Optional(CONF_REGION_MAPPING, default=REGION_MAPPING): dict -}) +async def async_setup_entry(hass, entry, async_see): + """Set up OwnTracks based off an entry.""" + hass.data[OT_DOMAIN]['context'].async_see = async_see + hass.helpers.dispatcher.async_dispatcher_connect( + OT_DOMAIN, async_handle_message) + return True def get_cipher(): @@ -72,29 +46,6 @@ def decrypt(ciphertext, key): return (KEYLEN, decrypt) -async def async_setup_scanner(hass, config, async_see, discovery_info=None): - """Set up an OwnTracks tracker.""" - context = context_from_config(async_see, config) - - async def async_handle_mqtt_message(topic, payload, qos): - """Handle incoming OwnTracks message.""" - try: - message = json.loads(payload) - except ValueError: - # If invalid JSON - _LOGGER.error("Unable to parse payload as JSON: %s", payload) - return - - message['topic'] = topic - - await async_handle_message(hass, context, message) - - await mqtt.async_subscribe( - hass, context.mqtt_topic, async_handle_mqtt_message, 1) - - return True - - def _parse_topic(topic, subscribe_topic): """Parse an MQTT topic {sub_topic}/user/dev, return (user, dev) tuple. @@ -202,93 +153,6 @@ def _decrypt_payload(secret, topic, ciphertext): return None -def context_from_config(async_see, config): - """Create an async context from Home Assistant config.""" - max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) - waypoint_import = config.get(CONF_WAYPOINT_IMPORT) - waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) - secret = config.get(CONF_SECRET) - region_mapping = config.get(CONF_REGION_MAPPING) - events_only = config.get(CONF_EVENTS_ONLY) - mqtt_topic = config.get(CONF_MQTT_TOPIC) - - return OwnTracksContext(async_see, secret, max_gps_accuracy, - waypoint_import, waypoint_whitelist, - region_mapping, events_only, mqtt_topic) - - -class OwnTracksContext: - """Hold the current OwnTracks context.""" - - def __init__(self, async_see, secret, max_gps_accuracy, import_waypoints, - waypoint_whitelist, region_mapping, events_only, mqtt_topic): - """Initialize an OwnTracks context.""" - self.async_see = async_see - self.secret = secret - self.max_gps_accuracy = max_gps_accuracy - self.mobile_beacons_active = defaultdict(set) - self.regions_entered = defaultdict(list) - self.import_waypoints = import_waypoints - self.waypoint_whitelist = waypoint_whitelist - self.region_mapping = region_mapping - self.events_only = events_only - self.mqtt_topic = mqtt_topic - - @callback - def async_valid_accuracy(self, message): - """Check if we should ignore this message.""" - acc = message.get('acc') - - if acc is None: - return False - - try: - acc = float(acc) - except ValueError: - return False - - if acc == 0: - _LOGGER.warning( - "Ignoring %s update because GPS accuracy is zero: %s", - message['_type'], message) - return False - - if self.max_gps_accuracy is not None and \ - acc > self.max_gps_accuracy: - _LOGGER.info("Ignoring %s update because expected GPS " - "accuracy %s is not met: %s", - message['_type'], self.max_gps_accuracy, - message) - return False - - return True - - async def async_see_beacons(self, hass, dev_id, kwargs_param): - """Set active beacons to the current location.""" - kwargs = kwargs_param.copy() - - # Mobile beacons should always be set to the location of the - # tracking device. I get the device state and make the necessary - # changes to kwargs. - device_tracker_state = hass.states.get( - "device_tracker.{}".format(dev_id)) - - if device_tracker_state is not None: - acc = device_tracker_state.attributes.get("gps_accuracy") - lat = device_tracker_state.attributes.get("latitude") - lon = device_tracker_state.attributes.get("longitude") - kwargs['gps_accuracy'] = acc - kwargs['gps'] = (lat, lon) - - # the battery state applies to the tracking device, not the beacon - # kwargs location is the beacon's configured lat/lon - kwargs.pop('battery', None) - for beacon in self.mobile_beacons_active[dev_id]: - kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) - kwargs['host_name'] = beacon - await self.async_see(**kwargs) - - @HANDLERS.register('location') async def async_handle_location_message(hass, context, message): """Handle a location message.""" @@ -485,6 +349,8 @@ async def async_handle_message(hass, context, message): """Handle an OwnTracks message.""" msgtype = message.get('_type') + _LOGGER.debug("Received %s", message) + handler = HANDLERS.get(msgtype, async_handle_unsupported_msg) await handler(hass, context, message) diff --git a/homeassistant/components/device_tracker/owntracks_http.py b/homeassistant/components/device_tracker/owntracks_http.py deleted file mode 100644 index b9f379e753433..0000000000000 --- a/homeassistant/components/device_tracker/owntracks_http.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -Device tracker platform that adds support for OwnTracks over HTTP. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.owntracks_http/ -""" -import json -import logging -import re - -from aiohttp.web import Response -import voluptuous as vol - -# pylint: disable=unused-import -from homeassistant.components.device_tracker.owntracks import ( # NOQA - PLATFORM_SCHEMA, REQUIREMENTS, async_handle_message, context_from_config) -from homeassistant.const import CONF_WEBHOOK_ID -import homeassistant.helpers.config_validation as cv - -DEPENDENCIES = ['webhook'] - -_LOGGER = logging.getLogger(__name__) - -EVENT_RECEIVED = 'owntracks_http_webhook_received' -EVENT_RESPONSE = 'owntracks_http_webhook_response_' - -DOMAIN = 'device_tracker.owntracks_http' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_WEBHOOK_ID): cv.string -}) - - -async def async_setup_scanner(hass, config, async_see, discovery_info=None): - """Set up OwnTracks HTTP component.""" - context = context_from_config(async_see, config) - - subscription = context.mqtt_topic - topic = re.sub('/#$', '', subscription) - - async def handle_webhook(hass, webhook_id, request): - """Handle webhook callback.""" - headers = request.headers - data = dict() - - if 'X-Limit-U' in headers: - data['user'] = headers['X-Limit-U'] - elif 'u' in request.query: - data['user'] = request.query['u'] - else: - return Response( - body=json.dumps({'error': 'You need to supply username.'}), - content_type="application/json" - ) - - if 'X-Limit-D' in headers: - data['device'] = headers['X-Limit-D'] - elif 'd' in request.query: - data['device'] = request.query['d'] - else: - return Response( - body=json.dumps({'error': 'You need to supply device name.'}), - content_type="application/json" - ) - - message = await request.json() - - message['topic'] = '{}/{}/{}'.format(topic, data['user'], - data['device']) - - try: - await async_handle_message(hass, context, message) - return Response(body=json.dumps([]), status=200, - content_type="application/json") - except ValueError: - _LOGGER.error("Received invalid JSON") - return None - - hass.components.webhook.async_register( - 'owntracks', 'OwnTracks', config['webhook_id'], handle_webhook) - - return True diff --git a/homeassistant/components/owntracks/.translations/en.json b/homeassistant/components/owntracks/.translations/en.json new file mode 100644 index 0000000000000..a34077a0a8329 --- /dev/null +++ b/homeassistant/components/owntracks/.translations/en.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "Only a single instance is necessary." + }, + "create_entry": { + "default": "\n\nOn Android, open [the OwnTracks app]({android_url}), go to preferences -> connection. Change the following settings:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\nOn iOS, open [the OwnTracks app]({ios_url}), tap (i) icon in top left -> settings. Change the following settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\nSee [the documentation]({docs_url}) for more information." + }, + "step": { + "user": { + "description": "Are you sure you want to set up OwnTracks?", + "title": "Set up OwnTracks" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py new file mode 100644 index 0000000000000..a5da7f5fc483d --- /dev/null +++ b/homeassistant/components/owntracks/__init__.py @@ -0,0 +1,219 @@ +"""Component for OwnTracks.""" +from collections import defaultdict +import json +import logging +import re + +from aiohttp.web import json_response +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.core import callback +from homeassistant.components import mqtt +from homeassistant.setup import async_when_setup +import homeassistant.helpers.config_validation as cv + +from .config_flow import CONF_SECRET + +DOMAIN = "owntracks" +REQUIREMENTS = ['libnacl==1.6.1'] +DEPENDENCIES = ['device_tracker', 'webhook'] + +CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' +CONF_WAYPOINT_IMPORT = 'waypoints' +CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist' +CONF_MQTT_TOPIC = 'mqtt_topic' +CONF_REGION_MAPPING = 'region_mapping' +CONF_EVENTS_ONLY = 'events_only' +BEACON_DEV_ID = 'beacon' + +DEFAULT_OWNTRACKS_TOPIC = 'owntracks/#' + +CONFIG_SCHEMA = vol.Schema({ + vol.Optional(DOMAIN, default={}): { + vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), + vol.Optional(CONF_WAYPOINT_IMPORT, default=True): cv.boolean, + vol.Optional(CONF_EVENTS_ONLY, default=False): cv.boolean, + vol.Optional(CONF_MQTT_TOPIC, default=DEFAULT_OWNTRACKS_TOPIC): + mqtt.valid_subscribe_topic, + vol.Optional(CONF_WAYPOINT_WHITELIST): vol.All( + cv.ensure_list, [cv.string]), + vol.Optional(CONF_SECRET): vol.Any( + vol.Schema({vol.Optional(cv.string): cv.string}), + cv.string), + vol.Optional(CONF_REGION_MAPPING, default={}): dict, + vol.Optional(CONF_WEBHOOK_ID): cv.string, + } +}, extra=vol.ALLOW_EXTRA) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass, config): + """Initialize OwnTracks component.""" + hass.data[DOMAIN] = { + 'config': config[DOMAIN] + } + if not hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, + data={} + )) + + return True + + +async def async_setup_entry(hass, entry): + """Set up OwnTracks entry.""" + config = hass.data[DOMAIN]['config'] + max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) + waypoint_import = config.get(CONF_WAYPOINT_IMPORT) + waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) + secret = config.get(CONF_SECRET) or entry.data[CONF_SECRET] + region_mapping = config.get(CONF_REGION_MAPPING) + events_only = config.get(CONF_EVENTS_ONLY) + mqtt_topic = config.get(CONF_MQTT_TOPIC) + + context = OwnTracksContext(hass, secret, max_gps_accuracy, + waypoint_import, waypoint_whitelist, + region_mapping, events_only, mqtt_topic) + + webhook_id = config.get(CONF_WEBHOOK_ID) or entry.data[CONF_WEBHOOK_ID] + + hass.data[DOMAIN]['context'] = context + + async_when_setup(hass, 'mqtt', async_connect_mqtt) + + hass.components.webhook.async_register( + DOMAIN, 'OwnTracks', webhook_id, handle_webhook) + + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + entry, 'device_tracker')) + + return True + + +async def async_connect_mqtt(hass, component): + """Subscribe to MQTT topic.""" + context = hass.data[DOMAIN]['context'] + + async def async_handle_mqtt_message(topic, payload, qos): + """Handle incoming OwnTracks message.""" + try: + message = json.loads(payload) + except ValueError: + # If invalid JSON + _LOGGER.error("Unable to parse payload as JSON: %s", payload) + return + + message['topic'] = topic + hass.helpers.dispatcher.async_dispatcher_send( + DOMAIN, hass, context, message) + + await hass.components.mqtt.async_subscribe( + context.mqtt_topic, async_handle_mqtt_message, 1) + + return True + + +async def handle_webhook(hass, webhook_id, request): + """Handle webhook callback.""" + context = hass.data[DOMAIN]['context'] + message = await request.json() + + # Android doesn't populate topic + if 'topic' not in message: + headers = request.headers + user = headers.get('X-Limit-U') + device = headers.get('X-Limit-D', user) + + if user is None: + _LOGGER.warning('Set a username in Connection -> Identification') + return json_response( + {'error': 'You need to supply username.'}, + status=400 + ) + + topic_base = re.sub('/#$', '', context.mqtt_topic) + message['topic'] = '{}/{}/{}'.format(topic_base, user, device) + + hass.helpers.dispatcher.async_dispatcher_send( + DOMAIN, hass, context, message) + return json_response([]) + + +class OwnTracksContext: + """Hold the current OwnTracks context.""" + + def __init__(self, hass, secret, max_gps_accuracy, import_waypoints, + waypoint_whitelist, region_mapping, events_only, mqtt_topic): + """Initialize an OwnTracks context.""" + self.hass = hass + self.secret = secret + self.max_gps_accuracy = max_gps_accuracy + self.mobile_beacons_active = defaultdict(set) + self.regions_entered = defaultdict(list) + self.import_waypoints = import_waypoints + self.waypoint_whitelist = waypoint_whitelist + self.region_mapping = region_mapping + self.events_only = events_only + self.mqtt_topic = mqtt_topic + + @callback + def async_valid_accuracy(self, message): + """Check if we should ignore this message.""" + acc = message.get('acc') + + if acc is None: + return False + + try: + acc = float(acc) + except ValueError: + return False + + if acc == 0: + _LOGGER.warning( + "Ignoring %s update because GPS accuracy is zero: %s", + message['_type'], message) + return False + + if self.max_gps_accuracy is not None and \ + acc > self.max_gps_accuracy: + _LOGGER.info("Ignoring %s update because expected GPS " + "accuracy %s is not met: %s", + message['_type'], self.max_gps_accuracy, + message) + return False + + return True + + async def async_see(self, **data): + """Send a see message to the device tracker.""" + await self.hass.components.device_tracker.async_see(**data) + + async def async_see_beacons(self, hass, dev_id, kwargs_param): + """Set active beacons to the current location.""" + kwargs = kwargs_param.copy() + + # Mobile beacons should always be set to the location of the + # tracking device. I get the device state and make the necessary + # changes to kwargs. + device_tracker_state = hass.states.get( + "device_tracker.{}".format(dev_id)) + + if device_tracker_state is not None: + acc = device_tracker_state.attributes.get("gps_accuracy") + lat = device_tracker_state.attributes.get("latitude") + lon = device_tracker_state.attributes.get("longitude") + kwargs['gps_accuracy'] = acc + kwargs['gps'] = (lat, lon) + + # the battery state applies to the tracking device, not the beacon + # kwargs location is the beacon's configured lat/lon + kwargs.pop('battery', None) + for beacon in self.mobile_beacons_active[dev_id]: + kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) + kwargs['host_name'] = beacon + await self.async_see(**kwargs) diff --git a/homeassistant/components/owntracks/config_flow.py b/homeassistant/components/owntracks/config_flow.py new file mode 100644 index 0000000000000..8836294642833 --- /dev/null +++ b/homeassistant/components/owntracks/config_flow.py @@ -0,0 +1,79 @@ +"""Config flow for OwnTracks.""" +from homeassistant import config_entries +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.auth.util import generate_secret + +CONF_SECRET = 'secret' + + +def supports_encryption(): + """Test if we support encryption.""" + try: + # pylint: disable=unused-variable + import libnacl # noqa + return True + except OSError: + return False + + +@config_entries.HANDLERS.register('owntracks') +class OwnTracksFlow(config_entries.ConfigFlow): + """Set up OwnTracks.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle a user initiated set up flow to create OwnTracks webhook.""" + if self._async_current_entries(): + return self.async_abort(reason='one_instance_allowed') + + if user_input is None: + return self.async_show_form( + step_id='user', + ) + + webhook_id = self.hass.components.webhook.async_generate_id() + webhook_url = \ + self.hass.components.webhook.async_generate_url(webhook_id) + + secret = generate_secret(16) + + if supports_encryption(): + secret_desc = ( + "The encryption key is {secret} " + "(on Android under preferences -> advanced)") + else: + secret_desc = ( + "Encryption is not supported because libsodium is not " + "installed.") + + return self.async_create_entry( + title="OwnTracks", + data={ + CONF_WEBHOOK_ID: webhook_id, + CONF_SECRET: secret + }, + description_placeholders={ + 'secret': secret_desc, + 'webhook_url': webhook_url, + 'android_url': + 'https://play.google.com/store/apps/details?' + 'id=org.owntracks.android', + 'ios_url': + 'https://itunes.apple.com/us/app/owntracks/id692424691?mt=8', + 'docs_url': + 'https://www.home-assistant.io/components/owntracks/' + } + ) + + async def async_step_import(self, user_input): + """Import a config flow from configuration.""" + webhook_id = self.hass.components.webhook.async_generate_id() + secret = generate_secret(16) + return self.async_create_entry( + title="OwnTracks", + data={ + CONF_WEBHOOK_ID: webhook_id, + CONF_SECRET: secret + } + ) diff --git a/homeassistant/components/owntracks/strings.json b/homeassistant/components/owntracks/strings.json new file mode 100644 index 0000000000000..fcf7305d714c9 --- /dev/null +++ b/homeassistant/components/owntracks/strings.json @@ -0,0 +1,17 @@ +{ + "config": { + "title": "OwnTracks", + "step": { + "user": { + "title": "Set up OwnTracks", + "description": "Are you sure you want to set up OwnTracks?" + } + }, + "abort": { + "one_instance_allowed": "Only a single instance is necessary." + }, + "create_entry": { + "default": "\n\nOn Android, open [the OwnTracks app]({android_url}), go to preferences -> connection. Change the following settings:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\nOn iOS, open [the OwnTracks app]({ios_url}), tap (i) icon in top left -> settings. Change the following settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\nSee [the documentation]({docs_url}) for more information." + } + } +} diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 42bc8b089da65..2325f35822fd5 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -149,6 +149,7 @@ async def async_step_discovery(info): 'mqtt', 'nest', 'openuv', + 'owntracks', 'point', 'rainmachine', 'simplisafe', diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 057843834c051..cc7c4284f9c96 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -4,7 +4,7 @@ from timeit import default_timer as timer from types import ModuleType -from typing import Optional, Dict, List +from typing import Awaitable, Callable, Optional, Dict, List from homeassistant import requirements, core, loader, config as conf_util from homeassistant.config import async_notify_setup_error @@ -248,3 +248,35 @@ async def async_process_deps_reqs( raise HomeAssistantError("Could not install all requirements.") processed.add(name) + + +@core.callback +def async_when_setup( + hass: core.HomeAssistant, component: str, + when_setup_cb: Callable[ + [core.HomeAssistant, str], Awaitable[None]]) -> None: + """Call a method when a component is setup.""" + async def when_setup() -> None: + """Call the callback.""" + try: + await when_setup_cb(hass, component) + except Exception: # pylint: disable=broad-except + _LOGGER.exception('Error handling when_setup callback for %s', + component) + + # Running it in a new task so that it always runs after + if component in hass.config.components: + hass.async_create_task(when_setup()) + return + + unsub = None + + async def loaded_event(event: core.Event) -> None: + """Call the callback.""" + if event.data[ATTR_COMPONENT] != component: + return + + unsub() # type: ignore + await when_setup() + + unsub = hass.bus.async_listen(EVENT_COMPONENT_LOADED, loaded_event) diff --git a/requirements_all.txt b/requirements_all.txt index bc53dbce24e79..197f9be02d04e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -558,8 +558,7 @@ konnected==0.1.4 # homeassistant.components.eufy lakeside==0.10 -# homeassistant.components.device_tracker.owntracks -# homeassistant.components.device_tracker.owntracks_http +# homeassistant.components.owntracks libnacl==1.6.1 # homeassistant.components.dyson diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index 2d7397692f8e7..6f457f30ed0a6 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -4,12 +4,11 @@ import pytest from tests.common import ( - assert_setup_component, async_fire_mqtt_message, mock_coro, mock_component, - async_mock_mqtt_component) -import homeassistant.components.device_tracker.owntracks as owntracks + async_fire_mqtt_message, mock_coro, mock_component, + async_mock_mqtt_component, MockConfigEntry) +from homeassistant.components import owntracks from homeassistant.setup import async_setup_component -from homeassistant.components import device_tracker -from homeassistant.const import CONF_PLATFORM, STATE_NOT_HOME +from homeassistant.const import STATE_NOT_HOME USER = 'greg' DEVICE = 'phone' @@ -290,6 +289,25 @@ def setup_comp(hass): 'zone.outer', 'zoning', OUTER_ZONE) +async def setup_owntracks(hass, config, + ctx_cls=owntracks.OwnTracksContext): + """Set up OwnTracks.""" + await async_mock_mqtt_component(hass) + + MockConfigEntry(domain='owntracks', data={ + 'webhook_id': 'owntracks_test', + 'secret': 'abcd', + }).add_to_hass(hass) + + with patch('homeassistant.components.device_tracker.async_load_config', + return_value=mock_coro([])), \ + patch('homeassistant.components.device_tracker.' + 'load_yaml_config_file', return_value=mock_coro({})), \ + patch.object(owntracks, 'OwnTracksContext', ctx_cls): + assert await async_setup_component( + hass, 'owntracks', {'owntracks': config}) + + @pytest.fixture def context(hass, setup_comp): """Set up the mocked context.""" @@ -306,20 +324,11 @@ def store_context(*args): context = orig_context(*args) return context - with patch('homeassistant.components.device_tracker.async_load_config', - return_value=mock_coro([])), \ - patch('homeassistant.components.device_tracker.' - 'load_yaml_config_file', return_value=mock_coro({})), \ - patch.object(owntracks, 'OwnTracksContext', store_context), \ - assert_setup_component(1, device_tracker.DOMAIN): - assert hass.loop.run_until_complete(async_setup_component( - hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_MAX_GPS_ACCURACY: 200, - CONF_WAYPOINT_IMPORT: True, - CONF_WAYPOINT_WHITELIST: ['jon', 'greg'] - }})) + hass.loop.run_until_complete(setup_owntracks(hass, { + CONF_MAX_GPS_ACCURACY: 200, + CONF_WAYPOINT_IMPORT: True, + CONF_WAYPOINT_WHITELIST: ['jon', 'greg'] + }, store_context)) def get_context(): """Get the current context.""" @@ -1211,19 +1220,14 @@ async def test_waypoint_import_blacklist(hass, context): assert wayp is None -async def test_waypoint_import_no_whitelist(hass, context): +async def test_waypoint_import_no_whitelist(hass, config_context): """Test import of list of waypoints with no whitelist set.""" - async def mock_see(**kwargs): - """Fake see method for owntracks.""" - return - - test_config = { - CONF_PLATFORM: 'owntracks', + await setup_owntracks(hass, { CONF_MAX_GPS_ACCURACY: 200, CONF_WAYPOINT_IMPORT: True, CONF_MQTT_TOPIC: 'owntracks/#', - } - await owntracks.async_setup_scanner(hass, test_config, mock_see) + }) + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() await send_message(hass, WAYPOINTS_TOPIC_BLOCKED, waypoints_message) # Check if it made it into states @@ -1364,12 +1368,9 @@ def config_context(hass, setup_comp): mock_cipher) async def test_encrypted_payload(hass, config_context): """Test encrypted payload.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: TEST_SECRET_KEY, - }}) + await setup_owntracks(hass, { + CONF_SECRET: TEST_SECRET_KEY, + }) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert_location_latitude(hass, LOCATION_MESSAGE['lat']) @@ -1378,13 +1379,11 @@ async def test_encrypted_payload(hass, config_context): mock_cipher) async def test_encrypted_payload_topic_key(hass, config_context): """Test encrypted payload with a topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - LOCATION_TOPIC: TEST_SECRET_KEY, - }}}) + await setup_owntracks(hass, { + CONF_SECRET: { + LOCATION_TOPIC: TEST_SECRET_KEY, + } + }) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert_location_latitude(hass, LOCATION_MESSAGE['lat']) @@ -1394,12 +1393,10 @@ async def test_encrypted_payload_topic_key(hass, config_context): async def test_encrypted_payload_no_key(hass, config_context): """Test encrypted payload with no key, .""" assert hass.states.get(DEVICE_TRACKER_STATE) is None - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - # key missing - }}) + await setup_owntracks(hass, { + CONF_SECRET: { + } + }) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert hass.states.get(DEVICE_TRACKER_STATE) is None @@ -1408,12 +1405,9 @@ async def test_encrypted_payload_no_key(hass, config_context): mock_cipher) async def test_encrypted_payload_wrong_key(hass, config_context): """Test encrypted payload with wrong key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: 'wrong key', - }}) + await setup_owntracks(hass, { + CONF_SECRET: 'wrong key', + }) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert hass.states.get(DEVICE_TRACKER_STATE) is None @@ -1422,13 +1416,11 @@ async def test_encrypted_payload_wrong_key(hass, config_context): mock_cipher) async def test_encrypted_payload_wrong_topic_key(hass, config_context): """Test encrypted payload with wrong topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - LOCATION_TOPIC: 'wrong key' - }}}) + await setup_owntracks(hass, { + CONF_SECRET: { + LOCATION_TOPIC: 'wrong key' + }, + }) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert hass.states.get(DEVICE_TRACKER_STATE) is None @@ -1437,13 +1429,10 @@ async def test_encrypted_payload_wrong_topic_key(hass, config_context): mock_cipher) async def test_encrypted_payload_no_topic_key(hass, config_context): """Test encrypted payload with no topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - 'owntracks/{}/{}'.format(USER, 'otherdevice'): 'foobar' - }}}) + await setup_owntracks(hass, { + CONF_SECRET: { + 'owntracks/{}/{}'.format(USER, 'otherdevice'): 'foobar' + }}) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert hass.states.get(DEVICE_TRACKER_STATE) is None @@ -1456,12 +1445,9 @@ async def test_encrypted_payload_libsodium(hass, config_context): pytest.skip("libnacl/libsodium is not installed") return - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: TEST_SECRET_KEY, - }}) + await setup_owntracks(hass, { + CONF_SECRET: TEST_SECRET_KEY, + }) await send_message(hass, LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE) assert_location_latitude(hass, LOCATION_MESSAGE['lat']) @@ -1469,12 +1455,9 @@ async def test_encrypted_payload_libsodium(hass, config_context): async def test_customized_mqtt_topic(hass, config_context): """Test subscribing to a custom mqtt topic.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_MQTT_TOPIC: 'mytracks/#', - }}) + await setup_owntracks(hass, { + CONF_MQTT_TOPIC: 'mytracks/#', + }) topic = 'mytracks/{}/{}'.format(USER, DEVICE) @@ -1484,14 +1467,11 @@ async def test_customized_mqtt_topic(hass, config_context): async def test_region_mapping(hass, config_context): """Test region to zone mapping.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_REGION_MAPPING: { - 'foo': 'inner' - }, - }}) + await setup_owntracks(hass, { + CONF_REGION_MAPPING: { + 'foo': 'inner' + }, + }) hass.states.async_set( 'zone.inner', 'zoning', INNER_ZONE) diff --git a/tests/components/owntracks/__init__.py b/tests/components/owntracks/__init__.py new file mode 100644 index 0000000000000..a95431913b24d --- /dev/null +++ b/tests/components/owntracks/__init__.py @@ -0,0 +1 @@ +"""Tests for OwnTracks component.""" diff --git a/tests/components/owntracks/test_config_flow.py b/tests/components/owntracks/test_config_flow.py new file mode 100644 index 0000000000000..079fdfafea09d --- /dev/null +++ b/tests/components/owntracks/test_config_flow.py @@ -0,0 +1 @@ +"""Tests for OwnTracks config flow.""" diff --git a/tests/components/device_tracker/test_owntracks_http.py b/tests/components/owntracks/test_init.py similarity index 51% rename from tests/components/device_tracker/test_owntracks_http.py rename to tests/components/owntracks/test_init.py index a49f30c683996..ee79c8b9e10be 100644 --- a/tests/components/device_tracker/test_owntracks_http.py +++ b/tests/components/owntracks/test_init.py @@ -1,14 +1,11 @@ """Test the owntracks_http platform.""" import asyncio -from unittest.mock import patch -import os import pytest -from homeassistant.components import device_tracker from homeassistant.setup import async_setup_component -from tests.common import mock_component, mock_coro +from tests.common import mock_component, MockConfigEntry MINIMAL_LOCATION_MESSAGE = { '_type': 'location', @@ -36,38 +33,33 @@ } -@pytest.fixture(autouse=True) -def owntracks_http_cleanup(hass): - """Remove known_devices.yaml.""" - try: - os.remove(hass.config.path(device_tracker.YAML_DEVICES)) - except OSError: - pass - - @pytest.fixture def mock_client(hass, aiohttp_client): """Start the Hass HTTP component.""" mock_component(hass, 'group') mock_component(hass, 'zone') - with patch('homeassistant.components.device_tracker.async_load_config', - return_value=mock_coro([])): - hass.loop.run_until_complete( - async_setup_component(hass, 'device_tracker', { - 'device_tracker': { - 'platform': 'owntracks_http', - 'webhook_id': 'owntracks_test' - } - })) + mock_component(hass, 'device_tracker') + + MockConfigEntry(domain='owntracks', data={ + 'webhook_id': 'owntracks_test', + 'secret': 'abcd', + }).add_to_hass(hass) + hass.loop.run_until_complete(async_setup_component(hass, 'owntracks', {})) + return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) @asyncio.coroutine def test_handle_valid_message(mock_client): """Test that we forward messages correctly to OwnTracks.""" - resp = yield from mock_client.post('/api/webhook/owntracks_test?' - 'u=test&d=test', - json=LOCATION_MESSAGE) + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json=LOCATION_MESSAGE, + headers={ + 'X-Limit-u': 'Paulus', + 'X-Limit-d': 'Pixel', + } + ) assert resp.status == 200 @@ -78,9 +70,14 @@ def test_handle_valid_message(mock_client): @asyncio.coroutine def test_handle_valid_minimal_message(mock_client): """Test that we forward messages correctly to OwnTracks.""" - resp = yield from mock_client.post('/api/webhook/owntracks_test?' - 'u=test&d=test', - json=MINIMAL_LOCATION_MESSAGE) + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json=MINIMAL_LOCATION_MESSAGE, + headers={ + 'X-Limit-u': 'Paulus', + 'X-Limit-d': 'Pixel', + } + ) assert resp.status == 200 @@ -91,8 +88,14 @@ def test_handle_valid_minimal_message(mock_client): @asyncio.coroutine def test_handle_value_error(mock_client): """Test we don't disclose that this is a valid webhook.""" - resp = yield from mock_client.post('/api/webhook/owntracks_test' - '?u=test&d=test', json='') + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json='', + headers={ + 'X-Limit-u': 'Paulus', + 'X-Limit-d': 'Pixel', + } + ) assert resp.status == 200 @@ -103,10 +106,15 @@ def test_handle_value_error(mock_client): @asyncio.coroutine def test_returns_error_missing_username(mock_client): """Test that an error is returned when username is missing.""" - resp = yield from mock_client.post('/api/webhook/owntracks_test?d=test', - json=LOCATION_MESSAGE) + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json=LOCATION_MESSAGE, + headers={ + 'X-Limit-d': 'Pixel', + } + ) - assert resp.status == 200 + assert resp.status == 400 json = yield from resp.json() assert json == {'error': 'You need to supply username.'} @@ -115,10 +123,27 @@ def test_returns_error_missing_username(mock_client): @asyncio.coroutine def test_returns_error_missing_device(mock_client): """Test that an error is returned when device name is missing.""" - resp = yield from mock_client.post('/api/webhook/owntracks_test?u=test', - json=LOCATION_MESSAGE) + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json=LOCATION_MESSAGE, + headers={ + 'X-Limit-u': 'Paulus', + } + ) assert resp.status == 200 json = yield from resp.json() - assert json == {'error': 'You need to supply device name.'} + assert json == [] + + +async def test_config_flow_import(hass): + """Test that we automatically create a config flow.""" + assert not hass.config_entries.async_entries('owntracks') + assert await async_setup_component(hass, 'owntracks', { + 'owntracks': { + + } + }) + await hass.async_block_till_done() + assert hass.config_entries.async_entries('owntracks') diff --git a/tests/test_setup.py b/tests/test_setup.py index 29712f40ebc01..2e44ee539d7b6 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -9,7 +9,8 @@ import voluptuous as vol from homeassistant.core import callback -from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_COMPONENT_LOADED) import homeassistant.config as config_util from homeassistant import setup, loader import homeassistant.util.dt as dt_util @@ -459,3 +460,35 @@ def test_platform_no_warn_slow(hass): hass, 'test_component1', {}) assert result assert not mock_call.called + + +async def test_when_setup_already_loaded(hass): + """Test when setup.""" + calls = [] + + async def mock_callback(hass, component): + """Mock callback.""" + calls.append(component) + + setup.async_when_setup(hass, 'test', mock_callback) + await hass.async_block_till_done() + assert calls == [] + + hass.config.components.add('test') + hass.bus.async_fire(EVENT_COMPONENT_LOADED, { + 'component': 'test' + }) + await hass.async_block_till_done() + assert calls == ['test'] + + # Event listener should be gone + hass.bus.async_fire(EVENT_COMPONENT_LOADED, { + 'component': 'test' + }) + await hass.async_block_till_done() + assert calls == ['test'] + + # Should be called right away + setup.async_when_setup(hass, 'test', mock_callback) + await hass.async_block_till_done() + assert calls == ['test', 'test'] From 311c796da7e9dac2c74aaec380d7541ec8de318e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 28 Nov 2018 22:17:37 +0100 Subject: [PATCH 113/325] Default to on if logged in (#18766) --- homeassistant/components/cloud/__init__.py | 2 +- homeassistant/components/cloud/prefs.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index b968850668d8b..4f4b0c582fc9c 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -248,7 +248,7 @@ def load_config(): info = await self.hass.async_add_job(load_config) - await self.prefs.async_initialize(not info) + await self.prefs.async_initialize(bool(info)) if info is None: return diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index d29b356cfc0a0..7e1ec6a02328d 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -27,6 +27,7 @@ async def async_initialize(self, logged_in): PREF_ENABLE_GOOGLE: logged_in, PREF_GOOGLE_ALLOW_UNLOCK: False, } + await self._store.async_save(prefs) self._prefs = prefs From 05915775e37fa2be565dda6348947ed63dd37f92 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 28 Nov 2018 22:47:37 +0100 Subject: [PATCH 114/325] Bumped version to 0.83.0b3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 9fc6d61cb336f..585d9ff3b83f7 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 83 -PATCH_VERSION = '0b2' +PATCH_VERSION = '0b3' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From aadf72d4453bd34a3600a97260ca964b2a687268 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Thu, 29 Nov 2018 01:01:56 -0700 Subject: [PATCH 115/325] Fix statistics for binary sensor (#18764) * Fix statistics for binary sensor -) Binary sensors have 'on' and 'off' for state resulting in issue as numbers were expected. Fixed so that it works with non-numeric states as well. -) Added check to skip unknown states. -) Updates test so that binary sensor test will use non-numeric values for states. * Using guard clause and changed debug to error Changed to use a guard clause for state unknown. Writing error on value error instead of debug. * Add docstring --- homeassistant/components/sensor/statistics.py | 15 ++++++++++++--- tests/components/sensor/test_statistics.py | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/statistics.py b/homeassistant/components/sensor/statistics.py index e7a35b5fdf0d3..e011121f4a2ce 100644 --- a/homeassistant/components/sensor/statistics.py +++ b/homeassistant/components/sensor/statistics.py @@ -120,7 +120,7 @@ def async_stats_sensor_startup(event): self.hass, self._entity_id, async_stats_sensor_state_listener) if 'recorder' in self.hass.config.components: - # only use the database if it's configured + # Only use the database if it's configured self.hass.async_create_task( self._async_initialize_from_database() ) @@ -129,11 +129,20 @@ def async_stats_sensor_startup(event): EVENT_HOMEASSISTANT_START, async_stats_sensor_startup) def _add_state_to_queue(self, new_state): + """Add the state to the queue.""" + if new_state.state == STATE_UNKNOWN: + return + try: - self.states.append(float(new_state.state)) + if self.is_binary: + self.states.append(new_state.state) + else: + self.states.append(float(new_state.state)) + self.ages.append(new_state.last_updated) except ValueError: - pass + _LOGGER.error("%s: parsing error, expected number and received %s", + self.entity_id, new_state.state) @property def name(self): diff --git a/tests/components/sensor/test_statistics.py b/tests/components/sensor/test_statistics.py index 8552ed9efad91..9b4e53dbab9f0 100644 --- a/tests/components/sensor/test_statistics.py +++ b/tests/components/sensor/test_statistics.py @@ -40,7 +40,7 @@ def teardown_method(self, method): def test_binary_sensor_source(self): """Test if source is a sensor.""" - values = [1, 0, 1, 0, 1, 0, 1] + values = ['on', 'off', 'on', 'off', 'on', 'off', 'on'] assert setup_component(self.hass, 'sensor', { 'sensor': { 'platform': 'statistics', From faeaa433930f526c1c8850d304a068615b7759df Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 29 Nov 2018 09:26:48 +0100 Subject: [PATCH 116/325] Update lang list (fixes #18768) --- homeassistant/components/tts/amazon_polly.py | 63 +++++++++++++------- 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/tts/amazon_polly.py b/homeassistant/components/tts/amazon_polly.py index 7b3fe4ef04e8c..ca9be93c41174 100644 --- a/homeassistant/components/tts/amazon_polly.py +++ b/homeassistant/components/tts/amazon_polly.py @@ -10,9 +10,10 @@ from homeassistant.components.tts import Provider, PLATFORM_SCHEMA import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['boto3==1.9.16'] +_LOGGER = logging.getLogger(__name__) + CONF_REGION = 'region_name' CONF_ACCESS_KEY_ID = 'aws_access_key_id' CONF_SECRET_ACCESS_KEY = 'aws_secret_access_key' @@ -31,15 +32,37 @@ CONF_SAMPLE_RATE = 'sample_rate' CONF_TEXT_TYPE = 'text_type' -SUPPORTED_VOICES = ['Geraint', 'Gwyneth', 'Mads', 'Naja', 'Hans', 'Marlene', - 'Nicole', 'Russell', 'Amy', 'Brian', 'Emma', 'Raveena', - 'Ivy', 'Joanna', 'Joey', 'Justin', 'Kendra', 'Kimberly', - 'Salli', 'Conchita', 'Enrique', 'Miguel', 'Penelope', - 'Chantal', 'Celine', 'Mathieu', 'Dora', 'Karl', 'Carla', - 'Giorgio', 'Mizuki', 'Liv', 'Lotte', 'Ruben', 'Ewa', - 'Jacek', 'Jan', 'Maja', 'Ricardo', 'Vitoria', 'Cristiano', - 'Ines', 'Carmen', 'Maxim', 'Tatyana', 'Astrid', 'Filiz', - 'Aditi', 'Léa', 'Matthew', 'Seoyeon', 'Takumi', 'Vicki'] +SUPPORTED_VOICES = [ + 'Zhiyu', # Chinese + 'Mads', 'Naja', # Danish + 'Ruben', 'Lotte', # Dutch + 'Russell', 'Nicole', # English Austrailian + 'Brian', 'Amy', 'Emma', # English + 'Aditi', 'Raveena', # English, Indian + 'Joey', 'Justin', 'Matthew', 'Ivy', 'Joanna', 'Kendra', 'Kimberly', + 'Salli', # English + 'Geraint', # English Welsh + 'Mathieu', 'Celine', 'Léa', # French + 'Chantal', # French Canadian + 'Hans', 'Marlene', 'Vicki', # German + 'Aditi', # Hindi + 'Karl', 'Dora', # Icelandic + 'Giorgio', 'Carla', 'Bianca', # Italian + 'Takumi', 'Mizuki', # Japanese + 'Seoyeon', # Korean + 'Liv', # Norwegian + 'Jacek', 'Jan', 'Ewa', 'Maja', # Polish + 'Ricardo', 'Vitoria', # Portuguese, Brazilian + 'Cristiano', 'Ines', # Portuguese, European + 'Carmen', # Romanian + 'Maxim', 'Tatyana', # Russian + 'Enrique', 'Conchita', 'Lucia' # Spanish European + 'Mia', # Spanish Mexican + 'Miguel', 'Penelope', # Spanish US + 'Astrid', # Swedish + 'Filiz', # Turkish + 'Gwyneth', # Welsh +] SUPPORTED_OUTPUT_FORMATS = ['mp3', 'ogg_vorbis', 'pcm'] @@ -48,7 +71,7 @@ SUPPORTED_SAMPLE_RATES_MAP = { 'mp3': ['8000', '16000', '22050'], 'ogg_vorbis': ['8000', '16000', '22050'], - 'pcm': ['8000', '16000'] + 'pcm': ['8000', '16000'], } SUPPORTED_TEXT_TYPES = ['text', 'ssml'] @@ -56,7 +79,7 @@ CONTENT_TYPE_EXTENSIONS = { 'audio/mpeg': 'mp3', 'audio/ogg': 'ogg', - 'audio/pcm': 'pcm' + 'audio/pcm': 'pcm', } DEFAULT_VOICE = 'Joanna' @@ -66,7 +89,7 @@ DEFAULT_SAMPLE_RATES = { 'mp3': '22050', 'ogg_vorbis': '22050', - 'pcm': '16000' + 'pcm': '16000', } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -78,8 +101,8 @@ vol.Optional(CONF_VOICE, default=DEFAULT_VOICE): vol.In(SUPPORTED_VOICES), vol.Optional(CONF_OUTPUT_FORMAT, default=DEFAULT_OUTPUT_FORMAT): vol.In(SUPPORTED_OUTPUT_FORMATS), - vol.Optional(CONF_SAMPLE_RATE): vol.All(cv.string, - vol.In(SUPPORTED_SAMPLE_RATES)), + vol.Optional(CONF_SAMPLE_RATE): + vol.All(cv.string, vol.In(SUPPORTED_SAMPLE_RATES)), vol.Optional(CONF_TEXT_TYPE, default=DEFAULT_TEXT_TYPE): vol.In(SUPPORTED_TEXT_TYPES), }) @@ -88,8 +111,8 @@ def get_engine(hass, config): """Set up Amazon Polly speech component.""" output_format = config.get(CONF_OUTPUT_FORMAT) - sample_rate = config.get(CONF_SAMPLE_RATE, - DEFAULT_SAMPLE_RATES[output_format]) + sample_rate = config.get( + CONF_SAMPLE_RATE, DEFAULT_SAMPLE_RATES[output_format]) if sample_rate not in SUPPORTED_SAMPLE_RATES_MAP.get(output_format): _LOGGER.error("%s is not a valid sample rate for %s", sample_rate, output_format) @@ -127,8 +150,8 @@ def get_engine(hass, config): if voice.get('LanguageCode') not in supported_languages: supported_languages.append(voice.get('LanguageCode')) - return AmazonPollyProvider(polly_client, config, supported_languages, - all_voices) + return AmazonPollyProvider( + polly_client, config, supported_languages, all_voices) class AmazonPollyProvider(Provider): @@ -171,7 +194,7 @@ def get_tts_audio(self, message, language=None, options=None): if language != voice_in_dict.get('LanguageCode'): _LOGGER.error("%s does not support the %s language", voice_id, language) - return (None, None) + return None, None resp = self.client.synthesize_speech( OutputFormat=self.config[CONF_OUTPUT_FORMAT], From a306475065d8f794762ade133799554e823c5864 Mon Sep 17 00:00:00 2001 From: Ian Richardson Date: Thu, 29 Nov 2018 03:06:18 -0600 Subject: [PATCH 117/325] Convert shopping-list clear to WebSockets (#18769) --- homeassistant/components/shopping_list.py | 18 +++++++++++ tests/components/test_shopping_list.py | 37 ++++++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/shopping_list.py b/homeassistant/components/shopping_list.py index ad4680982b424..2ebd80c3de09d 100644 --- a/homeassistant/components/shopping_list.py +++ b/homeassistant/components/shopping_list.py @@ -40,6 +40,7 @@ WS_TYPE_SHOPPING_LIST_ITEMS = 'shopping_list/items' WS_TYPE_SHOPPING_LIST_ADD_ITEM = 'shopping_list/items/add' WS_TYPE_SHOPPING_LIST_UPDATE_ITEM = 'shopping_list/items/update' +WS_TYPE_SHOPPING_LIST_CLEAR_ITEMS = 'shopping_list/items/clear' SCHEMA_WEBSOCKET_ITEMS = \ websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ @@ -60,6 +61,11 @@ vol.Optional('complete'): bool }) +SCHEMA_WEBSOCKET_CLEAR_ITEMS = \ + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_SHOPPING_LIST_CLEAR_ITEMS + }) + @asyncio.coroutine def async_setup(hass, config): @@ -127,6 +133,10 @@ def complete_item_service(call): WS_TYPE_SHOPPING_LIST_UPDATE_ITEM, websocket_handle_update, SCHEMA_WEBSOCKET_UPDATE_ITEM) + hass.components.websocket_api.async_register_command( + WS_TYPE_SHOPPING_LIST_CLEAR_ITEMS, + websocket_handle_clear, + SCHEMA_WEBSOCKET_CLEAR_ITEMS) return True @@ -327,3 +337,11 @@ async def websocket_handle_update(hass, connection, msg): except KeyError: connection.send_message(websocket_api.error_message( msg_id, 'item_not_found', 'Item not found')) + + +@callback +def websocket_handle_clear(hass, connection, msg): + """Handle clearing shopping_list items.""" + hass.data[DOMAIN].async_clear_completed() + hass.bus.async_fire(EVENT) + connection.send_message(websocket_api.result_message(msg['id'])) diff --git a/tests/components/test_shopping_list.py b/tests/components/test_shopping_list.py index 1e89287bcc106..f4095b773167b 100644 --- a/tests/components/test_shopping_list.py +++ b/tests/components/test_shopping_list.py @@ -275,7 +275,7 @@ async def test_ws_update_item_fail(hass, hass_ws_client): @asyncio.coroutine -def test_api_clear_completed(hass, hass_client): +def test_deprecated_api_clear_completed(hass, hass_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -311,6 +311,41 @@ def test_api_clear_completed(hass, hass_client): } +async def test_ws_clear_items(hass, hass_ws_client): + """Test clearing shopping_list items websocket command.""" + await async_setup_component(hass, 'shopping_list', {}) + await intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}} + ) + await intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'wine'}} + ) + beer_id = hass.data['shopping_list'].items[0]['id'] + wine_id = hass.data['shopping_list'].items[1]['id'] + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'shopping_list/items/update', + 'item_id': beer_id, + 'complete': True + }) + msg = await client.receive_json() + assert msg['success'] is True + await client.send_json({ + 'id': 6, + 'type': 'shopping_list/items/clear' + }) + msg = await client.receive_json() + assert msg['success'] is True + items = hass.data['shopping_list'].items + assert len(items) == 1 + assert items[0] == { + 'id': wine_id, + 'name': 'wine', + 'complete': False + } + + @asyncio.coroutine def test_deprecated_api_create(hass, hass_client): """Test the API.""" From 1364114dc189a33ab96c9837e71470b10ad43c1d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 29 Nov 2018 10:57:40 +0100 Subject: [PATCH 118/325] Bumped version to 0.83.0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 585d9ff3b83f7..dc00267cdf862 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 83 -PATCH_VERSION = '0b3' +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 07a7ee0ac766d0852f0ee91d83b5d5da458d6043 Mon Sep 17 00:00:00 2001 From: mdallaire <23340663+mdallaire@users.noreply.github.com> Date: Thu, 29 Nov 2018 06:04:12 -0500 Subject: [PATCH 119/325] Add more waterfurnace sensors (#18451) Add the following sensors that provide interesting data when using a variable speed geothermal system: * Compressor Power * Fan Power * Aux Power * Loop Pump Power * Compressor Speed * Fan Speed --- homeassistant/components/sensor/waterfurnace.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/sensor/waterfurnace.py b/homeassistant/components/sensor/waterfurnace.py index 60da761cf75c9..65632f51494e6 100644 --- a/homeassistant/components/sensor/waterfurnace.py +++ b/homeassistant/components/sensor/waterfurnace.py @@ -42,6 +42,14 @@ def __init__(self, friendly_name, field, icon="mdi:gauge", "mdi:water-percent", "%"), WFSensorConfig("Humidity", "tstatrelativehumidity", "mdi:water-percent", "%"), + WFSensorConfig("Compressor Power", "compressorpower", "mdi:flash", "W"), + WFSensorConfig("Fan Power", "fanpower", "mdi:flash", "W"), + WFSensorConfig("Aux Power", "auxpower", "mdi:flash", "W"), + WFSensorConfig("Loop Pump Power", "looppumppower", "mdi:flash", "W"), + WFSensorConfig("Compressor Speed", "actualcompressorspeed", + "mdi:speedometer"), + WFSensorConfig("Fan Speed", "airflowcurrentspeed", "mdi:fan"), + ] From c976ac3b3909511bfea3ef13793e631dcb5f1b6a Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 29 Nov 2018 12:28:50 +0100 Subject: [PATCH 120/325] Fix lint issues --- homeassistant/components/tts/amazon_polly.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tts/amazon_polly.py b/homeassistant/components/tts/amazon_polly.py index ca9be93c41174..e3f5b7407cdf2 100644 --- a/homeassistant/components/tts/amazon_polly.py +++ b/homeassistant/components/tts/amazon_polly.py @@ -37,9 +37,9 @@ 'Mads', 'Naja', # Danish 'Ruben', 'Lotte', # Dutch 'Russell', 'Nicole', # English Austrailian - 'Brian', 'Amy', 'Emma', # English + 'Brian', 'Amy', 'Emma', # English 'Aditi', 'Raveena', # English, Indian - 'Joey', 'Justin', 'Matthew', 'Ivy', 'Joanna', 'Kendra', 'Kimberly', + 'Joey', 'Justin', 'Matthew', 'Ivy', 'Joanna', 'Kendra', 'Kimberly', 'Salli', # English 'Geraint', # English Welsh 'Mathieu', 'Celine', 'Léa', # French From 8c9a39845cac0b18ed4a2f0a7460dab0d0d8557f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Thu, 29 Nov 2018 16:39:39 +0100 Subject: [PATCH 121/325] Round average price for Tibber (#18784) --- 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 703f2bbbd172b..997ecdd4c3dbf 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -152,7 +152,7 @@ def _update_current_price(self): sum_price += price_total self._state = state self._device_state_attributes['max_price'] = max_price - self._device_state_attributes['avg_price'] = sum_price / num + self._device_state_attributes['avg_price'] = round(sum_price / num, 3) self._device_state_attributes['min_price'] = min_price return state is not None From 9aeb4892823ca95a2518dcabc915fc61582f96df Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 29 Nov 2018 16:40:49 +0100 Subject: [PATCH 122/325] Raise NotImplementedError (#18777) --- homeassistant/components/owntracks/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index a5da7f5fc483d..0bb7a2390b7b7 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -191,7 +191,7 @@ def async_valid_accuracy(self, message): async def async_see(self, **data): """Send a see message to the device tracker.""" - await self.hass.components.device_tracker.async_see(**data) + raise NotImplementedError async def async_see_beacons(self, hass, dev_id, kwargs_param): """Set active beacons to the current location.""" From 46389fb6caa9fab9c9bc37d5f562b1e28dd0bdd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Thu, 29 Nov 2018 19:13:08 +0100 Subject: [PATCH 123/325] Update switchmate lib (#18785) --- homeassistant/components/switch/switchmate.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switch/switchmate.py b/homeassistant/components/switch/switchmate.py index e2ca3accdc9cc..23794abeba49d 100644 --- a/homeassistant/components/switch/switchmate.py +++ b/homeassistant/components/switch/switchmate.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA from homeassistant.const import CONF_NAME, CONF_MAC -REQUIREMENTS = ['pySwitchmate==0.4.3'] +REQUIREMENTS = ['pySwitchmate==0.4.4'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 9f094a387fe04..c5b26e47e80e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -821,7 +821,7 @@ pyMetno==0.3.0 pyRFXtrx==0.23 # homeassistant.components.switch.switchmate -pySwitchmate==0.4.3 +pySwitchmate==0.4.4 # homeassistant.components.tibber pyTibber==0.8.2 From 474567e762ea9bd0e69b018a5f4964abf9fbbbd3 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Thu, 29 Nov 2018 20:16:39 +0100 Subject: [PATCH 124/325] Fix logbook domain filter - alexa, homekit (#18790) --- homeassistant/components/logbook.py | 6 ++++++ tests/components/test_logbook.py | 27 +++++++++++++++++++++------ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index c7a37411f1eaa..b6f434a82ad7d 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -445,6 +445,12 @@ def _exclude_events(events, entities_filter): domain = event.data.get(ATTR_DOMAIN) entity_id = event.data.get(ATTR_ENTITY_ID) + elif event.event_type == EVENT_ALEXA_SMART_HOME: + domain = 'alexa' + + elif event.event_type == EVENT_HOMEKIT_CHANGED: + domain = DOMAIN_HOMEKIT + if not entity_id and domain: entity_id = "%s." % (domain, ) diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index 4619dc7ec2ea4..5761ce8714bfb 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -242,9 +242,11 @@ def test_exclude_events_domain(self): config = logbook.CONFIG_SCHEMA({ ha.DOMAIN: {}, logbook.DOMAIN: {logbook.CONF_EXCLUDE: { - logbook.CONF_DOMAINS: ['switch', ]}}}) + logbook.CONF_DOMAINS: ['switch', 'alexa', DOMAIN_HOMEKIT]}}}) events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_START), eventA, eventB), + (ha.Event(EVENT_HOMEASSISTANT_START), + ha.Event(EVENT_ALEXA_SMART_HOME), + ha.Event(EVENT_HOMEKIT_CHANGED), eventA, eventB), logbook._generate_filter_from_config(config[logbook.DOMAIN])) entries = list(logbook.humanify(self.hass, events)) @@ -325,22 +327,35 @@ def test_include_events_domain(self): pointA = dt_util.utcnow() pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES) + event_alexa = ha.Event(EVENT_ALEXA_SMART_HOME, {'request': { + 'namespace': 'Alexa.Discovery', + 'name': 'Discover', + }}) + event_homekit = ha.Event(EVENT_HOMEKIT_CHANGED, { + ATTR_ENTITY_ID: 'lock.front_door', + ATTR_DISPLAY_NAME: 'Front Door', + ATTR_SERVICE: 'lock', + }) + eventA = self.create_state_changed_event(pointA, entity_id, 10) eventB = self.create_state_changed_event(pointB, entity_id2, 20) config = logbook.CONFIG_SCHEMA({ ha.DOMAIN: {}, logbook.DOMAIN: {logbook.CONF_INCLUDE: { - logbook.CONF_DOMAINS: ['sensor', ]}}}) + logbook.CONF_DOMAINS: ['sensor', 'alexa', DOMAIN_HOMEKIT]}}}) events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_START), eventA, eventB), + (ha.Event(EVENT_HOMEASSISTANT_START), + event_alexa, event_homekit, eventA, eventB), logbook._generate_filter_from_config(config[logbook.DOMAIN])) entries = list(logbook.humanify(self.hass, events)) - assert 2 == len(entries) + assert 4 == len(entries) self.assert_entry(entries[0], name='Home Assistant', message='started', domain=ha.DOMAIN) - self.assert_entry(entries[1], pointB, 'blu', domain='sensor', + self.assert_entry(entries[1], name='Amazon Alexa', domain='alexa') + self.assert_entry(entries[2], name='HomeKit', domain=DOMAIN_HOMEKIT) + self.assert_entry(entries[3], pointB, 'blu', domain='sensor', entity_id=entity_id2) def test_include_exclude_events(self): From 5c026b1fa2c9b4c71df9b92658dbb952c83afc57 Mon Sep 17 00:00:00 2001 From: Eliseo Martelli Date: Thu, 29 Nov 2018 20:40:26 +0100 Subject: [PATCH 125/325] Added qbittorrent sensor platform (#18618) * added qbittorrent sensor platform * Added requirements * linting * disabled broad-except * added noqa * removed pass statement (left that from development session) * Added to coveragerc & moved to async * fixed linting * fixed indentation * removed white space * added await * Removed generic exception * removed pylint disable * added auth checks * linting * fixed linting * fixed error * should be fixed now * linting * ordered imports * added requested changes * Update homeassistant/components/sensor/qbittorrent.py Co-Authored-By: eliseomartelli * Update qbittorrent.py * Minor changes --- .coveragerc | 1 + .../components/sensor/qbittorrent.py | 142 ++++++++++++++++++ requirements_all.txt | 3 + 3 files changed, 146 insertions(+) create mode 100644 homeassistant/components/sensor/qbittorrent.py diff --git a/.coveragerc b/.coveragerc index 7fa418f0b4603..f894d1edd4a6b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -782,6 +782,7 @@ omit = homeassistant/components/sensor/pushbullet.py homeassistant/components/sensor/pvoutput.py homeassistant/components/sensor/pyload.py + homeassistant/components/sensor/qbittorrent.py homeassistant/components/sensor/qnap.py homeassistant/components/sensor/radarr.py homeassistant/components/sensor/rainbird.py diff --git a/homeassistant/components/sensor/qbittorrent.py b/homeassistant/components/sensor/qbittorrent.py new file mode 100644 index 0000000000000..8718f3a9d7449 --- /dev/null +++ b/homeassistant/components/sensor/qbittorrent.py @@ -0,0 +1,142 @@ +""" +Support for monitoring the qBittorrent API. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.qbittorrent/ +""" +import logging + +import voluptuous as vol + +from requests.exceptions import RequestException + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME, STATE_IDLE) +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv +from homeassistant.exceptions import PlatformNotReady + +REQUIREMENTS = ['python-qbittorrent==0.3.1'] + +_LOGGER = logging.getLogger(__name__) + +SENSOR_TYPE_CURRENT_STATUS = 'current_status' +SENSOR_TYPE_DOWNLOAD_SPEED = 'download_speed' +SENSOR_TYPE_UPLOAD_SPEED = 'upload_speed' + +DEFAULT_NAME = 'qBittorrent' + +SENSOR_TYPES = { + SENSOR_TYPE_CURRENT_STATUS: ['Status', None], + SENSOR_TYPE_DOWNLOAD_SPEED: ['Down Speed', 'kB/s'], + SENSOR_TYPE_UPLOAD_SPEED: ['Up Speed', 'kB/s'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_URL): cv.url, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up the qBittorrent sensors.""" + from qbittorrent.client import Client, LoginRequired + + try: + client = Client(config[CONF_URL]) + client.login(config[CONF_USERNAME], config[CONF_PASSWORD]) + except LoginRequired: + _LOGGER.error("Invalid authentication") + return + except RequestException: + _LOGGER.error("Connection failed") + raise PlatformNotReady + + name = config.get(CONF_NAME) + + dev = [] + for sensor_type in SENSOR_TYPES: + sensor = QBittorrentSensor(sensor_type, client, name, LoginRequired) + dev.append(sensor) + + async_add_entities(dev, True) + + +def format_speed(speed): + """Return a bytes/s measurement as a human readable string.""" + kb_spd = float(speed) / 1024 + return round(kb_spd, 2 if kb_spd < 0.1 else 1) + + +class QBittorrentSensor(Entity): + """Representation of an qBittorrent sensor.""" + + def __init__(self, sensor_type, qbittorrent_client, + client_name, exception): + """Initialize the qBittorrent sensor.""" + self._name = SENSOR_TYPES[sensor_type][0] + self.client = qbittorrent_client + self.type = sensor_type + self.client_name = client_name + self._state = None + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._available = False + self._exception = exception + + @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 available(self): + """Return true if device is available.""" + return self._available + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + async def async_update(self): + """Get the latest data from qBittorrent and updates the state.""" + try: + data = self.client.sync() + self._available = True + except RequestException: + _LOGGER.error("Connection lost") + self._available = False + return + except self._exception: + _LOGGER.error("Invalid authentication") + return + + if data is None: + return + + download = data['server_state']['dl_info_speed'] + upload = data['server_state']['up_info_speed'] + + if self.type == SENSOR_TYPE_CURRENT_STATUS: + if upload > 0 and download > 0: + self._state = 'up_down' + elif upload > 0 and download == 0: + self._state = 'seeding' + elif upload == 0 and download > 0: + self._state = 'downloading' + else: + self._state = STATE_IDLE + + elif self.type == SENSOR_TYPE_DOWNLOAD_SPEED: + self._state = format_speed(download) + elif self.type == SENSOR_TYPE_UPLOAD_SPEED: + self._state = format_speed(upload) diff --git a/requirements_all.txt b/requirements_all.txt index c5b26e47e80e1..d3157bc7c2583 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1233,6 +1233,9 @@ python-nmap==0.6.1 # homeassistant.components.notify.pushover python-pushover==0.3 +# homeassistant.components.sensor.qbittorrent +python-qbittorrent==0.3.1 + # homeassistant.components.sensor.ripple python-ripple-api==0.0.3 From e50a6ef8af6192113d9b51fb89f6f42898411613 Mon Sep 17 00:00:00 2001 From: Eric Nagley Date: Thu, 29 Nov 2018 15:14:17 -0500 Subject: [PATCH 126/325] Add support for Mode trait in Google Assistant. (#18772) * Add support for Mode trait in Google Assistant. * Simplify supported logic. * Fix SUPPORTED_MODE_SETTINGS to correct rip failures. * more stray commas * update tests. --- .../components/google_assistant/trait.py | 188 +++++++++++++++++- homeassistant/components/media_player/demo.py | 4 +- tests/components/google_assistant/__init__.py | 12 +- .../components/google_assistant/test_trait.py | 88 ++++++++ 4 files changed, 286 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index e0d12e00e305f..c0d496d2cfbdc 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -43,6 +43,7 @@ TRAIT_TEMPERATURE_SETTING = PREFIX_TRAITS + 'TemperatureSetting' TRAIT_LOCKUNLOCK = PREFIX_TRAITS + 'LockUnlock' TRAIT_FANSPEED = PREFIX_TRAITS + 'FanSpeed' +TRAIT_MODES = PREFIX_TRAITS + 'Modes' PREFIX_COMMANDS = 'action.devices.commands.' COMMAND_ONOFF = PREFIX_COMMANDS + 'OnOff' @@ -59,7 +60,7 @@ COMMAND_THERMOSTAT_SET_MODE = PREFIX_COMMANDS + 'ThermostatSetMode' COMMAND_LOCKUNLOCK = PREFIX_COMMANDS + 'LockUnlock' COMMAND_FANSPEED = PREFIX_COMMANDS + 'SetFanSpeed' - +COMMAND_MODES = PREFIX_COMMANDS + 'SetModes' TRAITS = [] @@ -752,3 +753,188 @@ async def execute(self, command, params): ATTR_ENTITY_ID: self.state.entity_id, fan.ATTR_SPEED: params['fanSpeed'] }, blocking=True) + + +@register_trait +class ModesTrait(_Trait): + """Trait to set modes. + + https://developers.google.com/actions/smarthome/traits/modes + """ + + name = TRAIT_MODES + commands = [ + COMMAND_MODES + ] + + # Google requires specific mode names and settings. Here is the full list. + # https://developers.google.com/actions/reference/smarthome/traits/modes + # All settings are mapped here as of 2018-11-28 and can be used for other + # entity types. + + HA_TO_GOOGLE = { + media_player.ATTR_INPUT_SOURCE: "input source", + } + SUPPORTED_MODE_SETTINGS = { + 'xsmall': [ + 'xsmall', 'extra small', 'min', 'minimum', 'tiny', 'xs'], + 'small': ['small', 'half'], + 'large': ['large', 'big', 'full'], + 'xlarge': ['extra large', 'xlarge', 'xl'], + 'Cool': ['cool', 'rapid cool', 'rapid cooling'], + 'Heat': ['heat'], 'Low': ['low'], + 'Medium': ['medium', 'med', 'mid', 'half'], + 'High': ['high'], + 'Auto': ['auto', 'automatic'], + 'Bake': ['bake'], 'Roast': ['roast'], + 'Convection Bake': ['convection bake', 'convect bake'], + 'Convection Roast': ['convection roast', 'convect roast'], + 'Favorite': ['favorite'], + 'Broil': ['broil'], + 'Warm': ['warm'], + 'Off': ['off'], + 'On': ['on'], + 'Normal': [ + 'normal', 'normal mode', 'normal setting', 'standard', + 'schedule', 'original', 'default', 'old settings' + ], + 'None': ['none'], + 'Tap Cold': ['tap cold'], + 'Cold Warm': ['cold warm'], + 'Hot': ['hot'], + 'Extra Hot': ['extra hot'], + 'Eco': ['eco'], + 'Wool': ['wool', 'fleece'], + 'Turbo': ['turbo'], + 'Rinse': ['rinse', 'rinsing', 'rinse wash'], + 'Away': ['away', 'holiday'], + 'maximum': ['maximum'], + 'media player': ['media player'], + 'chromecast': ['chromecast'], + 'tv': [ + 'tv', 'television', 'tv position', 'television position', + 'watching tv', 'watching tv position', 'entertainment', + 'entertainment position' + ], + 'am fm': ['am fm', 'am radio', 'fm radio'], + 'internet radio': ['internet radio'], + 'satellite': ['satellite'], + 'game console': ['game console'], + 'antifrost': ['antifrost', 'anti-frost'], + 'boost': ['boost'], + 'Clock': ['clock'], + 'Message': ['message'], + 'Messages': ['messages'], + 'News': ['news'], + 'Disco': ['disco'], + 'antifreeze': ['antifreeze', 'anti-freeze', 'anti freeze'], + 'balanced': ['balanced', 'normal'], + 'swing': ['swing'], + 'media': ['media', 'media mode'], + 'panic': ['panic'], + 'ring': ['ring'], + 'frozen': ['frozen', 'rapid frozen', 'rapid freeze'], + 'cotton': ['cotton', 'cottons'], + 'blend': ['blend', 'mix'], + 'baby wash': ['baby wash'], + 'synthetics': ['synthetic', 'synthetics', 'compose'], + 'hygiene': ['hygiene', 'sterilization'], + 'smart': ['smart', 'intelligent', 'intelligence'], + 'comfortable': ['comfortable', 'comfort'], + 'manual': ['manual'], + 'energy saving': ['energy saving'], + 'sleep': ['sleep'], + 'quick wash': ['quick wash', 'fast wash'], + 'cold': ['cold'], + 'airsupply': ['airsupply', 'air supply'], + 'dehumidification': ['dehumidication', 'dehumidify'], + 'game': ['game', 'game mode'] + } + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + if domain != media_player.DOMAIN: + return False + + return features & media_player.SUPPORT_SELECT_SOURCE + + def sync_attributes(self): + """Return mode attributes for a sync request.""" + sources_list = self.state.attributes.get( + media_player.ATTR_INPUT_SOURCE_LIST, []) + modes = [] + sources = {} + + if sources_list: + sources = { + "name": self.HA_TO_GOOGLE.get(media_player.ATTR_INPUT_SOURCE), + "name_values": [{ + "name_synonym": ['input source'], + "lang": "en" + }], + "settings": [], + "ordered": False + } + for source in sources_list: + if source in self.SUPPORTED_MODE_SETTINGS: + src = source + synonyms = self.SUPPORTED_MODE_SETTINGS.get(src) + elif source.lower() in self.SUPPORTED_MODE_SETTINGS: + src = source.lower() + synonyms = self.SUPPORTED_MODE_SETTINGS.get(src) + + else: + continue + + sources['settings'].append( + { + "setting_name": src, + "setting_values": [{ + "setting_synonym": synonyms, + "lang": "en" + }] + } + ) + if sources: + modes.append(sources) + payload = {'availableModes': modes} + + return payload + + def query_attributes(self): + """Return current modes.""" + attrs = self.state.attributes + response = {} + mode_settings = {} + + if attrs.get(media_player.ATTR_INPUT_SOURCE_LIST): + mode_settings.update({ + media_player.ATTR_INPUT_SOURCE: attrs.get( + media_player.ATTR_INPUT_SOURCE) + }) + if mode_settings: + response['on'] = self.state.state != STATE_OFF + response['online'] = True + response['currentModeSettings'] = mode_settings + + return response + + async def execute(self, command, params): + """Execute an SetModes command.""" + settings = params.get('updateModeSettings') + requested_source = settings.get( + self.HA_TO_GOOGLE.get(media_player.ATTR_INPUT_SOURCE)) + + if requested_source: + for src in self.state.attributes.get( + media_player.ATTR_INPUT_SOURCE_LIST): + if src.lower() == requested_source.lower(): + source = src + + await self.hass.services.async_call( + media_player.DOMAIN, + media_player.SERVICE_SELECT_SOURCE, { + ATTR_ENTITY_ID: self.state.entity_id, + media_player.ATTR_INPUT_SOURCE: source + }, blocking=True) diff --git a/homeassistant/components/media_player/demo.py b/homeassistant/components/media_player/demo.py index c2a736f531e0b..8a88e3bd74e42 100644 --- a/homeassistant/components/media_player/demo.py +++ b/homeassistant/components/media_player/demo.py @@ -4,6 +4,7 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ +import homeassistant.util.dt as dt_util from homeassistant.components.media_player import ( MEDIA_TYPE_MOVIE, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, SUPPORT_CLEAR_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, @@ -12,7 +13,6 @@ SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice) from homeassistant.const import STATE_OFF, STATE_PAUSED, STATE_PLAYING -import homeassistant.util.dt as dt_util def setup_platform(hass, config, add_entities, discovery_info=None): @@ -34,7 +34,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): YOUTUBE_PLAYER_SUPPORT = \ SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA | SUPPORT_PLAY | \ - SUPPORT_SHUFFLE_SET | SUPPORT_SELECT_SOUND_MODE + SUPPORT_SHUFFLE_SET | SUPPORT_SELECT_SOUND_MODE | SUPPORT_SELECT_SOURCE MUSIC_PLAYER_SUPPORT = \ SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index c8748ade00e57..03cc327a5c51f 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -141,7 +141,10 @@ 'name': 'Bedroom' }, 'traits': - ['action.devices.traits.OnOff', 'action.devices.traits.Brightness'], + [ + 'action.devices.traits.OnOff', 'action.devices.traits.Brightness', + 'action.devices.traits.Modes' + ], 'type': 'action.devices.types.SWITCH', 'willReportState': @@ -153,7 +156,10 @@ 'name': 'Living Room' }, 'traits': - ['action.devices.traits.OnOff', 'action.devices.traits.Brightness'], + [ + 'action.devices.traits.OnOff', 'action.devices.traits.Brightness', + 'action.devices.traits.Modes' + ], 'type': 'action.devices.types.SWITCH', 'willReportState': @@ -163,7 +169,7 @@ 'name': { 'name': 'Lounge room' }, - 'traits': ['action.devices.traits.OnOff'], + 'traits': ['action.devices.traits.OnOff', 'action.devices.traits.Modes'], 'type': 'action.devices.types.SWITCH', 'willReportState': False }, { diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index ef6ed7a4b8f13..5bf7b2fe566e6 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -916,3 +916,91 @@ async def test_fan_speed(hass): 'entity_id': 'fan.living_room_fan', 'speed': 'medium' } + + +async def test_modes(hass): + """Test Mode trait.""" + assert trait.ModesTrait.supported( + media_player.DOMAIN, media_player.SUPPORT_SELECT_SOURCE) + + trt = trait.ModesTrait( + hass, State( + 'media_player.living_room', media_player.STATE_PLAYING, + attributes={ + media_player.ATTR_INPUT_SOURCE_LIST: [ + 'media', 'game', 'chromecast', 'plex' + ], + media_player.ATTR_INPUT_SOURCE: 'game' + }), + BASIC_CONFIG) + + attribs = trt.sync_attributes() + assert attribs == { + 'availableModes': [ + { + 'name': 'input source', + 'name_values': [ + { + 'name_synonym': ['input source'], + 'lang': 'en' + } + ], + 'settings': [ + { + 'setting_name': 'media', + 'setting_values': [ + { + 'setting_synonym': ['media', 'media mode'], + 'lang': 'en' + } + ] + }, + { + 'setting_name': 'game', + 'setting_values': [ + { + 'setting_synonym': ['game', 'game mode'], + 'lang': 'en' + } + ] + }, + { + 'setting_name': 'chromecast', + 'setting_values': [ + { + 'setting_synonym': ['chromecast'], + 'lang': 'en' + } + ] + } + ], + 'ordered': False + } + ] + } + + assert trt.query_attributes() == { + 'currentModeSettings': {'source': 'game'}, + 'on': True, + 'online': True + } + + assert trt.can_execute( + trait.COMMAND_MODES, params={ + 'updateModeSettings': { + trt.HA_TO_GOOGLE.get(media_player.ATTR_INPUT_SOURCE): 'media' + }}) + + calls = async_mock_service( + hass, media_player.DOMAIN, media_player.SERVICE_SELECT_SOURCE) + await trt.execute( + trait.COMMAND_MODES, params={ + 'updateModeSettings': { + trt.HA_TO_GOOGLE.get(media_player.ATTR_INPUT_SOURCE): 'media' + }}) + + assert len(calls) == 1 + assert calls[0].data == { + 'entity_id': 'media_player.living_room', + 'source': 'media' + } From ca74f5efde6b898ed2a82412111567139930e118 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 29 Nov 2018 22:17:01 +0100 Subject: [PATCH 127/325] Render the secret (#18793) --- homeassistant/components/owntracks/config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/owntracks/config_flow.py b/homeassistant/components/owntracks/config_flow.py index 8836294642833..8cf19e84bcdd2 100644 --- a/homeassistant/components/owntracks/config_flow.py +++ b/homeassistant/components/owntracks/config_flow.py @@ -40,8 +40,8 @@ async def async_step_user(self, user_input=None): if supports_encryption(): secret_desc = ( - "The encryption key is {secret} " - "(on Android under preferences -> advanced)") + "The encryption key is {} " + "(on Android under preferences -> advanced)".format(secret)) else: secret_desc = ( "Encryption is not supported because libsodium is not " From ab4d0a7fc3e96569a794c4f48592acc25215d95f Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 29 Nov 2018 14:24:32 -0700 Subject: [PATCH 128/325] Bumped py17track to 2.1.0 (#18804) --- homeassistant/components/sensor/seventeentrack.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/seventeentrack.py b/homeassistant/components/sensor/seventeentrack.py index 7ad0e45376088..b4c869e726710 100644 --- a/homeassistant/components/sensor/seventeentrack.py +++ b/homeassistant/components/sensor/seventeentrack.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle, slugify -REQUIREMENTS = ['py17track==2.0.2'] +REQUIREMENTS = ['py17track==2.1.0'] _LOGGER = logging.getLogger(__name__) ATTR_DESTINATION_COUNTRY = 'destination_country' diff --git a/requirements_all.txt b/requirements_all.txt index d3157bc7c2583..e69f5e516dec1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -805,7 +805,7 @@ py-melissa-climate==2.0.0 py-synology==0.2.0 # homeassistant.components.sensor.seventeentrack -py17track==2.0.2 +py17track==2.1.0 # homeassistant.components.hdmi_cec pyCEC==0.4.13 From 4e272624ebdfd90ad5632f90fc1a234f93210458 Mon Sep 17 00:00:00 2001 From: Eric Nagley Date: Thu, 29 Nov 2018 16:24:53 -0500 Subject: [PATCH 129/325] BUGFIX: handle extra fan speeds. (#18799) * BUGFIX: add support for extra fan speeds. * Drop extra fan speeds. Remove catch all, drop missing fan speeds. * fix self.speed_synonyms call. Remove un-needed keys() call --- homeassistant/components/google_assistant/trait.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index c0d496d2cfbdc..f2cb819fcc900 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -715,6 +715,8 @@ def sync_attributes(self): modes = self.state.attributes.get(fan.ATTR_SPEED_LIST, []) speeds = [] for mode in modes: + if mode not in self.speed_synonyms: + continue speed = { "speed_name": mode, "speed_values": [{ From 38ecf71307ea6721b49693481e7a4859c1ee1ad2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 29 Nov 2018 22:26:06 +0100 Subject: [PATCH 130/325] Fix race condition in group.set (#18796) --- homeassistant/components/group/__init__.py | 9 ++++++++- tests/helpers/test_entity_component.py | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 4dd3571e69c2a..15a3816c559e8 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -207,6 +207,13 @@ async def reload_service_handler(service): DOMAIN, SERVICE_RELOAD, reload_service_handler, schema=RELOAD_SERVICE_SCHEMA) + service_lock = asyncio.Lock() + + async def locked_service_handler(service): + """Handle a service with an async lock.""" + async with service_lock: + await groups_service_handler(service) + async def groups_service_handler(service): """Handle dynamic group service functions.""" object_id = service.data[ATTR_OBJECT_ID] @@ -284,7 +291,7 @@ async def groups_service_handler(service): await component.async_remove_entity(entity_id) hass.services.async_register( - DOMAIN, SERVICE_SET, groups_service_handler, + DOMAIN, SERVICE_SET, locked_service_handler, schema=SET_SERVICE_SCHEMA) hass.services.async_register( diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 2bef8c0b53e5c..7562a38d268e7 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -431,3 +431,24 @@ async def test_update_entity(hass): assert len(entity.async_update_ha_state.mock_calls) == 2 assert entity.async_update_ha_state.mock_calls[-1][1][0] is True + + +async def test_set_service_race(hass): + """Test race condition on setting service.""" + exception = False + + def async_loop_exception_handler(_, _2) -> None: + """Handle all exception inside the core loop.""" + nonlocal exception + exception = True + + hass.loop.set_exception_handler(async_loop_exception_handler) + + await async_setup_component(hass, 'group', {}) + component = EntityComponent(_LOGGER, DOMAIN, hass, group_name='yo') + + for i in range(2): + hass.async_create_task(component.async_add_entities([MockEntity()])) + + await hass.async_block_till_done() + assert not exception From 28215d7edd550563235a541a4cbf80ed10176a7b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 29 Nov 2018 22:26:19 +0100 Subject: [PATCH 131/325] Make auth backwards compat again (#18792) * Made auth not backwards compat * Fix tests --- homeassistant/auth/auth_store.py | 3 ++- tests/auth/test_auth_store.py | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index cf82c40a4d374..bad1bdcf913e6 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -462,10 +462,11 @@ def _data_to_save(self) -> Dict: for group in self._groups.values(): g_dict = { 'id': group.id, + # Name not read for sys groups. Kept here for backwards compat + 'name': group.name } # type: Dict[str, Any] if group.id not in (GROUP_ID_READ_ONLY, GROUP_ID_ADMIN): - g_dict['name'] = group.name g_dict['policy'] = group.policy groups.append(g_dict) diff --git a/tests/auth/test_auth_store.py b/tests/auth/test_auth_store.py index b76d68fbeac27..7e9df869a048b 100644 --- a/tests/auth/test_auth_store.py +++ b/tests/auth/test_auth_store.py @@ -199,13 +199,22 @@ async def test_loading_empty_data(hass, hass_storage): assert len(users) == 0 -async def test_system_groups_only_store_id(hass, hass_storage): - """Test that for system groups we only store the ID.""" +async def test_system_groups_store_id_and_name(hass, hass_storage): + """Test that for system groups we store the ID and name. + + Name is stored so that we remain backwards compat with < 0.82. + """ store = auth_store.AuthStore(hass) await store._async_load() data = store._data_to_save() assert len(data['users']) == 0 assert data['groups'] == [ - {'id': auth_store.GROUP_ID_ADMIN}, - {'id': auth_store.GROUP_ID_READ_ONLY}, + { + 'id': auth_store.GROUP_ID_ADMIN, + 'name': auth_store.GROUP_NAME_ADMIN, + }, + { + 'id': auth_store.GROUP_ID_READ_ONLY, + 'name': auth_store.GROUP_NAME_READ_ONLY, + }, ] From 4bc9e6dfe02f51262475675d9b3e4239f687045c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Thu, 29 Nov 2018 22:28:27 +0100 Subject: [PATCH 132/325] Remove self from update function in rainmachine (#18807) --- homeassistant/components/binary_sensor/rainmachine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/binary_sensor/rainmachine.py b/homeassistant/components/binary_sensor/rainmachine.py index 4a671fc95122b..efae93303650a 100644 --- a/homeassistant/components/binary_sensor/rainmachine.py +++ b/homeassistant/components/binary_sensor/rainmachine.py @@ -74,7 +74,7 @@ def unique_id(self) -> str: async def async_added_to_hass(self): """Register callbacks.""" @callback - def update(self): + def update(): """Update the state.""" self.async_schedule_update_ha_state(True) From 6f7ff9a18a707d3cf1d3e8b6ffa94b374ff432c0 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 29 Nov 2018 14:47:41 -0700 Subject: [PATCH 133/325] Remove additional self from update function in RainMachine (#18810) --- homeassistant/components/sensor/rainmachine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/rainmachine.py b/homeassistant/components/sensor/rainmachine.py index 5131b25510af8..86a97bc291cfe 100644 --- a/homeassistant/components/sensor/rainmachine.py +++ b/homeassistant/components/sensor/rainmachine.py @@ -77,7 +77,7 @@ def unit_of_measurement(self): async def async_added_to_hass(self): """Register callbacks.""" @callback - def update(self): + def update(): """Update the state.""" self.async_schedule_update_ha_state(True) From 2ba521caf867da5f9639a89810e35a2ff4a3112a Mon Sep 17 00:00:00 2001 From: Ian Richardson Date: Fri, 23 Nov 2018 01:56:18 -0600 Subject: [PATCH 134/325] Add websocket call for adding item to shopping-list (#18623) --- homeassistant/components/shopping_list.py | 20 ++++++++++++ tests/components/test_shopping_list.py | 38 +++++++++++++++++++++-- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shopping_list.py b/homeassistant/components/shopping_list.py index 45650ece62169..650d23fe1dfdf 100644 --- a/homeassistant/components/shopping_list.py +++ b/homeassistant/components/shopping_list.py @@ -38,12 +38,19 @@ }) WS_TYPE_SHOPPING_LIST_ITEMS = 'shopping_list/items' +WS_TYPE_SHOPPING_LIST_ADD_ITEM = 'shopping_list/items/add' SCHEMA_WEBSOCKET_ITEMS = \ websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_SHOPPING_LIST_ITEMS }) +SCHEMA_WEBSOCKET_ADD_ITEM = \ + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_SHOPPING_LIST_ADD_ITEM, + vol.Required('name'): str + }) + @asyncio.coroutine def async_setup(hass, config): @@ -103,6 +110,10 @@ def complete_item_service(call): WS_TYPE_SHOPPING_LIST_ITEMS, websocket_handle_items, SCHEMA_WEBSOCKET_ITEMS) + hass.components.websocket_api.async_register_command( + WS_TYPE_SHOPPING_LIST_ADD_ITEM, + websocket_handle_add, + SCHEMA_WEBSOCKET_ADD_ITEM) return True @@ -276,3 +287,12 @@ def websocket_handle_items(hass, connection, msg): """Handle get shopping_list items.""" connection.send_message(websocket_api.result_message( msg['id'], hass.data[DOMAIN].items)) + + +@callback +def websocket_handle_add(hass, connection, msg): + """Handle add item to shopping_list.""" + item = hass.data[DOMAIN].async_add(msg['name']) + hass.bus.async_fire(EVENT) + connection.send_message(websocket_api.result_message( + msg['id'], item)) diff --git a/tests/components/test_shopping_list.py b/tests/components/test_shopping_list.py index e64b9a5ae26c7..44714138eb355 100644 --- a/tests/components/test_shopping_list.py +++ b/tests/components/test_shopping_list.py @@ -228,7 +228,7 @@ def test_api_clear_completed(hass, aiohttp_client): @asyncio.coroutine -def test_api_create(hass, aiohttp_client): +def test_deprecated_api_create(hass, aiohttp_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -249,7 +249,7 @@ def test_api_create(hass, aiohttp_client): @asyncio.coroutine -def test_api_create_fail(hass, aiohttp_client): +def test_deprecated_api_create_fail(hass, aiohttp_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -260,3 +260,37 @@ def test_api_create_fail(hass, aiohttp_client): assert resp.status == 400 assert len(hass.data['shopping_list'].items) == 0 + + +async def test_ws_add_item(hass, hass_ws_client): + """Test adding shopping_list item websocket command.""" + await async_setup_component(hass, 'shopping_list', {}) + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'shopping_list/items/add', + 'name': 'soda', + }) + msg = await client.receive_json() + assert msg['success'] is True + data = msg['result'] + assert data['name'] == 'soda' + assert data['complete'] is False + items = hass.data['shopping_list'].items + assert len(items) == 1 + assert items[0]['name'] == 'soda' + assert items[0]['complete'] is False + + +async def test_ws_add_item_fail(hass, hass_ws_client): + """Test adding shopping_list item failure websocket command.""" + await async_setup_component(hass, 'shopping_list', {}) + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'shopping_list/items/add', + 'name': 123, + }) + msg = await client.receive_json() + assert msg['success'] is False + assert len(hass.data['shopping_list'].items) == 0 From 601389302a08bc4a15ed36652d00350f37691e24 Mon Sep 17 00:00:00 2001 From: Ian Richardson Date: Mon, 26 Nov 2018 02:59:53 -0600 Subject: [PATCH 135/325] Convert shopping-list update to WebSockets (#18713) * Convert shopping-list update to WebSockets * Update shopping_list.py * Update test_shopping_list.py --- homeassistant/components/shopping_list.py | 31 ++++++++ tests/components/test_shopping_list.py | 86 ++++++++++++++++++++++- 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/shopping_list.py b/homeassistant/components/shopping_list.py index 650d23fe1dfdf..ad4680982b424 100644 --- a/homeassistant/components/shopping_list.py +++ b/homeassistant/components/shopping_list.py @@ -39,6 +39,7 @@ WS_TYPE_SHOPPING_LIST_ITEMS = 'shopping_list/items' WS_TYPE_SHOPPING_LIST_ADD_ITEM = 'shopping_list/items/add' +WS_TYPE_SHOPPING_LIST_UPDATE_ITEM = 'shopping_list/items/update' SCHEMA_WEBSOCKET_ITEMS = \ websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ @@ -51,6 +52,14 @@ vol.Required('name'): str }) +SCHEMA_WEBSOCKET_UPDATE_ITEM = \ + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_SHOPPING_LIST_UPDATE_ITEM, + vol.Required('item_id'): str, + vol.Optional('name'): str, + vol.Optional('complete'): bool + }) + @asyncio.coroutine def async_setup(hass, config): @@ -114,6 +123,10 @@ def complete_item_service(call): WS_TYPE_SHOPPING_LIST_ADD_ITEM, websocket_handle_add, SCHEMA_WEBSOCKET_ADD_ITEM) + hass.components.websocket_api.async_register_command( + WS_TYPE_SHOPPING_LIST_UPDATE_ITEM, + websocket_handle_update, + SCHEMA_WEBSOCKET_UPDATE_ITEM) return True @@ -296,3 +309,21 @@ def websocket_handle_add(hass, connection, msg): hass.bus.async_fire(EVENT) connection.send_message(websocket_api.result_message( msg['id'], item)) + + +@websocket_api.async_response +async def websocket_handle_update(hass, connection, msg): + """Handle update shopping_list item.""" + msg_id = msg.pop('id') + item_id = msg.pop('item_id') + msg.pop('type') + data = msg + + try: + item = hass.data[DOMAIN].async_update(item_id, data) + hass.bus.async_fire(EVENT) + connection.send_message(websocket_api.result_message( + msg_id, item)) + except KeyError: + connection.send_message(websocket_api.error_message( + msg_id, 'item_not_found', 'Item not found')) diff --git a/tests/components/test_shopping_list.py b/tests/components/test_shopping_list.py index 44714138eb355..c2899f6b7535c 100644 --- a/tests/components/test_shopping_list.py +++ b/tests/components/test_shopping_list.py @@ -110,7 +110,7 @@ async def test_ws_get_items(hass, hass_ws_client): @asyncio.coroutine -def test_api_update(hass, aiohttp_client): +def test_deprecated_api_update(hass, aiohttp_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -164,6 +164,61 @@ def test_api_update(hass, aiohttp_client): } +async def test_ws_update_item(hass, hass_ws_client): + """Test update shopping_list item websocket command.""" + await async_setup_component(hass, 'shopping_list', {}) + await intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}} + ) + await intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'wine'}} + ) + + beer_id = hass.data['shopping_list'].items[0]['id'] + wine_id = hass.data['shopping_list'].items[1]['id'] + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'shopping_list/items/update', + 'item_id': beer_id, + 'name': 'soda' + }) + msg = await client.receive_json() + assert msg['success'] is True + data = msg['result'] + assert data == { + 'id': beer_id, + 'name': 'soda', + 'complete': False + } + await client.send_json({ + 'id': 6, + 'type': 'shopping_list/items/update', + 'item_id': wine_id, + 'complete': True + }) + msg = await client.receive_json() + assert msg['success'] is True + data = msg['result'] + assert data == { + 'id': wine_id, + 'name': 'wine', + 'complete': True + } + + beer, wine = hass.data['shopping_list'].items + assert beer == { + 'id': beer_id, + 'name': 'soda', + 'complete': False + } + assert wine == { + 'id': wine_id, + 'name': 'wine', + 'complete': True + } + + @asyncio.coroutine def test_api_update_fails(hass, aiohttp_client): """Test the API.""" @@ -190,6 +245,35 @@ def test_api_update_fails(hass, aiohttp_client): assert resp.status == 400 +async def test_ws_update_item_fail(hass, hass_ws_client): + """Test failure of update shopping_list item websocket command.""" + await async_setup_component(hass, 'shopping_list', {}) + await intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}} + ) + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'shopping_list/items/update', + 'item_id': 'non_existing', + 'name': 'soda' + }) + msg = await client.receive_json() + assert msg['success'] is False + data = msg['error'] + assert data == { + 'code': 'item_not_found', + 'message': 'Item not found' + } + await client.send_json({ + 'id': 6, + 'type': 'shopping_list/items/update', + 'name': 123, + }) + msg = await client.receive_json() + assert msg['success'] is False + + @asyncio.coroutine def test_api_clear_completed(hass, aiohttp_client): """Test the API.""" From ff33d34b818d43cb6f414c567beeba0fc00013f5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 27 Nov 2018 10:41:44 +0100 Subject: [PATCH 136/325] Legacy api fix (#18733) * Set user for API password requests * Fix tests * Fix typing --- .../auth/providers/legacy_api_password.py | 29 +++++++++-- homeassistant/components/http/auth.py | 5 ++ tests/components/alexa/test_intent.py | 4 +- tests/components/alexa/test_smart_home.py | 12 ++--- tests/components/conftest.py | 39 ++++++++++++++- tests/components/hassio/conftest.py | 2 +- tests/components/http/test_auth.py | 11 +++-- tests/components/http/test_init.py | 2 +- tests/components/test_api.py | 22 ++++++--- tests/components/test_conversation.py | 12 ++--- tests/components/test_history.py | 4 +- tests/components/test_shopping_list.py | 24 +++++----- tests/components/test_spaceapi.py | 4 +- tests/components/test_system_log.py | 48 +++++++++---------- tests/components/test_webhook.py | 4 +- 15 files changed, 148 insertions(+), 74 deletions(-) diff --git a/homeassistant/auth/providers/legacy_api_password.py b/homeassistant/auth/providers/legacy_api_password.py index 111b9e7d39f34..6cdb12b7157fb 100644 --- a/homeassistant/auth/providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -4,16 +4,19 @@ It will be removed when auth system production ready """ import hmac -from typing import Any, Dict, Optional, cast +from typing import Any, Dict, Optional, cast, TYPE_CHECKING import voluptuous as vol -from homeassistant.components.http import HomeAssistantHTTP # noqa: F401 -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow -from ..models import Credentials, UserMeta +from .. import AuthManager +from ..models import Credentials, UserMeta, User + +if TYPE_CHECKING: + from homeassistant.components.http import HomeAssistantHTTP # noqa: F401 USER_SCHEMA = vol.Schema({ @@ -31,6 +34,24 @@ class InvalidAuthError(HomeAssistantError): """Raised when submitting invalid authentication.""" +async def async_get_user(hass: HomeAssistant) -> User: + """Return the legacy API password user.""" + auth = cast(AuthManager, hass.auth) # type: ignore + found = None + + for prv in auth.auth_providers: + if prv.type == 'legacy_api_password': + found = prv + break + + if found is None: + raise ValueError('Legacy API password provider not found') + + return await auth.async_get_or_create_user( + await found.async_get_or_create_credentials({}) + ) + + @AUTH_PROVIDERS.register('legacy_api_password') class LegacyApiPasswordAuthProvider(AuthProvider): """Example auth provider based on hardcoded usernames and passwords.""" diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 64ee7fb8a3fb1..1f9782bb4fe1d 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -10,6 +10,7 @@ from homeassistant.core import callback from homeassistant.const import HTTP_HEADER_HA_AUTH +from homeassistant.auth.providers import legacy_api_password from homeassistant.auth.util import generate_secret from homeassistant.util import dt as dt_util @@ -78,12 +79,16 @@ async def auth_middleware(request, handler): request.headers[HTTP_HEADER_HA_AUTH].encode('utf-8'))): # A valid auth header has been set authenticated = True + request['hass_user'] = await legacy_api_password.async_get_user( + app['hass']) elif (legacy_auth and DATA_API_PASSWORD in request.query and hmac.compare_digest( api_password.encode('utf-8'), request.query[DATA_API_PASSWORD].encode('utf-8'))): authenticated = True + request['hass_user'] = await legacy_api_password.async_get_user( + app['hass']) elif _is_trusted_ip(request, trusted_networks): authenticated = True diff --git a/tests/components/alexa/test_intent.py b/tests/components/alexa/test_intent.py index d15c7ccbb34ed..ab84dd2a3bc53 100644 --- a/tests/components/alexa/test_intent.py +++ b/tests/components/alexa/test_intent.py @@ -23,7 +23,7 @@ @pytest.fixture -def alexa_client(loop, hass, aiohttp_client): +def alexa_client(loop, hass, hass_client): """Initialize a Home Assistant server for testing this module.""" @callback def mock_service(call): @@ -95,7 +95,7 @@ def mock_service(call): }, } })) - return loop.run_until_complete(aiohttp_client(hass.http.app)) + return loop.run_until_complete(hass_client()) def _intent_req(client, data=None): diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 766075f8eb53f..3cfb8068177f7 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1437,10 +1437,10 @@ async def test_unsupported_domain(hass): assert not msg['payload']['endpoints'] -async def do_http_discovery(config, hass, aiohttp_client): +async def do_http_discovery(config, hass, hass_client): """Submit a request to the Smart Home HTTP API.""" await async_setup_component(hass, alexa.DOMAIN, config) - http_client = await aiohttp_client(hass.http.app) + http_client = await hass_client() request = get_new_request('Alexa.Discovery', 'Discover') response = await http_client.post( @@ -1450,7 +1450,7 @@ async def do_http_discovery(config, hass, aiohttp_client): return response -async def test_http_api(hass, aiohttp_client): +async def test_http_api(hass, hass_client): """With `smart_home:` HTTP API is exposed.""" config = { 'alexa': { @@ -1458,7 +1458,7 @@ async def test_http_api(hass, aiohttp_client): } } - response = await do_http_discovery(config, hass, aiohttp_client) + response = await do_http_discovery(config, hass, hass_client) response_data = await response.json() # Here we're testing just the HTTP view glue -- details of discovery are @@ -1466,12 +1466,12 @@ async def test_http_api(hass, aiohttp_client): assert response_data['event']['header']['name'] == 'Discover.Response' -async def test_http_api_disabled(hass, aiohttp_client): +async def test_http_api_disabled(hass, hass_client): """Without `smart_home:`, the HTTP API is disabled.""" config = { 'alexa': {} } - response = await do_http_discovery(config, hass, aiohttp_client) + response = await do_http_discovery(config, hass, hass_client) assert response.status == 404 diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 97f2044baea84..5d5a964b2ce64 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -4,6 +4,7 @@ import pytest from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY +from homeassistant.auth.providers import legacy_api_password, homeassistant from homeassistant.setup import async_setup_component from homeassistant.components.websocket_api.http import URL from homeassistant.components.websocket_api.auth import ( @@ -80,7 +81,7 @@ def hass_access_token(hass, hass_admin_user): @pytest.fixture -def hass_admin_user(hass): +def hass_admin_user(hass, local_auth): """Return a Home Assistant admin user.""" admin_group = hass.loop.run_until_complete(hass.auth.async_get_group( GROUP_ID_ADMIN)) @@ -88,8 +89,42 @@ def hass_admin_user(hass): @pytest.fixture -def hass_read_only_user(hass): +def hass_read_only_user(hass, local_auth): """Return a Home Assistant read only user.""" read_only_group = hass.loop.run_until_complete(hass.auth.async_get_group( GROUP_ID_READ_ONLY)) return MockUser(groups=[read_only_group]).add_to_hass(hass) + + +@pytest.fixture +def legacy_auth(hass): + """Load legacy API password provider.""" + prv = legacy_api_password.LegacyApiPasswordAuthProvider( + hass, hass.auth._store, { + 'type': 'legacy_api_password' + } + ) + hass.auth._providers[(prv.type, prv.id)] = prv + + +@pytest.fixture +def local_auth(hass): + """Load local auth provider.""" + prv = homeassistant.HassAuthProvider( + hass, hass.auth._store, { + 'type': 'homeassistant' + } + ) + hass.auth._providers[(prv.type, prv.id)] = prv + + +@pytest.fixture +def hass_client(hass, aiohttp_client, hass_access_token): + """Return an authenticated HTTP client.""" + async def auth_client(): + """Return an authenticated client.""" + return await aiohttp_client(hass.http.app, headers={ + 'Authorization': "Bearer {}".format(hass_access_token) + }) + + return auth_client diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index f9ad1c578de10..435de6d1edf95 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -27,7 +27,7 @@ def hassio_env(): @pytest.fixture -def hassio_client(hassio_env, hass, aiohttp_client): +def hassio_client(hassio_env, hass, aiohttp_client, legacy_auth): """Create mock hassio http client.""" with patch('homeassistant.components.hassio.HassIO.update_hass_api', Mock(return_value=mock_coro({"result": "ok"}))), \ diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 2746abcf15c54..979bfc28689ee 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -83,7 +83,8 @@ async def test_access_without_password(app, aiohttp_client): assert resp.status == 200 -async def test_access_with_password_in_header(app, aiohttp_client): +async def test_access_with_password_in_header(app, aiohttp_client, + legacy_auth): """Test access with password in header.""" setup_auth(app, [], False, api_password=API_PASSWORD) client = await aiohttp_client(app) @@ -97,7 +98,7 @@ async def test_access_with_password_in_header(app, aiohttp_client): assert req.status == 401 -async def test_access_with_password_in_query(app, aiohttp_client): +async def test_access_with_password_in_query(app, aiohttp_client, legacy_auth): """Test access with password in URL.""" setup_auth(app, [], False, api_password=API_PASSWORD) client = await aiohttp_client(app) @@ -219,7 +220,8 @@ async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client): "{} should be trusted".format(remote_addr) -async def test_auth_active_blocked_api_password_access(app, aiohttp_client): +async def test_auth_active_blocked_api_password_access( + app, aiohttp_client, legacy_auth): """Test access using api_password should be blocked when auth.active.""" setup_auth(app, [], True, api_password=API_PASSWORD) client = await aiohttp_client(app) @@ -239,7 +241,8 @@ async def test_auth_active_blocked_api_password_access(app, aiohttp_client): assert req.status == 401 -async def test_auth_legacy_support_api_password_access(app, aiohttp_client): +async def test_auth_legacy_support_api_password_access( + app, aiohttp_client, legacy_auth): """Test access using api_password if auth.support_legacy.""" setup_auth(app, [], True, support_legacy=True, api_password=API_PASSWORD) client = await aiohttp_client(app) diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 9f6441c52386f..1c1afe711c617 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -124,7 +124,7 @@ async def test_api_no_base_url(hass): assert hass.config.api.base_url == 'http://127.0.0.1:8123' -async def test_not_log_password(hass, aiohttp_client, caplog): +async def test_not_log_password(hass, aiohttp_client, caplog, legacy_auth): """Test access with password doesn't get logged.""" assert await async_setup_component(hass, 'api', { 'http': { diff --git a/tests/components/test_api.py b/tests/components/test_api.py index 3ebfa05a3d39c..0bc89292855e1 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -16,12 +16,10 @@ @pytest.fixture -def mock_api_client(hass, aiohttp_client, hass_access_token): +def mock_api_client(hass, hass_client): """Start the Hass HTTP component and return admin API client.""" hass.loop.run_until_complete(async_setup_component(hass, 'api', {})) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app, headers={ - 'Authorization': 'Bearer {}'.format(hass_access_token) - })) + return hass.loop.run_until_complete(hass_client()) @asyncio.coroutine @@ -408,7 +406,7 @@ def _listen_count(hass): async def test_api_error_log(hass, aiohttp_client, hass_access_token, - hass_admin_user): + hass_admin_user, legacy_auth): """Test if we can fetch the error log.""" hass.data[DATA_LOGGING] = '/some/path' await async_setup_component(hass, 'api', { @@ -566,5 +564,17 @@ async def test_rendering_template_admin(hass, mock_api_client, hass_admin_user): """Test rendering a template requires admin.""" hass_admin_user.groups = [] - resp = await mock_api_client.post('/api/template') + resp = await mock_api_client.post(const.URL_API_TEMPLATE) + assert resp.status == 401 + + +async def test_rendering_template_legacy_user( + hass, mock_api_client, aiohttp_client, legacy_auth): + """Test rendering a template with legacy API password.""" + hass.states.async_set('sensor.temperature', 10) + client = await aiohttp_client(hass.http.app) + resp = await client.post( + const.URL_API_TEMPLATE, + json={"template": '{{ states.sensor.temperature.state }}'} + ) assert resp.status == 401 diff --git a/tests/components/test_conversation.py b/tests/components/test_conversation.py index 7934e01628160..2aa1f499a768e 100644 --- a/tests/components/test_conversation.py +++ b/tests/components/test_conversation.py @@ -90,7 +90,7 @@ async def test_register_before_setup(hass): assert intent.text_input == 'I would like the Grolsch beer' -async def test_http_processing_intent(hass, aiohttp_client): +async def test_http_processing_intent(hass, hass_client): """Test processing intent via HTTP API.""" class TestIntentHandler(intent.IntentHandler): """Test Intent Handler.""" @@ -120,7 +120,7 @@ async def async_handle(self, intent): }) assert result - client = await aiohttp_client(hass.http.app) + client = await hass_client() resp = await client.post('/api/conversation/process', json={ 'text': 'I would like the Grolsch beer' }) @@ -244,7 +244,7 @@ async def test_toggle_intent(hass, sentence): assert call.data == {'entity_id': 'light.kitchen'} -async def test_http_api(hass, aiohttp_client): +async def test_http_api(hass, hass_client): """Test the HTTP conversation API.""" result = await component.async_setup(hass, {}) assert result @@ -252,7 +252,7 @@ async def test_http_api(hass, aiohttp_client): result = await async_setup_component(hass, 'conversation', {}) assert result - client = await aiohttp_client(hass.http.app) + client = await hass_client() hass.states.async_set('light.kitchen', 'off') calls = async_mock_service(hass, HASS_DOMAIN, 'turn_on') @@ -268,7 +268,7 @@ async def test_http_api(hass, aiohttp_client): assert call.data == {'entity_id': 'light.kitchen'} -async def test_http_api_wrong_data(hass, aiohttp_client): +async def test_http_api_wrong_data(hass, hass_client): """Test the HTTP conversation API.""" result = await component.async_setup(hass, {}) assert result @@ -276,7 +276,7 @@ async def test_http_api_wrong_data(hass, aiohttp_client): result = await async_setup_component(hass, 'conversation', {}) assert result - client = await aiohttp_client(hass.http.app) + client = await hass_client() resp = await client.post('/api/conversation/process', json={ 'text': 123 diff --git a/tests/components/test_history.py b/tests/components/test_history.py index 9764af1592ccc..641dff3b4e630 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -515,13 +515,13 @@ def set_state(entity_id, state, **kwargs): return zero, four, states -async def test_fetch_period_api(hass, aiohttp_client): +async def test_fetch_period_api(hass, hass_client): """Test the fetch period view for history.""" await hass.async_add_job(init_recorder_component, hass) await async_setup_component(hass, 'history', {}) await hass.components.recorder.wait_connection_ready() await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) - client = await aiohttp_client(hass.http.app) + client = await hass_client() response = await client.get( '/api/history/period/{}'.format(dt_util.utcnow().isoformat())) assert response.status == 200 diff --git a/tests/components/test_shopping_list.py b/tests/components/test_shopping_list.py index c2899f6b7535c..1e89287bcc106 100644 --- a/tests/components/test_shopping_list.py +++ b/tests/components/test_shopping_list.py @@ -55,7 +55,7 @@ def test_recent_items_intent(hass): @asyncio.coroutine -def test_deprecated_api_get_all(hass, aiohttp_client): +def test_deprecated_api_get_all(hass, hass_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -66,7 +66,7 @@ def test_deprecated_api_get_all(hass, aiohttp_client): hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'wine'}} ) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.get('/api/shopping_list') assert resp.status == 200 @@ -110,7 +110,7 @@ async def test_ws_get_items(hass, hass_ws_client): @asyncio.coroutine -def test_deprecated_api_update(hass, aiohttp_client): +def test_deprecated_api_update(hass, hass_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -124,7 +124,7 @@ def test_deprecated_api_update(hass, aiohttp_client): beer_id = hass.data['shopping_list'].items[0]['id'] wine_id = hass.data['shopping_list'].items[1]['id'] - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.post( '/api/shopping_list/item/{}'.format(beer_id), json={ 'name': 'soda' @@ -220,7 +220,7 @@ async def test_ws_update_item(hass, hass_ws_client): @asyncio.coroutine -def test_api_update_fails(hass, aiohttp_client): +def test_api_update_fails(hass, hass_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -228,7 +228,7 @@ def test_api_update_fails(hass, aiohttp_client): hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}} ) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.post( '/api/shopping_list/non_existing', json={ 'name': 'soda' @@ -275,7 +275,7 @@ async def test_ws_update_item_fail(hass, hass_ws_client): @asyncio.coroutine -def test_api_clear_completed(hass, aiohttp_client): +def test_api_clear_completed(hass, hass_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -289,7 +289,7 @@ def test_api_clear_completed(hass, aiohttp_client): beer_id = hass.data['shopping_list'].items[0]['id'] wine_id = hass.data['shopping_list'].items[1]['id'] - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() # Mark beer as completed resp = yield from client.post( @@ -312,11 +312,11 @@ def test_api_clear_completed(hass, aiohttp_client): @asyncio.coroutine -def test_deprecated_api_create(hass, aiohttp_client): +def test_deprecated_api_create(hass, hass_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.post('/api/shopping_list/item', json={ 'name': 'soda' }) @@ -333,11 +333,11 @@ def test_deprecated_api_create(hass, aiohttp_client): @asyncio.coroutine -def test_deprecated_api_create_fail(hass, aiohttp_client): +def test_deprecated_api_create_fail(hass, hass_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.post('/api/shopping_list/item', json={ 'name': 1234 }) diff --git a/tests/components/test_spaceapi.py b/tests/components/test_spaceapi.py index e7e7d158a31ad..61bb009ff8f21 100644 --- a/tests/components/test_spaceapi.py +++ b/tests/components/test_spaceapi.py @@ -56,7 +56,7 @@ @pytest.fixture -def mock_client(hass, aiohttp_client): +def mock_client(hass, hass_client): """Start the Home Assistant HTTP component.""" with patch('homeassistant.components.spaceapi', return_value=mock_coro(True)): @@ -70,7 +70,7 @@ def mock_client(hass, aiohttp_client): hass.states.async_set('test.hum1', 88, attributes={'unit_of_measurement': '%'}) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + return hass.loop.run_until_complete(hass_client()) async def test_spaceapi_get(hass, mock_client): diff --git a/tests/components/test_system_log.py b/tests/components/test_system_log.py index 5d48fd881273c..6afd792be9c6b 100644 --- a/tests/components/test_system_log.py +++ b/tests/components/test_system_log.py @@ -14,9 +14,9 @@ } -async def get_error_log(hass, aiohttp_client, expected_count): +async def get_error_log(hass, hass_client, expected_count): """Fetch all entries from system_log via the API.""" - client = await aiohttp_client(hass.http.app) + client = await hass_client() resp = await client.get('/api/error/all') assert resp.status == 200 @@ -45,37 +45,37 @@ def get_frame(name): return (name, None, None, None) -async def test_normal_logs(hass, aiohttp_client): +async def test_normal_logs(hass, hass_client): """Test that debug and info are not logged.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.debug('debug') _LOGGER.info('info') # Assert done by get_error_log - await get_error_log(hass, aiohttp_client, 0) + await get_error_log(hass, hass_client, 0) -async def test_exception(hass, aiohttp_client): +async def test_exception(hass, hass_client): """Test that exceptions are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _generate_and_log_exception('exception message', 'log message') - log = (await get_error_log(hass, aiohttp_client, 1))[0] + log = (await get_error_log(hass, hass_client, 1))[0] assert_log(log, 'exception message', 'log message', 'ERROR') -async def test_warning(hass, aiohttp_client): +async def test_warning(hass, hass_client): """Test that warning are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.warning('warning message') - log = (await get_error_log(hass, aiohttp_client, 1))[0] + log = (await get_error_log(hass, hass_client, 1))[0] assert_log(log, '', 'warning message', 'WARNING') -async def test_error(hass, aiohttp_client): +async def test_error(hass, hass_client): """Test that errors are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.error('error message') - log = (await get_error_log(hass, aiohttp_client, 1))[0] + log = (await get_error_log(hass, hass_client, 1))[0] assert_log(log, '', 'error message', 'ERROR') @@ -121,26 +121,26 @@ def event_listener(event): assert_log(events[0].data, '', 'error message', 'ERROR') -async def test_critical(hass, aiohttp_client): +async def test_critical(hass, hass_client): """Test that critical are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.critical('critical message') - log = (await get_error_log(hass, aiohttp_client, 1))[0] + log = (await get_error_log(hass, hass_client, 1))[0] assert_log(log, '', 'critical message', 'CRITICAL') -async def test_remove_older_logs(hass, aiohttp_client): +async def test_remove_older_logs(hass, hass_client): """Test that older logs are rotated out.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.error('error message 1') _LOGGER.error('error message 2') _LOGGER.error('error message 3') - log = await get_error_log(hass, aiohttp_client, 2) + log = await get_error_log(hass, hass_client, 2) assert_log(log[0], '', 'error message 3', 'ERROR') assert_log(log[1], '', 'error message 2', 'ERROR') -async def test_clear_logs(hass, aiohttp_client): +async def test_clear_logs(hass, hass_client): """Test that the log can be cleared via a service call.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.error('error message') @@ -151,7 +151,7 @@ async def test_clear_logs(hass, aiohttp_client): await hass.async_block_till_done() # Assert done by get_error_log - await get_error_log(hass, aiohttp_client, 0) + await get_error_log(hass, hass_client, 0) async def test_write_log(hass): @@ -197,13 +197,13 @@ async def test_write_choose_level(hass): assert logger.method_calls[0] == ('debug', ('test_message',)) -async def test_unknown_path(hass, aiohttp_client): +async def test_unknown_path(hass, hass_client): """Test error logged from unknown path.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.findCaller = MagicMock( return_value=('unknown_path', 0, None, None)) _LOGGER.error('error message') - log = (await get_error_log(hass, aiohttp_client, 1))[0] + log = (await get_error_log(hass, hass_client, 1))[0] assert log['source'] == 'unknown_path' @@ -222,31 +222,31 @@ def log_error_from_test_path(path): _LOGGER.error('error message') -async def test_homeassistant_path(hass, aiohttp_client): +async def test_homeassistant_path(hass, hass_client): """Test error logged from homeassistant path.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) with patch('homeassistant.components.system_log.HOMEASSISTANT_PATH', new=['venv_path/homeassistant']): log_error_from_test_path( 'venv_path/homeassistant/component/component.py') - log = (await get_error_log(hass, aiohttp_client, 1))[0] + log = (await get_error_log(hass, hass_client, 1))[0] assert log['source'] == 'component/component.py' -async def test_config_path(hass, aiohttp_client): +async def test_config_path(hass, hass_client): """Test error logged from config path.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) with patch.object(hass.config, 'config_dir', new='config'): log_error_from_test_path('config/custom_component/test.py') - log = (await get_error_log(hass, aiohttp_client, 1))[0] + log = (await get_error_log(hass, hass_client, 1))[0] assert log['source'] == 'custom_component/test.py' -async def test_netdisco_path(hass, aiohttp_client): +async def test_netdisco_path(hass, hass_client): """Test error logged from netdisco path.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) with patch.dict('sys.modules', netdisco=MagicMock(__path__=['venv_path/netdisco'])): log_error_from_test_path('venv_path/netdisco/disco_component.py') - log = (await get_error_log(hass, aiohttp_client, 1))[0] + log = (await get_error_log(hass, hass_client, 1))[0] assert log['source'] == 'disco_component.py' diff --git a/tests/components/test_webhook.py b/tests/components/test_webhook.py index c16fef3e0592a..e67cf7481ccbe 100644 --- a/tests/components/test_webhook.py +++ b/tests/components/test_webhook.py @@ -7,10 +7,10 @@ @pytest.fixture -def mock_client(hass, aiohttp_client): +def mock_client(hass, hass_client): """Create http client for webhooks.""" hass.loop.run_until_complete(async_setup_component(hass, 'webhook', {})) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + return hass.loop.run_until_complete(hass_client()) async def test_unregistering_webhook(hass, mock_client): From f1c5e756ff159877a7a2fc842c95e6fe670fabca Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Thu, 29 Nov 2018 20:16:39 +0100 Subject: [PATCH 137/325] Fix logbook domain filter - alexa, homekit (#18790) --- homeassistant/components/logbook.py | 6 ++++++ tests/components/test_logbook.py | 27 +++++++++++++++++++++------ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index c7a37411f1eaa..b6f434a82ad7d 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -445,6 +445,12 @@ def _exclude_events(events, entities_filter): domain = event.data.get(ATTR_DOMAIN) entity_id = event.data.get(ATTR_ENTITY_ID) + elif event.event_type == EVENT_ALEXA_SMART_HOME: + domain = 'alexa' + + elif event.event_type == EVENT_HOMEKIT_CHANGED: + domain = DOMAIN_HOMEKIT + if not entity_id and domain: entity_id = "%s." % (domain, ) diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index ae1e3d1d51aba..0d204773241ad 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -242,9 +242,11 @@ def test_exclude_events_domain(self): config = logbook.CONFIG_SCHEMA({ ha.DOMAIN: {}, logbook.DOMAIN: {logbook.CONF_EXCLUDE: { - logbook.CONF_DOMAINS: ['switch', ]}}}) + logbook.CONF_DOMAINS: ['switch', 'alexa', DOMAIN_HOMEKIT]}}}) events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_START), eventA, eventB), + (ha.Event(EVENT_HOMEASSISTANT_START), + ha.Event(EVENT_ALEXA_SMART_HOME), + ha.Event(EVENT_HOMEKIT_CHANGED), eventA, eventB), logbook._generate_filter_from_config(config[logbook.DOMAIN])) entries = list(logbook.humanify(self.hass, events)) @@ -325,22 +327,35 @@ def test_include_events_domain(self): pointA = dt_util.utcnow() pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES) + event_alexa = ha.Event(EVENT_ALEXA_SMART_HOME, {'request': { + 'namespace': 'Alexa.Discovery', + 'name': 'Discover', + }}) + event_homekit = ha.Event(EVENT_HOMEKIT_CHANGED, { + ATTR_ENTITY_ID: 'lock.front_door', + ATTR_DISPLAY_NAME: 'Front Door', + ATTR_SERVICE: 'lock', + }) + eventA = self.create_state_changed_event(pointA, entity_id, 10) eventB = self.create_state_changed_event(pointB, entity_id2, 20) config = logbook.CONFIG_SCHEMA({ ha.DOMAIN: {}, logbook.DOMAIN: {logbook.CONF_INCLUDE: { - logbook.CONF_DOMAINS: ['sensor', ]}}}) + logbook.CONF_DOMAINS: ['sensor', 'alexa', DOMAIN_HOMEKIT]}}}) events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_START), eventA, eventB), + (ha.Event(EVENT_HOMEASSISTANT_START), + event_alexa, event_homekit, eventA, eventB), logbook._generate_filter_from_config(config[logbook.DOMAIN])) entries = list(logbook.humanify(self.hass, events)) - assert 2 == len(entries) + assert 4 == len(entries) self.assert_entry(entries[0], name='Home Assistant', message='started', domain=ha.DOMAIN) - self.assert_entry(entries[1], pointB, 'blu', domain='sensor', + self.assert_entry(entries[1], name='Amazon Alexa', domain='alexa') + self.assert_entry(entries[2], name='HomeKit', domain=DOMAIN_HOMEKIT) + self.assert_entry(entries[3], pointB, 'blu', domain='sensor', entity_id=entity_id2) def test_include_exclude_events(self): From 0ca67bf6f7ebc196a04b47f6b80a192f9791e27d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 29 Nov 2018 22:26:19 +0100 Subject: [PATCH 138/325] Make auth backwards compat again (#18792) * Made auth not backwards compat * Fix tests --- homeassistant/auth/auth_store.py | 3 ++- tests/auth/test_auth_store.py | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index cf82c40a4d374..bad1bdcf913e6 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -462,10 +462,11 @@ def _data_to_save(self) -> Dict: for group in self._groups.values(): g_dict = { 'id': group.id, + # Name not read for sys groups. Kept here for backwards compat + 'name': group.name } # type: Dict[str, Any] if group.id not in (GROUP_ID_READ_ONLY, GROUP_ID_ADMIN): - g_dict['name'] = group.name g_dict['policy'] = group.policy groups.append(g_dict) diff --git a/tests/auth/test_auth_store.py b/tests/auth/test_auth_store.py index b76d68fbeac27..7e9df869a048b 100644 --- a/tests/auth/test_auth_store.py +++ b/tests/auth/test_auth_store.py @@ -199,13 +199,22 @@ async def test_loading_empty_data(hass, hass_storage): assert len(users) == 0 -async def test_system_groups_only_store_id(hass, hass_storage): - """Test that for system groups we only store the ID.""" +async def test_system_groups_store_id_and_name(hass, hass_storage): + """Test that for system groups we store the ID and name. + + Name is stored so that we remain backwards compat with < 0.82. + """ store = auth_store.AuthStore(hass) await store._async_load() data = store._data_to_save() assert len(data['users']) == 0 assert data['groups'] == [ - {'id': auth_store.GROUP_ID_ADMIN}, - {'id': auth_store.GROUP_ID_READ_ONLY}, + { + 'id': auth_store.GROUP_ID_ADMIN, + 'name': auth_store.GROUP_NAME_ADMIN, + }, + { + 'id': auth_store.GROUP_ID_READ_ONLY, + 'name': auth_store.GROUP_NAME_READ_ONLY, + }, ] From fa9a200e3c2baf8024aaa1f419196b59ac2f1bb4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 29 Nov 2018 22:17:01 +0100 Subject: [PATCH 139/325] Render the secret (#18793) --- homeassistant/components/owntracks/config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/owntracks/config_flow.py b/homeassistant/components/owntracks/config_flow.py index 8836294642833..8cf19e84bcdd2 100644 --- a/homeassistant/components/owntracks/config_flow.py +++ b/homeassistant/components/owntracks/config_flow.py @@ -40,8 +40,8 @@ async def async_step_user(self, user_input=None): if supports_encryption(): secret_desc = ( - "The encryption key is {secret} " - "(on Android under preferences -> advanced)") + "The encryption key is {} " + "(on Android under preferences -> advanced)".format(secret)) else: secret_desc = ( "Encryption is not supported because libsodium is not " From 7fa5f0721880a26a8e4e5754f897dc1ccbb5de1d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 29 Nov 2018 22:26:06 +0100 Subject: [PATCH 140/325] Fix race condition in group.set (#18796) --- homeassistant/components/group/__init__.py | 9 ++++++++- tests/helpers/test_entity_component.py | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 4dd3571e69c2a..15a3816c559e8 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -207,6 +207,13 @@ async def reload_service_handler(service): DOMAIN, SERVICE_RELOAD, reload_service_handler, schema=RELOAD_SERVICE_SCHEMA) + service_lock = asyncio.Lock() + + async def locked_service_handler(service): + """Handle a service with an async lock.""" + async with service_lock: + await groups_service_handler(service) + async def groups_service_handler(service): """Handle dynamic group service functions.""" object_id = service.data[ATTR_OBJECT_ID] @@ -284,7 +291,7 @@ async def groups_service_handler(service): await component.async_remove_entity(entity_id) hass.services.async_register( - DOMAIN, SERVICE_SET, groups_service_handler, + DOMAIN, SERVICE_SET, locked_service_handler, schema=SET_SERVICE_SCHEMA) hass.services.async_register( diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 2bef8c0b53e5c..7562a38d268e7 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -431,3 +431,24 @@ async def test_update_entity(hass): assert len(entity.async_update_ha_state.mock_calls) == 2 assert entity.async_update_ha_state.mock_calls[-1][1][0] is True + + +async def test_set_service_race(hass): + """Test race condition on setting service.""" + exception = False + + def async_loop_exception_handler(_, _2) -> None: + """Handle all exception inside the core loop.""" + nonlocal exception + exception = True + + hass.loop.set_exception_handler(async_loop_exception_handler) + + await async_setup_component(hass, 'group', {}) + component = EntityComponent(_LOGGER, DOMAIN, hass, group_name='yo') + + for i in range(2): + hass.async_create_task(component.async_add_entities([MockEntity()])) + + await hass.async_block_till_done() + assert not exception From 5a6ac9ee7219512fa0a364fa10accd64db8d00cf Mon Sep 17 00:00:00 2001 From: Eric Nagley Date: Thu, 29 Nov 2018 16:24:53 -0500 Subject: [PATCH 141/325] BUGFIX: handle extra fan speeds. (#18799) * BUGFIX: add support for extra fan speeds. * Drop extra fan speeds. Remove catch all, drop missing fan speeds. * fix self.speed_synonyms call. Remove un-needed keys() call --- homeassistant/components/google_assistant/trait.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index d32dd91a3c1c8..61231a7894de0 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -712,6 +712,8 @@ def sync_attributes(self): modes = self.state.attributes.get(fan.ATTR_SPEED_LIST, []) speeds = [] for mode in modes: + if mode not in self.speed_synonyms: + continue speed = { "speed_name": mode, "speed_values": [{ From f2b818658f4c020b99473257781db923943253c3 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 29 Nov 2018 14:24:32 -0700 Subject: [PATCH 142/325] Bumped py17track to 2.1.0 (#18804) --- homeassistant/components/sensor/seventeentrack.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/seventeentrack.py b/homeassistant/components/sensor/seventeentrack.py index 7ad0e45376088..b4c869e726710 100644 --- a/homeassistant/components/sensor/seventeentrack.py +++ b/homeassistant/components/sensor/seventeentrack.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle, slugify -REQUIREMENTS = ['py17track==2.0.2'] +REQUIREMENTS = ['py17track==2.1.0'] _LOGGER = logging.getLogger(__name__) ATTR_DESTINATION_COUNTRY = 'destination_country' diff --git a/requirements_all.txt b/requirements_all.txt index 197f9be02d04e..dc0f7e8679f37 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -804,7 +804,7 @@ py-melissa-climate==2.0.0 py-synology==0.2.0 # homeassistant.components.sensor.seventeentrack -py17track==2.0.2 +py17track==2.1.0 # homeassistant.components.hdmi_cec pyCEC==0.4.13 From d9124b182ac822405403509922b5b0d64cce67df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Thu, 29 Nov 2018 22:28:27 +0100 Subject: [PATCH 143/325] Remove self from update function in rainmachine (#18807) --- homeassistant/components/binary_sensor/rainmachine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/binary_sensor/rainmachine.py b/homeassistant/components/binary_sensor/rainmachine.py index 4a671fc95122b..efae93303650a 100644 --- a/homeassistant/components/binary_sensor/rainmachine.py +++ b/homeassistant/components/binary_sensor/rainmachine.py @@ -74,7 +74,7 @@ def unique_id(self) -> str: async def async_added_to_hass(self): """Register callbacks.""" @callback - def update(self): + def update(): """Update the state.""" self.async_schedule_update_ha_state(True) From 31d7221c90245dd3b79289b6a27734bd0fc084f4 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 29 Nov 2018 14:47:41 -0700 Subject: [PATCH 144/325] Remove additional self from update function in RainMachine (#18810) --- homeassistant/components/sensor/rainmachine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/rainmachine.py b/homeassistant/components/sensor/rainmachine.py index 5131b25510af8..86a97bc291cfe 100644 --- a/homeassistant/components/sensor/rainmachine.py +++ b/homeassistant/components/sensor/rainmachine.py @@ -77,7 +77,7 @@ def unit_of_measurement(self): async def async_added_to_hass(self): """Register callbacks.""" @callback - def update(self): + def update(): """Update the state.""" self.async_schedule_update_ha_state(True) From 2b52f27eb9e52dc3a0c1100d40330b796633ec64 Mon Sep 17 00:00:00 2001 From: pbalogh77 Date: Thu, 29 Nov 2018 22:57:05 +0100 Subject: [PATCH 145/325] Hotfix for crash with virtual devices (#18808) * Quickfix for crash with virtual devices Added try/except to critical loops of processing Reinforced read_devices, map_device_to_type and update processing * oops --- homeassistant/components/fibaro.py | 90 +++++++++++++++++------------- 1 file changed, 50 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/fibaro.py b/homeassistant/components/fibaro.py index c9dd19b4bc870..85bd5c3c0181e 100644 --- a/homeassistant/components/fibaro.py +++ b/homeassistant/components/fibaro.py @@ -103,29 +103,31 @@ def _on_state_change(self, state): """Handle change report received from the HomeCenter.""" callback_set = set() for change in state.get('changes', []): - dev_id = change.pop('id') - for property_name, value in change.items(): - if property_name == 'log': - if value and value != "transfer OK": - _LOGGER.debug("LOG %s: %s", - self._device_map[dev_id].friendly_name, - value) + try: + dev_id = change.pop('id') + if dev_id not in self._device_map.keys(): continue - if property_name == 'logTemp': - continue - if property_name in self._device_map[dev_id].properties: - self._device_map[dev_id].properties[property_name] = \ - value - _LOGGER.debug("<- %s.%s = %s", - self._device_map[dev_id].ha_id, - property_name, - str(value)) - else: - _LOGGER.warning("Error updating %s data of %s, not found", - property_name, - self._device_map[dev_id].ha_id) - if dev_id in self._callbacks: - callback_set.add(dev_id) + device = self._device_map[dev_id] + for property_name, value in change.items(): + if property_name == 'log': + if value and value != "transfer OK": + _LOGGER.debug("LOG %s: %s", + device.friendly_name, value) + continue + if property_name == 'logTemp': + continue + if property_name in device.properties: + device.properties[property_name] = \ + value + _LOGGER.debug("<- %s.%s = %s", device.ha_id, + property_name, str(value)) + else: + _LOGGER.warning("%s.%s not found", device.ha_id, + property_name) + if dev_id in self._callbacks: + callback_set.add(dev_id) + except (ValueError, KeyError): + pass for item in callback_set: self._callbacks[item]() @@ -137,8 +139,12 @@ def register(self, device_id, callback): def _map_device_to_type(device): """Map device to HA device type.""" # Use our lookup table to identify device type - device_type = FIBARO_TYPEMAP.get( - device.type, FIBARO_TYPEMAP.get(device.baseType)) + if 'type' in device: + device_type = FIBARO_TYPEMAP.get(device.type) + elif 'baseType' in device: + device_type = FIBARO_TYPEMAP.get(device.baseType) + else: + device_type = None # We can also identify device type by its capabilities if device_type is None: @@ -156,8 +162,7 @@ def _map_device_to_type(device): # Switches that control lights should show up as lights if device_type == 'switch' and \ - 'isLight' in device.properties and \ - device.properties.isLight == 'true': + device.properties.get('isLight', 'false') == 'true': device_type = 'light' return device_type @@ -165,26 +170,31 @@ def _read_devices(self): """Read and process the device list.""" devices = self._client.devices.list() self._device_map = {} - for device in devices: - if device.roomID == 0: - room_name = 'Unknown' - else: - room_name = self._room_map[device.roomID].name - device.friendly_name = room_name + ' ' + device.name - device.ha_id = '{}_{}_{}'.format( - slugify(room_name), slugify(device.name), device.id) - self._device_map[device.id] = device self.fibaro_devices = defaultdict(list) - for device in self._device_map.values(): - if device.enabled and \ - (not device.isPlugin or self._import_plugins): - device.mapped_type = self._map_device_to_type(device) + for device in devices: + try: + if device.roomID == 0: + room_name = 'Unknown' + else: + room_name = self._room_map[device.roomID].name + device.friendly_name = room_name + ' ' + device.name + device.ha_id = '{}_{}_{}'.format( + slugify(room_name), slugify(device.name), device.id) + if device.enabled and \ + ('isPlugin' not in device or + (not device.isPlugin or self._import_plugins)): + device.mapped_type = self._map_device_to_type(device) + else: + device.mapped_type = None if device.mapped_type: + self._device_map[device.id] = device self.fibaro_devices[device.mapped_type].append(device) else: - _LOGGER.debug("%s (%s, %s) not mapped", + _LOGGER.debug("%s (%s, %s) not used", device.ha_id, device.type, device.baseType) + except (KeyError, ValueError): + pass def setup(hass, config): From 0467d0563acc0f213082bdc2c1066de847d6a978 Mon Sep 17 00:00:00 2001 From: pbalogh77 Date: Thu, 29 Nov 2018 22:57:05 +0100 Subject: [PATCH 146/325] Hotfix for crash with virtual devices (#18808) * Quickfix for crash with virtual devices Added try/except to critical loops of processing Reinforced read_devices, map_device_to_type and update processing * oops --- homeassistant/components/fibaro.py | 90 +++++++++++++++++------------- 1 file changed, 50 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/fibaro.py b/homeassistant/components/fibaro.py index c9dd19b4bc870..85bd5c3c0181e 100644 --- a/homeassistant/components/fibaro.py +++ b/homeassistant/components/fibaro.py @@ -103,29 +103,31 @@ def _on_state_change(self, state): """Handle change report received from the HomeCenter.""" callback_set = set() for change in state.get('changes', []): - dev_id = change.pop('id') - for property_name, value in change.items(): - if property_name == 'log': - if value and value != "transfer OK": - _LOGGER.debug("LOG %s: %s", - self._device_map[dev_id].friendly_name, - value) + try: + dev_id = change.pop('id') + if dev_id not in self._device_map.keys(): continue - if property_name == 'logTemp': - continue - if property_name in self._device_map[dev_id].properties: - self._device_map[dev_id].properties[property_name] = \ - value - _LOGGER.debug("<- %s.%s = %s", - self._device_map[dev_id].ha_id, - property_name, - str(value)) - else: - _LOGGER.warning("Error updating %s data of %s, not found", - property_name, - self._device_map[dev_id].ha_id) - if dev_id in self._callbacks: - callback_set.add(dev_id) + device = self._device_map[dev_id] + for property_name, value in change.items(): + if property_name == 'log': + if value and value != "transfer OK": + _LOGGER.debug("LOG %s: %s", + device.friendly_name, value) + continue + if property_name == 'logTemp': + continue + if property_name in device.properties: + device.properties[property_name] = \ + value + _LOGGER.debug("<- %s.%s = %s", device.ha_id, + property_name, str(value)) + else: + _LOGGER.warning("%s.%s not found", device.ha_id, + property_name) + if dev_id in self._callbacks: + callback_set.add(dev_id) + except (ValueError, KeyError): + pass for item in callback_set: self._callbacks[item]() @@ -137,8 +139,12 @@ def register(self, device_id, callback): def _map_device_to_type(device): """Map device to HA device type.""" # Use our lookup table to identify device type - device_type = FIBARO_TYPEMAP.get( - device.type, FIBARO_TYPEMAP.get(device.baseType)) + if 'type' in device: + device_type = FIBARO_TYPEMAP.get(device.type) + elif 'baseType' in device: + device_type = FIBARO_TYPEMAP.get(device.baseType) + else: + device_type = None # We can also identify device type by its capabilities if device_type is None: @@ -156,8 +162,7 @@ def _map_device_to_type(device): # Switches that control lights should show up as lights if device_type == 'switch' and \ - 'isLight' in device.properties and \ - device.properties.isLight == 'true': + device.properties.get('isLight', 'false') == 'true': device_type = 'light' return device_type @@ -165,26 +170,31 @@ def _read_devices(self): """Read and process the device list.""" devices = self._client.devices.list() self._device_map = {} - for device in devices: - if device.roomID == 0: - room_name = 'Unknown' - else: - room_name = self._room_map[device.roomID].name - device.friendly_name = room_name + ' ' + device.name - device.ha_id = '{}_{}_{}'.format( - slugify(room_name), slugify(device.name), device.id) - self._device_map[device.id] = device self.fibaro_devices = defaultdict(list) - for device in self._device_map.values(): - if device.enabled and \ - (not device.isPlugin or self._import_plugins): - device.mapped_type = self._map_device_to_type(device) + for device in devices: + try: + if device.roomID == 0: + room_name = 'Unknown' + else: + room_name = self._room_map[device.roomID].name + device.friendly_name = room_name + ' ' + device.name + device.ha_id = '{}_{}_{}'.format( + slugify(room_name), slugify(device.name), device.id) + if device.enabled and \ + ('isPlugin' not in device or + (not device.isPlugin or self._import_plugins)): + device.mapped_type = self._map_device_to_type(device) + else: + device.mapped_type = None if device.mapped_type: + self._device_map[device.id] = device self.fibaro_devices[device.mapped_type].append(device) else: - _LOGGER.debug("%s (%s, %s) not mapped", + _LOGGER.debug("%s (%s, %s) not used", device.ha_id, device.type, device.baseType) + except (KeyError, ValueError): + pass def setup(hass, config): From 163c881ced824af536364657f336c48bf4df19c6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 29 Nov 2018 22:58:06 +0100 Subject: [PATCH 147/325] Bumped version to 0.83.1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index dc00267cdf862..6b69609be2211 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 83 -PATCH_VERSION = '0' +PATCH_VERSION = '1' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 440614dd9d3c7874c47b57c431db2af93d7e954c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Nov 2018 20:55:21 +0100 Subject: [PATCH 148/325] Use proper signals (#18613) * Emulated Hue not use deprecated handler * Remove no longer needed workaround * Add middleware directly * Dont always load the ban config file * Update homeassistant/components/http/ban.py Co-Authored-By: balloob * Update __init__.py --- .../components/emulated_hue/__init__.py | 27 +++++++++---------- homeassistant/components/http/__init__.py | 7 +---- homeassistant/components/http/auth.py | 6 +---- homeassistant/components/http/ban.py | 17 ++++++------ homeassistant/components/http/real_ip.py | 6 +---- tests/components/conftest.py | 10 ++++++- tests/components/http/test_ban.py | 15 ++++++----- 7 files changed, 43 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 5f1d61dd602b6..9c0df0f9f0342 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -97,8 +97,8 @@ async def async_setup(hass, yaml_config): app._on_startup.freeze() await app.startup() - handler = None - server = None + runner = None + site = None DescriptionXmlView(config).register(app, app.router) HueUsernameView().register(app, app.router) @@ -115,25 +115,24 @@ async def async_setup(hass, yaml_config): async def stop_emulated_hue_bridge(event): """Stop the emulated hue bridge.""" upnp_listener.stop() - if server: - server.close() - await server.wait_closed() - await app.shutdown() - if handler: - await handler.shutdown(10) - await app.cleanup() + if site: + await site.stop() + if runner: + await runner.cleanup() async def start_emulated_hue_bridge(event): """Start the emulated hue bridge.""" upnp_listener.start() - nonlocal handler - nonlocal server + nonlocal site + nonlocal runner - handler = app.make_handler(loop=hass.loop) + runner = web.AppRunner(app) + await runner.setup() + + site = web.TCPSite(runner, config.host_ip_addr, config.listen_port) try: - server = await hass.loop.create_server( - handler, config.host_ip_addr, config.listen_port) + await site.start() except OSError as error: _LOGGER.error("Failed to create HTTP server at port %d: %s", config.listen_port, error) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 1b22f8e62d431..7180002430aad 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -302,12 +302,6 @@ async def serve_file(request): async def start(self): """Start the aiohttp server.""" - # We misunderstood the startup signal. You're not allowed to change - # anything during startup. Temp workaround. - # pylint: disable=protected-access - self.app._on_startup.freeze() - await self.app.startup() - if self.ssl_certificate: try: if self.ssl_profile == SSL_INTERMEDIATE: @@ -335,6 +329,7 @@ async def start(self): # However in Home Assistant components can be discovered after boot. # This will now raise a RunTimeError. # To work around this we now prevent the router from getting frozen + # pylint: disable=protected-access self.app._router.freeze = lambda: None self.runner = web.AppRunner(self.app) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 1f9782bb4fe1d..0e943b33fb834 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -101,11 +101,7 @@ async def auth_middleware(request, handler): request[KEY_AUTHENTICATED] = authenticated return await handler(request) - async def auth_startup(app): - """Initialize auth middleware when app starts up.""" - app.middlewares.append(auth_middleware) - - app.on_startup.append(auth_startup) + app.middlewares.append(auth_middleware) def _is_trusted_ip(request, trusted_networks): diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 2a25de96edc1d..d6d7168ce6d75 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -9,7 +9,7 @@ from aiohttp.web_exceptions import HTTPForbidden, HTTPUnauthorized import voluptuous as vol -from homeassistant.core import callback +from homeassistant.core import callback, HomeAssistant from homeassistant.config import load_yaml_config_file from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv @@ -36,13 +36,14 @@ @callback def setup_bans(hass, app, login_threshold): """Create IP Ban middleware for the app.""" + app.middlewares.append(ban_middleware) + app[KEY_FAILED_LOGIN_ATTEMPTS] = defaultdict(int) + app[KEY_LOGIN_THRESHOLD] = login_threshold + async def ban_startup(app): """Initialize bans when app starts up.""" - app.middlewares.append(ban_middleware) - app[KEY_BANNED_IPS] = await 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[KEY_BANNED_IPS] = await async_load_ip_bans_config( + hass, hass.config.path(IP_BANS_FILE)) app.on_startup.append(ban_startup) @@ -149,7 +150,7 @@ def __init__(self, ip_ban: str, banned_at: datetime = None) -> None: self.banned_at = banned_at or datetime.utcnow() -def load_ip_bans_config(path: str): +async def async_load_ip_bans_config(hass: HomeAssistant, path: str): """Load list of banned IPs from config file.""" ip_list = [] @@ -157,7 +158,7 @@ def load_ip_bans_config(path: str): return ip_list try: - list_ = load_yaml_config_file(path) + list_ = await hass.async_add_executor_job(load_yaml_config_file, path) except HomeAssistantError as err: _LOGGER.error('Unable to load %s: %s', path, str(err)) return ip_list diff --git a/homeassistant/components/http/real_ip.py b/homeassistant/components/http/real_ip.py index f8adc815fdef2..27a8550ab8cac 100644 --- a/homeassistant/components/http/real_ip.py +++ b/homeassistant/components/http/real_ip.py @@ -33,8 +33,4 @@ async def real_ip_middleware(request, handler): return await handler(request) - async def app_startup(app): - """Initialize bans when app starts up.""" - app.middlewares.append(real_ip_middleware) - - app.on_startup.append(app_startup) + app.middlewares.append(real_ip_middleware) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 5d5a964b2ce64..110ba8d5ad6d6 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -10,7 +10,15 @@ from homeassistant.components.websocket_api.auth import ( TYPE_AUTH, TYPE_AUTH_OK, TYPE_AUTH_REQUIRED) -from tests.common import MockUser, CLIENT_ID +from tests.common import MockUser, CLIENT_ID, mock_coro + + +@pytest.fixture(autouse=True) +def prevent_io(): + """Fixture to prevent certain I/O from happening.""" + with patch('homeassistant.components.http.ban.async_load_ip_bans_config', + side_effect=lambda *args: mock_coro([])): + yield @pytest.fixture diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index a6a07928113b3..6624937da8dfb 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -16,6 +16,9 @@ from . import mock_real_ip +from tests.common import mock_coro + + BANNED_IPS = ['200.201.202.203', '100.64.0.2'] @@ -25,9 +28,9 @@ async def test_access_from_banned_ip(hass, aiohttp_client): 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]): + with patch('homeassistant.components.http.ban.async_load_ip_bans_config', + return_value=mock_coro([IpBan(banned_ip) for banned_ip + in BANNED_IPS])): client = await aiohttp_client(app) for remote_addr in BANNED_IPS: @@ -71,9 +74,9 @@ async def unauth_handler(request): 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]): + with patch('homeassistant.components.http.ban.async_load_ip_bans_config', + return_value=mock_coro([IpBan(banned_ip) for banned_ip + in BANNED_IPS])): client = await aiohttp_client(app) m = mock_open() From a035725c67a09b852c4eaf34aaaa1e619b33f372 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Thu, 29 Nov 2018 15:15:48 -0700 Subject: [PATCH 149/325] Service already discovered log entry (#18800) Add debug log entry if service is already discovered. --- homeassistant/components/discovery.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 96c79053dffbc..bbf40c7307048 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -134,6 +134,7 @@ async def new_service_found(service, info): discovery_hash = json.dumps([service, info], sort_keys=True) if discovery_hash in already_discovered: + logger.debug("Already discoverd service %s %s.", service, info) return already_discovered.add(discovery_hash) From a9dc4ba297b8873927b3ca30c7bd684318c9d7fe Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Thu, 29 Nov 2018 15:44:29 -0700 Subject: [PATCH 150/325] Increase pyatv to 0.3.11 (#18801) --- homeassistant/components/apple_tv.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index b8774d76873f2..ff17b6d5e39ea 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -16,7 +16,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyatv==0.3.10'] +REQUIREMENTS = ['pyatv==0.3.11'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index e69f5e516dec1..578d0315e3b95 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -851,7 +851,7 @@ pyarlo==0.2.2 pyatmo==1.3 # homeassistant.components.apple_tv -pyatv==0.3.10 +pyatv==0.3.11 # homeassistant.components.device_tracker.bbox # homeassistant.components.sensor.bbox From 22f27b8621491c5e1ebaeee0bef2c040ee698ddb Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Fri, 30 Nov 2018 02:26:19 -0500 Subject: [PATCH 151/325] Store state last seen time separately (#18806) * Store state last seen time separately This ensures that infrequently updated entities aren't accidentally dropped from the restore states store * Fix mock restore cache --- homeassistant/helpers/restore_state.py | 69 +++++++++++++++++++------- tests/common.py | 4 +- tests/helpers/test_restore_state.py | 51 ++++++++++--------- 3 files changed, 80 insertions(+), 44 deletions(-) diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 51f1bd76c2ab8..cabaf64d859e8 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -1,7 +1,7 @@ """Support for restoring entity states on startup.""" import asyncio import logging -from datetime import timedelta +from datetime import timedelta, datetime from typing import Any, Dict, List, Set, Optional # noqa pylint_disable=unused-import from homeassistant.core import HomeAssistant, callback, State, CoreState @@ -28,6 +28,32 @@ STATE_EXPIRATION = timedelta(days=7) +class StoredState: + """Object to represent a stored state.""" + + def __init__(self, state: State, last_seen: datetime) -> None: + """Initialize a new stored state.""" + self.state = state + self.last_seen = last_seen + + def as_dict(self) -> Dict: + """Return a dict representation of the stored state.""" + return { + 'state': self.state.as_dict(), + 'last_seen': self.last_seen, + } + + @classmethod + def from_dict(cls, json_dict: Dict) -> 'StoredState': + """Initialize a stored state from a dict.""" + last_seen = json_dict['last_seen'] + + if isinstance(last_seen, str): + last_seen = dt_util.parse_datetime(last_seen) + + return cls(State.from_dict(json_dict['state']), last_seen) + + class RestoreStateData(): """Helper class for managing the helper saved data.""" @@ -43,18 +69,18 @@ async def load_instance(hass: HomeAssistant) -> 'RestoreStateData': data = cls(hass) try: - states = await data.store.async_load() + stored_states = await data.store.async_load() except HomeAssistantError as exc: _LOGGER.error("Error loading last states", exc_info=exc) - states = None + stored_states = None - if states is None: + if stored_states is None: _LOGGER.debug('Not creating cache - no saved states found') data.last_states = {} else: data.last_states = { - state['entity_id']: State.from_dict(state) - for state in states} + item['state']['entity_id']: StoredState.from_dict(item) + for item in stored_states} _LOGGER.debug( 'Created cache with %s', list(data.last_states)) @@ -74,46 +100,49 @@ async def load_instance(hass: HomeAssistant) -> 'RestoreStateData': def __init__(self, hass: HomeAssistant) -> None: """Initialize the restore state data class.""" self.hass = hass # type: HomeAssistant - self.store = Store(hass, STORAGE_VERSION, STORAGE_KEY, - encoder=JSONEncoder) # type: Store - self.last_states = {} # type: Dict[str, State] + self.store = Store( + hass, STORAGE_VERSION, STORAGE_KEY, + encoder=JSONEncoder) # type: Store + self.last_states = {} # type: Dict[str, StoredState] self.entity_ids = set() # type: Set[str] - def async_get_states(self) -> List[State]: + def async_get_stored_states(self) -> List[StoredState]: """Get the set of states which should be stored. This includes the states of all registered entities, as well as the stored states from the previous run, which have not been created as entities on this run, and have not expired. """ + now = dt_util.utcnow() all_states = self.hass.states.async_all() current_entity_ids = set(state.entity_id for state in all_states) # Start with the currently registered states - states = [state for state in all_states - if state.entity_id in self.entity_ids] + stored_states = [StoredState(state, now) for state in all_states + if state.entity_id in self.entity_ids] - expiration_time = dt_util.utcnow() - STATE_EXPIRATION + expiration_time = now - STATE_EXPIRATION - for entity_id, state in self.last_states.items(): + for entity_id, stored_state in self.last_states.items(): # Don't save old states that have entities in the current run if entity_id in current_entity_ids: continue # Don't save old states that have expired - if state.last_updated < expiration_time: + if stored_state.last_seen < expiration_time: continue - states.append(state) + stored_states.append(stored_state) - return states + return stored_states async def async_dump_states(self) -> None: """Save the current state machine to storage.""" _LOGGER.debug("Dumping states") try: await self.store.async_save([ - state.as_dict() for state in self.async_get_states()]) + stored_state.as_dict() + for stored_state in self.async_get_stored_states()]) except HomeAssistantError as exc: _LOGGER.error("Error saving current states", exc_info=exc) @@ -172,4 +201,6 @@ async def async_get_last_state(self) -> Optional[State]: _LOGGER.warning("Cannot get last state. Entity not added to hass") return None data = await RestoreStateData.async_get_instance(self.hass) - return data.last_states.get(self.entity_id) + if self.entity_id not in data.last_states: + return None + return data.last_states[self.entity_id].state diff --git a/tests/common.py b/tests/common.py index 86bc0643d657b..db7ce6e3a1722 100644 --- a/tests/common.py +++ b/tests/common.py @@ -715,9 +715,11 @@ def mock_restore_cache(hass, states): """Mock the DATA_RESTORE_CACHE.""" key = restore_state.DATA_RESTORE_STATE_TASK data = restore_state.RestoreStateData(hass) + now = date_util.utcnow() data.last_states = { - state.entity_id: state for state in states} + state.entity_id: restore_state.StoredState(state, now) + for state in states} _LOGGER.debug('Restore cache: %s', data.last_states) assert len(data.last_states) == len(states), \ "Duplicate entity_id? {}".format(states) diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index 1ac48264d45b0..e6693d2cf6180 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -6,7 +6,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import Entity from homeassistant.helpers.restore_state import ( - RestoreStateData, RestoreEntity, DATA_RESTORE_STATE_TASK) + RestoreStateData, RestoreEntity, StoredState, DATA_RESTORE_STATE_TASK) from homeassistant.util import dt as dt_util from asynctest import patch @@ -16,14 +16,15 @@ async def test_caching_data(hass): """Test that we cache data.""" - states = [ - State('input_boolean.b0', 'on'), - State('input_boolean.b1', 'on'), - State('input_boolean.b2', 'on'), + now = dt_util.utcnow() + stored_states = [ + StoredState(State('input_boolean.b0', 'on'), now), + StoredState(State('input_boolean.b1', 'on'), now), + StoredState(State('input_boolean.b2', 'on'), now), ] data = await RestoreStateData.async_get_instance(hass) - await data.store.async_save([state.as_dict() for state in states]) + await data.store.async_save([state.as_dict() for state in stored_states]) # Emulate a fresh load hass.data[DATA_RESTORE_STATE_TASK] = None @@ -48,14 +49,15 @@ async def test_hass_starting(hass): """Test that we cache data.""" hass.state = CoreState.starting - states = [ - State('input_boolean.b0', 'on'), - State('input_boolean.b1', 'on'), - State('input_boolean.b2', 'on'), + now = dt_util.utcnow() + stored_states = [ + StoredState(State('input_boolean.b0', 'on'), now), + StoredState(State('input_boolean.b1', 'on'), now), + StoredState(State('input_boolean.b2', 'on'), now), ] data = await RestoreStateData.async_get_instance(hass) - await data.store.async_save([state.as_dict() for state in states]) + await data.store.async_save([state.as_dict() for state in stored_states]) # Emulate a fresh load hass.data[DATA_RESTORE_STATE_TASK] = None @@ -109,14 +111,15 @@ async def test_dump_data(hass): await entity.async_added_to_hass() data = await RestoreStateData.async_get_instance(hass) + now = dt_util.utcnow() data.last_states = { - 'input_boolean.b0': State('input_boolean.b0', 'off'), - 'input_boolean.b1': State('input_boolean.b1', 'off'), - 'input_boolean.b2': State('input_boolean.b2', 'off'), - 'input_boolean.b3': State('input_boolean.b3', 'off'), - 'input_boolean.b4': State( - 'input_boolean.b4', 'off', last_updated=datetime( - 1985, 10, 26, 1, 22, tzinfo=dt_util.UTC)), + 'input_boolean.b0': StoredState(State('input_boolean.b0', 'off'), now), + 'input_boolean.b1': StoredState(State('input_boolean.b1', 'off'), now), + 'input_boolean.b2': StoredState(State('input_boolean.b2', 'off'), now), + 'input_boolean.b3': StoredState(State('input_boolean.b3', 'off'), now), + 'input_boolean.b4': StoredState( + State('input_boolean.b4', 'off'), + datetime(1985, 10, 26, 1, 22, tzinfo=dt_util.UTC)), } with patch('homeassistant.helpers.restore_state.Store.async_save' @@ -134,10 +137,10 @@ async def test_dump_data(hass): # b3 should be written, since it is still not expired # b4 should not be written, since it is now expired assert len(written_states) == 2 - assert written_states[0]['entity_id'] == 'input_boolean.b1' - assert written_states[0]['state'] == 'on' - assert written_states[1]['entity_id'] == 'input_boolean.b3' - assert written_states[1]['state'] == 'off' + assert written_states[0]['state']['entity_id'] == 'input_boolean.b1' + assert written_states[0]['state']['state'] == 'on' + assert written_states[1]['state']['entity_id'] == 'input_boolean.b3' + assert written_states[1]['state']['state'] == 'off' # Test that removed entities are not persisted await entity.async_will_remove_from_hass() @@ -151,8 +154,8 @@ async def test_dump_data(hass): args = mock_write_data.mock_calls[0][1] written_states = args[0] assert len(written_states) == 1 - assert written_states[0]['entity_id'] == 'input_boolean.b3' - assert written_states[0]['state'] == 'off' + assert written_states[0]['state']['entity_id'] == 'input_boolean.b3' + assert written_states[0]['state']['state'] == 'off' async def test_dump_error(hass): From 5f53627c0a0de568f170598d674795cddcd1acfb Mon Sep 17 00:00:00 2001 From: Andrew Hayworth Date: Fri, 30 Nov 2018 01:47:05 -0600 Subject: [PATCH 152/325] Bump python_awair to 0.0.3 (#18819) --- homeassistant/components/sensor/awair.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/awair.py b/homeassistant/components/sensor/awair.py index 3995309de421c..bce0acb514161 100644 --- a/homeassistant/components/sensor/awair.py +++ b/homeassistant/components/sensor/awair.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle, dt -REQUIREMENTS = ['python_awair==0.0.2'] +REQUIREMENTS = ['python_awair==0.0.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 578d0315e3b95..25c1ec3dae5df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1270,7 +1270,7 @@ python-vlc==1.1.2 python-wink==1.10.1 # homeassistant.components.sensor.awair -python_awair==0.0.2 +python_awair==0.0.3 # homeassistant.components.sensor.swiss_public_transport python_opendata_transport==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8ea99fdeaedcb..77e51c477a063 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -200,7 +200,7 @@ python-forecastio==1.4.0 python-nest==4.0.5 # homeassistant.components.sensor.awair -python_awair==0.0.2 +python_awair==0.0.3 # homeassistant.components.sensor.whois pythonwhois==2.4.3 From 4bee3f760f43fadf917301bad2ea408fac2fb40b Mon Sep 17 00:00:00 2001 From: Heine Furubotten Date: Fri, 30 Nov 2018 09:06:59 +0100 Subject: [PATCH 153/325] Add Entur departure information sensor (#17286) * Added Entur departure information sensor. * Fixed houndci-bot comments. * Removed tailing whitespace. * Fixed some comments from tox lint. * Improved docstring, i think. * Fix for C1801 * Unit test for entur platform setup * Rewritten entur component to have pypi dependecy. * Propper client id for api usage. * Minor cleanup of usage of constants. * Made location output configurable. * Cleaned up usage of constants. * Moved logic to be contained within setup or update methods. * Moved icon consts to root in module. * Using config directly in test * Minor changes --- .../sensor/entur_public_transport.py | 193 ++++++++++++++++++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + .../sensor/test_entur_public_transport.py | 66 ++++++ tests/fixtures/entur_public_transport.json | 111 ++++++++++ 6 files changed, 377 insertions(+) create mode 100644 homeassistant/components/sensor/entur_public_transport.py create mode 100644 tests/components/sensor/test_entur_public_transport.py create mode 100644 tests/fixtures/entur_public_transport.json diff --git a/homeassistant/components/sensor/entur_public_transport.py b/homeassistant/components/sensor/entur_public_transport.py new file mode 100644 index 0000000000000..01fb22f675c5b --- /dev/null +++ b/homeassistant/components/sensor/entur_public_transport.py @@ -0,0 +1,193 @@ +""" +Real-time information about public transport departures in Norway. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.entur_public_transport/ +""" +from datetime import datetime, timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, + CONF_SHOW_ON_MAP) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +import homeassistant.util.dt as dt_util + +REQUIREMENTS = ['enturclient==0.1.0'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_NEXT_UP_IN = 'next_due_in' + +API_CLIENT_NAME = 'homeassistant-homeassistant' + +CONF_ATTRIBUTION = "Data provided by entur.org under NLOD." +CONF_STOP_IDS = 'stop_ids' +CONF_EXPAND_PLATFORMS = 'expand_platforms' + +DEFAULT_NAME = 'Entur' +DEFAULT_ICON_KEY = 'bus' + +ICONS = { + 'air': 'mdi:airplane', + 'bus': 'mdi:bus', + 'rail': 'mdi:train', + 'water': 'mdi:ferry', +} + +SCAN_INTERVAL = timedelta(minutes=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_STOP_IDS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_EXPAND_PLATFORMS, default=True): cv.boolean, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_SHOW_ON_MAP, default=False): cv.boolean, +}) + + +def due_in_minutes(timestamp: str) -> str: + """Get the time in minutes from a timestamp. + + The timestamp should be in the format + year-month-yearThour:minute:second+timezone + """ + if timestamp is None: + return None + diff = datetime.strptime( + timestamp, "%Y-%m-%dT%H:%M:%S%z") - dt_util.now() + + return str(int(diff.total_seconds() / 60)) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Entur public transport sensor.""" + from enturclient import EnturPublicTransportData + from enturclient.consts import CONF_NAME as API_NAME + + expand = config.get(CONF_EXPAND_PLATFORMS) + name = config.get(CONF_NAME) + show_on_map = config.get(CONF_SHOW_ON_MAP) + stop_ids = config.get(CONF_STOP_IDS) + + stops = [s for s in stop_ids if "StopPlace" in s] + quays = [s for s in stop_ids if "Quay" in s] + + data = EnturPublicTransportData(API_CLIENT_NAME, stops, quays, expand) + data.update() + + proxy = EnturProxy(data) + + entities = [] + for item in data.all_stop_places_quays(): + try: + given_name = "{} {}".format( + name, data.get_stop_info(item)[API_NAME]) + except KeyError: + given_name = "{} {}".format(name, item) + + entities.append( + EnturPublicTransportSensor(proxy, given_name, item, show_on_map)) + + add_entities(entities, True) + + +class EnturProxy: + """Proxy for the Entur client. + + Ensure throttle to not hit rate limiting on the API. + """ + + def __init__(self, api): + """Initialize the proxy.""" + self._api = api + + @Throttle(SCAN_INTERVAL) + def update(self) -> None: + """Update data in client.""" + self._api.update() + + def get_stop_info(self, stop_id: str) -> dict: + """Get info about specific stop place.""" + return self._api.get_stop_info(stop_id) + + +class EnturPublicTransportSensor(Entity): + """Implementation of a Entur public transport sensor.""" + + def __init__( + self, api: EnturProxy, name: str, stop: str, show_on_map: bool): + """Initialize the sensor.""" + from enturclient.consts import ATTR_STOP_ID + + self.api = api + self._stop = stop + self._show_on_map = show_on_map + self._name = name + self._data = None + self._state = None + self._icon = ICONS[DEFAULT_ICON_KEY] + self._attributes = { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_STOP_ID: self._stop, + } + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self._name + + @property + def state(self) -> str: + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self) -> dict: + """Return the state attributes.""" + return self._attributes + + @property + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return 'min' + + @property + def icon(self) -> str: + """Icon to use in the frontend.""" + return self._icon + + def update(self) -> None: + """Get the latest data and update the states.""" + from enturclient.consts import ( + ATTR, ATTR_EXPECTED_AT, ATTR_NEXT_UP_AT, CONF_LOCATION, + CONF_LATITUDE as LAT, CONF_LONGITUDE as LONG, CONF_TRANSPORT_MODE) + + self.api.update() + + self._data = self.api.get_stop_info(self._stop) + if self._data is not None: + attrs = self._data[ATTR] + self._attributes.update(attrs) + + if ATTR_NEXT_UP_AT in attrs: + self._attributes[ATTR_NEXT_UP_IN] = \ + due_in_minutes(attrs[ATTR_NEXT_UP_AT]) + + if CONF_LOCATION in self._data and self._show_on_map: + self._attributes[CONF_LATITUDE] = \ + self._data[CONF_LOCATION][LAT] + self._attributes[CONF_LONGITUDE] = \ + self._data[CONF_LOCATION][LONG] + + if ATTR_EXPECTED_AT in attrs: + self._state = due_in_minutes(attrs[ATTR_EXPECTED_AT]) + else: + self._state = None + + self._icon = ICONS.get( + self._data[CONF_TRANSPORT_MODE], ICONS[DEFAULT_ICON_KEY]) diff --git a/requirements_all.txt b/requirements_all.txt index 25c1ec3dae5df..6519ab303795a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -338,6 +338,9 @@ elkm1-lib==0.7.12 # homeassistant.components.enocean enocean==0.40 +# homeassistant.components.sensor.entur_public_transport +enturclient==0.1.0 + # homeassistant.components.sensor.envirophat # envirophat==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 77e51c477a063..ccfb277c72172 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -58,6 +58,9 @@ defusedxml==0.5.0 # homeassistant.components.sensor.dsmr dsmr_parser==0.12 +# homeassistant.components.sensor.entur_public_transport +enturclient==0.1.0 + # homeassistant.components.sensor.season ephem==3.7.6.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index b0ad953e2b5a8..e5840d62e17e9 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -46,6 +46,7 @@ 'coinmarketcap', 'defusedxml', 'dsmr_parser', + 'enturclient', 'ephem', 'evohomeclient', 'feedparser', diff --git a/tests/components/sensor/test_entur_public_transport.py b/tests/components/sensor/test_entur_public_transport.py new file mode 100644 index 0000000000000..20b50ce9ddd3a --- /dev/null +++ b/tests/components/sensor/test_entur_public_transport.py @@ -0,0 +1,66 @@ +"""The tests for the entur platform.""" +from datetime import datetime +import unittest +from unittest.mock import patch + +from enturclient.api import RESOURCE +from enturclient.consts import ATTR_EXPECTED_AT, ATTR_ROUTE, ATTR_STOP_ID +import requests_mock + +from homeassistant.components.sensor.entur_public_transport import ( + CONF_EXPAND_PLATFORMS, CONF_STOP_IDS) +from homeassistant.setup import setup_component +import homeassistant.util.dt as dt_util + +from tests.common import get_test_home_assistant, load_fixture + +VALID_CONFIG = { + 'platform': 'entur_public_transport', + CONF_EXPAND_PLATFORMS: False, + CONF_STOP_IDS: [ + 'NSR:StopPlace:548', + 'NSR:Quay:48550', + ] +} + +FIXTURE_FILE = 'entur_public_transport.json' +TEST_TIMESTAMP = datetime(2018, 10, 10, 7, tzinfo=dt_util.UTC) + + +class TestEnturPublicTransportSensor(unittest.TestCase): + """Test the entur platform.""" + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @requests_mock.Mocker() + @patch( + 'homeassistant.components.sensor.entur_public_transport.dt_util.now', + return_value=TEST_TIMESTAMP) + def test_setup(self, mock_req, mock_patch): + """Test for correct sensor setup with state and proper attributes.""" + mock_req.post(RESOURCE, + text=load_fixture(FIXTURE_FILE), + status_code=200) + self.assertTrue( + setup_component(self.hass, 'sensor', {'sensor': VALID_CONFIG})) + + state = self.hass.states.get('sensor.entur_bergen_stasjon') + assert state.state == '28' + assert state.attributes.get(ATTR_STOP_ID) == 'NSR:StopPlace:548' + assert state.attributes.get(ATTR_ROUTE) == "59 Bergen" + assert state.attributes.get(ATTR_EXPECTED_AT) \ + == '2018-10-10T09:28:00+0200' + + state = self.hass.states.get('sensor.entur_fiskepiren_platform_2') + assert state.state == '0' + assert state.attributes.get(ATTR_STOP_ID) == 'NSR:Quay:48550' + assert state.attributes.get(ATTR_ROUTE) \ + == "5 Stavanger Airport via Forum" + assert state.attributes.get(ATTR_EXPECTED_AT) \ + == '2018-10-10T09:00:00+0200' diff --git a/tests/fixtures/entur_public_transport.json b/tests/fixtures/entur_public_transport.json new file mode 100644 index 0000000000000..24eafe94b23ee --- /dev/null +++ b/tests/fixtures/entur_public_transport.json @@ -0,0 +1,111 @@ +{ + "data": { + "stopPlaces": [ + { + "id": "NSR:StopPlace:548", + "name": "Bergen stasjon", + "estimatedCalls": [ + { + "realtime": false, + "aimedArrivalTime": "2018-10-10T09:28:00+0200", + "aimedDepartureTime": "2018-10-10T09:28:00+0200", + "expectedArrivalTime": "2018-10-10T09:28:00+0200", + "expectedDepartureTime": "2018-10-10T09:28:00+0200", + "requestStop": false, + "notices": [], + "destinationDisplay": { + "frontText": "Bergen" + }, + "serviceJourney": { + "journeyPattern": { + "line": { + "id": "NSB:Line:45", + "name": "Vossabanen", + "transportMode": "rail", + "publicCode": "59" + } + } + } + }, + { + "realtime": false, + "aimedArrivalTime": "2018-10-10T09:35:00+0200", + "aimedDepartureTime": "2018-10-10T09:35:00+0200", + "expectedArrivalTime": "2018-10-10T09:35:00+0200", + "expectedDepartureTime": "2018-10-10T09:35:00+0200", + "requestStop": false, + "notices": [], + "destinationDisplay": { + "frontText": "Arna" + }, + "serviceJourney": { + "journeyPattern": { + "line": { + "id": "NSB:Line:45", + "name": "Vossabanen", + "transportMode": "rail", + "publicCode": "58" + } + } + } + } + ] + } + ], + "quays": [ + { + "id": "NSR:Quay:48550", + "name": "Fiskepiren", + "publicCode": "2", + "latitude": 59.960904, + "longitude": 10.882942, + "estimatedCalls": [ + { + "realtime": false, + "aimedArrivalTime": "2018-10-10T09:00:00+0200", + "aimedDepartureTime": "2018-10-10T09:00:00+0200", + "expectedArrivalTime": "2018-10-10T09:00:00+0200", + "expectedDepartureTime": "2018-10-10T09:00:00+0200", + "requestStop": false, + "notices": [], + "destinationDisplay": { + "frontText": "Stavanger Airport via Forum" + }, + "serviceJourney": { + "journeyPattern": { + "line": { + "id": "KOL:Line:2900_234", + "name": "Flybussen", + "transportMode": "bus", + "publicCode": "5" + } + } + } + }, + { + "realtime": false, + "aimedArrivalTime": "2018-10-10T09:06:00+0200", + "aimedDepartureTime": "2018-10-10T09:06:00+0200", + "expectedArrivalTime": "2018-10-10T09:06:00+0200", + "expectedDepartureTime": "2018-10-10T09:06:00+0200", + "requestStop": false, + "notices": [], + "destinationDisplay": { + "frontText": "Stavanger" + }, + "serviceJourney": { + "journeyPattern": { + "line": { + "id": "KOL:Line:1000_234", + "name": "1", + "transportMode": "bus", + "publicCode": "1" + } + } + } + } + ] + } + ] + } +} \ No newline at end of file From fcdb25eb3c8197c3b8c6f9c4637d7c34a056d467 Mon Sep 17 00:00:00 2001 From: Darren Foo Date: Fri, 30 Nov 2018 02:18:24 -0800 Subject: [PATCH 154/325] bump gtts-token to 1.1.3 (#18824) --- homeassistant/components/tts/google.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tts/google.py b/homeassistant/components/tts/google.py index 5e1da2595af5f..0d449083f7218 100644 --- a/homeassistant/components/tts/google.py +++ b/homeassistant/components/tts/google.py @@ -17,7 +17,7 @@ from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['gTTS-token==1.1.2'] +REQUIREMENTS = ['gTTS-token==1.1.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 6519ab303795a..413a89ea336ff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -409,7 +409,7 @@ freesms==0.1.2 fritzhome==1.0.4 # homeassistant.components.tts.google -gTTS-token==1.1.2 +gTTS-token==1.1.3 # homeassistant.components.sensor.gearbest gearbest_parser==1.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ccfb277c72172..e7fed2cb68672 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -75,7 +75,7 @@ feedparser==5.2.1 foobot_async==0.3.1 # homeassistant.components.tts.google -gTTS-token==1.1.2 +gTTS-token==1.1.3 # homeassistant.components.geo_location.geo_json_events # homeassistant.components.geo_location.nsw_rural_fire_service_feed From a9990c130dc613d83a63128375767458e52f4937 Mon Sep 17 00:00:00 2001 From: emontnemery Date: Fri, 30 Nov 2018 13:57:17 +0100 Subject: [PATCH 155/325] Revert change to MQTT discovery_hash introduced in #18169 (#18763) --- homeassistant/components/mqtt/discovery.py | 7 ++--- tests/components/binary_sensor/test_mqtt.py | 33 --------------------- 2 files changed, 2 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 9ea3151c65c1b..8d5f28278d9e0 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -202,11 +202,8 @@ async def async_device_message_received(topic, payload, qos): if value[-1] == TOPIC_BASE and key.endswith('_topic'): payload[key] = "{}{}".format(value[:-1], base) - # If present, unique_id is used as the discovered object id. Otherwise, - # if present, the node_id will be included in the discovered object id - discovery_id = payload.get( - 'unique_id', ' '.join( - (node_id, object_id)) if node_id else object_id) + # If present, the node_id will be included in the discovered object id + discovery_id = ' '.join((node_id, object_id)) if node_id else object_id discovery_hash = (component, discovery_id) if payload: diff --git a/tests/components/binary_sensor/test_mqtt.py b/tests/components/binary_sensor/test_mqtt.py index 88bd39ebfe265..71d179211a231 100644 --- a/tests/components/binary_sensor/test_mqtt.py +++ b/tests/components/binary_sensor/test_mqtt.py @@ -333,39 +333,6 @@ async def test_discovery_update_binary_sensor(hass, mqtt_mock, caplog): assert state is None -async def test_discovery_unique_id(hass, mqtt_mock, caplog): - """Test unique id option only creates one sensor per unique_id.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, 'homeassistant', {}, entry) - data1 = ( - '{ "name": "Beer",' - ' "state_topic": "test_topic",' - ' "unique_id": "TOTALLY_UNIQUE" }' - ) - data2 = ( - '{ "name": "Milk",' - ' "state_topic": "test_topic",' - ' "unique_id": "TOTALLY_DIFFERENT" }' - ) - async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', - data1) - await hass.async_block_till_done() - state = hass.states.get('binary_sensor.beer') - assert state is not None - assert state.name == 'Beer' - async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', - data2) - await hass.async_block_till_done() - await hass.async_block_till_done() - state = hass.states.get('binary_sensor.beer') - assert state is not None - assert state.name == 'Beer' - - state = hass.states.get('binary_sensor.milk') - assert state is not None - assert state.name == 'Milk' - - async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT binary sensor device registry integration.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) From 44e35ec9a1c0f2fcf4b549a2e04b38bd10a60670 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Fri, 30 Nov 2018 14:45:40 +0100 Subject: [PATCH 156/325] update netatmo library (#18823) --- homeassistant/components/netatmo.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/netatmo.py b/homeassistant/components/netatmo.py index b5b349d5073f7..50bd290797d69 100644 --- a/homeassistant/components/netatmo.py +++ b/homeassistant/components/netatmo.py @@ -16,7 +16,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -REQUIREMENTS = ['pyatmo==1.3'] +REQUIREMENTS = ['pyatmo==1.4'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 413a89ea336ff..bc8740cc4c6c5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -851,7 +851,7 @@ pyalarmdotcom==0.3.2 pyarlo==0.2.2 # homeassistant.components.netatmo -pyatmo==1.3 +pyatmo==1.4 # homeassistant.components.apple_tv pyatv==0.3.11 From e0f0487ce26bfa937d7a492ebcd2e898e177a93c Mon Sep 17 00:00:00 2001 From: Matt Schmitt Date: Fri, 30 Nov 2018 10:31:35 -0500 Subject: [PATCH 157/325] Add services description (#18839) --- homeassistant/components/nest/services.yaml | 37 +++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 homeassistant/components/nest/services.yaml diff --git a/homeassistant/components/nest/services.yaml b/homeassistant/components/nest/services.yaml new file mode 100644 index 0000000000000..e10e626464378 --- /dev/null +++ b/homeassistant/components/nest/services.yaml @@ -0,0 +1,37 @@ +# Describes the format for available Nest services + +set_away_mode: + description: Set the away mode for a Nest structure. + fields: + away_mode: + description: New mode to set. Valid modes are "away" or "home". + example: "away" + structure: + description: Name(s) of structure(s) to change. Defaults to all structures if not specified. + example: "Apartment" + +set_eta: + description: Set or update the estimated time of arrival window for a Nest structure. + fields: + eta: + description: Estimated time of arrival from now. + example: "00:10:30" + eta_window: + description: Estimated time of arrival window. Default is 1 minute. + example: "00:05" + trip_id: + description: Unique ID for the trip. Default is auto-generated using a timestamp. + example: "Leave Work" + structure: + description: Name(s) of structure(s) to change. Defaults to all structures if not specified. + example: "Apartment" + +cancel_eta: + description: Cancel an existing estimated time of arrival window for a Nest structure. + fields: + trip_id: + description: Unique ID for the trip. + example: "Leave Work" + structure: + description: Name(s) of structure(s) to change. Defaults to all structures if not specified. + example: "Apartment" From deb9a1133c39157ad4e48a2917b9c1a317c662e5 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 30 Nov 2018 16:53:14 +0100 Subject: [PATCH 158/325] Small refactoring of MQTT fan --- homeassistant/components/fan/mqtt.py | 35 +++++++++++++--------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/fan/mqtt.py index 75be8e0277c8a..8f3ec84282995 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/fan/mqtt.py @@ -117,23 +117,19 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, def __init__(self, config, discovery_hash): """Initialize the MQTT fan.""" + self._unique_id = config.get(CONF_UNIQUE_ID) self._state = False self._speed = None self._oscillation = None self._supported_features = 0 self._sub_state = None - self._name = None self._topic = None - self._qos = None - self._retain = None self._payload = None self._templates = None - self._speed_list = None self._optimistic = None self._optimistic_oscillation = None self._optimistic_speed = None - self._unique_id = None # Load config self._setup_from_config(config) @@ -141,9 +137,10 @@ def __init__(self, config, discovery_hash): availability_topic = config.get(CONF_AVAILABILITY_TOPIC) payload_available = config.get(CONF_PAYLOAD_AVAILABLE) payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) + qos = config.get(CONF_QOS) device_config = config.get(CONF_DEVICE) - MqttAvailability.__init__(self, availability_topic, self._qos, + MqttAvailability.__init__(self, availability_topic, qos, payload_available, payload_not_available) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) @@ -164,7 +161,7 @@ async def discovery_update(self, discovery_payload): def _setup_from_config(self, config): """(Re)Setup the entity.""" - self._name = config.get(CONF_NAME) + self._config = config self._topic = { key: config.get(key) for key in ( CONF_STATE_TOPIC, @@ -180,8 +177,6 @@ def _setup_from_config(self, config): ATTR_SPEED: config.get(CONF_SPEED_VALUE_TEMPLATE), OSCILLATION: config.get(CONF_OSCILLATION_VALUE_TEMPLATE) } - self._qos = config.get(CONF_QOS) - self._retain = config.get(CONF_RETAIN) self._payload = { STATE_ON: config.get(CONF_PAYLOAD_ON), STATE_OFF: config.get(CONF_PAYLOAD_OFF), @@ -191,7 +186,6 @@ def _setup_from_config(self, config): SPEED_MEDIUM: config.get(CONF_PAYLOAD_MEDIUM_SPEED), SPEED_HIGH: config.get(CONF_PAYLOAD_HIGH_SPEED), } - self._speed_list = config.get(CONF_SPEED_LIST) optimistic = config.get(CONF_OPTIMISTIC) self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None self._optimistic_oscillation = ( @@ -232,7 +226,7 @@ def state_received(topic, payload, qos): topics[CONF_STATE_TOPIC] = { 'topic': self._topic[CONF_STATE_TOPIC], 'msg_callback': state_received, - 'qos': self._qos} + 'qos': self._config.get(CONF_QOS)} @callback def speed_received(topic, payload, qos): @@ -250,7 +244,7 @@ def speed_received(topic, payload, qos): topics[CONF_SPEED_STATE_TOPIC] = { 'topic': self._topic[CONF_SPEED_STATE_TOPIC], 'msg_callback': speed_received, - 'qos': self._qos} + 'qos': self._config.get(CONF_QOS)} self._speed = SPEED_OFF @callback @@ -267,7 +261,7 @@ def oscillation_received(topic, payload, qos): topics[CONF_OSCILLATION_STATE_TOPIC] = { 'topic': self._topic[CONF_OSCILLATION_STATE_TOPIC], 'msg_callback': oscillation_received, - 'qos': self._qos} + 'qos': self._config.get(CONF_QOS)} self._oscillation = False self._sub_state = await subscription.async_subscribe_topics( @@ -297,12 +291,12 @@ def is_on(self): @property def name(self) -> str: """Get entity name.""" - return self._name + return self._config.get(CONF_NAME) @property def speed_list(self) -> list: """Get the list of available speeds.""" - return self._speed_list + return self._config.get(CONF_SPEED_LIST) @property def supported_features(self) -> int: @@ -326,7 +320,8 @@ async def async_turn_on(self, speed: str = None, **kwargs) -> None: """ mqtt.async_publish( self.hass, self._topic[CONF_COMMAND_TOPIC], - self._payload[STATE_ON], self._qos, self._retain) + self._payload[STATE_ON], self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if speed: await self.async_set_speed(speed) @@ -337,7 +332,8 @@ async def async_turn_off(self, **kwargs) -> None: """ mqtt.async_publish( self.hass, self._topic[CONF_COMMAND_TOPIC], - self._payload[STATE_OFF], self._qos, self._retain) + self._payload[STATE_OFF], self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) async def async_set_speed(self, speed: str) -> None: """Set the speed of the fan. @@ -358,7 +354,8 @@ async def async_set_speed(self, speed: str) -> None: mqtt.async_publish( self.hass, self._topic[CONF_SPEED_COMMAND_TOPIC], - mqtt_payload, self._qos, self._retain) + mqtt_payload, self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic_speed: self._speed = speed @@ -379,7 +376,7 @@ async def async_oscillate(self, oscillating: bool) -> None: mqtt.async_publish( self.hass, self._topic[CONF_OSCILLATION_COMMAND_TOPIC], - payload, self._qos, self._retain) + payload, self._config.get(CONF_QOS), self._config.get(CONF_RETAIN)) if self._optimistic_oscillation: self._oscillation = oscillating From 1686f737492bc9b974f569262e6872fa5537ba21 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 30 Nov 2018 16:53:56 +0100 Subject: [PATCH 159/325] Small refactoring of MQTT sensor --- homeassistant/components/sensor/mqtt.py | 73 +++++++++---------------- 1 file changed, 25 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/sensor/mqtt.py index bd97cc0e90ddb..7d0908c564548 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -48,8 +48,8 @@ vol.Optional(CONF_JSON_ATTRS, default=[]): cv.ensure_list_csv, vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, - # Integrations shouldn't never expose unique_id through configuration - # this here is an exception because MQTT is a msg transport, not a protocol + # Integrations should never expose unique_id through configuration. + # This is an exception because MQTT is a message transport, not a protocol. vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @@ -86,32 +86,20 @@ class MqttSensor(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, def __init__(self, config, discovery_hash): """Initialize the sensor.""" + self._config = config + self._unique_id = config.get(CONF_UNIQUE_ID) self._state = STATE_UNKNOWN self._sub_state = None self._expiration_trigger = None self._attributes = None - self._name = None - self._state_topic = None - self._qos = None - self._unit_of_measurement = None - self._force_update = None - self._template = None - self._expire_after = None - self._icon = None - self._device_class = None - self._json_attributes = None - self._unique_id = None - - # Load config - self._setup_from_config(config) - availability_topic = config.get(CONF_AVAILABILITY_TOPIC) payload_available = config.get(CONF_PAYLOAD_AVAILABLE) payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) + qos = config.get(CONF_QOS) device_config = config.get(CONF_DEVICE) - MqttAvailability.__init__(self, availability_topic, self._qos, + MqttAvailability.__init__(self, availability_topic, qos, payload_available, payload_not_available) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) @@ -125,35 +113,23 @@ async def async_added_to_hass(self): async def discovery_update(self, discovery_payload): """Handle updated discovery message.""" config = PLATFORM_SCHEMA(discovery_payload) - self._setup_from_config(config) + self._config = config await self.availability_discovery_update(config) await self._subscribe_topics() self.async_schedule_update_ha_state() - def _setup_from_config(self, config): - """(Re)Setup the entity.""" - self._name = config.get(CONF_NAME) - self._state_topic = config.get(CONF_STATE_TOPIC) - self._qos = config.get(CONF_QOS) - self._unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) - self._force_update = config.get(CONF_FORCE_UPDATE) - self._expire_after = config.get(CONF_EXPIRE_AFTER) - self._icon = config.get(CONF_ICON) - self._device_class = config.get(CONF_DEVICE_CLASS) - self._template = config.get(CONF_VALUE_TEMPLATE) - self._json_attributes = set(config.get(CONF_JSON_ATTRS)) - self._unique_id = config.get(CONF_UNIQUE_ID) - async def _subscribe_topics(self): """(Re)Subscribe to topics.""" - if self._template is not None: - self._template.hass = self.hass + template = self._config.get(CONF_VALUE_TEMPLATE) + if template is not None: + template.hass = self.hass @callback def message_received(topic, payload, qos): """Handle new MQTT messages.""" # auto-expire enabled? - if self._expire_after is not None and self._expire_after > 0: + expire_after = self._config.get(CONF_EXPIRE_AFTER) + if expire_after is not None and expire_after > 0: # Reset old trigger if self._expiration_trigger: self._expiration_trigger() @@ -161,18 +137,19 @@ def message_received(topic, payload, qos): # Set new trigger expiration_at = ( - dt_util.utcnow() + timedelta(seconds=self._expire_after)) + dt_util.utcnow() + timedelta(seconds=expire_after)) self._expiration_trigger = async_track_point_in_utc_time( self.hass, self.value_is_expired, expiration_at) - if self._json_attributes: + json_attributes = set(self._config.get(CONF_JSON_ATTRS)) + if json_attributes: self._attributes = {} try: json_dict = json.loads(payload) if isinstance(json_dict, dict): attrs = {k: json_dict[k] for k in - self._json_attributes & json_dict.keys()} + json_attributes & json_dict.keys()} self._attributes = attrs else: _LOGGER.warning("JSON result was not a dictionary") @@ -180,17 +157,17 @@ def message_received(topic, payload, qos): _LOGGER.warning("MQTT payload could not be parsed as JSON") _LOGGER.debug("Erroneous JSON: %s", payload) - if self._template is not None: - payload = self._template.async_render_with_possible_json_value( + if template is not None: + payload = template.async_render_with_possible_json_value( payload, self._state) self._state = payload self.async_schedule_update_ha_state() self._sub_state = await subscription.async_subscribe_topics( self.hass, self._sub_state, - {'state_topic': {'topic': self._state_topic, + {'state_topic': {'topic': self._config.get(CONF_STATE_TOPIC), 'msg_callback': message_received, - 'qos': self._qos}}) + 'qos': self._config.get(CONF_QOS)}}) async def async_will_remove_from_hass(self): """Unsubscribe when removed.""" @@ -212,17 +189,17 @@ def should_poll(self): @property def name(self): """Return the name of the sensor.""" - return self._name + return self._config.get(CONF_NAME) @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return self._unit_of_measurement + return self._config.get(CONF_UNIT_OF_MEASUREMENT) @property def force_update(self): """Force update.""" - return self._force_update + return self._config.get(CONF_FORCE_UPDATE) @property def state(self): @@ -242,9 +219,9 @@ def unique_id(self): @property def icon(self): """Return the icon.""" - return self._icon + return self._config.get(CONF_ICON) @property def device_class(self) -> Optional[str]: """Return the device class of the sensor.""" - return self._device_class + return self._config.get(CONF_DEVICE_CLASS) From 8f501805980f42fd713ee19f29fa549020da0ed3 Mon Sep 17 00:00:00 2001 From: pbalogh77 Date: Fri, 30 Nov 2018 17:23:25 +0100 Subject: [PATCH 160/325] Hotfix for Fibaro wall plug (#18845) Fibaro wall plug with a lamp plugged in was misrecognized as a color light, generating crashes in the update function. --- homeassistant/components/light/fibaro.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/fibaro.py b/homeassistant/components/light/fibaro.py index 96069d50335fe..7157dcfd31b34 100644 --- a/homeassistant/components/light/fibaro.py +++ b/homeassistant/components/light/fibaro.py @@ -65,7 +65,8 @@ def __init__(self, fibaro_device, controller): self._update_lock = asyncio.Lock() if 'levelChange' in fibaro_device.interfaces: self._supported_flags |= SUPPORT_BRIGHTNESS - if 'color' in fibaro_device.properties: + if 'color' in fibaro_device.properties and \ + 'setColor' in fibaro_device.actions: self._supported_flags |= SUPPORT_COLOR if 'setW' in fibaro_device.actions: self._supported_flags |= SUPPORT_WHITE_VALUE @@ -168,7 +169,9 @@ def _update(self): if self._supported_flags & SUPPORT_BRIGHTNESS: self._brightness = float(self.fibaro_device.properties.value) # Color handling - if self._supported_flags & SUPPORT_COLOR: + if self._supported_flags & SUPPORT_COLOR and \ + 'color' in self.fibaro_device.properties and \ + ',' in self.fibaro_device.properties.color: # Fibaro communicates the color as an 'R, G, B, W' string rgbw_s = self.fibaro_device.properties.color if rgbw_s == '0,0,0,0' and\ From d014517ce2eb77c3a283d5d9372758922dd85382 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 30 Nov 2018 17:32:47 +0100 Subject: [PATCH 161/325] Always set hass_user (#18844) --- homeassistant/components/http/auth.py | 18 ++++++-- tests/components/conftest.py | 6 +++ tests/components/http/test_auth.py | 65 ++++++++++++++++++++++++--- 3 files changed, 79 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 0e943b33fb834..ae6abf04c0285 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -45,6 +45,7 @@ def setup_auth(app, trusted_networks, use_auth, support_legacy=False, api_password=None): """Create auth middleware for the app.""" old_auth_warning = set() + legacy_auth = (not use_auth or support_legacy) and api_password @middleware async def auth_middleware(request, handler): @@ -60,7 +61,6 @@ async def auth_middleware(request, handler): request.path, request[KEY_REAL_IP]) old_auth_warning.add(request.path) - legacy_auth = (not use_auth or support_legacy) and api_password if (hdrs.AUTHORIZATION in request.headers and await async_validate_auth_header( request, api_password if legacy_auth else None)): @@ -91,6 +91,11 @@ async def auth_middleware(request, handler): app['hass']) elif _is_trusted_ip(request, trusted_networks): + users = await app['hass'].auth.async_get_users() + for user in users: + if user.is_owner: + request['hass_user'] = user + break authenticated = True elif not use_auth and api_password is None: @@ -136,8 +141,9 @@ async def async_validate_auth_header(request, api_password=None): # If no space in authorization header return False + hass = request.app['hass'] + if auth_type == 'Bearer': - hass = request.app['hass'] refresh_token = await hass.auth.async_validate_access_token(auth_val) if refresh_token is None: return False @@ -157,8 +163,12 @@ async def async_validate_auth_header(request, api_password=None): if username != 'homeassistant': return False - return hmac.compare_digest(api_password.encode('utf-8'), - password.encode('utf-8')) + if not hmac.compare_digest(api_password.encode('utf-8'), + password.encode('utf-8')): + return False + + request['hass_user'] = await legacy_api_password.async_get_user(hass) + return True return False diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 110ba8d5ad6d6..d3cbdba63b4dc 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -88,6 +88,12 @@ def hass_access_token(hass, hass_admin_user): yield hass.auth.async_create_access_token(refresh_token) +@pytest.fixture +def hass_owner_user(hass, local_auth): + """Return a Home Assistant admin user.""" + return MockUser(is_owner=True).add_to_hass(hass) + + @pytest.fixture def hass_admin_user(hass, local_auth): """Return a Home Assistant admin user.""" diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 979bfc28689ee..222e8ced6e7aa 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -7,6 +7,7 @@ from aiohttp import BasicAuth, web from aiohttp.web_exceptions import HTTPUnauthorized +from homeassistant.auth.providers import legacy_api_password from homeassistant.components.http.auth import setup_auth, async_sign_path from homeassistant.components.http.const import KEY_AUTHENTICATED from homeassistant.components.http.real_ip import setup_real_ip @@ -84,29 +85,40 @@ async def test_access_without_password(app, aiohttp_client): async def test_access_with_password_in_header(app, aiohttp_client, - legacy_auth): + legacy_auth, hass): """Test access with password in header.""" setup_auth(app, [], False, api_password=API_PASSWORD) client = await aiohttp_client(app) + user = await legacy_api_password.async_get_user(hass) req = await client.get( '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) assert req.status == 200 + assert await req.json() == { + 'refresh_token_id': None, + 'user_id': user.id, + } req = await client.get( '/', headers={HTTP_HEADER_HA_AUTH: 'wrong-pass'}) assert req.status == 401 -async def test_access_with_password_in_query(app, aiohttp_client, legacy_auth): +async def test_access_with_password_in_query(app, aiohttp_client, legacy_auth, + hass): """Test access with password in URL.""" setup_auth(app, [], False, api_password=API_PASSWORD) client = await aiohttp_client(app) + user = await legacy_api_password.async_get_user(hass) resp = await client.get('/', params={ 'api_password': API_PASSWORD }) assert resp.status == 200 + assert await resp.json() == { + 'refresh_token_id': None, + 'user_id': user.id, + } resp = await client.get('/') assert resp.status == 401 @@ -117,15 +129,20 @@ async def test_access_with_password_in_query(app, aiohttp_client, legacy_auth): assert resp.status == 401 -async def test_basic_auth_works(app, aiohttp_client): +async def test_basic_auth_works(app, aiohttp_client, hass, legacy_auth): """Test access with basic authentication.""" setup_auth(app, [], False, api_password=API_PASSWORD) client = await aiohttp_client(app) + user = await legacy_api_password.async_get_user(hass) req = await client.get( '/', auth=BasicAuth('homeassistant', API_PASSWORD)) assert req.status == 200 + assert await req.json() == { + 'refresh_token_id': None, + 'user_id': user.id, + } req = await client.get( '/', @@ -145,7 +162,7 @@ async def test_basic_auth_works(app, aiohttp_client): assert req.status == 401 -async def test_access_with_trusted_ip(app2, aiohttp_client): +async def test_access_with_trusted_ip(app2, aiohttp_client, hass_owner_user): """Test access with an untrusted ip address.""" setup_auth(app2, TRUSTED_NETWORKS, False, api_password='some-pass') @@ -163,6 +180,10 @@ async def test_access_with_trusted_ip(app2, aiohttp_client): resp = await client.get('/') assert resp.status == 200, \ "{} should be trusted".format(remote_addr) + assert await resp.json() == { + 'refresh_token_id': None, + 'user_id': hass_owner_user.id, + } async def test_auth_active_access_with_access_token_in_header( @@ -171,18 +192,32 @@ async def test_auth_active_access_with_access_token_in_header( token = hass_access_token setup_auth(app, [], True, api_password=None) client = await aiohttp_client(app) + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) req = await client.get( '/', headers={'Authorization': 'Bearer {}'.format(token)}) assert req.status == 200 + assert await req.json() == { + 'refresh_token_id': refresh_token.id, + 'user_id': refresh_token.user.id, + } req = await client.get( '/', headers={'AUTHORIZATION': 'Bearer {}'.format(token)}) assert req.status == 200 + assert await req.json() == { + 'refresh_token_id': refresh_token.id, + 'user_id': refresh_token.user.id, + } req = await client.get( '/', headers={'authorization': 'Bearer {}'.format(token)}) assert req.status == 200 + assert await req.json() == { + 'refresh_token_id': refresh_token.id, + 'user_id': refresh_token.user.id, + } req = await client.get( '/', headers={'Authorization': token}) @@ -200,7 +235,8 @@ async def test_auth_active_access_with_access_token_in_header( assert req.status == 401 -async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client): +async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client, + hass_owner_user): """Test access with an untrusted ip address.""" setup_auth(app2, TRUSTED_NETWORKS, True, api_password=None) @@ -218,6 +254,10 @@ async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client): resp = await client.get('/') assert resp.status == 200, \ "{} should be trusted".format(remote_addr) + assert await resp.json() == { + 'refresh_token_id': None, + 'user_id': hass_owner_user.id, + } async def test_auth_active_blocked_api_password_access( @@ -242,24 +282,37 @@ async def test_auth_active_blocked_api_password_access( async def test_auth_legacy_support_api_password_access( - app, aiohttp_client, legacy_auth): + app, aiohttp_client, legacy_auth, hass): """Test access using api_password if auth.support_legacy.""" setup_auth(app, [], True, support_legacy=True, api_password=API_PASSWORD) client = await aiohttp_client(app) + user = await legacy_api_password.async_get_user(hass) req = await client.get( '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) assert req.status == 200 + assert await req.json() == { + 'refresh_token_id': None, + 'user_id': user.id, + } resp = await client.get('/', params={ 'api_password': API_PASSWORD }) assert resp.status == 200 + assert await resp.json() == { + 'refresh_token_id': None, + 'user_id': user.id, + } req = await client.get( '/', auth=BasicAuth('homeassistant', API_PASSWORD)) assert req.status == 200 + assert await req.json() == { + 'refresh_token_id': None, + 'user_id': user.id, + } async def test_auth_access_signed_path( From 449cde539632c09dfbcbdc2f019b012fe6923607 Mon Sep 17 00:00:00 2001 From: emontnemery Date: Fri, 30 Nov 2018 13:57:17 +0100 Subject: [PATCH 162/325] Revert change to MQTT discovery_hash introduced in #18169 (#18763) --- homeassistant/components/mqtt/discovery.py | 7 ++--- tests/components/binary_sensor/test_mqtt.py | 33 --------------------- 2 files changed, 2 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index d91ab6ee445e9..bf83b1739724c 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -208,11 +208,8 @@ async def async_device_message_received(topic, payload, qos): if value[-1] == TOPIC_BASE and key.endswith('_topic'): payload[key] = "{}{}".format(value[:-1], base) - # If present, unique_id is used as the discovered object id. Otherwise, - # if present, the node_id will be included in the discovered object id - discovery_id = payload.get( - 'unique_id', ' '.join( - (node_id, object_id)) if node_id else object_id) + # If present, the node_id will be included in the discovered object id + discovery_id = ' '.join((node_id, object_id)) if node_id else object_id discovery_hash = (component, discovery_id) if payload: diff --git a/tests/components/binary_sensor/test_mqtt.py b/tests/components/binary_sensor/test_mqtt.py index 88bd39ebfe265..71d179211a231 100644 --- a/tests/components/binary_sensor/test_mqtt.py +++ b/tests/components/binary_sensor/test_mqtt.py @@ -333,39 +333,6 @@ async def test_discovery_update_binary_sensor(hass, mqtt_mock, caplog): assert state is None -async def test_discovery_unique_id(hass, mqtt_mock, caplog): - """Test unique id option only creates one sensor per unique_id.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, 'homeassistant', {}, entry) - data1 = ( - '{ "name": "Beer",' - ' "state_topic": "test_topic",' - ' "unique_id": "TOTALLY_UNIQUE" }' - ) - data2 = ( - '{ "name": "Milk",' - ' "state_topic": "test_topic",' - ' "unique_id": "TOTALLY_DIFFERENT" }' - ) - async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', - data1) - await hass.async_block_till_done() - state = hass.states.get('binary_sensor.beer') - assert state is not None - assert state.name == 'Beer' - async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', - data2) - await hass.async_block_till_done() - await hass.async_block_till_done() - state = hass.states.get('binary_sensor.beer') - assert state is not None - assert state.name == 'Beer' - - state = hass.states.get('binary_sensor.milk') - assert state is not None - assert state.name == 'Milk' - - async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT binary sensor device registry integration.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) From ada148eeae1da328f4e8b0eb377ae226575948ad Mon Sep 17 00:00:00 2001 From: Darren Foo Date: Fri, 30 Nov 2018 02:18:24 -0800 Subject: [PATCH 163/325] bump gtts-token to 1.1.3 (#18824) --- homeassistant/components/tts/google.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tts/google.py b/homeassistant/components/tts/google.py index 5e1da2595af5f..0d449083f7218 100644 --- a/homeassistant/components/tts/google.py +++ b/homeassistant/components/tts/google.py @@ -17,7 +17,7 @@ from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['gTTS-token==1.1.2'] +REQUIREMENTS = ['gTTS-token==1.1.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index dc0f7e8679f37..e4020ca41e75e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -406,7 +406,7 @@ freesms==0.1.2 fritzhome==1.0.4 # homeassistant.components.tts.google -gTTS-token==1.1.2 +gTTS-token==1.1.3 # homeassistant.components.sensor.gearbest gearbest_parser==1.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f722377189106..f602c04bd7983 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -72,7 +72,7 @@ feedparser==5.2.1 foobot_async==0.3.1 # homeassistant.components.tts.google -gTTS-token==1.1.2 +gTTS-token==1.1.3 # homeassistant.components.geo_location.geo_json_events # homeassistant.components.geo_location.nsw_rural_fire_service_feed From 80f2c2b12451698c6ba1608fa23eae813b520bc4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 30 Nov 2018 17:32:47 +0100 Subject: [PATCH 164/325] Always set hass_user (#18844) --- homeassistant/components/http/auth.py | 18 ++++++-- tests/components/conftest.py | 6 +++ tests/components/http/test_auth.py | 65 ++++++++++++++++++++++++--- 3 files changed, 79 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 0e943b33fb834..ae6abf04c0285 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -45,6 +45,7 @@ def setup_auth(app, trusted_networks, use_auth, support_legacy=False, api_password=None): """Create auth middleware for the app.""" old_auth_warning = set() + legacy_auth = (not use_auth or support_legacy) and api_password @middleware async def auth_middleware(request, handler): @@ -60,7 +61,6 @@ async def auth_middleware(request, handler): request.path, request[KEY_REAL_IP]) old_auth_warning.add(request.path) - legacy_auth = (not use_auth or support_legacy) and api_password if (hdrs.AUTHORIZATION in request.headers and await async_validate_auth_header( request, api_password if legacy_auth else None)): @@ -91,6 +91,11 @@ async def auth_middleware(request, handler): app['hass']) elif _is_trusted_ip(request, trusted_networks): + users = await app['hass'].auth.async_get_users() + for user in users: + if user.is_owner: + request['hass_user'] = user + break authenticated = True elif not use_auth and api_password is None: @@ -136,8 +141,9 @@ async def async_validate_auth_header(request, api_password=None): # If no space in authorization header return False + hass = request.app['hass'] + if auth_type == 'Bearer': - hass = request.app['hass'] refresh_token = await hass.auth.async_validate_access_token(auth_val) if refresh_token is None: return False @@ -157,8 +163,12 @@ async def async_validate_auth_header(request, api_password=None): if username != 'homeassistant': return False - return hmac.compare_digest(api_password.encode('utf-8'), - password.encode('utf-8')) + if not hmac.compare_digest(api_password.encode('utf-8'), + password.encode('utf-8')): + return False + + request['hass_user'] = await legacy_api_password.async_get_user(hass) + return True return False diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 110ba8d5ad6d6..d3cbdba63b4dc 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -88,6 +88,12 @@ def hass_access_token(hass, hass_admin_user): yield hass.auth.async_create_access_token(refresh_token) +@pytest.fixture +def hass_owner_user(hass, local_auth): + """Return a Home Assistant admin user.""" + return MockUser(is_owner=True).add_to_hass(hass) + + @pytest.fixture def hass_admin_user(hass, local_auth): """Return a Home Assistant admin user.""" diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 979bfc28689ee..222e8ced6e7aa 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -7,6 +7,7 @@ from aiohttp import BasicAuth, web from aiohttp.web_exceptions import HTTPUnauthorized +from homeassistant.auth.providers import legacy_api_password from homeassistant.components.http.auth import setup_auth, async_sign_path from homeassistant.components.http.const import KEY_AUTHENTICATED from homeassistant.components.http.real_ip import setup_real_ip @@ -84,29 +85,40 @@ async def test_access_without_password(app, aiohttp_client): async def test_access_with_password_in_header(app, aiohttp_client, - legacy_auth): + legacy_auth, hass): """Test access with password in header.""" setup_auth(app, [], False, api_password=API_PASSWORD) client = await aiohttp_client(app) + user = await legacy_api_password.async_get_user(hass) req = await client.get( '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) assert req.status == 200 + assert await req.json() == { + 'refresh_token_id': None, + 'user_id': user.id, + } req = await client.get( '/', headers={HTTP_HEADER_HA_AUTH: 'wrong-pass'}) assert req.status == 401 -async def test_access_with_password_in_query(app, aiohttp_client, legacy_auth): +async def test_access_with_password_in_query(app, aiohttp_client, legacy_auth, + hass): """Test access with password in URL.""" setup_auth(app, [], False, api_password=API_PASSWORD) client = await aiohttp_client(app) + user = await legacy_api_password.async_get_user(hass) resp = await client.get('/', params={ 'api_password': API_PASSWORD }) assert resp.status == 200 + assert await resp.json() == { + 'refresh_token_id': None, + 'user_id': user.id, + } resp = await client.get('/') assert resp.status == 401 @@ -117,15 +129,20 @@ async def test_access_with_password_in_query(app, aiohttp_client, legacy_auth): assert resp.status == 401 -async def test_basic_auth_works(app, aiohttp_client): +async def test_basic_auth_works(app, aiohttp_client, hass, legacy_auth): """Test access with basic authentication.""" setup_auth(app, [], False, api_password=API_PASSWORD) client = await aiohttp_client(app) + user = await legacy_api_password.async_get_user(hass) req = await client.get( '/', auth=BasicAuth('homeassistant', API_PASSWORD)) assert req.status == 200 + assert await req.json() == { + 'refresh_token_id': None, + 'user_id': user.id, + } req = await client.get( '/', @@ -145,7 +162,7 @@ async def test_basic_auth_works(app, aiohttp_client): assert req.status == 401 -async def test_access_with_trusted_ip(app2, aiohttp_client): +async def test_access_with_trusted_ip(app2, aiohttp_client, hass_owner_user): """Test access with an untrusted ip address.""" setup_auth(app2, TRUSTED_NETWORKS, False, api_password='some-pass') @@ -163,6 +180,10 @@ async def test_access_with_trusted_ip(app2, aiohttp_client): resp = await client.get('/') assert resp.status == 200, \ "{} should be trusted".format(remote_addr) + assert await resp.json() == { + 'refresh_token_id': None, + 'user_id': hass_owner_user.id, + } async def test_auth_active_access_with_access_token_in_header( @@ -171,18 +192,32 @@ async def test_auth_active_access_with_access_token_in_header( token = hass_access_token setup_auth(app, [], True, api_password=None) client = await aiohttp_client(app) + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) req = await client.get( '/', headers={'Authorization': 'Bearer {}'.format(token)}) assert req.status == 200 + assert await req.json() == { + 'refresh_token_id': refresh_token.id, + 'user_id': refresh_token.user.id, + } req = await client.get( '/', headers={'AUTHORIZATION': 'Bearer {}'.format(token)}) assert req.status == 200 + assert await req.json() == { + 'refresh_token_id': refresh_token.id, + 'user_id': refresh_token.user.id, + } req = await client.get( '/', headers={'authorization': 'Bearer {}'.format(token)}) assert req.status == 200 + assert await req.json() == { + 'refresh_token_id': refresh_token.id, + 'user_id': refresh_token.user.id, + } req = await client.get( '/', headers={'Authorization': token}) @@ -200,7 +235,8 @@ async def test_auth_active_access_with_access_token_in_header( assert req.status == 401 -async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client): +async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client, + hass_owner_user): """Test access with an untrusted ip address.""" setup_auth(app2, TRUSTED_NETWORKS, True, api_password=None) @@ -218,6 +254,10 @@ async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client): resp = await client.get('/') assert resp.status == 200, \ "{} should be trusted".format(remote_addr) + assert await resp.json() == { + 'refresh_token_id': None, + 'user_id': hass_owner_user.id, + } async def test_auth_active_blocked_api_password_access( @@ -242,24 +282,37 @@ async def test_auth_active_blocked_api_password_access( async def test_auth_legacy_support_api_password_access( - app, aiohttp_client, legacy_auth): + app, aiohttp_client, legacy_auth, hass): """Test access using api_password if auth.support_legacy.""" setup_auth(app, [], True, support_legacy=True, api_password=API_PASSWORD) client = await aiohttp_client(app) + user = await legacy_api_password.async_get_user(hass) req = await client.get( '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) assert req.status == 200 + assert await req.json() == { + 'refresh_token_id': None, + 'user_id': user.id, + } resp = await client.get('/', params={ 'api_password': API_PASSWORD }) assert resp.status == 200 + assert await resp.json() == { + 'refresh_token_id': None, + 'user_id': user.id, + } req = await client.get( '/', auth=BasicAuth('homeassistant', API_PASSWORD)) assert req.status == 200 + assert await req.json() == { + 'refresh_token_id': None, + 'user_id': user.id, + } async def test_auth_access_signed_path( From 474909b515dd4221fdeb93d63d91b52313d66824 Mon Sep 17 00:00:00 2001 From: pbalogh77 Date: Fri, 30 Nov 2018 17:23:25 +0100 Subject: [PATCH 165/325] Hotfix for Fibaro wall plug (#18845) Fibaro wall plug with a lamp plugged in was misrecognized as a color light, generating crashes in the update function. --- homeassistant/components/light/fibaro.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/light/fibaro.py b/homeassistant/components/light/fibaro.py index 96069d50335fe..7157dcfd31b34 100644 --- a/homeassistant/components/light/fibaro.py +++ b/homeassistant/components/light/fibaro.py @@ -65,7 +65,8 @@ def __init__(self, fibaro_device, controller): self._update_lock = asyncio.Lock() if 'levelChange' in fibaro_device.interfaces: self._supported_flags |= SUPPORT_BRIGHTNESS - if 'color' in fibaro_device.properties: + if 'color' in fibaro_device.properties and \ + 'setColor' in fibaro_device.actions: self._supported_flags |= SUPPORT_COLOR if 'setW' in fibaro_device.actions: self._supported_flags |= SUPPORT_WHITE_VALUE @@ -168,7 +169,9 @@ def _update(self): if self._supported_flags & SUPPORT_BRIGHTNESS: self._brightness = float(self.fibaro_device.properties.value) # Color handling - if self._supported_flags & SUPPORT_COLOR: + if self._supported_flags & SUPPORT_COLOR and \ + 'color' in self.fibaro_device.properties and \ + ',' in self.fibaro_device.properties.color: # Fibaro communicates the color as an 'R, G, B, W' string rgbw_s = self.fibaro_device.properties.color if rgbw_s == '0,0,0,0' and\ From 9b3373a15bc77aea6de006c36fb5c26441ac6cf6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 30 Nov 2018 17:53:14 +0100 Subject: [PATCH 166/325] Bumped version to 0.83.2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 6b69609be2211..1a1d45396f016 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 83 -PATCH_VERSION = '1' +PATCH_VERSION = '2' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From d7809c5398ac04a70fbe3dabb4832e51c27d037b Mon Sep 17 00:00:00 2001 From: Erik Eriksson <8228319+molobrakos@users.noreply.github.com> Date: Fri, 30 Nov 2018 19:07:42 +0100 Subject: [PATCH 167/325] Update of volvooncall component (#18702) --- .../components/binary_sensor/volvooncall.py | 21 +- .../components/device_tracker/volvooncall.py | 33 ++- homeassistant/components/lock/volvooncall.py | 17 +- .../components/sensor/volvooncall.py | 43 +--- .../components/switch/volvooncall.py | 22 +- homeassistant/components/volvooncall.py | 192 ++++++++++++------ requirements_all.txt | 2 +- 7 files changed, 182 insertions(+), 148 deletions(-) diff --git a/homeassistant/components/binary_sensor/volvooncall.py b/homeassistant/components/binary_sensor/volvooncall.py index e70d309887440..e7092ff16d527 100644 --- a/homeassistant/components/binary_sensor/volvooncall.py +++ b/homeassistant/components/binary_sensor/volvooncall.py @@ -6,17 +6,19 @@ """ import logging -from homeassistant.components.volvooncall import VolvoEntity -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.volvooncall import VolvoEntity, DATA_KEY +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, DEVICE_CLASSES) _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): """Set up the Volvo sensors.""" if discovery_info is None: return - add_entities([VolvoSensor(hass, *discovery_info)]) + async_add_entities([VolvoSensor(hass.data[DATA_KEY], *discovery_info)]) class VolvoSensor(VolvoEntity, BinarySensorDevice): @@ -25,14 +27,11 @@ class VolvoSensor(VolvoEntity, BinarySensorDevice): @property def is_on(self): """Return True if the binary sensor is on.""" - val = getattr(self.vehicle, self._attribute) - if self._attribute == 'bulb_failures': - return bool(val) - if self._attribute in ['doors', 'windows']: - return any([val[key] for key in val if 'Open' in key]) - return val != 'Normal' + return self.instrument.is_on @property def device_class(self): """Return the class of this sensor, from DEVICE_CLASSES.""" - return 'safety' + if self.instrument.device_class in DEVICE_CLASSES: + return self.instrument.device_class + return None diff --git a/homeassistant/components/device_tracker/volvooncall.py b/homeassistant/components/device_tracker/volvooncall.py index 7872f8f1f1c89..395b539a065cb 100644 --- a/homeassistant/components/device_tracker/volvooncall.py +++ b/homeassistant/components/device_tracker/volvooncall.py @@ -7,33 +7,32 @@ import logging from homeassistant.util import slugify -from homeassistant.helpers.dispatcher import ( - dispatcher_connect, dispatcher_send) -from homeassistant.components.volvooncall import DATA_KEY, SIGNAL_VEHICLE_SEEN +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.components.device_tracker import SOURCE_TYPE_GPS +from homeassistant.components.volvooncall import DATA_KEY, SIGNAL_STATE_UPDATED _LOGGER = logging.getLogger(__name__) -def setup_scanner(hass, config, see, discovery_info=None): +async def async_setup_scanner(hass, config, async_see, discovery_info=None): """Set up the Volvo tracker.""" if discovery_info is None: return - vin, _ = discovery_info - voc = hass.data[DATA_KEY] - vehicle = voc.vehicles[vin] + vin, component, attr = discovery_info + data = hass.data[DATA_KEY] + instrument = data.instrument(vin, component, attr) - def see_vehicle(vehicle): + async def see_vehicle(): """Handle the reporting of the vehicle position.""" - host_name = voc.vehicle_name(vehicle) + host_name = instrument.vehicle_name dev_id = 'volvo_{}'.format(slugify(host_name)) - see(dev_id=dev_id, - host_name=host_name, - gps=(vehicle.position['latitude'], - vehicle.position['longitude']), - icon='mdi:car') - - dispatcher_connect(hass, SIGNAL_VEHICLE_SEEN, see_vehicle) - dispatcher_send(hass, SIGNAL_VEHICLE_SEEN, vehicle) + await async_see(dev_id=dev_id, + host_name=host_name, + source_type=SOURCE_TYPE_GPS, + gps=instrument.state, + icon='mdi:car') + + async_dispatcher_connect(hass, SIGNAL_STATE_UPDATED, see_vehicle) return True diff --git a/homeassistant/components/lock/volvooncall.py b/homeassistant/components/lock/volvooncall.py index 58fa83cef30a7..83301aa3d4eca 100644 --- a/homeassistant/components/lock/volvooncall.py +++ b/homeassistant/components/lock/volvooncall.py @@ -7,17 +7,18 @@ import logging from homeassistant.components.lock import LockDevice -from homeassistant.components.volvooncall import VolvoEntity +from homeassistant.components.volvooncall import VolvoEntity, DATA_KEY _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): """Set up the Volvo On Call lock.""" if discovery_info is None: return - add_entities([VolvoLock(hass, *discovery_info)]) + async_add_entities([VolvoLock(hass.data[DATA_KEY], *discovery_info)]) class VolvoLock(VolvoEntity, LockDevice): @@ -26,12 +27,12 @@ class VolvoLock(VolvoEntity, LockDevice): @property def is_locked(self): """Return true if lock is locked.""" - return self.vehicle.is_locked + return self.instrument.is_locked - def lock(self, **kwargs): + async def async_lock(self, **kwargs): """Lock the car.""" - self.vehicle.lock() + await self.instrument.lock() - def unlock(self, **kwargs): + async def async_unlock(self, **kwargs): """Unlock the car.""" - self.vehicle.unlock() + await self.instrument.unlock() diff --git a/homeassistant/components/sensor/volvooncall.py b/homeassistant/components/sensor/volvooncall.py index a3f0c55b954d9..65b996a5bd5a1 100644 --- a/homeassistant/components/sensor/volvooncall.py +++ b/homeassistant/components/sensor/volvooncall.py @@ -6,19 +6,18 @@ """ import logging -from math import floor -from homeassistant.components.volvooncall import ( - VolvoEntity, RESOURCES, CONF_SCANDINAVIAN_MILES) +from homeassistant.components.volvooncall import VolvoEntity, DATA_KEY _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): """Set up the Volvo sensors.""" if discovery_info is None: return - add_entities([VolvoSensor(hass, *discovery_info)]) + async_add_entities([VolvoSensor(hass.data[DATA_KEY], *discovery_info)]) class VolvoSensor(VolvoEntity): @@ -26,38 +25,10 @@ class VolvoSensor(VolvoEntity): @property def state(self): - """Return the state of the sensor.""" - val = getattr(self.vehicle, self._attribute) - - if val is None: - return val - - if self._attribute == 'odometer': - val /= 1000 # m -> km - - if 'mil' in self.unit_of_measurement: - val /= 10 # km -> mil - - if self._attribute == 'average_fuel_consumption': - val /= 10 # L/1000km -> L/100km - if 'mil' in self.unit_of_measurement: - return round(val, 2) - return round(val, 1) - if self._attribute == 'distance_to_empty': - return int(floor(val)) - return int(round(val)) + """Return the state.""" + return self.instrument.state @property def unit_of_measurement(self): """Return the unit of measurement.""" - unit = RESOURCES[self._attribute][3] - if self._state.config[CONF_SCANDINAVIAN_MILES] and 'km' in unit: - if self._attribute == 'average_fuel_consumption': - return 'L/mil' - return unit.replace('km', 'mil') - return unit - - @property - def icon(self): - """Return the icon.""" - return RESOURCES[self._attribute][2] + return self.instrument.unit diff --git a/homeassistant/components/switch/volvooncall.py b/homeassistant/components/switch/volvooncall.py index 42c753725abee..81abf7d0e6cd4 100644 --- a/homeassistant/components/switch/volvooncall.py +++ b/homeassistant/components/switch/volvooncall.py @@ -8,17 +8,18 @@ """ import logging -from homeassistant.components.volvooncall import VolvoEntity, RESOURCES +from homeassistant.components.volvooncall import VolvoEntity, DATA_KEY from homeassistant.helpers.entity import ToggleEntity _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): """Set up a Volvo switch.""" if discovery_info is None: return - add_entities([VolvoSwitch(hass, *discovery_info)]) + async_add_entities([VolvoSwitch(hass.data[DATA_KEY], *discovery_info)]) class VolvoSwitch(VolvoEntity, ToggleEntity): @@ -27,17 +28,12 @@ class VolvoSwitch(VolvoEntity, ToggleEntity): @property def is_on(self): """Return true if switch is on.""" - return self.vehicle.is_heater_on + return self.instrument.state - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the switch on.""" - self.vehicle.start_heater() + await self.instrument.turn_on() - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the switch off.""" - self.vehicle.stop_heater() - - @property - def icon(self): - """Return the icon.""" - return RESOURCES[self._attribute][2] + await self.instrument.turn_off() diff --git a/homeassistant/components/volvooncall.py b/homeassistant/components/volvooncall.py index 0ce8870bedfa0..fe7ec46067419 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -14,15 +14,17 @@ from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv 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.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.dispatcher import ( + async_dispatcher_send, + async_dispatcher_connect) from homeassistant.util.dt import utcnow DOMAIN = 'volvooncall' DATA_KEY = DOMAIN -REQUIREMENTS = ['volvooncall==0.4.0'] +REQUIREMENTS = ['volvooncall==0.7.4'] _LOGGER = logging.getLogger(__name__) @@ -33,25 +35,56 @@ CONF_REGION = 'region' CONF_SERVICE_URL = 'service_url' CONF_SCANDINAVIAN_MILES = 'scandinavian_miles' - -SIGNAL_VEHICLE_SEEN = '{}.vehicle_seen'.format(DOMAIN) - -RESOURCES = {'position': ('device_tracker',), - 'lock': ('lock', 'Lock'), - 'heater': ('switch', 'Heater', 'mdi:radiator'), - 'odometer': ('sensor', 'Odometer', 'mdi:speedometer', 'km'), - 'fuel_amount': ('sensor', 'Fuel amount', 'mdi:gas-station', 'L'), - 'fuel_amount_level': ( - 'sensor', 'Fuel level', 'mdi:water-percent', '%'), - 'average_fuel_consumption': ( - 'sensor', 'Fuel consumption', 'mdi:gas-station', 'L/100 km'), - 'distance_to_empty': ('sensor', 'Range', 'mdi:ruler', 'km'), - 'washer_fluid_level': ('binary_sensor', 'Washer fluid'), - 'brake_fluid': ('binary_sensor', 'Brake Fluid'), - 'service_warning_status': ('binary_sensor', 'Service'), - 'bulb_failures': ('binary_sensor', 'Bulbs'), - 'doors': ('binary_sensor', 'Doors'), - 'windows': ('binary_sensor', 'Windows')} +CONF_MUTABLE = 'mutable' + +SIGNAL_STATE_UPDATED = '{}.updated'.format(DOMAIN) + +COMPONENTS = { + 'sensor': 'sensor', + 'binary_sensor': 'binary_sensor', + 'lock': 'lock', + 'device_tracker': 'device_tracker', + 'switch': 'switch' +} + +RESOURCES = [ + 'position', + 'lock', + 'heater', + 'odometer', + 'trip_meter1', + 'trip_meter2', + 'fuel_amount', + 'fuel_amount_level', + 'average_fuel_consumption', + 'distance_to_empty', + 'washer_fluid_level', + 'brake_fluid', + 'service_warning_status', + 'bulb_failures', + 'battery_range', + 'battery_level', + 'time_to_fully_charged', + 'battery_charge_status', + 'engine_start', + 'last_trip', + 'is_engine_running', + 'doors.hood_open', + 'doors.front_left_door_open', + 'doors.front_right_door_open', + 'doors.rear_left_door_open', + 'doors.rear_right_door_open', + 'windows.front_left_window_open', + 'windows.front_right_window_open', + 'windows.rear_left_window_open', + 'windows.rear_right_window_open', + 'tyre_pressure.front_left_tyre_pressure', + 'tyre_pressure.front_right_tyre_pressure', + 'tyre_pressure.rear_left_tyre_pressure', + 'tyre_pressure.rear_right_tyre_pressure', + 'any_door_open', + 'any_window_open' +] CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -65,12 +98,13 @@ cv.ensure_list, [vol.In(RESOURCES)]), vol.Optional(CONF_REGION): cv.string, vol.Optional(CONF_SERVICE_URL): cv.string, + vol.Optional(CONF_MUTABLE, default=True): cv.boolean, vol.Optional(CONF_SCANDINAVIAN_MILES, default=False): cv.boolean, }), }, extra=vol.ALLOW_EXTRA) -def setup(hass, config): +async def async_setup(hass, config): """Set up the Volvo On Call component.""" from volvooncall import Connection connection = Connection( @@ -81,44 +115,57 @@ def setup(hass, config): interval = config[DOMAIN].get(CONF_UPDATE_INTERVAL) - state = hass.data[DATA_KEY] = VolvoData(config) + data = hass.data[DATA_KEY] = VolvoData(config) def discover_vehicle(vehicle): """Load relevant platforms.""" - state.entities[vehicle.vin] = [] - for attr, (component, *_) in RESOURCES.items(): - if (getattr(vehicle, attr + '_supported', True) and - attr in config[DOMAIN].get(CONF_RESOURCES, [attr])): - discovery.load_platform( - hass, component, DOMAIN, (vehicle.vin, attr), config) - - def update_vehicle(vehicle): - """Receive updated information on vehicle.""" - state.vehicles[vehicle.vin] = vehicle - if vehicle.vin not in state.entities: - discover_vehicle(vehicle) - - for entity in state.entities[vehicle.vin]: - entity.schedule_update_ha_state() - - dispatcher_send(hass, SIGNAL_VEHICLE_SEEN, vehicle) - - def update(now): + data.vehicles.add(vehicle.vin) + + dashboard = vehicle.dashboard( + mutable=config[DOMAIN][CONF_MUTABLE], + scandinavian_miles=config[DOMAIN][CONF_SCANDINAVIAN_MILES]) + + def is_enabled(attr): + """Return true if the user has enabled the resource.""" + return attr in config[DOMAIN].get(CONF_RESOURCES, [attr]) + + for instrument in ( + instrument + for instrument in dashboard.instruments + if instrument.component in COMPONENTS and + is_enabled(instrument.slug_attr)): + + data.instruments.append(instrument) + + hass.async_create_task( + discovery.async_load_platform( + hass, + COMPONENTS[instrument.component], + DOMAIN, + (vehicle.vin, + instrument.component, + instrument.attr), + config)) + + async def update(now): """Update status from the online service.""" try: - if not connection.update(): + if not await connection.update(journal=True): _LOGGER.warning("Could not query server") return False for vehicle in connection.vehicles: - update_vehicle(vehicle) + if vehicle.vin not in data.vehicles: + discover_vehicle(vehicle) + + async_dispatcher_send(hass, SIGNAL_STATE_UPDATED) return True finally: - track_point_in_utc_time(hass, update, utcnow() + interval) + async_track_point_in_utc_time(hass, update, utcnow() + interval) _LOGGER.info("Logging in to service") - return update(utcnow()) + return await update(utcnow()) class VolvoData: @@ -126,11 +173,19 @@ class VolvoData: def __init__(self, config): """Initialize the component state.""" - self.entities = {} - self.vehicles = {} + self.vehicles = set() + self.instruments = [] self.config = config[DOMAIN] self.names = self.config.get(CONF_NAME) + def instrument(self, vin, component, attr): + """Return corresponding instrument.""" + return next((instrument + for instrument in self.instruments + if instrument.vehicle.vin == vin and + instrument.component == component and + instrument.attr == attr), None) + def vehicle_name(self, vehicle): """Provide a friendly name for a vehicle.""" if (vehicle.registration_number and @@ -148,29 +203,41 @@ def vehicle_name(self, vehicle): class VolvoEntity(Entity): """Base class for all VOC entities.""" - def __init__(self, hass, vin, attribute): + def __init__(self, data, vin, component, attribute): """Initialize the entity.""" - self._hass = hass - self._vin = vin - self._attribute = attribute - self._state.entities[self._vin].append(self) + self.data = data + self.vin = vin + self.component = component + self.attribute = attribute + + async def async_added_to_hass(self): + """Register update dispatcher.""" + async_dispatcher_connect( + self.hass, SIGNAL_STATE_UPDATED, + self.async_schedule_update_ha_state) + + @property + def instrument(self): + """Return corresponding instrument.""" + return self.data.instrument(self.vin, self.component, self.attribute) @property - def _state(self): - return self._hass.data[DATA_KEY] + def icon(self): + """Return the icon.""" + return self.instrument.icon @property def vehicle(self): """Return vehicle.""" - return self._state.vehicles[self._vin] + return self.instrument.vehicle @property def _entity_name(self): - return RESOURCES[self._attribute][1] + return self.instrument.name @property def _vehicle_name(self): - return self._state.vehicle_name(self.vehicle) + return self.data.vehicle_name(self.vehicle) @property def name(self): @@ -192,6 +259,7 @@ def assumed_state(self): @property def device_state_attributes(self): """Return device specific state attributes.""" - return dict(model='{}/{}'.format( - self.vehicle.vehicle_type, - self.vehicle.model_year)) + return dict(self.instrument.attributes, + model='{}/{}'.format( + self.vehicle.vehicle_type, + self.vehicle.model_year)) diff --git a/requirements_all.txt b/requirements_all.txt index bc8740cc4c6c5..8609f1aeda1ff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1584,7 +1584,7 @@ venstarcolortouch==0.6 volkszaehler==0.1.2 # homeassistant.components.volvooncall -volvooncall==0.4.0 +volvooncall==0.7.4 # homeassistant.components.verisure vsure==1.5.2 From 53cbb28926efe13ae96128250448bd6e376d8b2e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 30 Nov 2018 20:06:10 +0100 Subject: [PATCH 168/325] Fix flaky geofency test (#18855) --- tests/components/geofency/test_init.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py index 442660c2daf3f..6f6d78ba73c86 100644 --- a/tests/components/geofency/test_init.py +++ b/tests/components/geofency/test_init.py @@ -113,6 +113,8 @@ def geofency_client(loop, hass, aiohttp_client): CONF_MOBILE_BEACONS: ['Car 1'] }})) + loop.run_until_complete(hass.async_block_till_done()) + with patch('homeassistant.components.device_tracker.update_config'): yield loop.run_until_complete(aiohttp_client(hass.http.app)) From df21dd21f2c59ca4cb0ff338e122ab4513263f51 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 30 Nov 2018 21:28:35 +0100 Subject: [PATCH 169/325] RFC: Call services directly (#18720) * Call services directly * Simplify * Type * Lint * Update name * Fix tests * Catch exceptions in HTTP view * Lint * Handle ServiceNotFound in API endpoints that call services * Type * Don't crash recorder on non-JSON serializable objects --- homeassistant/auth/mfa_modules/notify.py | 8 +- homeassistant/auth/providers/__init__.py | 6 +- homeassistant/components/api.py | 12 +- homeassistant/components/http/view.py | 8 +- homeassistant/components/mqtt_eventstream.py | 12 +- homeassistant/components/recorder/__init__.py | 22 ++- .../components/websocket_api/commands.py | 15 +- homeassistant/const.py | 4 - homeassistant/core.py | 134 ++++++------------ homeassistant/exceptions.py | 11 ++ tests/auth/mfa_modules/test_notify.py | 6 +- tests/components/climate/test_demo.py | 26 ++-- tests/components/climate/test_init.py | 8 +- tests/components/climate/test_mqtt.py | 12 +- tests/components/deconz/test_init.py | 15 +- tests/components/http/test_view.py | 48 ++++++- tests/components/media_player/test_demo.py | 15 +- .../components/media_player/test_monoprice.py | 2 +- tests/components/mqtt/test_init.py | 11 +- tests/components/notify/test_demo.py | 6 +- tests/components/test_alert.py | 1 + tests/components/test_api.py | 27 ++++ tests/components/test_input_datetime.py | 12 +- tests/components/test_logbook.py | 7 +- tests/components/test_snips.py | 17 ++- tests/components/test_wake_on_lan.py | 9 +- tests/components/water_heater/test_demo.py | 9 +- .../components/websocket_api/test_commands.py | 19 +++ tests/components/zwave/test_init.py | 2 +- tests/test_core.py | 12 +- 30 files changed, 311 insertions(+), 185 deletions(-) diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py index 8eea3acb6ed2b..3c26f8b4bde44 100644 --- a/homeassistant/auth/mfa_modules/notify.py +++ b/homeassistant/auth/mfa_modules/notify.py @@ -11,6 +11,7 @@ from homeassistant.const import CONF_EXCLUDE, CONF_INCLUDE from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceNotFound from homeassistant.helpers import config_validation as cv from . import MultiFactorAuthModule, MULTI_FACTOR_AUTH_MODULES, \ @@ -314,8 +315,11 @@ async def async_step_setup( _generate_otp, self._secret, self._count) assert self._notify_service - await self._auth_module.async_notify( - code, self._notify_service, self._target) + try: + await self._auth_module.async_notify( + code, self._notify_service, self._target) + except ServiceNotFound: + return self.async_abort(reason='notify_service_not_exist') return self.async_show_form( step_id='setup', diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index 9ca4232b6106e..8828782c886e9 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -226,7 +226,11 @@ async def async_step_mfa( if user_input is None and hasattr(auth_module, 'async_initialize_login_mfa_step'): - await auth_module.async_initialize_login_mfa_step(self.user.id) + try: + await auth_module.async_initialize_login_mfa_step(self.user.id) + except HomeAssistantError: + _LOGGER.exception('Error initializing MFA step') + return self.async_abort(reason='unknown_error') if user_input is not None: expires = self.created_at + MFA_SESSION_EXPIRATION diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index b001bcd043725..961350bfa8977 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -9,7 +9,9 @@ import logging from aiohttp import web +from aiohttp.web_exceptions import HTTPBadRequest import async_timeout +import voluptuous as vol from homeassistant.bootstrap import DATA_LOGGING from homeassistant.components.http import HomeAssistantView @@ -21,7 +23,8 @@ URL_API_TEMPLATE, __version__) import homeassistant.core as ha from homeassistant.auth.permissions.const import POLICY_READ -from homeassistant.exceptions import TemplateError, Unauthorized +from homeassistant.exceptions import ( + TemplateError, Unauthorized, ServiceNotFound) from homeassistant.helpers import template from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.helpers.state import AsyncTrackStates @@ -339,8 +342,11 @@ async def post(self, request, domain, service): "Data should be valid JSON.", HTTP_BAD_REQUEST) with AsyncTrackStates(hass) as changed_states: - await hass.services.async_call( - domain, service, data, True, self.context(request)) + try: + await hass.services.async_call( + domain, service, data, True, self.context(request)) + except (vol.Invalid, ServiceNotFound): + raise HTTPBadRequest() return self.json(changed_states) diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index c8f5d788dd281..beb5c647266f9 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -9,7 +9,9 @@ import logging from aiohttp import web -from aiohttp.web_exceptions import HTTPUnauthorized, HTTPInternalServerError +from aiohttp.web_exceptions import ( + HTTPUnauthorized, HTTPInternalServerError, HTTPBadRequest) +import voluptuous as vol from homeassistant.components.http.ban import process_success_login from homeassistant.core import Context, is_callback @@ -114,6 +116,10 @@ async def handle(request): if asyncio.iscoroutine(result): result = await result + except vol.Invalid: + raise HTTPBadRequest() + except exceptions.ServiceNotFound: + raise HTTPInternalServerError() except exceptions.Unauthorized: raise HTTPUnauthorized() diff --git a/homeassistant/components/mqtt_eventstream.py b/homeassistant/components/mqtt_eventstream.py index 0e01310115fde..2cde7825734f7 100644 --- a/homeassistant/components/mqtt_eventstream.py +++ b/homeassistant/components/mqtt_eventstream.py @@ -13,7 +13,7 @@ from homeassistant.components.mqtt import ( valid_publish_topic, valid_subscribe_topic) from homeassistant.const import ( - ATTR_SERVICE_DATA, EVENT_CALL_SERVICE, EVENT_SERVICE_EXECUTED, + ATTR_SERVICE_DATA, EVENT_CALL_SERVICE, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL) from homeassistant.core import EventOrigin, State import homeassistant.helpers.config_validation as cv @@ -69,16 +69,6 @@ def _event_publisher(event): ): return - # Filter out all the "event service executed" events because they - # are only used internally by core as callbacks for blocking - # during the interval while a service is being executed. - # They will serve no purpose to the external system, - # and thus are unnecessary traffic. - # And at any rate it would cause an infinite loop to publish them - # because publishing to an MQTT topic itself triggers one. - if event.event_type == EVENT_SERVICE_EXECUTED: - return - event_info = {'event_type': event.event_type, 'event_data': event.data} msg = json.dumps(event_info, cls=JSONEncoder) mqtt.async_publish(pub_topic, msg) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index c53fa051a2707..15de4c3f995ac 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -300,14 +300,24 @@ def async_purge(now): time.sleep(CONNECT_RETRY_WAIT) try: with session_scope(session=self.get_session()) as session: - dbevent = Events.from_event(event) - session.add(dbevent) - session.flush() + try: + dbevent = Events.from_event(event) + session.add(dbevent) + session.flush() + except (TypeError, ValueError): + _LOGGER.warning( + "Event is not JSON serializable: %s", event) if event.event_type == EVENT_STATE_CHANGED: - dbstate = States.from_event(event) - dbstate.event_id = dbevent.event_id - session.add(dbstate) + try: + dbstate = States.from_event(event) + dbstate.event_id = dbevent.event_id + session.add(dbstate) + except (TypeError, ValueError): + _LOGGER.warning( + "State is not JSON serializable: %s", + event.data.get('new_state')) + updated = True except exc.OperationalError as err: diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 53d1e9af807a0..ff928b4387398 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -3,7 +3,7 @@ from homeassistant.const import MATCH_ALL, EVENT_TIME_CHANGED from homeassistant.core import callback, DOMAIN as HASS_DOMAIN -from homeassistant.exceptions import Unauthorized +from homeassistant.exceptions import Unauthorized, ServiceNotFound from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_get_all_descriptions @@ -141,10 +141,15 @@ async def handle_call_service(hass, connection, msg): if (msg['domain'] == HASS_DOMAIN and msg['service'] in ['restart', 'stop']): blocking = False - await hass.services.async_call( - msg['domain'], msg['service'], msg.get('service_data'), blocking, - connection.context(msg)) - connection.send_message(messages.result_message(msg['id'])) + + try: + await hass.services.async_call( + msg['domain'], msg['service'], msg.get('service_data'), blocking, + connection.context(msg)) + connection.send_message(messages.result_message(msg['id'])) + except ServiceNotFound: + connection.send_message(messages.error_message( + msg['id'], const.ERR_NOT_FOUND, 'Service not found.')) @callback diff --git a/homeassistant/const.py b/homeassistant/const.py index fc97e1bc52d5d..eb53140339ad8 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -163,7 +163,6 @@ EVENT_STATE_CHANGED = 'state_changed' EVENT_TIME_CHANGED = 'time_changed' EVENT_CALL_SERVICE = 'call_service' -EVENT_SERVICE_EXECUTED = 'service_executed' EVENT_PLATFORM_DISCOVERED = 'platform_discovered' EVENT_COMPONENT_LOADED = 'component_loaded' EVENT_SERVICE_REGISTERED = 'service_registered' @@ -233,9 +232,6 @@ # Name ATTR_NAME = 'name' -# Data for a SERVICE_EXECUTED event -ATTR_SERVICE_CALL_ID = 'service_call_id' - # Contains one string or a list of strings, each being an entity id ATTR_ENTITY_ID = 'entity_id' diff --git a/homeassistant/core.py b/homeassistant/core.py index 1754a8b50141f..2a40d604ee0cf 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -25,18 +25,18 @@ from async_timeout import timeout import attr import voluptuous as vol -from voluptuous.humanize import humanize_error from homeassistant.const import ( ATTR_DOMAIN, ATTR_FRIENDLY_NAME, ATTR_NOW, ATTR_SERVICE, - ATTR_SERVICE_CALL_ID, ATTR_SERVICE_DATA, ATTR_SECONDS, EVENT_CALL_SERVICE, + ATTR_SERVICE_DATA, ATTR_SECONDS, EVENT_CALL_SERVICE, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_CLOSE, EVENT_SERVICE_REMOVED, - EVENT_SERVICE_EXECUTED, EVENT_SERVICE_REGISTERED, EVENT_STATE_CHANGED, + EVENT_SERVICE_REGISTERED, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, EVENT_TIMER_OUT_OF_SYNC, MATCH_ALL, __version__) from homeassistant import loader from homeassistant.exceptions import ( - HomeAssistantError, InvalidEntityFormatError, InvalidStateError) + HomeAssistantError, InvalidEntityFormatError, InvalidStateError, + Unauthorized, ServiceNotFound) from homeassistant.util.async_ import ( run_coroutine_threadsafe, run_callback_threadsafe, fire_coroutine_threadsafe) @@ -954,7 +954,6 @@ def __init__(self, hass: HomeAssistant) -> None: """Initialize a service registry.""" self._services = {} # type: Dict[str, Dict[str, Service]] self._hass = hass - self._async_unsub_call_event = None # type: Optional[CALLBACK_TYPE] @property def services(self) -> Dict[str, Dict[str, Service]]: @@ -1010,10 +1009,6 @@ def async_register(self, domain: str, service: str, service_func: Callable, else: self._services[domain] = {service: service_obj} - if self._async_unsub_call_event is None: - self._async_unsub_call_event = self._hass.bus.async_listen( - EVENT_CALL_SERVICE, self._event_to_service_call) - self._hass.bus.async_fire( EVENT_SERVICE_REGISTERED, {ATTR_DOMAIN: domain, ATTR_SERVICE: service} @@ -1092,100 +1087,61 @@ async def async_call(self, domain: str, service: str, This method is a coroutine. """ + domain = domain.lower() + service = service.lower() context = context or Context() - call_id = uuid.uuid4().hex - event_data = { + service_data = service_data or {} + + try: + handler = self._services[domain][service] + except KeyError: + raise ServiceNotFound(domain, service) from None + + if handler.schema: + service_data = handler.schema(service_data) + + service_call = ServiceCall(domain, service, service_data, context) + + self._hass.bus.async_fire(EVENT_CALL_SERVICE, { ATTR_DOMAIN: domain.lower(), ATTR_SERVICE: service.lower(), ATTR_SERVICE_DATA: service_data, - ATTR_SERVICE_CALL_ID: call_id, - } + }) if not blocking: - self._hass.bus.async_fire( - EVENT_CALL_SERVICE, event_data, EventOrigin.local, context) + self._hass.async_create_task( + self._safe_execute(handler, service_call)) return None - fut = asyncio.Future() # type: asyncio.Future - - @callback - def service_executed(event: Event) -> None: - """Handle an executed service.""" - if event.data[ATTR_SERVICE_CALL_ID] == call_id: - fut.set_result(True) - unsub() - - unsub = self._hass.bus.async_listen( - EVENT_SERVICE_EXECUTED, service_executed) - - self._hass.bus.async_fire(EVENT_CALL_SERVICE, event_data, - EventOrigin.local, context) - - done, _ = await asyncio.wait([fut], timeout=SERVICE_CALL_LIMIT) - success = bool(done) - if not success: - unsub() - return success - - async def _event_to_service_call(self, event: Event) -> None: - """Handle the SERVICE_CALLED events from the EventBus.""" - service_data = event.data.get(ATTR_SERVICE_DATA) or {} - domain = event.data.get(ATTR_DOMAIN).lower() # type: ignore - service = event.data.get(ATTR_SERVICE).lower() # type: ignore - call_id = event.data.get(ATTR_SERVICE_CALL_ID) - - if not self.has_service(domain, service): - if event.origin == EventOrigin.local: - _LOGGER.warning("Unable to find service %s/%s", - domain, service) - return - - service_handler = self._services[domain][service] - - def fire_service_executed() -> None: - """Fire service executed event.""" - if not call_id: - return - - data = {ATTR_SERVICE_CALL_ID: call_id} - - if (service_handler.is_coroutinefunction or - service_handler.is_callback): - self._hass.bus.async_fire(EVENT_SERVICE_EXECUTED, data, - EventOrigin.local, event.context) - else: - self._hass.bus.fire(EVENT_SERVICE_EXECUTED, data, - EventOrigin.local, event.context) - try: - if service_handler.schema: - service_data = service_handler.schema(service_data) - except vol.Invalid as ex: - _LOGGER.error("Invalid service data for %s.%s: %s", - domain, service, humanize_error(service_data, ex)) - fire_service_executed() - return - - service_call = ServiceCall( - domain, service, service_data, event.context) + with timeout(SERVICE_CALL_LIMIT): + await asyncio.shield( + self._execute_service(handler, service_call)) + return True + except asyncio.TimeoutError: + return False + async def _safe_execute(self, handler: Service, + service_call: ServiceCall) -> None: + """Execute a service and catch exceptions.""" try: - if service_handler.is_callback: - service_handler.func(service_call) - fire_service_executed() - elif service_handler.is_coroutinefunction: - await service_handler.func(service_call) - fire_service_executed() - else: - def execute_service() -> None: - """Execute a service and fires a SERVICE_EXECUTED event.""" - service_handler.func(service_call) - fire_service_executed() - - await self._hass.async_add_executor_job(execute_service) + await self._execute_service(handler, service_call) + except Unauthorized: + _LOGGER.warning('Unauthorized service called %s/%s', + service_call.domain, service_call.service) except Exception: # pylint: disable=broad-except _LOGGER.exception('Error executing service %s', service_call) + async def _execute_service(self, handler: Service, + service_call: ServiceCall) -> None: + """Execute a service.""" + if handler.is_callback: + handler.func(service_call) + elif handler.is_coroutinefunction: + await handler.func(service_call) + else: + await self._hass.async_add_executor_job(handler.func, service_call) + class Config: """Configuration settings for Home Assistant.""" diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 0613b7cb10c56..5e2ab4988b1c7 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -58,3 +58,14 @@ def __init__(self, context: Optional['Context'] = None, class UnknownUser(Unauthorized): """When call is made with user ID that doesn't exist.""" + + +class ServiceNotFound(HomeAssistantError): + """Raised when a service is not found.""" + + def __init__(self, domain: str, service: str) -> None: + """Initialize error.""" + super().__init__( + self, "Service {}.{} not found".format(domain, service)) + self.domain = domain + self.service = service diff --git a/tests/auth/mfa_modules/test_notify.py b/tests/auth/mfa_modules/test_notify.py index ffe0b103fc955..748b550782468 100644 --- a/tests/auth/mfa_modules/test_notify.py +++ b/tests/auth/mfa_modules/test_notify.py @@ -61,6 +61,7 @@ async def test_validating_mfa_counter(hass): 'counter': 0, 'notify_service': 'dummy', }) + async_mock_service(hass, 'notify', 'dummy') assert notify_auth_module._user_settings notify_setting = list(notify_auth_module._user_settings.values())[0] @@ -389,9 +390,8 @@ async def test_not_raise_exception_when_service_not_exist(hass): 'username': 'test-user', 'password': 'test-pass', }) - assert result['type'] == data_entry_flow.RESULT_TYPE_FORM - assert result['step_id'] == 'mfa' - assert result['data_schema'].schema.get('code') == str + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'unknown_error' # wait service call finished await hass.async_block_till_done() diff --git a/tests/components/climate/test_demo.py b/tests/components/climate/test_demo.py index 462939af23ad6..3a023916741c6 100644 --- a/tests/components/climate/test_demo.py +++ b/tests/components/climate/test_demo.py @@ -1,6 +1,9 @@ """The tests for the demo climate component.""" import unittest +import pytest +import voluptuous as vol + from homeassistant.util.unit_system import ( METRIC_SYSTEM ) @@ -57,7 +60,8 @@ def test_set_only_target_temp_bad_attr(self): """Test setting the target temperature without required attribute.""" state = self.hass.states.get(ENTITY_CLIMATE) assert 21 == state.attributes.get('temperature') - common.set_temperature(self.hass, None, ENTITY_CLIMATE) + with pytest.raises(vol.Invalid): + common.set_temperature(self.hass, None, ENTITY_CLIMATE) self.hass.block_till_done() assert 21 == state.attributes.get('temperature') @@ -99,9 +103,11 @@ def test_set_target_temp_range_bad_attr(self): assert state.attributes.get('temperature') is None assert 21.0 == state.attributes.get('target_temp_low') assert 24.0 == state.attributes.get('target_temp_high') - common.set_temperature(self.hass, temperature=None, - entity_id=ENTITY_ECOBEE, target_temp_low=None, - target_temp_high=None) + with pytest.raises(vol.Invalid): + common.set_temperature(self.hass, temperature=None, + entity_id=ENTITY_ECOBEE, + target_temp_low=None, + target_temp_high=None) self.hass.block_till_done() state = self.hass.states.get(ENTITY_ECOBEE) assert state.attributes.get('temperature') is None @@ -112,7 +118,8 @@ def test_set_target_humidity_bad_attr(self): """Test setting the target humidity without required attribute.""" state = self.hass.states.get(ENTITY_CLIMATE) assert 67 == state.attributes.get('humidity') - common.set_humidity(self.hass, None, ENTITY_CLIMATE) + with pytest.raises(vol.Invalid): + common.set_humidity(self.hass, None, ENTITY_CLIMATE) self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) assert 67 == state.attributes.get('humidity') @@ -130,7 +137,8 @@ def test_set_fan_mode_bad_attr(self): """Test setting fan mode without required attribute.""" state = self.hass.states.get(ENTITY_CLIMATE) assert "On High" == state.attributes.get('fan_mode') - common.set_fan_mode(self.hass, None, ENTITY_CLIMATE) + with pytest.raises(vol.Invalid): + common.set_fan_mode(self.hass, None, ENTITY_CLIMATE) self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) assert "On High" == state.attributes.get('fan_mode') @@ -148,7 +156,8 @@ def test_set_swing_mode_bad_attr(self): """Test setting swing mode without required attribute.""" state = self.hass.states.get(ENTITY_CLIMATE) assert "Off" == state.attributes.get('swing_mode') - common.set_swing_mode(self.hass, None, ENTITY_CLIMATE) + with pytest.raises(vol.Invalid): + common.set_swing_mode(self.hass, None, ENTITY_CLIMATE) self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) assert "Off" == state.attributes.get('swing_mode') @@ -170,7 +179,8 @@ def test_set_operation_bad_attr_and_state(self): state = self.hass.states.get(ENTITY_CLIMATE) assert "cool" == state.attributes.get('operation_mode') assert "cool" == state.state - common.set_operation_mode(self.hass, None, ENTITY_CLIMATE) + with pytest.raises(vol.Invalid): + common.set_operation_mode(self.hass, None, ENTITY_CLIMATE) self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) assert "cool" == state.attributes.get('operation_mode') diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index 2e942c5988caa..2aeb1228aba27 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -1,6 +1,9 @@ """The tests for the climate component.""" import asyncio +import pytest +import voluptuous as vol + from homeassistant.components.climate import SET_TEMPERATURE_SCHEMA from tests.common import async_mock_service @@ -14,12 +17,11 @@ def test_set_temp_schema_no_req(hass, caplog): calls = async_mock_service(hass, domain, service, schema) data = {'operation_mode': 'test', 'entity_id': ['climate.test_id']} - yield from hass.services.async_call(domain, service, data) + with pytest.raises(vol.Invalid): + yield from hass.services.async_call(domain, service, data) yield from hass.async_block_till_done() assert len(calls) == 0 - assert 'ERROR' in caplog.text - assert 'Invalid service data' in caplog.text @asyncio.coroutine diff --git a/tests/components/climate/test_mqtt.py b/tests/components/climate/test_mqtt.py index 894fc290c38cc..7beb3887ae0c5 100644 --- a/tests/components/climate/test_mqtt.py +++ b/tests/components/climate/test_mqtt.py @@ -2,6 +2,9 @@ import unittest import copy +import pytest +import voluptuous as vol + from homeassistant.util.unit_system import ( METRIC_SYSTEM ) @@ -91,7 +94,8 @@ def test_set_operation_bad_attr_and_state(self): state = self.hass.states.get(ENTITY_CLIMATE) assert "off" == state.attributes.get('operation_mode') assert "off" == state.state - common.set_operation_mode(self.hass, None, ENTITY_CLIMATE) + with pytest.raises(vol.Invalid): + common.set_operation_mode(self.hass, None, ENTITY_CLIMATE) self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) assert "off" == state.attributes.get('operation_mode') @@ -177,7 +181,8 @@ def test_set_fan_mode_bad_attr(self): state = self.hass.states.get(ENTITY_CLIMATE) assert "low" == state.attributes.get('fan_mode') - common.set_fan_mode(self.hass, None, ENTITY_CLIMATE) + with pytest.raises(vol.Invalid): + common.set_fan_mode(self.hass, None, ENTITY_CLIMATE) self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) assert "low" == state.attributes.get('fan_mode') @@ -225,7 +230,8 @@ def test_set_swing_mode_bad_attr(self): state = self.hass.states.get(ENTITY_CLIMATE) assert "off" == state.attributes.get('swing_mode') - common.set_swing_mode(self.hass, None, ENTITY_CLIMATE) + with pytest.raises(vol.Invalid): + common.set_swing_mode(self.hass, None, ENTITY_CLIMATE) self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) assert "off" == state.attributes.get('swing_mode') diff --git a/tests/components/deconz/test_init.py b/tests/components/deconz/test_init.py index b83756f6ebbae..5fa8ddcfe38e7 100644 --- a/tests/components/deconz/test_init.py +++ b/tests/components/deconz/test_init.py @@ -1,6 +1,9 @@ """Test deCONZ component setup process.""" from unittest.mock import Mock, patch +import pytest +import voluptuous as vol + from homeassistant.setup import async_setup_component from homeassistant.components import deconz @@ -163,11 +166,13 @@ async def test_service_configure(hass): await hass.async_block_till_done() # field does not start with / - with patch('pydeconz.DeconzSession.async_put_state', - return_value=mock_coro(True)): - await hass.services.async_call('deconz', 'configure', service_data={ - 'entity': 'light.test', 'field': 'state', 'data': data}) - await hass.async_block_till_done() + with pytest.raises(vol.Invalid): + with patch('pydeconz.DeconzSession.async_put_state', + return_value=mock_coro(True)): + await hass.services.async_call( + 'deconz', 'configure', service_data={ + 'entity': 'light.test', 'field': 'state', 'data': data}) + await hass.async_block_till_done() async def test_service_refresh_devices(hass): diff --git a/tests/components/http/test_view.py b/tests/components/http/test_view.py index ed97af9c76442..395849f066e8f 100644 --- a/tests/components/http/test_view.py +++ b/tests/components/http/test_view.py @@ -1,8 +1,25 @@ """Tests for Home Assistant View.""" -from aiohttp.web_exceptions import HTTPInternalServerError +from unittest.mock import Mock + +from aiohttp.web_exceptions import ( + HTTPInternalServerError, HTTPBadRequest, HTTPUnauthorized) import pytest +import voluptuous as vol + +from homeassistant.components.http.view import ( + HomeAssistantView, request_handler_factory) +from homeassistant.exceptions import ServiceNotFound, Unauthorized + +from tests.common import mock_coro_func -from homeassistant.components.http.view import HomeAssistantView + +@pytest.fixture +def mock_request(): + """Mock a request.""" + return Mock( + app={'hass': Mock(is_running=True)}, + match_info={}, + ) async def test_invalid_json(caplog): @@ -13,3 +30,30 @@ async def test_invalid_json(caplog): view.json(float("NaN")) assert str(float("NaN")) in caplog.text + + +async def test_handling_unauthorized(mock_request): + """Test handling unauth exceptions.""" + with pytest.raises(HTTPUnauthorized): + await request_handler_factory( + Mock(requires_auth=False), + mock_coro_func(exception=Unauthorized) + )(mock_request) + + +async def test_handling_invalid_data(mock_request): + """Test handling unauth exceptions.""" + with pytest.raises(HTTPBadRequest): + await request_handler_factory( + Mock(requires_auth=False), + mock_coro_func(exception=vol.Invalid('yo')) + )(mock_request) + + +async def test_handling_service_not_found(mock_request): + """Test handling unauth exceptions.""" + with pytest.raises(HTTPInternalServerError): + await request_handler_factory( + Mock(requires_auth=False), + mock_coro_func(exception=ServiceNotFound('test', 'test')) + )(mock_request) diff --git a/tests/components/media_player/test_demo.py b/tests/components/media_player/test_demo.py index e986ac0206584..b213cf0b5c195 100644 --- a/tests/components/media_player/test_demo.py +++ b/tests/components/media_player/test_demo.py @@ -3,6 +3,9 @@ from unittest.mock import patch import asyncio +import pytest +import voluptuous as vol + from homeassistant.setup import setup_component from homeassistant.const import HTTP_HEADER_HA_AUTH import homeassistant.components.media_player as mp @@ -43,7 +46,8 @@ def test_source_select(self): state = self.hass.states.get(entity_id) assert 'dvd' == state.attributes.get('source') - common.select_source(self.hass, None, entity_id) + with pytest.raises(vol.Invalid): + common.select_source(self.hass, None, entity_id) self.hass.block_till_done() state = self.hass.states.get(entity_id) assert 'dvd' == state.attributes.get('source') @@ -72,7 +76,8 @@ def test_volume_services(self): state = self.hass.states.get(entity_id) assert 1.0 == state.attributes.get('volume_level') - common.set_volume_level(self.hass, None, entity_id) + with pytest.raises(vol.Invalid): + common.set_volume_level(self.hass, None, entity_id) self.hass.block_till_done() state = self.hass.states.get(entity_id) assert 1.0 == state.attributes.get('volume_level') @@ -201,7 +206,8 @@ def test_play_media(self, mock_seek): state.attributes.get('supported_features')) assert state.attributes.get('media_content_id') is not None - common.play_media(self.hass, None, 'some_id', ent_id) + with pytest.raises(vol.Invalid): + common.play_media(self.hass, None, 'some_id', ent_id) self.hass.block_till_done() state = self.hass.states.get(ent_id) assert 0 < (mp.SUPPORT_PLAY_MEDIA & @@ -216,7 +222,8 @@ def test_play_media(self, mock_seek): assert 'some_id' == state.attributes.get('media_content_id') assert not mock_seek.called - common.media_seek(self.hass, None, ent_id) + with pytest.raises(vol.Invalid): + common.media_seek(self.hass, None, ent_id) self.hass.block_till_done() assert not mock_seek.called common.media_seek(self.hass, 100, ent_id) diff --git a/tests/components/media_player/test_monoprice.py b/tests/components/media_player/test_monoprice.py index 417cd42187fa8..c6a6b3036d97f 100644 --- a/tests/components/media_player/test_monoprice.py +++ b/tests/components/media_player/test_monoprice.py @@ -223,7 +223,7 @@ def test_service_calls_with_entity_id(self): # Restoring wrong media player to its previous state # Nothing should be done self.hass.services.call(DOMAIN, SERVICE_RESTORE, - {'entity_id': 'not_existing'}, + {'entity_id': 'media.not_existing'}, blocking=True) # self.hass.block_till_done() diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 5d7afbde8432c..81e6a7b298d44 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -113,11 +113,12 @@ def test_service_call_with_payload_doesnt_render_template(self): """ payload = "not a template" payload_template = "a template" - self.hass.services.call(mqtt.DOMAIN, mqtt.SERVICE_PUBLISH, { - mqtt.ATTR_TOPIC: "test/topic", - mqtt.ATTR_PAYLOAD: payload, - mqtt.ATTR_PAYLOAD_TEMPLATE: payload_template - }, blocking=True) + with pytest.raises(vol.Invalid): + self.hass.services.call(mqtt.DOMAIN, mqtt.SERVICE_PUBLISH, { + mqtt.ATTR_TOPIC: "test/topic", + mqtt.ATTR_PAYLOAD: payload, + mqtt.ATTR_PAYLOAD_TEMPLATE: payload_template + }, blocking=True) assert not self.hass.data['mqtt'].async_publish.called def test_service_call_with_ascii_qos_retain_flags(self): diff --git a/tests/components/notify/test_demo.py b/tests/components/notify/test_demo.py index 57397e21ba2cc..4c3f3bf3f73ef 100644 --- a/tests/components/notify/test_demo.py +++ b/tests/components/notify/test_demo.py @@ -2,6 +2,9 @@ import unittest from unittest.mock import patch +import pytest +import voluptuous as vol + import homeassistant.components.notify as notify from homeassistant.setup import setup_component from homeassistant.components.notify import demo @@ -81,7 +84,8 @@ def record_calls(self, *args): def test_sending_none_message(self): """Test send with None as message.""" self._setup_notify() - common.send_message(self.hass, None) + with pytest.raises(vol.Invalid): + common.send_message(self.hass, None) self.hass.block_till_done() assert len(self.events) == 0 diff --git a/tests/components/test_alert.py b/tests/components/test_alert.py index 7661042156361..9fda58c37a348 100644 --- a/tests/components/test_alert.py +++ b/tests/components/test_alert.py @@ -99,6 +99,7 @@ class TestAlert(unittest.TestCase): def setUp(self): """Set up things to be run when tests are started.""" self.hass = get_test_home_assistant() + self._setup_notify() def tearDown(self): """Stop everything that was started.""" diff --git a/tests/components/test_api.py b/tests/components/test_api.py index 0bc89292855e1..a88c828efe8ec 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -6,6 +6,7 @@ from aiohttp import web import pytest +import voluptuous as vol from homeassistant import const from homeassistant.bootstrap import DATA_LOGGING @@ -578,3 +579,29 @@ async def test_rendering_template_legacy_user( json={"template": '{{ states.sensor.temperature.state }}'} ) assert resp.status == 401 + + +async def test_api_call_service_not_found(hass, mock_api_client): + """Test if the API failes 400 if unknown service.""" + resp = await mock_api_client.post( + const.URL_API_SERVICES_SERVICE.format( + "test_domain", "test_service")) + assert resp.status == 400 + + +async def test_api_call_service_bad_data(hass, mock_api_client): + """Test if the API failes 400 if unknown service.""" + test_value = [] + + @ha.callback + def listener(service_call): + """Record that our service got called.""" + test_value.append(1) + + hass.services.async_register("test_domain", "test_service", listener, + schema=vol.Schema({'hello': str})) + + resp = await mock_api_client.post( + const.URL_API_SERVICES_SERVICE.format( + "test_domain", "test_service"), json={'hello': 5}) + assert resp.status == 400 diff --git a/tests/components/test_input_datetime.py b/tests/components/test_input_datetime.py index a61cefe34f2f6..2a4d0fef09de6 100644 --- a/tests/components/test_input_datetime.py +++ b/tests/components/test_input_datetime.py @@ -3,6 +3,9 @@ import asyncio import datetime +import pytest +import voluptuous as vol + from homeassistant.core import CoreState, State, Context from homeassistant.setup import async_setup_component from homeassistant.components.input_datetime import ( @@ -109,10 +112,11 @@ def test_set_invalid(hass): dt_obj = datetime.datetime(2017, 9, 7, 19, 46) time_portion = dt_obj.time() - yield from hass.services.async_call('input_datetime', 'set_datetime', { - 'entity_id': 'test_date', - 'time': time_portion - }) + with pytest.raises(vol.Invalid): + yield from hass.services.async_call('input_datetime', 'set_datetime', { + 'entity_id': 'test_date', + 'time': time_portion + }) yield from hass.async_block_till_done() state = hass.states.get(entity_id) diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index 5761ce8714bfb..6a272991798c5 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -4,6 +4,9 @@ from datetime import (timedelta, datetime) import unittest +import pytest +import voluptuous as vol + from homeassistant.components import sun import homeassistant.core as ha from homeassistant.const import ( @@ -89,7 +92,9 @@ def event_listener(event): calls.append(event) self.hass.bus.listen(logbook.EVENT_LOGBOOK_ENTRY, event_listener) - self.hass.services.call(logbook.DOMAIN, 'log', {}, True) + + with pytest.raises(vol.Invalid): + self.hass.services.call(logbook.DOMAIN, 'log', {}, True) # Logbook entry service call results in firing an event. # Our service call will unblock when the event listeners have been diff --git a/tests/components/test_snips.py b/tests/components/test_snips.py index bc044999bddc1..977cd96698158 100644 --- a/tests/components/test_snips.py +++ b/tests/components/test_snips.py @@ -2,6 +2,9 @@ import json import logging +import pytest +import voluptuous as vol + from homeassistant.bootstrap import async_setup_component from homeassistant.components.mqtt import MQTT_PUBLISH_SCHEMA import homeassistant.components.snips as snips @@ -452,12 +455,11 @@ async def test_snips_say_invalid_config(hass, caplog): snips.SERVICE_SCHEMA_SAY) data = {'text': 'Hello', 'badKey': 'boo'} - await hass.services.async_call('snips', 'say', data) + with pytest.raises(vol.Invalid): + await hass.services.async_call('snips', 'say', data) await hass.async_block_till_done() assert len(calls) == 0 - assert 'ERROR' in caplog.text - assert 'Invalid service data' in caplog.text async def test_snips_say_action_invalid(hass, caplog): @@ -466,12 +468,12 @@ async def test_snips_say_action_invalid(hass, caplog): snips.SERVICE_SCHEMA_SAY_ACTION) data = {'text': 'Hello', 'can_be_enqueued': 'notabool'} - await hass.services.async_call('snips', 'say_action', data) + + with pytest.raises(vol.Invalid): + await hass.services.async_call('snips', 'say_action', data) await hass.async_block_till_done() assert len(calls) == 0 - assert 'ERROR' in caplog.text - assert 'Invalid service data' in caplog.text async def test_snips_feedback_on(hass, caplog): @@ -510,7 +512,8 @@ async def test_snips_feedback_config(hass, caplog): snips.SERVICE_SCHEMA_FEEDBACK) data = {'site_id': 'remote', 'test': 'test'} - await hass.services.async_call('snips', 'feedback_on', data) + with pytest.raises(vol.Invalid): + await hass.services.async_call('snips', 'feedback_on', data) await hass.async_block_till_done() assert len(calls) == 0 diff --git a/tests/components/test_wake_on_lan.py b/tests/components/test_wake_on_lan.py index abaf7dd6d14eb..cb9f05ba47ba1 100644 --- a/tests/components/test_wake_on_lan.py +++ b/tests/components/test_wake_on_lan.py @@ -3,6 +3,7 @@ from unittest import mock import pytest +import voluptuous as vol from homeassistant.setup import async_setup_component from homeassistant.components.wake_on_lan import ( @@ -34,10 +35,10 @@ def test_send_magic_packet(hass, caplog, mock_wakeonlan): assert mock_wakeonlan.mock_calls[-1][1][0] == mac assert mock_wakeonlan.mock_calls[-1][2]['ip_address'] == bc_ip - yield from hass.services.async_call( - DOMAIN, SERVICE_SEND_MAGIC_PACKET, - {"broadcast_address": bc_ip}, blocking=True) - assert 'ERROR' in caplog.text + with pytest.raises(vol.Invalid): + yield from hass.services.async_call( + DOMAIN, SERVICE_SEND_MAGIC_PACKET, + {"broadcast_address": bc_ip}, blocking=True) assert len(mock_wakeonlan.mock_calls) == 1 yield from hass.services.async_call( diff --git a/tests/components/water_heater/test_demo.py b/tests/components/water_heater/test_demo.py index 66116db8cda13..d8c9c71935b05 100644 --- a/tests/components/water_heater/test_demo.py +++ b/tests/components/water_heater/test_demo.py @@ -1,6 +1,9 @@ """The tests for the demo water_heater component.""" import unittest +import pytest +import voluptuous as vol + from homeassistant.util.unit_system import ( IMPERIAL_SYSTEM ) @@ -48,7 +51,8 @@ def test_set_only_target_temp_bad_attr(self): """Test setting the target temperature without required attribute.""" state = self.hass.states.get(ENTITY_WATER_HEATER) assert 119 == state.attributes.get('temperature') - common.set_temperature(self.hass, None, ENTITY_WATER_HEATER) + with pytest.raises(vol.Invalid): + common.set_temperature(self.hass, None, ENTITY_WATER_HEATER) self.hass.block_till_done() assert 119 == state.attributes.get('temperature') @@ -69,7 +73,8 @@ def test_set_operation_bad_attr_and_state(self): state = self.hass.states.get(ENTITY_WATER_HEATER) assert "eco" == state.attributes.get('operation_mode') assert "eco" == state.state - common.set_operation_mode(self.hass, None, ENTITY_WATER_HEATER) + with pytest.raises(vol.Invalid): + common.set_operation_mode(self.hass, None, ENTITY_WATER_HEATER) self.hass.block_till_done() state = self.hass.states.get(ENTITY_WATER_HEATER) assert "eco" == state.attributes.get('operation_mode') diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index dc9d0318fd142..2406eefe08e40 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -49,6 +49,25 @@ def service_call(call): assert call.data == {'hello': 'world'} +async def test_call_service_not_found(hass, websocket_client): + """Test call service command.""" + await websocket_client.send_json({ + 'id': 5, + 'type': commands.TYPE_CALL_SERVICE, + 'domain': 'domain_test', + 'service': 'test_service', + 'service_data': { + 'hello': 'world' + } + }) + + msg = await websocket_client.receive_json() + assert msg['id'] == 5 + assert msg['type'] == const.TYPE_RESULT + assert not msg['success'] + assert msg['error']['code'] == const.ERR_NOT_FOUND + + async def test_subscribe_unsubscribe_events(hass, websocket_client): """Test subscribe/unsubscribe events command.""" init_count = sum(hass.bus.async_listeners().values()) diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index d4077345649d5..85cca89eefcd9 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -947,7 +947,7 @@ def test_stop_network(self): assert self.zwave_network.stop.called assert len(self.zwave_network.stop.mock_calls) == 1 assert mock_fire.called - assert len(mock_fire.mock_calls) == 2 + assert len(mock_fire.mock_calls) == 1 assert mock_fire.mock_calls[0][1][0] == const.EVENT_NETWORK_STOP def test_rename_node(self): diff --git a/tests/test_core.py b/tests/test_core.py index 69cde6c1403ba..724233cbf9874 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -21,7 +21,7 @@ __version__, EVENT_STATE_CHANGED, ATTR_FRIENDLY_NAME, CONF_UNIT_SYSTEM, ATTR_NOW, EVENT_TIME_CHANGED, EVENT_TIMER_OUT_OF_SYNC, ATTR_SECONDS, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_CLOSE, - EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED, EVENT_SERVICE_EXECUTED) + EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED) from tests.common import get_test_home_assistant, async_mock_service @@ -673,13 +673,8 @@ def service_handler(call): def test_call_non_existing_with_blocking(self): """Test non-existing with blocking.""" - prior = ha.SERVICE_CALL_LIMIT - try: - ha.SERVICE_CALL_LIMIT = 0.01 - assert not self.services.call('test_domain', 'i_do_not_exist', - blocking=True) - finally: - ha.SERVICE_CALL_LIMIT = prior + with pytest.raises(ha.ServiceNotFound): + self.services.call('test_domain', 'i_do_not_exist', blocking=True) def test_async_service(self): """Test registering and calling an async service.""" @@ -1005,4 +1000,3 @@ async def handle_outer(call): assert len(calls) == 4 assert [call.service for call in calls] == [ 'outer', 'inner', 'inner', 'outer'] - assert len(hass.bus.async_listeners().get(EVENT_SERVICE_EXECUTED, [])) == 0 From 8a75bee82f1a8b46e98ddb65ba07205d9628edfa Mon Sep 17 00:00:00 2001 From: meatheadmike Date: Fri, 30 Nov 2018 14:00:26 -0700 Subject: [PATCH 170/325] bump pywemo to 0.4.33 Bump pywemo to 0.4.33 - includes expended port range fix for dimmers --- homeassistant/components/wemo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/wemo.py b/homeassistant/components/wemo.py index 93760405e0805..1d0133739c350 100644 --- a/homeassistant/components/wemo.py +++ b/homeassistant/components/wemo.py @@ -15,7 +15,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP -REQUIREMENTS = ['pywemo==0.4.29'] +REQUIREMENTS = ['pywemo==0.4.33'] DOMAIN = 'wemo' From 0754a63969e00600fb9af2af6980826116d1d001 Mon Sep 17 00:00:00 2001 From: meatheadmike Date: Fri, 30 Nov 2018 14:03:32 -0700 Subject: [PATCH 171/325] Bumped pywemo to 0.4.33 --- requirements_all.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_all.txt b/requirements_all.txt index 8609f1aeda1ff..af828e051e543 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1327,7 +1327,7 @@ pyvlx==0.1.3 pywebpush==1.6.0 # homeassistant.components.wemo -pywemo==0.4.29 +pywemo==0.4.33 # homeassistant.components.camera.xeoma pyxeoma==1.4.0 From c24ddfb1be594966a661defde93a4489620035ca Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 30 Nov 2018 21:12:55 -0700 Subject: [PATCH 172/325] Bump py17track to 2.1.1 (#18861) --- homeassistant/components/sensor/seventeentrack.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/seventeentrack.py b/homeassistant/components/sensor/seventeentrack.py index b4c869e726710..7e3f84f2d48c3 100644 --- a/homeassistant/components/sensor/seventeentrack.py +++ b/homeassistant/components/sensor/seventeentrack.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle, slugify -REQUIREMENTS = ['py17track==2.1.0'] +REQUIREMENTS = ['py17track==2.1.1'] _LOGGER = logging.getLogger(__name__) ATTR_DESTINATION_COUNTRY = 'destination_country' diff --git a/requirements_all.txt b/requirements_all.txt index 8609f1aeda1ff..249f4f30a65e5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -808,7 +808,7 @@ py-melissa-climate==2.0.0 py-synology==0.2.0 # homeassistant.components.sensor.seventeentrack -py17track==2.1.0 +py17track==2.1.1 # homeassistant.components.hdmi_cec pyCEC==0.4.13 From 3a854f4c05a1eb5bf4dfc50def82e372e324163a Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 30 Nov 2018 21:54:40 -0700 Subject: [PATCH 173/325] Fix issues with 17track.net sensor names (#18860) --- homeassistant/components/sensor/seventeentrack.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/seventeentrack.py b/homeassistant/components/sensor/seventeentrack.py index 7e3f84f2d48c3..7c5dba3b0e110 100644 --- a/homeassistant/components/sensor/seventeentrack.py +++ b/homeassistant/components/sensor/seventeentrack.py @@ -25,6 +25,7 @@ ATTR_ORIGIN_COUNTRY = 'origin_country' ATTR_PACKAGE_TYPE = 'package_type' ATTR_TRACKING_INFO_LANGUAGE = 'tracking_info_language' +ATTR_TRACKING_NUMBER = 'tracking_number' CONF_SHOW_ARCHIVED = 'show_archived' CONF_SHOW_DELIVERED = 'show_delivered' @@ -116,7 +117,7 @@ def icon(self): @property def name(self): """Return the name.""" - return 'Packages {0}'.format(self._status) + return '17track Packages {0}'.format(self._status) @property def state(self): @@ -154,8 +155,10 @@ def __init__(self, data, package): ATTR_ORIGIN_COUNTRY: package.origin_country, ATTR_PACKAGE_TYPE: package.package_type, ATTR_TRACKING_INFO_LANGUAGE: package.tracking_info_language, + ATTR_TRACKING_NUMBER: package.tracking_number, } self._data = data + self._friendly_name = package.friendly_name self._state = package.status self._tracking_number = package.tracking_number @@ -180,7 +183,10 @@ def icon(self): @property def name(self): """Return the name.""" - return self._tracking_number + name = self._friendly_name + if not name: + name = self._tracking_number + return '17track Package: {0}'.format(name) @property def state(self): From ecca51b16bd806815bfe0ddec4a09b33a2bf5e30 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Sat, 1 Dec 2018 02:28:27 -0700 Subject: [PATCH 174/325] Add tests for directv platform (#18590) * Create test for platform Created test for platform. Added media_stop to common.py test * Multiple improvements Fixed lint issue in common.py Fixed lint issues in test_directv.py Improved patching import using modile_patcher.start() and stop() Added asserts for service calls. * Updates based on Martin's review Updates based on Martin's review. * Updated test based on PR#18474 Updated test to use service play_media instead of select_source based on change from PR18474 * Lint issues Lint issues * Further updates based on feedback Updates based on feedback provided. * Using async_load_platform for discovery test Using async_load_platform for discovery tests. Added asserts to ensure entities are created with correct names. * Used HASS event_loop to setup component Use HASS event_loop to setup the component async. * Updated to use state machine for # entities Updated to use state machine to count # entities instead of entities. * Use hass.loop instead of getting current loop Small update to use hass.loop instead, thanks Martin! * Forgot to remove asyncio Removed asyncio import. * Added fixtures Added fixtures. * Remove not needed updates and assertions * Return mocked dtv instance from side_effect * Fix return correct fixture instance * Clean up assertions * Fix remaining patches * Mock time when setting up component in fixture * Patch time correctly * Attribute _last_update should return utcnow --- .../components/media_player/directv.py | 4 +- tests/components/media_player/common.py | 13 +- tests/components/media_player/test_directv.py | 535 ++++++++++++++++++ 3 files changed, 547 insertions(+), 5 deletions(-) create mode 100644 tests/components/media_player/test_directv.py diff --git a/homeassistant/components/media_player/directv.py b/homeassistant/components/media_player/directv.py index 7a1e240d82e1d..d8c67e372b2fb 100644 --- a/homeassistant/components/media_player/directv.py +++ b/homeassistant/components/media_player/directv.py @@ -162,8 +162,8 @@ def update(self): self._current['offset'] self._assumed_state = self._is_recorded self._last_position = self._current['offset'] - self._last_update = dt_util.now() if not self._paused or\ - self._last_update is None else self._last_update + self._last_update = dt_util.utcnow() if not self._paused \ + or self._last_update is None else self._last_update else: self._available = False except requests.RequestException as ex: diff --git a/tests/components/media_player/common.py b/tests/components/media_player/common.py index 3f4d4cb9f241e..2174967eae53f 100644 --- a/tests/components/media_player/common.py +++ b/tests/components/media_player/common.py @@ -11,9 +11,9 @@ from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PREVIOUS_TRACK, - SERVICE_MEDIA_SEEK, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, - SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, - SERVICE_VOLUME_UP) + SERVICE_MEDIA_SEEK, SERVICE_MEDIA_STOP, SERVICE_TOGGLE, SERVICE_TURN_OFF, + SERVICE_TURN_ON, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, SERVICE_VOLUME_UP) from homeassistant.loader import bind_hass @@ -95,6 +95,13 @@ def media_pause(hass, entity_id=None): hass.services.call(DOMAIN, SERVICE_MEDIA_PAUSE, data) +@bind_hass +def media_stop(hass, entity_id=None): + """Send the media player the command for stop.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + hass.services.call(DOMAIN, SERVICE_MEDIA_STOP, data) + + @bind_hass def media_next_track(hass, entity_id=None): """Send the media player the command for next track.""" diff --git a/tests/components/media_player/test_directv.py b/tests/components/media_player/test_directv.py new file mode 100644 index 0000000000000..951f1319cc027 --- /dev/null +++ b/tests/components/media_player/test_directv.py @@ -0,0 +1,535 @@ +"""The tests for the DirecTV Media player platform.""" +from unittest.mock import call, patch + +from datetime import datetime, timedelta +import requests +import pytest + +import homeassistant.components.media_player as mp +from homeassistant.components.media_player import ( + ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_ENQUEUE, DOMAIN, + SERVICE_PLAY_MEDIA) +from homeassistant.components.media_player.directv import ( + ATTR_MEDIA_CURRENTLY_RECORDING, ATTR_MEDIA_RATING, ATTR_MEDIA_RECORDED, + ATTR_MEDIA_START_TIME, DEFAULT_DEVICE, DEFAULT_PORT) +from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_DEVICE, CONF_HOST, CONF_NAME, CONF_PORT, + SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP, SERVICE_TURN_OFF, + SERVICE_TURN_ON, STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_UNAVAILABLE) +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from tests.common import MockDependency, async_fire_time_changed + +CLIENT_ENTITY_ID = 'media_player.client_dvr' +MAIN_ENTITY_ID = 'media_player.main_dvr' +IP_ADDRESS = '127.0.0.1' + +DISCOVERY_INFO = { + 'host': IP_ADDRESS, + 'serial': 1234 +} + +LIVE = { + "callsign": "HASSTV", + "date": "20181110", + "duration": 3600, + "isOffAir": False, + "isPclocked": 1, + "isPpv": False, + "isRecording": False, + "isVod": False, + "major": 202, + "minor": 65535, + "offset": 1, + "programId": "102454523", + "rating": "No Rating", + "startTime": 1541876400, + "stationId": 3900947, + "title": "Using Home Assistant to automate your home" +} + +LOCATIONS = [ + { + 'locationName': 'Main DVR', + 'clientAddr': DEFAULT_DEVICE + } +] + +RECORDING = { + "callsign": "HASSTV", + "date": "20181110", + "duration": 3600, + "isOffAir": False, + "isPclocked": 1, + "isPpv": False, + "isRecording": True, + "isVod": False, + "major": 202, + "minor": 65535, + "offset": 1, + "programId": "102454523", + "rating": "No Rating", + "startTime": 1541876400, + "stationId": 3900947, + "title": "Using Home Assistant to automate your home", + 'uniqueId': '12345', + 'episodeTitle': 'Configure DirecTV platform.' +} + +WORKING_CONFIG = { + 'media_player': { + 'platform': 'directv', + CONF_HOST: IP_ADDRESS, + CONF_NAME: 'Main DVR', + CONF_PORT: DEFAULT_PORT, + CONF_DEVICE: DEFAULT_DEVICE + } +} + + +@pytest.fixture +def client_dtv(): + """Fixture for a client device.""" + mocked_dtv = MockDirectvClass('mock_ip') + mocked_dtv.attributes = RECORDING + mocked_dtv._standby = False + return mocked_dtv + + +@pytest.fixture +def main_dtv(): + """Fixture for main DVR.""" + return MockDirectvClass('mock_ip') + + +@pytest.fixture +def dtv_side_effect(client_dtv, main_dtv): + """Fixture to create DIRECTV instance for main and client.""" + def mock_dtv(ip, port, client_addr): + if client_addr != '0': + mocked_dtv = client_dtv + else: + mocked_dtv = main_dtv + mocked_dtv._host = ip + mocked_dtv._port = port + mocked_dtv._device = client_addr + return mocked_dtv + return mock_dtv + + +@pytest.fixture +def mock_now(): + """Fixture for dtutil.now.""" + return dt_util.utcnow() + + +@pytest.fixture +def platforms(hass, dtv_side_effect, mock_now): + """Fixture for setting up test platforms.""" + config = { + 'media_player': [{ + 'platform': 'directv', + 'name': 'Main DVR', + 'host': IP_ADDRESS, + 'port': DEFAULT_PORT, + 'device': DEFAULT_DEVICE + }, { + 'platform': 'directv', + 'name': 'Client DVR', + 'host': IP_ADDRESS, + 'port': DEFAULT_PORT, + 'device': '1' + }] + } + + with MockDependency('DirectPy'), \ + patch('DirectPy.DIRECTV', side_effect=dtv_side_effect), \ + patch('homeassistant.util.dt.utcnow', return_value=mock_now): + hass.loop.run_until_complete(async_setup_component( + hass, mp.DOMAIN, config)) + hass.loop.run_until_complete(hass.async_block_till_done()) + yield + + +async def async_turn_on(hass, entity_id=None): + """Turn on specified media player or all.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data) + + +async def async_turn_off(hass, entity_id=None): + """Turn off specified media player or all.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + await hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, data) + + +async def async_media_pause(hass, entity_id=None): + """Send the media player the command for pause.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + await hass.services.async_call(DOMAIN, SERVICE_MEDIA_PAUSE, data) + + +async def async_media_play(hass, entity_id=None): + """Send the media player the command for play/pause.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + await hass.services.async_call(DOMAIN, SERVICE_MEDIA_PLAY, data) + + +async def async_media_stop(hass, entity_id=None): + """Send the media player the command for stop.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + await hass.services.async_call(DOMAIN, SERVICE_MEDIA_STOP, data) + + +async def async_media_next_track(hass, entity_id=None): + """Send the media player the command for next track.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + await hass.services.async_call(DOMAIN, SERVICE_MEDIA_NEXT_TRACK, data) + + +async def async_media_previous_track(hass, entity_id=None): + """Send the media player the command for prev track.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + await hass.services.async_call(DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, data) + + +async def async_play_media(hass, media_type, media_id, entity_id=None, + enqueue=None): + """Send the media player the command for playing media.""" + data = {ATTR_MEDIA_CONTENT_TYPE: media_type, + ATTR_MEDIA_CONTENT_ID: media_id} + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + if enqueue: + data[ATTR_MEDIA_ENQUEUE] = enqueue + + await hass.services.async_call(DOMAIN, SERVICE_PLAY_MEDIA, data) + + +class MockDirectvClass: + """A fake DirecTV DVR device.""" + + def __init__(self, ip, port=8080, clientAddr='0'): + """Initialize the fake DirecTV device.""" + self._host = ip + self._port = port + self._device = clientAddr + self._standby = True + self._play = False + + self._locations = LOCATIONS + + self.attributes = LIVE + + def get_locations(self): + """Mock for get_locations method.""" + test_locations = { + 'locations': self._locations, + 'status': { + 'code': 200, + 'commandResult': 0, + 'msg': 'OK.', + 'query': '/info/getLocations' + } + } + + return test_locations + + def get_standby(self): + """Mock for get_standby method.""" + return self._standby + + def get_tuned(self): + """Mock for get_tuned method.""" + if self._play: + self.attributes['offset'] = self.attributes['offset']+1 + + test_attributes = self.attributes + test_attributes['status'] = { + "code": 200, + "commandResult": 0, + "msg": "OK.", + "query": "/tv/getTuned" + } + return test_attributes + + def key_press(self, keypress): + """Mock for key_press method.""" + if keypress == 'poweron': + self._standby = False + self._play = True + elif keypress == 'poweroff': + self._standby = True + self._play = False + elif keypress == 'play': + self._play = True + elif keypress == 'pause' or keypress == 'stop': + self._play = False + + def tune_channel(self, source): + """Mock for tune_channel method.""" + self.attributes['major'] = int(source) + + +async def test_setup_platform_config(hass): + """Test setting up the platform from configuration.""" + with MockDependency('DirectPy'), \ + patch('DirectPy.DIRECTV', new=MockDirectvClass): + + await async_setup_component(hass, mp.DOMAIN, WORKING_CONFIG) + await hass.async_block_till_done() + + state = hass.states.get(MAIN_ENTITY_ID) + assert state + assert len(hass.states.async_entity_ids('media_player')) == 1 + + +async def test_setup_platform_discover(hass): + """Test setting up the platform from discovery.""" + with MockDependency('DirectPy'), \ + patch('DirectPy.DIRECTV', new=MockDirectvClass): + + hass.async_create_task( + async_load_platform(hass, mp.DOMAIN, 'directv', DISCOVERY_INFO, + {'media_player': {}}) + ) + await hass.async_block_till_done() + + state = hass.states.get(MAIN_ENTITY_ID) + assert state + assert len(hass.states.async_entity_ids('media_player')) == 1 + + +async def test_setup_platform_discover_duplicate(hass): + """Test setting up the platform from discovery.""" + with MockDependency('DirectPy'), \ + patch('DirectPy.DIRECTV', new=MockDirectvClass): + + await async_setup_component(hass, mp.DOMAIN, WORKING_CONFIG) + await hass.async_block_till_done() + hass.async_create_task( + async_load_platform(hass, mp.DOMAIN, 'directv', DISCOVERY_INFO, + {'media_player': {}}) + ) + await hass.async_block_till_done() + + state = hass.states.get(MAIN_ENTITY_ID) + assert state + assert len(hass.states.async_entity_ids('media_player')) == 1 + + +async def test_setup_platform_discover_client(hass): + """Test setting up the platform from discovery.""" + LOCATIONS.append({ + 'locationName': 'Client 1', + 'clientAddr': '1' + }) + LOCATIONS.append({ + 'locationName': 'Client 2', + 'clientAddr': '2' + }) + + with MockDependency('DirectPy'), \ + patch('DirectPy.DIRECTV', new=MockDirectvClass): + + await async_setup_component(hass, mp.DOMAIN, WORKING_CONFIG) + await hass.async_block_till_done() + + hass.async_create_task( + async_load_platform(hass, mp.DOMAIN, 'directv', DISCOVERY_INFO, + {'media_player': {}}) + ) + await hass.async_block_till_done() + + del LOCATIONS[-1] + del LOCATIONS[-1] + state = hass.states.get(MAIN_ENTITY_ID) + assert state + state = hass.states.get('media_player.client_1') + assert state + state = hass.states.get('media_player.client_2') + assert state + + assert len(hass.states.async_entity_ids('media_player')) == 3 + + +async def test_supported_features(hass, platforms): + """Test supported features.""" + # Features supported for main DVR + state = hass.states.get(MAIN_ENTITY_ID) + assert mp.SUPPORT_PAUSE | mp.SUPPORT_TURN_ON | mp.SUPPORT_TURN_OFF |\ + mp.SUPPORT_PLAY_MEDIA | mp.SUPPORT_STOP | mp.SUPPORT_NEXT_TRACK |\ + mp.SUPPORT_PREVIOUS_TRACK | mp.SUPPORT_PLAY ==\ + state.attributes.get('supported_features') + + # Feature supported for clients. + state = hass.states.get(CLIENT_ENTITY_ID) + assert mp.SUPPORT_PAUSE |\ + mp.SUPPORT_PLAY_MEDIA | mp.SUPPORT_STOP | mp.SUPPORT_NEXT_TRACK |\ + mp.SUPPORT_PREVIOUS_TRACK | mp.SUPPORT_PLAY ==\ + state.attributes.get('supported_features') + + +async def test_check_attributes(hass, platforms, mock_now): + """Test attributes.""" + next_update = mock_now + timedelta(minutes=5) + with patch('homeassistant.util.dt.utcnow', return_value=next_update): + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + # Start playing TV + with patch('homeassistant.util.dt.utcnow', + return_value=next_update): + await async_media_play(hass, CLIENT_ENTITY_ID) + await hass.async_block_till_done() + + state = hass.states.get(CLIENT_ENTITY_ID) + assert state.state == STATE_PLAYING + + assert state.attributes.get(mp.ATTR_MEDIA_CONTENT_ID) == \ + RECORDING['programId'] + assert state.attributes.get(mp.ATTR_MEDIA_CONTENT_TYPE) == \ + mp.MEDIA_TYPE_TVSHOW + assert state.attributes.get(mp.ATTR_MEDIA_DURATION) == \ + RECORDING['duration'] + assert state.attributes.get(mp.ATTR_MEDIA_POSITION) == 2 + assert state.attributes.get( + mp.ATTR_MEDIA_POSITION_UPDATED_AT) == next_update + assert state.attributes.get(mp.ATTR_MEDIA_TITLE) == RECORDING['title'] + assert state.attributes.get(mp.ATTR_MEDIA_SERIES_TITLE) == \ + RECORDING['episodeTitle'] + assert state.attributes.get(mp.ATTR_MEDIA_CHANNEL) == \ + "{} ({})".format(RECORDING['callsign'], RECORDING['major']) + assert state.attributes.get(mp.ATTR_INPUT_SOURCE) == RECORDING['major'] + assert state.attributes.get(ATTR_MEDIA_CURRENTLY_RECORDING) == \ + RECORDING['isRecording'] + assert state.attributes.get(ATTR_MEDIA_RATING) == RECORDING['rating'] + assert state.attributes.get(ATTR_MEDIA_RECORDED) + assert state.attributes.get(ATTR_MEDIA_START_TIME) == \ + datetime(2018, 11, 10, 19, 0, tzinfo=dt_util.UTC) + + # Test to make sure that ATTR_MEDIA_POSITION_UPDATED_AT is not + # updated if TV is paused. + with patch('homeassistant.util.dt.utcnow', + return_value=next_update + timedelta(minutes=5)): + await async_media_pause(hass, CLIENT_ENTITY_ID) + await hass.async_block_till_done() + + state = hass.states.get(CLIENT_ENTITY_ID) + assert state.state == STATE_PAUSED + assert state.attributes.get( + mp.ATTR_MEDIA_POSITION_UPDATED_AT) == next_update + + +async def test_main_services(hass, platforms, main_dtv, mock_now): + """Test the different services.""" + next_update = mock_now + timedelta(minutes=5) + with patch('homeassistant.util.dt.utcnow', return_value=next_update): + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + # DVR starts in off state. + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state == STATE_OFF + + # All these should call key_press in our class. + with patch.object(main_dtv, 'key_press', + wraps=main_dtv.key_press) as mock_key_press, \ + patch.object(main_dtv, 'tune_channel', + wraps=main_dtv.tune_channel) as mock_tune_channel, \ + patch.object(main_dtv, 'get_tuned', + wraps=main_dtv.get_tuned) as mock_get_tuned, \ + patch.object(main_dtv, 'get_standby', + wraps=main_dtv.get_standby) as mock_get_standby: + + # Turn main DVR on. When turning on DVR is playing. + await async_turn_on(hass, MAIN_ENTITY_ID) + await hass.async_block_till_done() + assert mock_key_press.called + assert mock_key_press.call_args == call('poweron') + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state == STATE_PLAYING + + # Pause live TV. + await async_media_pause(hass, MAIN_ENTITY_ID) + await hass.async_block_till_done() + assert mock_key_press.called + assert mock_key_press.call_args == call('pause') + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state == STATE_PAUSED + + # Start play again for live TV. + await async_media_play(hass, MAIN_ENTITY_ID) + await hass.async_block_till_done() + assert mock_key_press.called + assert mock_key_press.call_args == call('play') + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state == STATE_PLAYING + + # Change channel, currently it should be 202 + assert state.attributes.get('source') == 202 + await async_play_media(hass, 'channel', 7, MAIN_ENTITY_ID) + await hass.async_block_till_done() + assert mock_tune_channel.called + assert mock_tune_channel.call_args == call('7') + state = hass.states.get(MAIN_ENTITY_ID) + assert state.attributes.get('source') == 7 + + # Stop live TV. + await async_media_stop(hass, MAIN_ENTITY_ID) + await hass.async_block_till_done() + assert mock_key_press.called + assert mock_key_press.call_args == call('stop') + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state == STATE_PAUSED + + # Turn main DVR off. + await async_turn_off(hass, MAIN_ENTITY_ID) + await hass.async_block_till_done() + assert mock_key_press.called + assert mock_key_press.call_args == call('poweroff') + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state == STATE_OFF + + # There should have been 6 calls to check if DVR is in standby + assert main_dtv.get_standby.call_count == 6 + assert mock_get_standby.call_count == 6 + # There should be 5 calls to get current info (only 1 time it will + # not be called as DVR is in standby.) + assert main_dtv.get_tuned.call_count == 5 + assert mock_get_tuned.call_count == 5 + + +async def test_available(hass, platforms, main_dtv, mock_now): + """Test available status.""" + next_update = mock_now + timedelta(minutes=5) + with patch('homeassistant.util.dt.utcnow', return_value=next_update): + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + # Confirm service is currently set to available. + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state != STATE_UNAVAILABLE + + # Make update fail (i.e. DVR offline) + next_update = next_update + timedelta(minutes=5) + with patch.object( + main_dtv, 'get_standby', side_effect=requests.RequestException), \ + patch('homeassistant.util.dt.utcnow', return_value=next_update): + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state == STATE_UNAVAILABLE + + # Recheck state, update should work again. + next_update = next_update + timedelta(minutes=5) + with patch('homeassistant.util.dt.utcnow', return_value=next_update): + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state != STATE_UNAVAILABLE From 1ae58ce48b81565745dec960d80185968c01715e Mon Sep 17 00:00:00 2001 From: damarco Date: Sat, 1 Dec 2018 10:31:49 +0100 Subject: [PATCH 175/325] Add support for zha device registry (#18755) --- homeassistant/components/zha/__init__.py | 14 ++++++++++++++ homeassistant/components/zha/entities/entity.py | 17 +++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 0fc2b978fbbe6..d67fbd02b8f7e 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -15,6 +15,7 @@ from homeassistant.components.zha.entities import ZhaDeviceEntity from homeassistant import config_entries, const as ha_const from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from . import const as zha_const # Loading the config flow file will register the flow @@ -116,10 +117,12 @@ async def async_setup_entry(hass, config_entry): import bellows.ezsp from bellows.zigbee.application import ControllerApplication radio = bellows.ezsp.EZSP() + radio_description = "EZSP" elif radio_type == RadioType.xbee.name: import zigpy_xbee.api from zigpy_xbee.zigbee.application import ControllerApplication radio = zigpy_xbee.api.XBee() + radio_description = "XBee" await radio.connect(usb_path, baudrate) hass.data[DATA_ZHA][DATA_ZHA_RADIO] = radio @@ -137,6 +140,17 @@ async def async_setup_entry(hass, config_entry): hass.async_create_task( listener.async_device_initialized(device, False)) + device_registry = await \ + hass.helpers.device_registry.async_get_registry() + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(CONNECTION_ZIGBEE, str(APPLICATION_CONTROLLER.ieee))}, + identifiers={(DOMAIN, str(APPLICATION_CONTROLLER.ieee))}, + name="Zigbee Coordinator", + manufacturer="ZHA", + model=radio_description, + ) + hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(APPLICATION_CONTROLLER.ieee) for component in COMPONENTS: diff --git a/homeassistant/components/zha/entities/entity.py b/homeassistant/components/zha/entities/entity.py index a16f29f447a48..a4454244364e7 100644 --- a/homeassistant/components/zha/entities/entity.py +++ b/homeassistant/components/zha/entities/entity.py @@ -7,6 +7,10 @@ from homeassistant.helpers import entity from homeassistant.util import slugify from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE +from homeassistant.components.zha.const import ( + DOMAIN, DATA_ZHA, DATA_ZHA_BRIDGE_ID +) class ZhaEntity(entity.Entity): @@ -87,3 +91,16 @@ def attribute_updated(self, attribute, value): def zdo_command(self, tsn, command_id, args): """Handle a ZDO command received on this cluster.""" pass + + @property + def device_info(self): + """Return a device description for device registry.""" + ieee = str(self._endpoint.device.ieee) + return { + 'connections': {(CONNECTION_ZIGBEE, ieee)}, + 'identifiers': {(DOMAIN, ieee)}, + 'manufacturer': self._endpoint.manufacturer, + 'model': self._endpoint.model, + 'name': self._device_state_attributes['friendly_name'], + 'via_hub': (DOMAIN, self.hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID]), + } From c23792d1fb1c6bb35e24239a0bb1c92bbf6c2cd5 Mon Sep 17 00:00:00 2001 From: Mahasri Kalavala Date: Sat, 1 Dec 2018 04:38:10 -0500 Subject: [PATCH 176/325] Added new filters for templates (#18125) * added additional filters Added base64_encode, base64_decode and ordinal filters. * added test cases added test cases for base64_encode, base64_decode and ordinal filters. * forgot to add filters :) --- homeassistant/helpers/template.py | 21 +++++++++++++++++++++ tests/helpers/test_template.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 66f289724befe..6d6fb1ed2000a 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -4,6 +4,7 @@ import logging import math import random +import base64 import re import jinja2 @@ -602,6 +603,23 @@ def bitwise_or(first_value, second_value): return first_value | second_value +def base64_encode(value): + """Perform base64 encode.""" + return base64.b64encode(value.encode('utf-8')).decode('utf-8') + + +def base64_decode(value): + """Perform base64 denode.""" + return base64.b64decode(value).decode('utf-8') + + +def ordinal(value): + """Perform ordinal conversion.""" + return str(value) + (list(['th', 'st', 'nd', 'rd'] + ['th'] * 6) + [(int(str(value)[-1])) % 10] if not + int(str(value)[-2:]) % 100 in range(11, 14) else 'th') + + @contextfilter def random_every_time(context, values): """Choose a random value. @@ -640,6 +658,9 @@ def is_safe_attribute(self, obj, attr, value): ENV.filters['max'] = max ENV.filters['min'] = min ENV.filters['random'] = random_every_time +ENV.filters['base64_encode'] = base64_encode +ENV.filters['base64_decode'] = base64_decode +ENV.filters['ordinal'] = ordinal ENV.filters['regex_match'] = regex_match ENV.filters['regex_replace'] = regex_replace ENV.filters['regex_search'] = regex_search diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 573a9f78b72f7..02331c400d367 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -274,6 +274,37 @@ def test_max(self): template.Template('{{ [1, 2, 3] | max }}', self.hass).render() + def test_base64_encode(self): + """Test the base64_encode filter.""" + self.assertEqual( + 'aG9tZWFzc2lzdGFudA==', + template.Template('{{ "homeassistant" | base64_encode }}', + self.hass).render()) + + def test_base64_decode(self): + """Test the base64_decode filter.""" + self.assertEqual( + 'homeassistant', + template.Template('{{ "aG9tZWFzc2lzdGFudA==" | base64_decode }}', + self.hass).render()) + + def test_ordinal(self): + """Test the ordinal filter.""" + tests = [ + (1, '1st'), + (2, '2nd'), + (3, '3rd'), + (4, '4th'), + (5, '5th'), + ] + + for value, expected in tests: + self.assertEqual( + expected, + template.Template( + '{{ %s | ordinal }}' % value, + self.hass).render()) + def test_timestamp_utc(self): """Test the timestamps to local filter.""" now = dt_util.utcnow() From 29f15393b13cea315a6aa2bb29c371d18ef87c52 Mon Sep 17 00:00:00 2001 From: Carlos Gustavo Sarmiento Date: Sat, 1 Dec 2018 02:58:59 -0800 Subject: [PATCH 177/325] Updated UVC camera component to support SSL connections (#18829) --- homeassistant/components/camera/uvc.py | 9 ++++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/camera/test_uvc.py | 21 ++++++++++++++++++--- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/camera/uvc.py b/homeassistant/components/camera/uvc.py index 0e65ac77c1fa0..50e7c3d8fe293 100644 --- a/homeassistant/components/camera/uvc.py +++ b/homeassistant/components/camera/uvc.py @@ -10,12 +10,12 @@ import requests import voluptuous as vol -from homeassistant.const import CONF_PORT +from homeassistant.const import CONF_PORT, CONF_SSL from homeassistant.components.camera import Camera, PLATFORM_SCHEMA import homeassistant.helpers.config_validation as cv from homeassistant.exceptions import PlatformNotReady -REQUIREMENTS = ['uvcclient==0.10.1'] +REQUIREMENTS = ['uvcclient==0.11.0'] _LOGGER = logging.getLogger(__name__) @@ -25,12 +25,14 @@ DEFAULT_PASSWORD = 'ubnt' DEFAULT_PORT = 7080 +DEFAULT_SSL = False PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_NVR): cv.string, vol.Required(CONF_KEY): cv.string, vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, }) @@ -40,11 +42,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): key = config[CONF_KEY] password = config[CONF_PASSWORD] port = config[CONF_PORT] + ssl = config[CONF_SSL] from uvcclient import nvr try: # Exceptions may be raised in all method calls to the nvr library. - nvrconn = nvr.UVCRemote(addr, port, key) + nvrconn = nvr.UVCRemote(addr, port, key, ssl=ssl) cameras = nvrconn.index() identifier = 'id' if nvrconn.server_version >= (3, 2, 0) else 'uuid' diff --git a/requirements_all.txt b/requirements_all.txt index 249f4f30a65e5..40cc0d39c43af 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1575,7 +1575,7 @@ upsmychoice==1.0.6 uscisstatus==0.1.1 # homeassistant.components.camera.uvc -uvcclient==0.10.1 +uvcclient==0.11.0 # homeassistant.components.climate.venstar venstarcolortouch==0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e7fed2cb68672..935757b37d77e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -255,7 +255,7 @@ srpenergy==1.0.5 statsd==3.2.1 # homeassistant.components.camera.uvc -uvcclient==0.10.1 +uvcclient==0.11.0 # homeassistant.components.vultr vultr==0.1.2 diff --git a/tests/components/camera/test_uvc.py b/tests/components/camera/test_uvc.py index b41cb9f865bb2..476e612eb0627 100644 --- a/tests/components/camera/test_uvc.py +++ b/tests/components/camera/test_uvc.py @@ -55,7 +55,12 @@ def mock_get_camera(uuid): assert setup_component(self.hass, 'camera', {'camera': config}) assert mock_remote.call_count == 1 - assert mock_remote.call_args == mock.call('foo', 123, 'secret') + assert mock_remote.call_args == mock.call( + 'foo', + 123, + 'secret', + ssl=False + ) mock_uvc.assert_has_calls([ mock.call(mock_remote.return_value, 'id1', 'Front', 'bar'), mock.call(mock_remote.return_value, 'id2', 'Back', 'bar'), @@ -81,7 +86,12 @@ def test_setup_partial_config(self, mock_uvc, mock_remote): assert setup_component(self.hass, 'camera', {'camera': config}) assert mock_remote.call_count == 1 - assert mock_remote.call_args == mock.call('foo', 7080, 'secret') + assert mock_remote.call_args == mock.call( + 'foo', + 7080, + 'secret', + ssl=False + ) mock_uvc.assert_has_calls([ mock.call(mock_remote.return_value, 'id1', 'Front', 'ubnt'), mock.call(mock_remote.return_value, 'id2', 'Back', 'ubnt'), @@ -107,7 +117,12 @@ def test_setup_partial_config_v31x(self, mock_uvc, mock_remote): assert setup_component(self.hass, 'camera', {'camera': config}) assert mock_remote.call_count == 1 - assert mock_remote.call_args == mock.call('foo', 7080, 'secret') + assert mock_remote.call_args == mock.call( + 'foo', + 7080, + 'secret', + ssl=False + ) mock_uvc.assert_has_calls([ mock.call(mock_remote.return_value, 'one', 'Front', 'ubnt'), mock.call(mock_remote.return_value, 'two', 'Back', 'ubnt'), From c69fe43e756f773f54561dd30664d9d44e2477ce Mon Sep 17 00:00:00 2001 From: Eliseo Martelli Date: Sat, 1 Dec 2018 12:00:35 +0100 Subject: [PATCH 178/325] fixed state case for rtorrent (#18778) --- homeassistant/components/sensor/rtorrent.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/rtorrent.py b/homeassistant/components/sensor/rtorrent.py index 7822bcd58b798..8ec6a45b639ca 100644 --- a/homeassistant/components/sensor/rtorrent.py +++ b/homeassistant/components/sensor/rtorrent.py @@ -110,11 +110,11 @@ def update(self): if self.type == SENSOR_TYPE_CURRENT_STATUS: if self.data: if upload > 0 and download > 0: - self._state = 'Up/Down' + self._state = 'up_down' elif upload > 0 and download == 0: - self._state = 'Seeding' + self._state = 'seeding' elif upload == 0 and download > 0: - self._state = 'Downloading' + self._state = 'downloading' else: self._state = STATE_IDLE else: From 558504c686e755183dc868db84e366272b15f063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sat, 1 Dec 2018 14:49:34 +0100 Subject: [PATCH 179/325] Fix ordinal filter in template (#18878) --- homeassistant/helpers/template.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 6d6fb1ed2000a..99eb0a9c0345c 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -616,8 +616,9 @@ def base64_decode(value): def ordinal(value): """Perform ordinal conversion.""" return str(value) + (list(['th', 'st', 'nd', 'rd'] + ['th'] * 6) - [(int(str(value)[-1])) % 10] if not - int(str(value)[-2:]) % 100 in range(11, 14) else 'th') + [(int(str(value)[-1])) % 10] if + int(str(value)[-2:]) % 100 not in range(11, 14) + else 'th') @contextfilter From d8b9bee7fb0d8cb811e3d4d0044161804e248a85 Mon Sep 17 00:00:00 2001 From: ludeeus Date: Sat, 1 Dec 2018 13:02:15 +0100 Subject: [PATCH 180/325] Fix IndexError for home stats --- homeassistant/components/sensor/tautulli.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/tautulli.py b/homeassistant/components/sensor/tautulli.py index 7b0d8e491d227..419ef6a11a12d 100644 --- a/homeassistant/components/sensor/tautulli.py +++ b/homeassistant/components/sensor/tautulli.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['pytautulli==0.4.0'] +REQUIREMENTS = ['pytautulli==0.4.1'] _LOGGER = logging.getLogger(__name__) @@ -90,9 +90,9 @@ async def async_update(self): await self.tautulli.async_update() self.home = self.tautulli.api.home_data self.sessions = self.tautulli.api.session_data - self._attributes['Top Movie'] = self.home[0]['rows'][0]['title'] - self._attributes['Top TV Show'] = self.home[3]['rows'][0]['title'] - self._attributes['Top User'] = self.home[7]['rows'][0]['user'] + self._attributes['Top Movie'] = self.home['movie'] + self._attributes['Top TV Show'] = self.home['tv'] + self._attributes['Top User'] = self.home['user'] for key in self.sessions: if 'sessions' not in key: self._attributes[key] = self.sessions[key] From 89bd6fa4949a38ffc7cd9e4ddbe9e96af9fd840d Mon Sep 17 00:00:00 2001 From: ludeeus Date: Sat, 1 Dec 2018 13:07:32 +0100 Subject: [PATCH 181/325] Fix requirements_all --- requirements_all.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_all.txt b/requirements_all.txt index 40cc0d39c43af..0a4f41a09e0fc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1158,7 +1158,7 @@ pystride==0.1.7 pysyncthru==0.3.1 # homeassistant.components.sensor.tautulli -pytautulli==0.4.0 +pytautulli==0.4.1 # homeassistant.components.media_player.liveboxplaytv pyteleloisirs==3.4 From 934eccfeee4fb46934dff10b3af30a7fd2728202 Mon Sep 17 00:00:00 2001 From: ludeeus Date: Sat, 1 Dec 2018 11:24:32 +0100 Subject: [PATCH 182/325] Fix stability issues with multiple units --- homeassistant/components/device_tracker/googlehome.py | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/device_tracker/googlehome.py b/homeassistant/components/device_tracker/googlehome.py index 575d9688493b2..e700301d5798b 100644 --- a/homeassistant/components/device_tracker/googlehome.py +++ b/homeassistant/components/device_tracker/googlehome.py @@ -14,7 +14,7 @@ DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST -REQUIREMENTS = ['ghlocalapi==0.1.0'] +REQUIREMENTS = ['ghlocalapi==0.3.4'] _LOGGER = logging.getLogger(__name__) @@ -77,8 +77,8 @@ async def get_extra_attributes(self, device): async def async_update_info(self): """Ensure the information from Google Home is up to date.""" _LOGGER.debug('Checking Devices...') - await self.scanner.scan_for_devices() await self.scanner.get_scan_result() + await self.scanner.scan_for_devices() ghname = self.deviceinfo.device_info['name'] devices = {} for device in self.scanner.devices: diff --git a/requirements_all.txt b/requirements_all.txt index 40cc0d39c43af..53818800a69b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -426,7 +426,7 @@ geojson_client==0.3 georss_client==0.4 # homeassistant.components.device_tracker.googlehome -ghlocalapi==0.1.0 +ghlocalapi==0.3.4 # homeassistant.components.sensor.gitter gitterpy==0.1.7 From 8e84401b68d0ed7caf86e414e5cf8b4e1da99144 Mon Sep 17 00:00:00 2001 From: ludeeus Date: Sat, 1 Dec 2018 16:28:22 +0100 Subject: [PATCH 183/325] bump ghlocalapi to use clear_scan_result --- homeassistant/components/device_tracker/googlehome.py | 5 +++-- requirements_all.txt | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/device_tracker/googlehome.py b/homeassistant/components/device_tracker/googlehome.py index e700301d5798b..dabb92a075114 100644 --- a/homeassistant/components/device_tracker/googlehome.py +++ b/homeassistant/components/device_tracker/googlehome.py @@ -14,7 +14,7 @@ DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST -REQUIREMENTS = ['ghlocalapi==0.3.4'] +REQUIREMENTS = ['ghlocalapi==0.3.5'] _LOGGER = logging.getLogger(__name__) @@ -77,8 +77,8 @@ async def get_extra_attributes(self, device): async def async_update_info(self): """Ensure the information from Google Home is up to date.""" _LOGGER.debug('Checking Devices...') - await self.scanner.get_scan_result() await self.scanner.scan_for_devices() + await self.scanner.get_scan_result() ghname = self.deviceinfo.device_info['name'] devices = {} for device in self.scanner.devices: @@ -89,4 +89,5 @@ async def async_update_info(self): devices[uuid]['btle_mac_address'] = device['mac_address'] devices[uuid]['ghname'] = ghname devices[uuid]['source_type'] = 'bluetooth' + await self.scanner.clear_scan_result() self.last_results = devices diff --git a/requirements_all.txt b/requirements_all.txt index 53818800a69b6..d7ecd986c5270 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -426,7 +426,7 @@ geojson_client==0.3 georss_client==0.4 # homeassistant.components.device_tracker.googlehome -ghlocalapi==0.3.4 +ghlocalapi==0.3.5 # homeassistant.components.sensor.gitter gitterpy==0.1.7 From bd09e9668192c958a2c87a19788818afab08af70 Mon Sep 17 00:00:00 2001 From: Michael Nosthoff Date: Sat, 1 Dec 2018 18:00:49 +0100 Subject: [PATCH 184/325] Reintroduce unique_id for Netatmo sensor (#18774) * netatmo: make module type identification more consistent For the interpretation of voltage values the different types of netatmo modules need to be distinguished. This is currently done by selecting the second character of the modules '_id'. The _id-field actually contains a mac address. This is an undocumented way of identifying the module_type. The netatmo API also delivers a field called 'type' which provides a more consistent way to differentiate the fields. This commit introduces a differentiation which uses this provided type. This should improve readability. Also the field module_id is renamed to module_type which should better resemble what it actually represents. * netatmo: reintroduce unique_id using actual module mac address Each netatmo module features a unique MAC-Address. The base station uses an actual assigned MAC Address it also uses on the Wifi it connects to. All other modules have unique MAC Addresses which are only assigned and used by Netatmo on the internal Wireless-Network. All theses Addresses are exposed via the API. So we could use the combination MAC-Address-Sensor_type as unique_id. In a previous commit this had already been tried but there was a misunderstanding in what the 'module_id' represented. It was actually only a module_type representation so it clashed when two modules of the same type where used. * Netatmo: fixed line length --- homeassistant/components/sensor/netatmo.py | 32 ++++++++++++++++------ 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sensor/netatmo.py b/homeassistant/components/sensor/netatmo.py index 2abaa801d6890..7590bccb5431a 100644 --- a/homeassistant/components/sensor/netatmo.py +++ b/homeassistant/components/sensor/netatmo.py @@ -63,6 +63,11 @@ vol.Optional(CONF_MODULES): MODULE_SCHEMA, }) +MODULE_TYPE_OUTDOOR = 'NAModule1' +MODULE_TYPE_WIND = 'NAModule2' +MODULE_TYPE_RAIN = 'NAModule3' +MODULE_TYPE_INDOOR = 'NAModule4' + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the available Netatmo weather sensors.""" @@ -74,7 +79,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): try: if CONF_MODULES in config: # Iterate each module - for module_name, monitored_conditions in\ + for module_name, monitored_conditions in \ config[CONF_MODULES].items(): # Test if module exists if module_name not in data.get_module_names(): @@ -85,7 +90,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): dev.append(NetAtmoSensor(data, module_name, variable)) else: for module_name in data.get_module_names(): - for variable in\ + for variable in \ data.station_data.monitoredConditions(module_name): if variable in SENSOR_TYPES.keys(): dev.append(NetAtmoSensor(data, module_name, variable)) @@ -112,9 +117,11 @@ def __init__(self, netatmo_data, module_name, sensor_type): self._device_class = SENSOR_TYPES[self.type][3] self._icon = SENSOR_TYPES[self.type][2] self._unit_of_measurement = SENSOR_TYPES[self.type][1] - module_id = self.netatmo_data.\ + self._module_type = self.netatmo_data. \ + station_data.moduleByName(module=module_name)['type'] + module_id = self.netatmo_data. \ station_data.moduleByName(module=module_name)['_id'] - self.module_id = module_id[1] + self._unique_id = '{}-{}'.format(module_id, self.type) @property def name(self): @@ -141,6 +148,11 @@ def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement + @property + def unique_id(self): + """Return the unique ID for this sensor.""" + return self._unique_id + def update(self): """Get the latest data from NetAtmo API and updates the states.""" self.netatmo_data.update() @@ -169,7 +181,8 @@ def update(self): self._state = round(data['Pressure'], 1) elif self.type == 'battery_lvl': self._state = data['battery_vp'] - elif self.type == 'battery_vp' and self.module_id == '6': + elif (self.type == 'battery_vp' and + self._module_type == MODULE_TYPE_WIND): if data['battery_vp'] >= 5590: self._state = "Full" elif data['battery_vp'] >= 5180: @@ -180,7 +193,8 @@ def update(self): self._state = "Low" elif data['battery_vp'] < 4360: self._state = "Very Low" - elif self.type == 'battery_vp' and self.module_id == '5': + elif (self.type == 'battery_vp' and + self._module_type == MODULE_TYPE_RAIN): if data['battery_vp'] >= 5500: self._state = "Full" elif data['battery_vp'] >= 5000: @@ -191,7 +205,8 @@ def update(self): self._state = "Low" elif data['battery_vp'] < 4000: self._state = "Very Low" - elif self.type == 'battery_vp' and self.module_id == '3': + elif (self.type == 'battery_vp' and + self._module_type == MODULE_TYPE_INDOOR): if data['battery_vp'] >= 5640: self._state = "Full" elif data['battery_vp'] >= 5280: @@ -202,7 +217,8 @@ def update(self): self._state = "Low" elif data['battery_vp'] < 4560: self._state = "Very Low" - elif self.type == 'battery_vp' and self.module_id == '2': + elif (self.type == 'battery_vp' and + self._module_type == MODULE_TYPE_OUTDOOR): if data['battery_vp'] >= 5500: self._state = "Full" elif data['battery_vp'] >= 5000: From 54904fb6c06d55796566085a5b928fe2cdcf2bf5 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 1 Dec 2018 19:27:21 +0100 Subject: [PATCH 185/325] Use string formatting --- .../components/binary_sensor/sense.py | 80 ++++++++++--------- homeassistant/components/sense.py | 14 ++-- homeassistant/components/sensor/sense.py | 47 +++++------ 3 files changed, 72 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/binary_sensor/sense.py b/homeassistant/components/binary_sensor/sense.py index 1f83bffdcb62e..a85a0c889d17a 100644 --- a/homeassistant/components/binary_sensor/sense.py +++ b/homeassistant/components/binary_sensor/sense.py @@ -14,46 +14,48 @@ _LOGGER = logging.getLogger(__name__) BIN_SENSOR_CLASS = 'power' -MDI_ICONS = {'ac': 'air-conditioner', - 'aquarium': 'fish', - 'car': 'car-electric', - 'computer': 'desktop-classic', - 'cup': 'coffee', - 'dehumidifier': 'water-off', - 'dishes': 'dishwasher', - 'drill': 'toolbox', - 'fan': 'fan', - 'freezer': 'fridge-top', - 'fridge': 'fridge-bottom', - 'game': 'gamepad-variant', - 'garage': 'garage', - 'grill': 'stove', - 'heat': 'fire', - 'heater': 'radiatior', - 'humidifier': 'water', - 'kettle': 'kettle', - 'leafblower': 'leaf', - 'lightbulb': 'lightbulb', - 'media_console': 'set-top-box', - 'modem': 'router-wireless', - 'outlet': 'power-socket-us', - 'papershredder': 'shredder', - 'printer': 'printer', - 'pump': 'water-pump', - 'settings': 'settings', - 'skillet': 'pot', - 'smartcamera': 'webcam', - 'socket': 'power-plug', - 'sound': 'speaker', - 'stove': 'stove', - 'trash': 'trash-can', - 'tv': 'television', - 'vacuum': 'robot-vacuum', - 'washer': 'washing-machine'} +MDI_ICONS = { + 'ac': 'air-conditioner', + 'aquarium': 'fish', + 'car': 'car-electric', + 'computer': 'desktop-classic', + 'cup': 'coffee', + 'dehumidifier': 'water-off', + 'dishes': 'dishwasher', + 'drill': 'toolbox', + 'fan': 'fan', + 'freezer': 'fridge-top', + 'fridge': 'fridge-bottom', + 'game': 'gamepad-variant', + 'garage': 'garage', + 'grill': 'stove', + 'heat': 'fire', + 'heater': 'radiatior', + 'humidifier': 'water', + 'kettle': 'kettle', + 'leafblower': 'leaf', + 'lightbulb': 'lightbulb', + 'media_console': 'set-top-box', + 'modem': 'router-wireless', + 'outlet': 'power-socket-us', + 'papershredder': 'shredder', + 'printer': 'printer', + 'pump': 'water-pump', + 'settings': 'settings', + 'skillet': 'pot', + 'smartcamera': 'webcam', + 'socket': 'power-plug', + 'sound': 'speaker', + 'stove': 'stove', + 'trash': 'trash-can', + 'tv': 'television', + 'vacuum': 'robot-vacuum', + 'washer': 'washing-machine', +} def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Sense sensor.""" + """Set up the Sense binary sensor.""" if discovery_info is None: return @@ -67,14 +69,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): def sense_to_mdi(sense_icon): """Convert sense icon to mdi icon.""" - return 'mdi:' + MDI_ICONS.get(sense_icon, 'power-plug') + return 'mdi:{}'.format(MDI_ICONS.get(sense_icon, 'power-plug')) class SenseDevice(BinarySensorDevice): """Implementation of a Sense energy device binary sensor.""" def __init__(self, data, device): - """Initialize the sensor.""" + """Initialize the Sense binary sensor.""" self._name = device['name'] self._id = device['id'] self._icon = sense_to_mdi(device['icon']) diff --git a/homeassistant/components/sense.py b/homeassistant/components/sense.py index 6e9204b80e196..8ddeb3d2ecc02 100644 --- a/homeassistant/components/sense.py +++ b/homeassistant/components/sense.py @@ -8,20 +8,20 @@ import voluptuous as vol -from homeassistant.helpers.discovery import load_platform -from homeassistant.const import (CONF_EMAIL, CONF_PASSWORD, CONF_TIMEOUT) +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TIMEOUT import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import load_platform REQUIREMENTS = ['sense_energy==0.5.1'] _LOGGER = logging.getLogger(__name__) -SENSE_DATA = 'sense_data' +ACTIVE_UPDATE_RATE = 60 +DEFAULT_TIMEOUT = 5 DOMAIN = 'sense' -ACTIVE_UPDATE_RATE = 60 -DEFAULT_TIMEOUT = 5 +SENSE_DATA = 'sense_data' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -41,8 +41,8 @@ def setup(hass, config): timeout = config[DOMAIN][CONF_TIMEOUT] try: - hass.data[SENSE_DATA] = Senseable(api_timeout=timeout, - wss_timeout=timeout) + hass.data[SENSE_DATA] = Senseable( + api_timeout=timeout, wss_timeout=timeout) hass.data[SENSE_DATA].authenticate(username, password) hass.data[SENSE_DATA].rate_limit = ACTIVE_UPDATE_RATE except SenseAuthenticationException: diff --git a/homeassistant/components/sensor/sense.py b/homeassistant/components/sensor/sense.py index b494257beb794..58054272902e3 100644 --- a/homeassistant/components/sensor/sense.py +++ b/homeassistant/components/sensor/sense.py @@ -4,27 +4,31 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.sense/ """ -import logging - from datetime import timedelta +import logging +from homeassistant.components.sense import SENSE_DATA from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -from homeassistant.components.sense import SENSE_DATA - -DEPENDENCIES = ['sense'] _LOGGER = logging.getLogger(__name__) ACTIVE_NAME = 'Energy' -PRODUCTION_NAME = 'Production' +ACTIVE_TYPE = 'active' + CONSUMPTION_NAME = 'Usage' -ACTIVE_TYPE = 'active' +DEPENDENCIES = ['sense'] + +ICON = 'mdi:flash' + +MIN_TIME_BETWEEN_DAILY_UPDATES = timedelta(seconds=300) + +PRODUCTION_NAME = 'Production' class SensorConfig: - """Data structure holding sensor config.""" + """Data structure holding sensor configuration.""" def __init__(self, name, sensor_type): """Sensor name and type to pass to API.""" @@ -33,19 +37,17 @@ def __init__(self, name, sensor_type): # Sensor types/ranges -SENSOR_TYPES = {'active': SensorConfig(ACTIVE_NAME, ACTIVE_TYPE), - 'daily': SensorConfig('Daily', 'DAY'), - 'weekly': SensorConfig('Weekly', 'WEEK'), - 'monthly': SensorConfig('Monthly', 'MONTH'), - 'yearly': SensorConfig('Yearly', 'YEAR')} +SENSOR_TYPES = { + 'active': SensorConfig(ACTIVE_NAME, ACTIVE_TYPE), + 'daily': SensorConfig('Daily', 'DAY'), + 'weekly': SensorConfig('Weekly', 'WEEK'), + 'monthly': SensorConfig('Monthly', 'MONTH'), + 'yearly': SensorConfig('Yearly', 'YEAR'), +} # Production/consumption variants SENSOR_VARIANTS = [PRODUCTION_NAME.lower(), CONSUMPTION_NAME.lower()] -ICON = 'mdi:flash' - -MIN_TIME_BETWEEN_DAILY_UPDATES = timedelta(seconds=300) - def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Sense sensor.""" @@ -73,8 +75,8 @@ def update_active(): update_call = update_active else: update_call = update_trends - devices.append(Sense(data, name, sensor_type, - is_production, update_call)) + devices.append(Sense( + data, name, sensor_type, is_production, update_call)) add_entities(devices) @@ -83,9 +85,9 @@ class Sense(Entity): """Implementation of a Sense energy sensor.""" def __init__(self, data, name, sensor_type, is_production, update_call): - """Initialize the sensor.""" + """Initialize the Sense sensor.""" name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME - self._name = "%s %s" % (name, name_type) + self._name = "%s %s".format(name, name_type) self._data = data self._sensor_type = sensor_type self.update_sensor = update_call @@ -132,6 +134,5 @@ def update(self): else: self._state = round(self._data.active_power) else: - state = self._data.get_trend(self._sensor_type, - self._is_production) + state = self._data.get_trend(self._sensor_type, self._is_production) self._state = round(state, 1) From fc1a4543d3071206665d7b7fc8eceab8e4c0fc1b Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 1 Dec 2018 20:57:39 +0100 Subject: [PATCH 186/325] Fix lint issue --- homeassistant/components/sensor/sense.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/sense.py b/homeassistant/components/sensor/sense.py index 58054272902e3..2aff89c591abb 100644 --- a/homeassistant/components/sensor/sense.py +++ b/homeassistant/components/sensor/sense.py @@ -134,5 +134,6 @@ def update(self): else: self._state = round(self._data.active_power) else: - state = self._data.get_trend(self._sensor_type, self._is_production) + state = self._data.get_trend( + self._sensor_type, self._is_production) self._state = round(state, 1) From da715c2a0396d8e479be89ce4e53db561dc5f485 Mon Sep 17 00:00:00 2001 From: ludeeus Date: Sat, 1 Dec 2018 21:32:31 +0100 Subject: [PATCH 187/325] Use dict.get('key') instead of dict['key'] --- homeassistant/components/sensor/tautulli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/tautulli.py b/homeassistant/components/sensor/tautulli.py index 419ef6a11a12d..29c11c934f9e6 100644 --- a/homeassistant/components/sensor/tautulli.py +++ b/homeassistant/components/sensor/tautulli.py @@ -90,9 +90,9 @@ async def async_update(self): await self.tautulli.async_update() self.home = self.tautulli.api.home_data self.sessions = self.tautulli.api.session_data - self._attributes['Top Movie'] = self.home['movie'] - self._attributes['Top TV Show'] = self.home['tv'] - self._attributes['Top User'] = self.home['user'] + self._attributes['Top Movie'] = self.home.get('movie') + self._attributes['Top TV Show'] = self.home,get('tv') + self._attributes['Top User'] = self.home.get('user') for key in self.sessions: if 'sessions' not in key: self._attributes[key] = self.sessions[key] From 1dac84e9dd9dd02b6b69708a3f305fbf0eff8334 Mon Sep 17 00:00:00 2001 From: ludeeus Date: Sat, 1 Dec 2018 21:34:31 +0100 Subject: [PATCH 188/325] corrects , -> . typo --- homeassistant/components/sensor/tautulli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/tautulli.py b/homeassistant/components/sensor/tautulli.py index 29c11c934f9e6..f47f0e5c38291 100644 --- a/homeassistant/components/sensor/tautulli.py +++ b/homeassistant/components/sensor/tautulli.py @@ -91,7 +91,7 @@ async def async_update(self): self.home = self.tautulli.api.home_data self.sessions = self.tautulli.api.session_data self._attributes['Top Movie'] = self.home.get('movie') - self._attributes['Top TV Show'] = self.home,get('tv') + self._attributes['Top TV Show'] = self.home.get('tv') self._attributes['Top User'] = self.home.get('user') for key in self.sessions: if 'sessions' not in key: From 9156a827ce470e3cf2339d6bef7dce14a34e3da0 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 1 Dec 2018 21:45:16 +0100 Subject: [PATCH 189/325] Upgrade Sphinx to 1.8.2 --- requirements_docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_docs.txt b/requirements_docs.txt index 16c861a75fc4f..1b23d62e1f315 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,3 +1,3 @@ -Sphinx==1.8.1 +Sphinx==1.8.2 sphinx-autodoc-typehints==1.5.0 sphinx-autodoc-annotation==1.0.post1 From 2ca4893948613599c926b90fa6e03ae213da5c45 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 1 Dec 2018 21:48:56 +0100 Subject: [PATCH 190/325] Upgrade sphinx-autodoc-typehints to 1.5.1 --- requirements_docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_docs.txt b/requirements_docs.txt index 16c861a75fc4f..360745872f2c4 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,3 +1,3 @@ Sphinx==1.8.1 -sphinx-autodoc-typehints==1.5.0 +sphinx-autodoc-typehints==1.5.1 sphinx-autodoc-annotation==1.0.post1 From 4b85ffae4fdcefa9df9211d4ee1dea760ede1d3b Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 1 Dec 2018 22:01:22 +0100 Subject: [PATCH 191/325] Upgrade slacker to 0.11.0 --- homeassistant/components/notify/slack.py | 21 +++++++++++---------- requirements_all.txt | 2 +- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/notify/slack.py b/homeassistant/components/notify/slack.py index d576cdcc95e78..599633ff5ff2f 100644 --- a/homeassistant/components/notify/slack.py +++ b/homeassistant/components/notify/slack.py @@ -17,7 +17,7 @@ BaseNotificationService) from homeassistant.const import (CONF_API_KEY, CONF_USERNAME, CONF_ICON) -REQUIREMENTS = ['slacker==0.9.65'] +REQUIREMENTS = ['slacker==0.11.0'] _LOGGER = logging.getLogger(__name__) @@ -39,14 +39,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_CHANNEL): cv.string, - vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_ICON): cv.string, + vol.Optional(CONF_USERNAME): cv.string, }) def get_service(hass, config, discovery_info=None): """Get the Slack notification service.""" import slacker + channel = config.get(CONF_CHANNEL) api_key = config.get(CONF_API_KEY) username = config.get(CONF_USERNAME) @@ -115,15 +116,15 @@ def send_message(self, message="", **kwargs): 'content': None, 'filetype': None, 'filename': filename, - # if optional title is none use the filename + # If optional title is none use the filename 'title': title if title else filename, 'initial_comment': message, 'channels': target } # Post to slack - self.slack.files.post('files.upload', - data=data, - files={'file': file_as_bytes}) + self.slack.files.post( + 'files.upload', data=data, + files={'file': file_as_bytes}) else: self.slack.chat.post_message( target, message, as_user=self._as_user, @@ -154,13 +155,13 @@ def load_file(self, url=None, local_path=None, username=None, elif local_path: # Check whether path is whitelisted in configuration.yaml if self.is_allowed_path(local_path): - return open(local_path, "rb") - _LOGGER.warning("'%s' is not secure to load data from!", - local_path) + return open(local_path, 'rb') + _LOGGER.warning( + "'%s' is not secure to load data from!", local_path) else: _LOGGER.warning("Neither URL nor local path found in params!") except OSError as error: - _LOGGER.error("Can't load from url or local path: %s", error) + _LOGGER.error("Can't load from URL or local path: %s", error) return None diff --git a/requirements_all.txt b/requirements_all.txt index 40cc0d39c43af..a306ea3f31250 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1427,7 +1427,7 @@ sisyphus-control==2.1 skybellpy==0.1.2 # homeassistant.components.notify.slack -slacker==0.9.65 +slacker==0.11.0 # homeassistant.components.sleepiq sleepyq==0.6 From 7b6893c9d3495c2cf8e638e56126132746c00133 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sat, 1 Dec 2018 22:08:15 +0100 Subject: [PATCH 192/325] Fix change --- homeassistant/components/sensor/sense.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/sense.py b/homeassistant/components/sensor/sense.py index 2aff89c591abb..769b3a9e148cf 100644 --- a/homeassistant/components/sensor/sense.py +++ b/homeassistant/components/sensor/sense.py @@ -87,7 +87,7 @@ class Sense(Entity): def __init__(self, data, name, sensor_type, is_production, update_call): """Initialize the Sense sensor.""" name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME - self._name = "%s %s".format(name, name_type) + self._name = "{} {}".format(name, name_type) self._data = data self._sensor_type = sensor_type self.update_sensor = update_call From 4807ad7875b3ad194af8601cd08d1044bd9582c8 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 2 Dec 2018 00:11:47 +0100 Subject: [PATCH 193/325] Upgrade restrictedpython to 4.0b7 --- homeassistant/components/python_script.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/python_script.py b/homeassistant/components/python_script.py index bf9957f36ee6a..3cfa069664499 100644 --- a/homeassistant/components/python_script.py +++ b/homeassistant/components/python_script.py @@ -18,7 +18,7 @@ from homeassistant.util import sanitize_filename import homeassistant.util.dt as dt_util -REQUIREMENTS = ['restrictedpython==4.0b6'] +REQUIREMENTS = ['restrictedpython==4.0b7'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 40cc0d39c43af..fd2a884916a87 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1357,7 +1357,7 @@ raincloudy==0.0.5 regenmaschine==1.0.7 # homeassistant.components.python_script -restrictedpython==4.0b6 +restrictedpython==4.0b7 # homeassistant.components.rflink rflink==0.0.37 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 935757b37d77e..5707847a78943 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -221,7 +221,7 @@ pywebpush==1.6.0 regenmaschine==1.0.7 # homeassistant.components.python_script -restrictedpython==4.0b6 +restrictedpython==4.0b7 # homeassistant.components.rflink rflink==0.0.37 From 48b8fc9e0163eb59920f6e7e661cb67ebc29b827 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 2 Dec 2018 00:17:41 +0100 Subject: [PATCH 194/325] Upgrade ruamel.yaml to 0.15.80 --- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 11f9659170549..481cd9da3ea58 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,7 +11,7 @@ pip>=8.0.3 pytz>=2018.04 pyyaml>=3.13,<4 requests==2.20.1 -ruamel.yaml==0.15.78 +ruamel.yaml==0.15.80 voluptuous==0.11.5 voluptuous-serialize==2.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 40cc0d39c43af..f0d276ca811d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -12,7 +12,7 @@ pip>=8.0.3 pytz>=2018.04 pyyaml>=3.13,<4 requests==2.20.1 -ruamel.yaml==0.15.78 +ruamel.yaml==0.15.80 voluptuous==0.11.5 voluptuous-serialize==2.0.0 diff --git a/setup.py b/setup.py index 49147afdd705f..68c830190abb4 100755 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ 'pytz>=2018.04', 'pyyaml>=3.13,<4', 'requests==2.20.1', - 'ruamel.yaml==0.15.78', + 'ruamel.yaml==0.15.80', 'voluptuous==0.11.5', 'voluptuous-serialize==2.0.0', ] From 9f3c9cdb119d1433868187be8075692c3787f72f Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 2 Dec 2018 00:30:02 +0100 Subject: [PATCH 195/325] Upgrade pillow to 5.3.0 --- homeassistant/components/camera/proxy.py | 2 +- homeassistant/components/image_processing/tensorflow.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/camera/proxy.py b/homeassistant/components/camera/proxy.py index 6e7ab9385bdf5..e5a0d6727569a 100644 --- a/homeassistant/components/camera/proxy.py +++ b/homeassistant/components/camera/proxy.py @@ -18,7 +18,7 @@ import homeassistant.util.dt as dt_util from homeassistant.components.camera import async_get_still_stream -REQUIREMENTS = ['pillow==5.2.0'] +REQUIREMENTS = ['pillow==5.3.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/image_processing/tensorflow.py b/homeassistant/components/image_processing/tensorflow.py index 8f5b599bb884d..6172963525e18 100644 --- a/homeassistant/components/image_processing/tensorflow.py +++ b/homeassistant/components/image_processing/tensorflow.py @@ -20,7 +20,7 @@ from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['numpy==1.15.4', 'pillow==5.2.0', 'protobuf==3.6.1'] +REQUIREMENTS = ['numpy==1.15.4', 'pillow==5.3.0', 'protobuf==3.6.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 40cc0d39c43af..34a8a4bb16103 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -748,7 +748,7 @@ pilight==0.1.1 # homeassistant.components.camera.proxy # homeassistant.components.image_processing.tensorflow -pillow==5.2.0 +pillow==5.3.0 # homeassistant.components.dominos pizzapi==0.0.3 From e591234b599277a47feda0baa94ebb4d563a8bab Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 2 Dec 2018 08:26:23 +0100 Subject: [PATCH 196/325] Upgrade keyring to 17.0.0 (#18901) --- homeassistant/scripts/keyring.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/scripts/keyring.py b/homeassistant/scripts/keyring.py index 76a9d9318f206..16c2638f26e11 100644 --- a/homeassistant/scripts/keyring.py +++ b/homeassistant/scripts/keyring.py @@ -5,7 +5,7 @@ from homeassistant.util.yaml import _SECRET_NAMESPACE -REQUIREMENTS = ['keyring==15.1.0', 'keyrings.alt==3.1'] +REQUIREMENTS = ['keyring==17.0.0', 'keyrings.alt==3.1'] def run(args): diff --git a/requirements_all.txt b/requirements_all.txt index 40cc0d39c43af..221d35b4940e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -548,7 +548,7 @@ jsonrpc-async==0.6 jsonrpc-websocket==0.6 # homeassistant.scripts.keyring -keyring==15.1.0 +keyring==17.0.0 # homeassistant.scripts.keyring keyrings.alt==3.1 From db4a0e324407b2db50b49589a2a097b7c10f5778 Mon Sep 17 00:00:00 2001 From: emontnemery Date: Sun, 2 Dec 2018 10:27:50 +0100 Subject: [PATCH 197/325] Small refactoring of MQTT cover (#18850) --- homeassistant/components/cover/mqtt.py | 202 +++++++++++-------------- 1 file changed, 88 insertions(+), 114 deletions(-) diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index 94e2b948c4898..55df204f2757a 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -145,8 +145,7 @@ async def async_discover(discovery_payload): async_discover) -async def _async_setup_entity(config, async_add_entities, - discovery_hash=None): +async def _async_setup_entity(config, async_add_entities, discovery_hash=None): """Set up the MQTT Cover.""" async_add_entities([MqttCover(config, discovery_hash)]) @@ -157,37 +156,14 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, def __init__(self, config, discovery_hash): """Initialize the cover.""" + self._unique_id = config.get(CONF_UNIQUE_ID) self._position = None self._state = None self._sub_state = None - self._name = None - self._state_topic = None - self._get_position_topic = None - self._command_topic = None - self._tilt_command_topic = None - self._tilt_status_topic = None - self._qos = None - self._payload_open = None - self._payload_close = None - self._payload_stop = None - self._state_open = None - self._state_closed = None - self._position_open = None - self._position_closed = None - self._retain = None - self._tilt_open_position = None - self._tilt_closed_position = None self._optimistic = None - self._template = None self._tilt_value = None - self._tilt_min = None - self._tilt_max = None self._tilt_optimistic = None - self._tilt_invert = None - self._set_position_topic = None - self._set_position_template = None - self._unique_id = None # Load config self._setup_from_config(config) @@ -195,9 +171,10 @@ def __init__(self, config, discovery_hash): availability_topic = config.get(CONF_AVAILABILITY_TOPIC) payload_available = config.get(CONF_PAYLOAD_AVAILABLE) payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) + qos = config.get(CONF_QOS) device_config = config.get(CONF_DEVICE) - MqttAvailability.__init__(self, availability_topic, self._qos, + MqttAvailability.__init__(self, availability_topic, qos, payload_available, payload_not_available) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) @@ -217,42 +194,20 @@ async def discovery_update(self, discovery_payload): self.async_schedule_update_ha_state() def _setup_from_config(self, config): - self._name = config.get(CONF_NAME) - self._state_topic = config.get(CONF_STATE_TOPIC) - self._get_position_topic = config.get(CONF_GET_POSITION_TOPIC) - self._command_topic = config.get(CONF_COMMAND_TOPIC) - self._tilt_command_topic = config.get(CONF_TILT_COMMAND_TOPIC) - self._tilt_status_topic = config.get(CONF_TILT_STATUS_TOPIC) - self._qos = config.get(CONF_QOS) - self._retain = config.get(CONF_RETAIN) - self._state_open = config.get(CONF_STATE_OPEN) - self._state_closed = config.get(CONF_STATE_CLOSED) - self._position_open = config.get(CONF_POSITION_OPEN) - self._position_closed = config.get(CONF_POSITION_CLOSED) - self._payload_open = config.get(CONF_PAYLOAD_OPEN) - self._payload_close = config.get(CONF_PAYLOAD_CLOSE) - self._payload_stop = config.get(CONF_PAYLOAD_STOP) + self._config = config self._optimistic = (config.get(CONF_OPTIMISTIC) or - (self._state_topic is None and - self._get_position_topic is None)) - self._template = config.get(CONF_VALUE_TEMPLATE) - self._tilt_open_position = config.get(CONF_TILT_OPEN_POSITION) - self._tilt_closed_position = config.get(CONF_TILT_CLOSED_POSITION) - self._tilt_min = config.get(CONF_TILT_MIN) - self._tilt_max = config.get(CONF_TILT_MAX) + (config.get(CONF_STATE_TOPIC) is None and + config.get(CONF_GET_POSITION_TOPIC) is None)) self._tilt_optimistic = config.get(CONF_TILT_STATE_OPTIMISTIC) - self._tilt_invert = config.get(CONF_TILT_INVERT_STATE) - self._set_position_topic = config.get(CONF_SET_POSITION_TOPIC) - self._set_position_template = config.get(CONF_SET_POSITION_TEMPLATE) - - self._unique_id = config.get(CONF_UNIQUE_ID) async def _subscribe_topics(self): """(Re)Subscribe to topics.""" - if self._template is not None: - self._template.hass = self.hass - if self._set_position_template is not None: - self._set_position_template.hass = self.hass + template = self._config.get(CONF_VALUE_TEMPLATE) + if template is not None: + template.hass = self.hass + set_position_template = self._config.get(CONF_SET_POSITION_TEMPLATE) + if set_position_template is not None: + set_position_template.hass = self.hass topics = {} @@ -260,7 +215,8 @@ async def _subscribe_topics(self): def tilt_updated(topic, payload, qos): """Handle tilt updates.""" if (payload.isnumeric() and - self._tilt_min <= int(payload) <= self._tilt_max): + (self._config.get(CONF_TILT_MIN) <= int(payload) <= + self._config.get(CONF_TILT_MAX))): level = self.find_percentage_in_range(float(payload)) self._tilt_value = level @@ -269,13 +225,13 @@ def tilt_updated(topic, payload, qos): @callback def state_message_received(topic, payload, qos): """Handle new MQTT state messages.""" - if self._template is not None: - payload = self._template.async_render_with_possible_json_value( + if template is not None: + payload = template.async_render_with_possible_json_value( payload) - if payload == self._state_open: + if payload == self._config.get(CONF_STATE_OPEN): self._state = False - elif payload == self._state_closed: + elif payload == self._config.get(CONF_STATE_CLOSED): self._state = True else: _LOGGER.warning("Payload is not True or False: %s", payload) @@ -285,8 +241,8 @@ def state_message_received(topic, payload, qos): @callback def position_message_received(topic, payload, qos): """Handle new MQTT state messages.""" - if self._template is not None: - payload = self._template.async_render_with_possible_json_value( + if template is not None: + payload = template.async_render_with_possible_json_value( payload) if payload.isnumeric(): @@ -301,29 +257,29 @@ def position_message_received(topic, payload, qos): return self.async_schedule_update_ha_state() - if self._get_position_topic: + if self._config.get(CONF_GET_POSITION_TOPIC): topics['get_position_topic'] = { - 'topic': self._get_position_topic, + 'topic': self._config.get(CONF_GET_POSITION_TOPIC), 'msg_callback': position_message_received, - 'qos': self._qos} - elif self._state_topic: + 'qos': self._config.get(CONF_QOS)} + elif self._config.get(CONF_STATE_TOPIC): topics['state_topic'] = { - 'topic': self._state_topic, + 'topic': self._config.get(CONF_STATE_TOPIC), 'msg_callback': state_message_received, - 'qos': self._qos} + 'qos': self._config.get(CONF_QOS)} else: # Force into optimistic mode. self._optimistic = True - if self._tilt_status_topic is None: + if self._config.get(CONF_TILT_STATUS_TOPIC) is None: self._tilt_optimistic = True else: self._tilt_optimistic = False self._tilt_value = STATE_UNKNOWN topics['tilt_status_topic'] = { - 'topic': self._tilt_status_topic, + 'topic': self._config.get(CONF_TILT_STATUS_TOPIC), 'msg_callback': tilt_updated, - 'qos': self._qos} + 'qos': self._config.get(CONF_QOS)} self._sub_state = await subscription.async_subscribe_topics( self.hass, self._sub_state, @@ -347,7 +303,7 @@ def assumed_state(self): @property def name(self): """Return the name of the cover.""" - return self._name + return self._config.get(CONF_NAME) @property def is_closed(self): @@ -371,13 +327,13 @@ def current_cover_tilt_position(self): def supported_features(self): """Flag supported features.""" supported_features = 0 - if self._command_topic is not None: + if self._config.get(CONF_COMMAND_TOPIC) is not None: supported_features = OPEN_CLOSE_FEATURES - if self._set_position_topic is not None: + if self._config.get(CONF_SET_POSITION_TOPIC) is not None: supported_features |= SUPPORT_SET_POSITION - if self._tilt_command_topic is not None: + if self._config.get(CONF_TILT_COMMAND_TOPIC) is not None: supported_features |= TILT_FEATURES return supported_features @@ -388,14 +344,15 @@ async def async_open_cover(self, **kwargs): This method is a coroutine. """ mqtt.async_publish( - self.hass, self._command_topic, self._payload_open, self._qos, - self._retain) + self.hass, self._config.get(CONF_COMMAND_TOPIC), + self._config.get(CONF_PAYLOAD_OPEN), self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic: # Optimistically assume that cover has changed state. self._state = False - if self._get_position_topic: + if self._config.get(CONF_GET_POSITION_TOPIC): self._position = self.find_percentage_in_range( - self._position_open, COVER_PAYLOAD) + self._config.get(CONF_POSITION_OPEN), COVER_PAYLOAD) self.async_schedule_update_ha_state() async def async_close_cover(self, **kwargs): @@ -404,14 +361,15 @@ async def async_close_cover(self, **kwargs): This method is a coroutine. """ mqtt.async_publish( - self.hass, self._command_topic, self._payload_close, self._qos, - self._retain) + self.hass, self._config.get(CONF_COMMAND_TOPIC), + self._config.get(CONF_PAYLOAD_CLOSE), self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic: # Optimistically assume that cover has changed state. self._state = True - if self._get_position_topic: + if self._config.get(CONF_GET_POSITION_TOPIC): self._position = self.find_percentage_in_range( - self._position_closed, COVER_PAYLOAD) + self._config.get(CONF_POSITION_CLOSED), COVER_PAYLOAD) self.async_schedule_update_ha_state() async def async_stop_cover(self, **kwargs): @@ -420,25 +378,30 @@ async def async_stop_cover(self, **kwargs): This method is a coroutine. """ mqtt.async_publish( - self.hass, self._command_topic, self._payload_stop, self._qos, - self._retain) + self.hass, self._config.get(CONF_COMMAND_TOPIC), + self._config.get(CONF_PAYLOAD_STOP), self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) async def async_open_cover_tilt(self, **kwargs): """Tilt the cover open.""" - mqtt.async_publish(self.hass, self._tilt_command_topic, - self._tilt_open_position, self._qos, - self._retain) + mqtt.async_publish(self.hass, + self._config.get(CONF_TILT_COMMAND_TOPIC), + self._config.get(CONF_TILT_OPEN_POSITION), + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._tilt_optimistic: - self._tilt_value = self._tilt_open_position + self._tilt_value = self._config.get(CONF_TILT_OPEN_POSITION) self.async_schedule_update_ha_state() async def async_close_cover_tilt(self, **kwargs): """Tilt the cover closed.""" - mqtt.async_publish(self.hass, self._tilt_command_topic, - self._tilt_closed_position, self._qos, - self._retain) + mqtt.async_publish(self.hass, + self._config.get(CONF_TILT_COMMAND_TOPIC), + self._config.get(CONF_TILT_CLOSED_POSITION), + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._tilt_optimistic: - self._tilt_value = self._tilt_closed_position + self._tilt_value = self._config.get(CONF_TILT_CLOSED_POSITION) self.async_schedule_update_ha_state() async def async_set_cover_tilt_position(self, **kwargs): @@ -451,29 +414,38 @@ async def async_set_cover_tilt_position(self, **kwargs): # The position needs to be between min and max level = self.find_in_range_from_percent(position) - mqtt.async_publish(self.hass, self._tilt_command_topic, - level, self._qos, self._retain) + mqtt.async_publish(self.hass, + self._config.get(CONF_TILT_COMMAND_TOPIC), + level, + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" + set_position_template = self._config.get(CONF_SET_POSITION_TEMPLATE) if ATTR_POSITION in kwargs: position = kwargs[ATTR_POSITION] percentage_position = position - if self._set_position_template is not None: + if set_position_template is not None: try: - position = self._set_position_template.async_render( + position = set_position_template.async_render( **kwargs) except TemplateError as ex: _LOGGER.error(ex) self._state = None - elif self._position_open != 100 and self._position_closed != 0: + elif (self._config.get(CONF_POSITION_OPEN) != 100 and + self._config.get(CONF_POSITION_CLOSED) != 0): position = self.find_in_range_from_percent( position, COVER_PAYLOAD) - mqtt.async_publish(self.hass, self._set_position_topic, - position, self._qos, self._retain) + mqtt.async_publish(self.hass, + self._config.get(CONF_SET_POSITION_TOPIC), + position, + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic: - self._state = percentage_position == self._position_closed + self._state = percentage_position == \ + self._config.get(CONF_POSITION_CLOSED) self._position = percentage_position self.async_schedule_update_ha_state() @@ -481,11 +453,11 @@ def find_percentage_in_range(self, position, range_type=TILT_PAYLOAD): """Find the 0-100% value within the specified range.""" # the range of motion as defined by the min max values if range_type == COVER_PAYLOAD: - max_range = self._position_open - min_range = self._position_closed + max_range = self._config.get(CONF_POSITION_OPEN) + min_range = self._config.get(CONF_POSITION_CLOSED) else: - max_range = self._tilt_max - min_range = self._tilt_min + max_range = self._config.get(CONF_TILT_MAX) + min_range = self._config.get(CONF_TILT_MIN) current_range = max_range - min_range # offset to be zero based offset_position = position - min_range @@ -496,7 +468,8 @@ def find_percentage_in_range(self, position, range_type=TILT_PAYLOAD): min_percent = 0 position_percentage = min(max(position_percentage, min_percent), max_percent) - if range_type == TILT_PAYLOAD and self._tilt_invert: + if range_type == TILT_PAYLOAD and \ + self._config.get(CONF_TILT_INVERT_STATE): return 100 - position_percentage return position_percentage @@ -510,17 +483,18 @@ def find_in_range_from_percent(self, percentage, range_type=TILT_PAYLOAD): returning the offset """ if range_type == COVER_PAYLOAD: - max_range = self._position_open - min_range = self._position_closed + max_range = self._config.get(CONF_POSITION_OPEN) + min_range = self._config.get(CONF_POSITION_CLOSED) else: - max_range = self._tilt_max - min_range = self._tilt_min + max_range = self._config.get(CONF_TILT_MAX) + min_range = self._config.get(CONF_TILT_MIN) offset = min_range current_range = max_range - min_range position = round(current_range * (percentage / 100.0)) position += offset - if range_type == TILT_PAYLOAD and self._tilt_invert: + if range_type == TILT_PAYLOAD and \ + self._config.get(CONF_TILT_INVERT_STATE): position = max_range - position + offset return position From 2e4e673bbe3ab62a21e08232b02e52fe07d2d073 Mon Sep 17 00:00:00 2001 From: emontnemery Date: Sun, 2 Dec 2018 10:29:31 +0100 Subject: [PATCH 198/325] Small refactoring of MQTT alarm (#18813) --- .../components/alarm_control_panel/mqtt.py | 73 +++++++------------ 1 file changed, 28 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/mqtt.py b/homeassistant/components/alarm_control_panel/mqtt.py index 5f0793ae58ce1..2a91ac77a8679 100644 --- a/homeassistant/components/alarm_control_panel/mqtt.py +++ b/homeassistant/components/alarm_control_panel/mqtt.py @@ -51,7 +51,7 @@ async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None): """Set up MQTT alarm control panel through configuration.yaml.""" - await _async_setup_entity(hass, config, async_add_entities) + await _async_setup_entity(config, async_add_entities) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -59,7 +59,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add an MQTT alarm control panel.""" config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(hass, config, async_add_entities, + await _async_setup_entity(config, async_add_entities, discovery_payload[ATTR_DISCOVERY_HASH]) async_dispatcher_connect( @@ -67,12 +67,10 @@ async def async_discover(discovery_payload): async_discover) -async def _async_setup_entity(hass, config, async_add_entities, +async def _async_setup_entity(config, async_add_entities, discovery_hash=None): """Set up the MQTT Alarm Control Panel platform.""" - async_add_entities([MqttAlarm( - config, - discovery_hash,)]) + async_add_entities([MqttAlarm(config, discovery_hash)]) class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, @@ -85,23 +83,12 @@ def __init__(self, config, discovery_hash): self._config = config self._sub_state = None - self._name = None - self._state_topic = None - self._command_topic = None - self._qos = None - self._retain = None - self._payload_disarm = None - self._payload_arm_home = None - self._payload_arm_away = None - self._code = None - - # Load config - self._setup_from_config(config) - availability_topic = config.get(CONF_AVAILABILITY_TOPIC) payload_available = config.get(CONF_PAYLOAD_AVAILABLE) payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) - MqttAvailability.__init__(self, availability_topic, self._qos, + qos = config.get(CONF_QOS) + + MqttAvailability.__init__(self, availability_topic, qos, payload_available, payload_not_available) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) @@ -114,23 +101,11 @@ async def async_added_to_hass(self): async def discovery_update(self, discovery_payload): """Handle updated discovery message.""" config = PLATFORM_SCHEMA(discovery_payload) - self._setup_from_config(config) + self._config = config await self.availability_discovery_update(config) await self._subscribe_topics() self.async_schedule_update_ha_state() - def _setup_from_config(self, config): - """(Re)Setup the entity.""" - self._name = config.get(CONF_NAME) - self._state_topic = config.get(CONF_STATE_TOPIC) - self._command_topic = config.get(CONF_COMMAND_TOPIC) - self._qos = config.get(CONF_QOS) - self._retain = config.get(CONF_RETAIN) - self._payload_disarm = config.get(CONF_PAYLOAD_DISARM) - self._payload_arm_home = config.get(CONF_PAYLOAD_ARM_HOME) - self._payload_arm_away = config.get(CONF_PAYLOAD_ARM_AWAY) - self._code = config.get(CONF_CODE) - async def _subscribe_topics(self): """(Re)Subscribe to topics.""" @callback @@ -146,9 +121,9 @@ def message_received(topic, payload, qos): self._sub_state = await subscription.async_subscribe_topics( self.hass, self._sub_state, - {'state_topic': {'topic': self._state_topic, + {'state_topic': {'topic': self._config.get(CONF_STATE_TOPIC), 'msg_callback': message_received, - 'qos': self._qos}}) + 'qos': self._config.get(CONF_QOS)}}) async def async_will_remove_from_hass(self): """Unsubscribe when removed.""" @@ -163,7 +138,7 @@ def should_poll(self): @property def name(self): """Return the name of the device.""" - return self._name + return self._config.get(CONF_NAME) @property def state(self): @@ -173,9 +148,10 @@ def state(self): @property def code_format(self): """Return one or more digits/characters.""" - if self._code is None: + code = self._config.get(CONF_CODE) + if code is None: return None - if isinstance(self._code, str) and re.search('^\\d+$', self._code): + if isinstance(code, str) and re.search('^\\d+$', code): return 'Number' return 'Any' @@ -187,8 +163,10 @@ async def async_alarm_disarm(self, code=None): if not self._validate_code(code, 'disarming'): return mqtt.async_publish( - self.hass, self._command_topic, self._payload_disarm, self._qos, - self._retain) + self.hass, self._config.get(CONF_COMMAND_TOPIC), + self._config.get(CONF_PAYLOAD_DISARM), + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) async def async_alarm_arm_home(self, code=None): """Send arm home command. @@ -198,8 +176,10 @@ async def async_alarm_arm_home(self, code=None): if not self._validate_code(code, 'arming home'): return mqtt.async_publish( - self.hass, self._command_topic, self._payload_arm_home, self._qos, - self._retain) + self.hass, self._config.get(CONF_COMMAND_TOPIC), + self._config.get(CONF_PAYLOAD_ARM_HOME), + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) async def async_alarm_arm_away(self, code=None): """Send arm away command. @@ -209,12 +189,15 @@ async def async_alarm_arm_away(self, code=None): if not self._validate_code(code, 'arming away'): return mqtt.async_publish( - self.hass, self._command_topic, self._payload_arm_away, self._qos, - self._retain) + self.hass, self._config.get(CONF_COMMAND_TOPIC), + self._config.get(CONF_PAYLOAD_ARM_AWAY), + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) def _validate_code(self, code, state): """Validate given code.""" - check = self._code is None or code == self._code + conf_code = self._config.get(CONF_CODE) + check = conf_code is None or code == conf_code if not check: _LOGGER.warning('Wrong code entered for %s', state) return check From ce218b172a7e554b8264c819b4a2a90366d90a4b Mon Sep 17 00:00:00 2001 From: emontnemery Date: Sun, 2 Dec 2018 10:30:07 +0100 Subject: [PATCH 199/325] Small refactoring of MQTT climate (#18814) --- homeassistant/components/climate/mqtt.py | 138 +++++++++++------------ 1 file changed, 67 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/climate/mqtt.py b/homeassistant/components/climate/mqtt.py index bccf282f055da..4995fa13b3a25 100644 --- a/homeassistant/components/climate/mqtt.py +++ b/homeassistant/components/climate/mqtt.py @@ -183,11 +183,8 @@ def __init__(self, hass, config, discovery_hash): self._sub_state = None self.hass = hass - self._name = None self._topic = None self._value_templates = None - self._qos = None - self._retain = None self._target_temperature = None self._current_fan_mode = None self._current_operation = None @@ -197,24 +194,15 @@ def __init__(self, hass, config, discovery_hash): self._hold = None self._current_temperature = None self._aux = False - self._fan_list = None - self._operation_list = None - self._swing_list = None - self._target_temperature_step = None - self._send_if_off = None - self._payload_on = None - self._payload_off = None - self._min_temp = None - self._max_temp = None - - # Load config + self._setup_from_config(config) availability_topic = config.get(CONF_AVAILABILITY_TOPIC) payload_available = config.get(CONF_PAYLOAD_AVAILABLE) payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) + qos = config.get(CONF_QOS) - MqttAvailability.__init__(self, availability_topic, self._qos, + MqttAvailability.__init__(self, availability_topic, qos, payload_available, payload_not_available) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) @@ -227,6 +215,7 @@ async def async_added_to_hass(self): async def discovery_update(self, discovery_payload): """Handle updated discovery message.""" config = PLATFORM_SCHEMA(discovery_payload) + self._config = config self._setup_from_config(config) await self.availability_discovery_update(config) await self._subscribe_topics() @@ -234,7 +223,7 @@ async def discovery_update(self, discovery_payload): def _setup_from_config(self, config): """(Re)Setup the entity.""" - self._name = config.get(CONF_NAME) + # self._name = config.get(CONF_NAME) self._topic = { key: config.get(key) for key in ( CONF_POWER_COMMAND_TOPIC, @@ -256,11 +245,6 @@ def _setup_from_config(self, config): CONF_CURRENT_TEMPERATURE_TOPIC ) } - self._qos = config.get(CONF_QOS) - self._retain = config.get(CONF_RETAIN) - self._operation_list = config.get(CONF_MODE_LIST) - self._fan_list = config.get(CONF_FAN_MODE_LIST) - self._swing_list = config.get(CONF_SWING_MODE_LIST) # set to None in non-optimistic mode self._target_temperature = self._current_fan_mode = \ @@ -276,16 +260,6 @@ def _setup_from_config(self, config): self._away = False self._hold = None self._aux = False - self._send_if_off = config.get(CONF_SEND_IF_OFF) - self._payload_on = config.get(CONF_PAYLOAD_ON) - self._payload_off = config.get(CONF_PAYLOAD_OFF) - self._min_temp = config.get(CONF_MIN_TEMP) - self._max_temp = config.get(CONF_MAX_TEMP) - self._target_temperature_step = config.get(CONF_TEMP_STEP) - - config.get(CONF_AVAILABILITY_TOPIC) - config.get(CONF_PAYLOAD_AVAILABLE) - config.get(CONF_PAYLOAD_NOT_AVAILABLE) value_templates = {} if CONF_VALUE_TEMPLATE in config: @@ -300,6 +274,7 @@ def _setup_from_config(self, config): async def _subscribe_topics(self): """(Re)Subscribe to topics.""" topics = {} + qos = self._config.get(CONF_QOS) @callback def handle_current_temp_received(topic, payload, qos): @@ -319,7 +294,7 @@ def handle_current_temp_received(topic, payload, qos): topics[CONF_CURRENT_TEMPERATURE_TOPIC] = { 'topic': self._topic[CONF_CURRENT_TEMPERATURE_TOPIC], 'msg_callback': handle_current_temp_received, - 'qos': self._qos} + 'qos': qos} @callback def handle_mode_received(topic, payload, qos): @@ -328,7 +303,7 @@ def handle_mode_received(topic, payload, qos): payload = self._value_templates[CONF_MODE_STATE_TEMPLATE].\ async_render_with_possible_json_value(payload) - if payload not in self._operation_list: + if payload not in self._config.get(CONF_MODE_LIST): _LOGGER.error("Invalid mode: %s", payload) else: self._current_operation = payload @@ -338,7 +313,7 @@ def handle_mode_received(topic, payload, qos): topics[CONF_MODE_STATE_TOPIC] = { 'topic': self._topic[CONF_MODE_STATE_TOPIC], 'msg_callback': handle_mode_received, - 'qos': self._qos} + 'qos': qos} @callback def handle_temperature_received(topic, payload, qos): @@ -358,7 +333,7 @@ def handle_temperature_received(topic, payload, qos): topics[CONF_TEMPERATURE_STATE_TOPIC] = { 'topic': self._topic[CONF_TEMPERATURE_STATE_TOPIC], 'msg_callback': handle_temperature_received, - 'qos': self._qos} + 'qos': qos} @callback def handle_fan_mode_received(topic, payload, qos): @@ -368,7 +343,7 @@ def handle_fan_mode_received(topic, payload, qos): self._value_templates[CONF_FAN_MODE_STATE_TEMPLATE].\ async_render_with_possible_json_value(payload) - if payload not in self._fan_list: + if payload not in self._config.get(CONF_FAN_MODE_LIST): _LOGGER.error("Invalid fan mode: %s", payload) else: self._current_fan_mode = payload @@ -378,7 +353,7 @@ def handle_fan_mode_received(topic, payload, qos): topics[CONF_FAN_MODE_STATE_TOPIC] = { 'topic': self._topic[CONF_FAN_MODE_STATE_TOPIC], 'msg_callback': handle_fan_mode_received, - 'qos': self._qos} + 'qos': qos} @callback def handle_swing_mode_received(topic, payload, qos): @@ -388,7 +363,7 @@ def handle_swing_mode_received(topic, payload, qos): self._value_templates[CONF_SWING_MODE_STATE_TEMPLATE].\ async_render_with_possible_json_value(payload) - if payload not in self._swing_list: + if payload not in self._config.get(CONF_SWING_MODE_LIST): _LOGGER.error("Invalid swing mode: %s", payload) else: self._current_swing_mode = payload @@ -398,23 +373,25 @@ def handle_swing_mode_received(topic, payload, qos): topics[CONF_SWING_MODE_STATE_TOPIC] = { 'topic': self._topic[CONF_SWING_MODE_STATE_TOPIC], 'msg_callback': handle_swing_mode_received, - 'qos': self._qos} + 'qos': qos} @callback def handle_away_mode_received(topic, payload, qos): """Handle receiving away mode via MQTT.""" + payload_on = self._config.get(CONF_PAYLOAD_ON) + payload_off = self._config.get(CONF_PAYLOAD_OFF) if CONF_AWAY_MODE_STATE_TEMPLATE in self._value_templates: payload = \ self._value_templates[CONF_AWAY_MODE_STATE_TEMPLATE].\ async_render_with_possible_json_value(payload) if payload == "True": - payload = self._payload_on + payload = payload_on elif payload == "False": - payload = self._payload_off + payload = payload_off - if payload == self._payload_on: + if payload == payload_on: self._away = True - elif payload == self._payload_off: + elif payload == payload_off: self._away = False else: _LOGGER.error("Invalid away mode: %s", payload) @@ -425,22 +402,24 @@ def handle_away_mode_received(topic, payload, qos): topics[CONF_AWAY_MODE_STATE_TOPIC] = { 'topic': self._topic[CONF_AWAY_MODE_STATE_TOPIC], 'msg_callback': handle_away_mode_received, - 'qos': self._qos} + 'qos': qos} @callback def handle_aux_mode_received(topic, payload, qos): """Handle receiving aux mode via MQTT.""" + payload_on = self._config.get(CONF_PAYLOAD_ON) + payload_off = self._config.get(CONF_PAYLOAD_OFF) if CONF_AUX_STATE_TEMPLATE in self._value_templates: payload = self._value_templates[CONF_AUX_STATE_TEMPLATE].\ async_render_with_possible_json_value(payload) if payload == "True": - payload = self._payload_on + payload = payload_on elif payload == "False": - payload = self._payload_off + payload = payload_off - if payload == self._payload_on: + if payload == payload_on: self._aux = True - elif payload == self._payload_off: + elif payload == payload_off: self._aux = False else: _LOGGER.error("Invalid aux mode: %s", payload) @@ -451,7 +430,7 @@ def handle_aux_mode_received(topic, payload, qos): topics[CONF_AUX_STATE_TOPIC] = { 'topic': self._topic[CONF_AUX_STATE_TOPIC], 'msg_callback': handle_aux_mode_received, - 'qos': self._qos} + 'qos': qos} @callback def handle_hold_mode_received(topic, payload, qos): @@ -467,7 +446,7 @@ def handle_hold_mode_received(topic, payload, qos): topics[CONF_HOLD_STATE_TOPIC] = { 'topic': self._topic[CONF_HOLD_STATE_TOPIC], 'msg_callback': handle_hold_mode_received, - 'qos': self._qos} + 'qos': qos} self._sub_state = await subscription.async_subscribe_topics( self.hass, self._sub_state, @@ -486,7 +465,7 @@ def should_poll(self): @property def name(self): """Return the name of the climate device.""" - return self._name + return self._config.get(CONF_NAME) @property def temperature_unit(self): @@ -511,12 +490,12 @@ def current_operation(self): @property def operation_list(self): """Return the list of available operation modes.""" - return self._operation_list + return self._config.get(CONF_MODE_LIST) @property def target_temperature_step(self): """Return the supported step of target temperature.""" - return self._target_temperature_step + return self._config.get(CONF_TEMP_STEP) @property def is_away_mode_on(self): @@ -541,7 +520,7 @@ def current_fan_mode(self): @property def fan_list(self): """Return the list of available fan modes.""" - return self._fan_list + return self._config.get(CONF_FAN_MODE_LIST) async def async_set_temperature(self, **kwargs): """Set new target temperatures.""" @@ -554,19 +533,23 @@ async def async_set_temperature(self, **kwargs): # optimistic mode self._target_temperature = kwargs.get(ATTR_TEMPERATURE) - if self._send_if_off or self._current_operation != STATE_OFF: + if (self._config.get(CONF_SEND_IF_OFF) or + self._current_operation != STATE_OFF): mqtt.async_publish( self.hass, self._topic[CONF_TEMPERATURE_COMMAND_TOPIC], - kwargs.get(ATTR_TEMPERATURE), self._qos, self._retain) + kwargs.get(ATTR_TEMPERATURE), self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) self.async_schedule_update_ha_state() async def async_set_swing_mode(self, swing_mode): """Set new swing mode.""" - if self._send_if_off or self._current_operation != STATE_OFF: + if (self._config.get(CONF_SEND_IF_OFF) or + self._current_operation != STATE_OFF): mqtt.async_publish( self.hass, self._topic[CONF_SWING_MODE_COMMAND_TOPIC], - swing_mode, self._qos, self._retain) + swing_mode, self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None: self._current_swing_mode = swing_mode @@ -574,10 +557,12 @@ async def async_set_swing_mode(self, swing_mode): async def async_set_fan_mode(self, fan_mode): """Set new target temperature.""" - if self._send_if_off or self._current_operation != STATE_OFF: + if (self._config.get(CONF_SEND_IF_OFF) or + self._current_operation != STATE_OFF): mqtt.async_publish( self.hass, self._topic[CONF_FAN_MODE_COMMAND_TOPIC], - fan_mode, self._qos, self._retain) + fan_mode, self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None: self._current_fan_mode = fan_mode @@ -585,22 +570,24 @@ async def async_set_fan_mode(self, fan_mode): async def async_set_operation_mode(self, operation_mode) -> None: """Set new operation mode.""" + qos = self._config.get(CONF_QOS) + retain = self._config.get(CONF_RETAIN) if self._topic[CONF_POWER_COMMAND_TOPIC] is not None: if (self._current_operation == STATE_OFF and operation_mode != STATE_OFF): mqtt.async_publish( self.hass, self._topic[CONF_POWER_COMMAND_TOPIC], - self._payload_on, self._qos, self._retain) + self._config.get(CONF_PAYLOAD_ON), qos, retain) elif (self._current_operation != STATE_OFF and operation_mode == STATE_OFF): mqtt.async_publish( self.hass, self._topic[CONF_POWER_COMMAND_TOPIC], - self._payload_off, self._qos, self._retain) + self._config.get(CONF_PAYLOAD_OFF), qos, retain) if self._topic[CONF_MODE_COMMAND_TOPIC] is not None: mqtt.async_publish( self.hass, self._topic[CONF_MODE_COMMAND_TOPIC], - operation_mode, self._qos, self._retain) + operation_mode, qos, retain) if self._topic[CONF_MODE_STATE_TOPIC] is None: self._current_operation = operation_mode @@ -614,14 +601,16 @@ def current_swing_mode(self): @property def swing_list(self): """List of available swing modes.""" - return self._swing_list + return self._config.get(CONF_SWING_MODE_LIST) async def async_turn_away_mode_on(self): """Turn away mode on.""" if self._topic[CONF_AWAY_MODE_COMMAND_TOPIC] is not None: mqtt.async_publish(self.hass, self._topic[CONF_AWAY_MODE_COMMAND_TOPIC], - self._payload_on, self._qos, self._retain) + self._config.get(CONF_PAYLOAD_ON), + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is None: self._away = True @@ -632,7 +621,9 @@ async def async_turn_away_mode_off(self): if self._topic[CONF_AWAY_MODE_COMMAND_TOPIC] is not None: mqtt.async_publish(self.hass, self._topic[CONF_AWAY_MODE_COMMAND_TOPIC], - self._payload_off, self._qos, self._retain) + self._config.get(CONF_PAYLOAD_OFF), + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is None: self._away = False @@ -643,7 +634,8 @@ async def async_set_hold_mode(self, hold_mode): if self._topic[CONF_HOLD_COMMAND_TOPIC] is not None: mqtt.async_publish(self.hass, self._topic[CONF_HOLD_COMMAND_TOPIC], - hold_mode, self._qos, self._retain) + hold_mode, self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._topic[CONF_HOLD_STATE_TOPIC] is None: self._hold = hold_mode @@ -653,7 +645,9 @@ async def async_turn_aux_heat_on(self): """Turn auxiliary heater on.""" if self._topic[CONF_AUX_COMMAND_TOPIC] is not None: mqtt.async_publish(self.hass, self._topic[CONF_AUX_COMMAND_TOPIC], - self._payload_on, self._qos, self._retain) + self._config.get(CONF_PAYLOAD_ON), + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._topic[CONF_AUX_STATE_TOPIC] is None: self._aux = True @@ -663,7 +657,9 @@ async def async_turn_aux_heat_off(self): """Turn auxiliary heater off.""" if self._topic[CONF_AUX_COMMAND_TOPIC] is not None: mqtt.async_publish(self.hass, self._topic[CONF_AUX_COMMAND_TOPIC], - self._payload_off, self._qos, self._retain) + self._config.get(CONF_PAYLOAD_OFF), + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._topic[CONF_AUX_STATE_TOPIC] is None: self._aux = False @@ -707,9 +703,9 @@ def supported_features(self): @property def min_temp(self): """Return the minimum temperature.""" - return self._min_temp + return self._config.get(CONF_MIN_TEMP) @property def max_temp(self): """Return the maximum temperature.""" - return self._max_temp + return self._config.get(CONF_MAX_TEMP) From bbb40fde849a3189125d83bfb07769ffb488d641 Mon Sep 17 00:00:00 2001 From: emontnemery Date: Sun, 2 Dec 2018 10:31:46 +0100 Subject: [PATCH 200/325] Optionally do not log template rendering errors (#18724) --- homeassistant/helpers/template.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 99eb0a9c0345c..2173f972cba81 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -170,8 +170,10 @@ def async_render_with_possible_json_value(self, value, try: return self._compiled.render(variables).strip() except jinja2.TemplateError as ex: - _LOGGER.error("Error parsing value: %s (value: %s, template: %s)", - ex, value, self.template) + if error_value is _SENTINEL: + _LOGGER.error( + "Error parsing value: %s (value: %s, template: %s)", + ex, value, self.template) return value if error_value is _SENTINEL else error_value def _ensure_compiled(self): From a10cbadb57157f49752b79d4433322e7fd8e77dc Mon Sep 17 00:00:00 2001 From: Adam Mills Date: Sun, 2 Dec 2018 04:51:15 -0500 Subject: [PATCH 201/325] Restore states when removing/adding entities (#18890) --- homeassistant/helpers/restore_state.py | 14 ++++++++++---- tests/helpers/test_restore_state.py | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index cabaf64d859e8..33b612b555a03 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -165,13 +165,19 @@ def async_setup_dump(self, *args: Any) -> None: self.async_dump_states())) @callback - def async_register_entity(self, entity_id: str) -> None: + def async_restore_entity_added(self, entity_id: str) -> None: """Store this entity's state when hass is shutdown.""" self.entity_ids.add(entity_id) @callback - def async_unregister_entity(self, entity_id: str) -> None: + def async_restore_entity_removed(self, entity_id: str) -> None: """Unregister this entity from saving state.""" + # When an entity is being removed from hass, store its last state. This + # allows us to support state restoration if the entity is removed, then + # re-added while hass is still running. + self.last_states[entity_id] = StoredState( + self.hass.states.get(entity_id), dt_util.utcnow()) + self.entity_ids.remove(entity_id) @@ -184,7 +190,7 @@ async def async_added_to_hass(self) -> None: super().async_added_to_hass(), RestoreStateData.async_get_instance(self.hass), ) - data.async_register_entity(self.entity_id) + data.async_restore_entity_added(self.entity_id) async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" @@ -192,7 +198,7 @@ async def async_will_remove_from_hass(self) -> None: super().async_will_remove_from_hass(), RestoreStateData.async_get_instance(self.hass), ) - data.async_unregister_entity(self.entity_id) + data.async_restore_entity_removed(self.entity_id) async def async_get_last_state(self) -> Optional[State]: """Get the entity state from the previous run.""" diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index e6693d2cf6180..b13bc87421b4e 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -198,3 +198,23 @@ async def test_load_error(hass): state = await entity.async_get_last_state() assert state is None + + +async def test_state_saved_on_remove(hass): + """Test that we save entity state on removal.""" + entity = RestoreEntity() + entity.hass = hass + entity.entity_id = 'input_boolean.b0' + await entity.async_added_to_hass() + + hass.states.async_set('input_boolean.b0', 'on') + + data = await RestoreStateData.async_get_instance(hass) + + # No last states should currently be saved + assert not data.last_states + + await entity.async_will_remove_from_hass() + + # We should store the input boolean state when it is removed + assert data.last_states['input_boolean.b0'].state.state == 'on' From 0a68cae50719078a6702ff711985e794fada2b48 Mon Sep 17 00:00:00 2001 From: pbalogh77 Date: Sun, 2 Dec 2018 10:52:37 +0100 Subject: [PATCH 202/325] Fibaro ubs (#18889) * Fibaro HC connection, initial commit Very first steps working, connects, fetches devices, represents sensors, binary_sensors and lights towards HA. * Cover, switch, bugfixes Initial support for covers Initial support for switches Bugfixes * Some cleanup and improved lights pylint based cleanup light switches handled properly light features reported correctly * Added status updates and actions Lights, Blinds, Switches are mostly working now * Code cleanup, fiblary3 req Fiblary3 is now in pypi, set it as req Cleanup based on pylint * Included in .coveragerc and added how to use guide Included the fibaro component in coveragerc Added usage instructions to file header * PyLint inspired fixes Fixed pylint warnings * PyLint inspired fixes PyLint inspired fixes * updated to fiblary3 0.1.5 * Minor fixes to finally pass pull req Fixed fiblary3 to work with python 3.5 Updated fiblary3 to 0.1.6 (added energy and batteryLevel dummies) * module import and flake8 fixes Finally (hopefully) figured out what lint is complaining about * Fixed color support for lights, simplified callback Fixed color support for lights Simplified callback for updates Uses updated fiblary3 for color light handling * Lean and mean refactor While waiting for a brave reviewer, I've been making the code smaller and easier to understand. * Minor fixes to please HoundCI * Removed unused component Scenes are not implemented yet * Nicer comments. * DEVICE_CLASS, ignore plugins, improved mapping Added support for device class and icons in sensors and binary_sensors Improved mapping of sensors and added heuristic matching Added support for hidden devices Fixed conversion to float in sensors * Fixed dimming Fibaro apparently does not need, nor like the extra turnOn commands for dimmers * flake8 * Cleanup, Light fixes, switch power Cleanup of the component to separate init from connect, handle connection error better Improved light handling, especially for RGBW strips and working around Fibaro quirks Added energy and power reporting to switches * Missing comment added Missing comment added to please flake8 * Removed everything but bin.sensors Stripdown, hoping for a review * better aligned comments OMG * Fixes based on code review Fixes based on code review * Implemented stopping Implemented stopping of StateHandler thread Cleanup for clarity * Minor fix Removed unnecessary list copying * Nicer wording on shutdown * Minor changes based on code review * minor fixes based on code review * removed extra line break * Added Fibaro omcponents Added cover, light, sensor and switch components * Improved support for Fibaro UBS Improved support for Fibaro Universal Binary Sensor, when configured to flood sensor or motion sensor. --- homeassistant/components/binary_sensor/fibaro.py | 2 ++ homeassistant/components/fibaro.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/binary_sensor/fibaro.py b/homeassistant/components/binary_sensor/fibaro.py index 124ff88a9a371..ae8029e13f843 100644 --- a/homeassistant/components/binary_sensor/fibaro.py +++ b/homeassistant/components/binary_sensor/fibaro.py @@ -16,6 +16,8 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPES = { + 'com.fibaro.floodSensor': ['Flood', 'mdi:water', 'flood'], + 'com.fibaro.motionSensor': ['Motion', 'mdi:run', 'motion'], 'com.fibaro.doorSensor': ['Door', 'mdi:window-open', 'door'], 'com.fibaro.windowSensor': ['Window', 'mdi:window-open', 'window'], 'com.fibaro.smokeSensor': ['Smoke', 'mdi:smoking', 'smoke'], diff --git a/homeassistant/components/fibaro.py b/homeassistant/components/fibaro.py index 85bd5c3c0181e..51d7dd2ef7ed9 100644 --- a/homeassistant/components/fibaro.py +++ b/homeassistant/components/fibaro.py @@ -43,7 +43,8 @@ 'com.fibaro.smokeSensor': 'binary_sensor', 'com.fibaro.remoteSwitch': 'switch', 'com.fibaro.sensor': 'sensor', - 'com.fibaro.colorController': 'light' + 'com.fibaro.colorController': 'light', + 'com.fibaro.securitySensor': 'binary_sensor' } CONFIG_SCHEMA = vol.Schema({ From b7e25220832a852ebb261d0e13be66e875367b06 Mon Sep 17 00:00:00 2001 From: Andrew Hayworth Date: Sun, 2 Dec 2018 04:14:46 -0600 Subject: [PATCH 203/325] bugfix: ensure the `google_assistant` component respects `allow_unlock` (#18874) The `Config` object specific to the `google_assistant` component had a default value for `allow_unlock`. We were not overriding this default when constructing the Config object during `google_assistant` component setup, whereas we do when setting up the `cloud` component. To fix, we thread the `allow_unlock` parameter down through http setup, and ensure that it's set correctly. Moreover, we also change the ordering of the `Config` parameters, and remove the default. Future refactoring should not miss it, as it is now a required parameter. --- homeassistant/components/cloud/__init__.py | 2 +- homeassistant/components/google_assistant/helpers.py | 4 ++-- homeassistant/components/google_assistant/http.py | 8 ++++++-- tests/components/google_assistant/test_smart_home.py | 2 ++ tests/components/google_assistant/test_trait.py | 1 + 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index fed812138d628..329f83768cea6 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -191,9 +191,9 @@ def should_expose(entity): self._gactions_config = ga_h.Config( should_expose=should_expose, + allow_unlock=self.prefs.google_allow_unlock, agent_user_id=self.claims['cognito:username'], entity_config=conf.get(CONF_ENTITY_CONFIG), - allow_unlock=self.prefs.google_allow_unlock, ) return self._gactions_config diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index e71756d9fee24..f20a4106a161b 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -16,8 +16,8 @@ def __init__(self, code, msg): class Config: """Hold the configuration for Google Assistant.""" - def __init__(self, should_expose, agent_user_id, entity_config=None, - allow_unlock=False): + def __init__(self, should_expose, allow_unlock, agent_user_id, + entity_config=None): """Initialize the configuration.""" self.should_expose = should_expose self.agent_user_id = agent_user_id diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index f29e8bbae12b8..d688491fe8925 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -15,6 +15,7 @@ from .const import ( GOOGLE_ASSISTANT_API_ENDPOINT, + CONF_ALLOW_UNLOCK, CONF_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS, CONF_ENTITY_CONFIG, @@ -32,6 +33,7 @@ def async_register_http(hass, cfg): expose_by_default = cfg.get(CONF_EXPOSE_BY_DEFAULT) exposed_domains = cfg.get(CONF_EXPOSED_DOMAINS) entity_config = cfg.get(CONF_ENTITY_CONFIG) or {} + allow_unlock = cfg.get(CONF_ALLOW_UNLOCK, False) def is_exposed(entity) -> bool: """Determine if an entity should be exposed to Google Assistant.""" @@ -57,7 +59,7 @@ def is_exposed(entity) -> bool: return is_default_exposed or explicit_expose hass.http.register_view( - GoogleAssistantView(is_exposed, entity_config)) + GoogleAssistantView(is_exposed, entity_config, allow_unlock)) class GoogleAssistantView(HomeAssistantView): @@ -67,15 +69,17 @@ class GoogleAssistantView(HomeAssistantView): name = 'api:google_assistant' requires_auth = True - def __init__(self, is_exposed, entity_config): + def __init__(self, is_exposed, entity_config, allow_unlock): """Initialize the Google Assistant request handler.""" self.is_exposed = is_exposed self.entity_config = entity_config + self.allow_unlock = allow_unlock async def post(self, request: Request) -> Response: """Handle Google Assistant requests.""" message = await request.json() # type: dict config = Config(self.is_exposed, + self.allow_unlock, request['hass_user'].id, self.entity_config) result = await async_handle_message( diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 66e7747e06a77..36971224f92ed 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -11,6 +11,7 @@ BASIC_CONFIG = helpers.Config( should_expose=lambda state: True, + allow_unlock=False, agent_user_id='test-agent', ) REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf' @@ -35,6 +36,7 @@ async def test_sync_message(hass): config = helpers.Config( should_expose=lambda state: state.entity_id != 'light.not_expose', + allow_unlock=False, agent_user_id='test-agent', entity_config={ 'light.demo_light': { diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 5bf7b2fe566e6..e9169c9bbbe16 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -25,6 +25,7 @@ BASIC_CONFIG = helpers.Config( should_expose=lambda state: True, + allow_unlock=False, agent_user_id='test-agent', ) From 08dbd792cdcd3d56123e1734039fdb68fe5d4149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Sun, 2 Dec 2018 15:35:59 +0100 Subject: [PATCH 204/325] Improve logging and error handling --- homeassistant/components/sensor/tibber.py | 16 ++++++++-------- homeassistant/components/tibber/__init__.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index 997ecdd4c3dbf..245c98a76f010 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -35,15 +35,15 @@ async def async_setup_platform(hass, config, async_add_entities, tibber_connection = hass.data.get(TIBBER_DOMAIN) - try: - dev = [] - for home in tibber_connection.get_homes(): + dev = [] + for home in tibber_connection.get_homes(): + try: await home.update_info() - dev.append(TibberSensorElPrice(home)) - if home.has_real_time_consumption: - dev.append(TibberSensorRT(home)) - except (asyncio.TimeoutError, aiohttp.ClientError): - raise PlatformNotReady() + except (asyncio.TimeoutError, aiohttp.ClientError): + pass + dev.append(TibberSensorElPrice(home)) + if home.has_real_time_consumption: + dev.append(TibberSensorRT(home)) async_add_entities(dev, True) diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 2545417e0335b..4f6761f0b402a 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -16,7 +16,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['pyTibber==0.8.2'] +REQUIREMENTS = ['pyTibber==0.8.3'] DOMAIN = 'tibber' diff --git a/requirements_all.txt b/requirements_all.txt index 5f439e0dd0783..06b49b6d51483 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -827,7 +827,7 @@ pyRFXtrx==0.23 pySwitchmate==0.4.4 # homeassistant.components.tibber -pyTibber==0.8.2 +pyTibber==0.8.3 # homeassistant.components.switch.dlink pyW215==0.6.0 From eec4564c71002ff34f3903346df83d799d47405d Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Sun, 2 Dec 2018 15:46:14 +0100 Subject: [PATCH 205/325] Show ANSI color codes in logs in Hass.io (#18834) * Hass.io: Show ANSI color codes in logs * Lint * Fix test * Lint --- homeassistant/components/hassio/http.py | 15 --------------- tests/components/hassio/test_http.py | 8 ++++---- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index c3bd18fa9bbe8..be2806716a765 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -15,7 +15,6 @@ 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 from .const import X_HASSIO @@ -63,8 +62,6 @@ async def _handle(self, request, path): client = await self._command_proxy(path, request) data = await client.read() - if path.endswith('/logs'): - return _create_response_log(client, data) return _create_response(client, data) get = _handle @@ -114,18 +111,6 @@ def _create_response(client, data): ) -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.""" if NO_TIMEOUT.match(path): diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py index 4370c011891b8..07db126312b0d 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -102,15 +102,15 @@ def test_forward_request_no_auth_for_logo(hassio_client): @asyncio.coroutine def test_forward_log_request(hassio_client): - """Test fetching normal log path.""" + """Test fetching normal log path doesn't remove ANSI color escape codes.""" 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' + '_create_response') as mresp: + mresp.return_value = '\033[32mresponse\033[0m' resp = yield from hassio_client.get('/api/hassio/beer/logs', headers={ HTTP_HEADER_HA_AUTH: API_PASSWORD }) @@ -118,7 +118,7 @@ def test_forward_log_request(hassio_client): # Check we got right response assert resp.status == 200 body = yield from resp.text() - assert body == 'response' + assert body == '\033[32mresponse\033[0m' # Check we forwarded command assert len(mresp.mock_calls) == 1 From debae6ad2ef762090291d5f3f10934b17170494f Mon Sep 17 00:00:00 2001 From: Vladimir Eremin Date: Sun, 2 Dec 2018 14:51:04 +0000 Subject: [PATCH 206/325] Fix hdmi_cec entity race (#18753) * Update shouldn't be called before adding the entity. * Transitional states from https://github.com/Pulse-Eight/libcec/blob/8adc786bac9234fc298c941dd442c3af3155a522/include/cectypes.h#L458-L459 Addressing https://github.com/home-assistant/home-assistant/issues/12846 --- homeassistant/components/hdmi_cec.py | 43 ++++++++-------- .../components/media_player/hdmi_cec.py | 51 ++++++++++--------- homeassistant/components/switch/hdmi_cec.py | 16 +++--- 3 files changed, 57 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/hdmi_cec.py b/homeassistant/components/hdmi_cec.py index b5d64f48dc757..a630a9ef1adb8 100644 --- a/homeassistant/components/hdmi_cec.py +++ b/homeassistant/components/hdmi_cec.py @@ -320,38 +320,39 @@ def _start_cec(event): class CecDevice(Entity): """Representation of a HDMI CEC device entity.""" - def __init__(self, hass: HomeAssistant, device, logical) -> None: + def __init__(self, device, logical) -> None: """Initialize the device.""" self._device = device - self.hass = hass self._icon = None self._state = STATE_UNKNOWN self._logical_address = logical self.entity_id = "%s.%d" % (DOMAIN, self._logical_address) - device.set_update_callback(self._update) def update(self): """Update device status.""" - self._update() + device = self._device + from pycec.const import STATUS_PLAY, STATUS_STOP, STATUS_STILL, \ + POWER_OFF, POWER_ON + if device.power_status in [POWER_OFF, 3]: + self._state = STATE_OFF + elif device.status == STATUS_PLAY: + self._state = STATE_PLAYING + elif device.status == STATUS_STOP: + self._state = STATE_IDLE + elif device.status == STATUS_STILL: + self._state = STATE_PAUSED + elif device.power_status in [POWER_ON, 4]: + self._state = STATE_ON + else: + _LOGGER.warning("Unknown state: %d", device.power_status) + + async def async_added_to_hass(self): + """Register HDMI callbacks after initialization.""" + self._device.set_update_callback(self._update) def _update(self, device=None): - """Update device status.""" - if device: - from pycec.const import STATUS_PLAY, STATUS_STOP, STATUS_STILL, \ - POWER_OFF, POWER_ON - if device.power_status == POWER_OFF: - self._state = STATE_OFF - elif device.status == STATUS_PLAY: - self._state = STATE_PLAYING - elif device.status == STATUS_STOP: - self._state = STATE_IDLE - elif device.status == STATUS_STILL: - self._state = STATE_PAUSED - elif device.power_status == POWER_ON: - self._state = STATE_ON - else: - _LOGGER.warning("Unknown state: %d", device.power_status) - self.schedule_update_ha_state() + """Device status changed, schedule an update.""" + self.schedule_update_ha_state(True) @property def name(self): diff --git a/homeassistant/components/media_player/hdmi_cec.py b/homeassistant/components/media_player/hdmi_cec.py index cb4afadd058f1..d69d8a74ce6fb 100644 --- a/homeassistant/components/media_player/hdmi_cec.py +++ b/homeassistant/components/media_player/hdmi_cec.py @@ -13,7 +13,6 @@ SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, MediaPlayerDevice) from homeassistant.const import ( STATE_IDLE, STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING) -from homeassistant.core import HomeAssistant DEPENDENCIES = ['hdmi_cec'] @@ -26,20 +25,23 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Find and return HDMI devices as +switches.""" if ATTR_NEW in discovery_info: _LOGGER.info("Setting up HDMI devices %s", discovery_info[ATTR_NEW]) - add_entities(CecPlayerDevice(hass, hass.data.get(device), - hass.data.get(device).logical_address) for - device in discovery_info[ATTR_NEW]) + entities = [] + for device in discovery_info[ATTR_NEW]: + hdmi_device = hass.data.get(device) + entities.append(CecPlayerDevice( + hdmi_device, hdmi_device.logical_address, + )) + add_entities(entities, True) class CecPlayerDevice(CecDevice, MediaPlayerDevice): """Representation of a HDMI device as a Media player.""" - def __init__(self, hass: HomeAssistant, device, logical) -> None: + def __init__(self, device, logical) -> None: """Initialize the HDMI device.""" - CecDevice.__init__(self, hass, device, logical) + CecDevice.__init__(self, device, logical) self.entity_id = "%s.%s_%s" % ( DOMAIN, 'hdmi', hex(self._logical_address)[2:]) - self.update() def send_keypress(self, key): """Send keypress to CEC adapter.""" @@ -137,25 +139,24 @@ def state(self) -> str: """Cache state of device.""" return self._state - def _update(self, device=None): + def update(self): """Update device status.""" - if device: - from pycec.const import STATUS_PLAY, STATUS_STOP, STATUS_STILL, \ - POWER_OFF, POWER_ON - if device.power_status == POWER_OFF: - self._state = STATE_OFF - elif not self.support_pause: - if device.power_status == POWER_ON: - self._state = STATE_ON - elif device.status == STATUS_PLAY: - self._state = STATE_PLAYING - elif device.status == STATUS_STOP: - self._state = STATE_IDLE - elif device.status == STATUS_STILL: - self._state = STATE_PAUSED - else: - _LOGGER.warning("Unknown state: %s", device.status) - self.schedule_update_ha_state() + device = self._device + from pycec.const import STATUS_PLAY, STATUS_STOP, STATUS_STILL, \ + POWER_OFF, POWER_ON + if device.power_status in [POWER_OFF, 3]: + self._state = STATE_OFF + elif not self.support_pause: + if device.power_status in [POWER_ON, 4]: + self._state = STATE_ON + elif device.status == STATUS_PLAY: + self._state = STATE_PLAYING + elif device.status == STATUS_STOP: + self._state = STATE_IDLE + elif device.status == STATUS_STILL: + self._state = STATE_PAUSED + else: + _LOGGER.warning("Unknown state: %s", device.status) @property def supported_features(self): diff --git a/homeassistant/components/switch/hdmi_cec.py b/homeassistant/components/switch/hdmi_cec.py index b2697b4a2c4ef..1016e91d8d28d 100644 --- a/homeassistant/components/switch/hdmi_cec.py +++ b/homeassistant/components/switch/hdmi_cec.py @@ -9,7 +9,6 @@ from homeassistant.components.hdmi_cec import CecDevice, ATTR_NEW from homeassistant.components.switch import SwitchDevice, DOMAIN from homeassistant.const import STATE_OFF, STATE_STANDBY, STATE_ON -from homeassistant.core import HomeAssistant DEPENDENCIES = ['hdmi_cec'] @@ -22,20 +21,23 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Find and return HDMI devices as switches.""" if ATTR_NEW in discovery_info: _LOGGER.info("Setting up HDMI devices %s", discovery_info[ATTR_NEW]) - add_entities(CecSwitchDevice(hass, hass.data.get(device), - hass.data.get(device).logical_address) for - device in discovery_info[ATTR_NEW]) + entities = [] + for device in discovery_info[ATTR_NEW]: + hdmi_device = hass.data.get(device) + entities.append(CecSwitchDevice( + hdmi_device, hdmi_device.logical_address, + )) + add_entities(entities, True) class CecSwitchDevice(CecDevice, SwitchDevice): """Representation of a HDMI device as a Switch.""" - def __init__(self, hass: HomeAssistant, device, logical) -> None: + def __init__(self, device, logical) -> None: """Initialize the HDMI device.""" - CecDevice.__init__(self, hass, device, logical) + CecDevice.__init__(self, device, logical) self.entity_id = "%s.%s_%s" % ( DOMAIN, 'hdmi', hex(self._logical_address)[2:]) - self.update() def turn_on(self, **kwargs) -> None: """Turn device on.""" From fb12294bb72f112a9820d1f0565a6c7f22addec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Sun, 2 Dec 2018 15:54:52 +0100 Subject: [PATCH 207/325] remove unused import --- homeassistant/components/sensor/tibber.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index 245c98a76f010..d900067f98b2c 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -12,7 +12,6 @@ import aiohttp from homeassistant.components.tibber import DOMAIN as TIBBER_DOMAIN -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity import Entity from homeassistant.util import dt as dt_util from homeassistant.util import Throttle From bb13829e13588568a36c0a932d260a1c3d8b41e6 Mon Sep 17 00:00:00 2001 From: Martin Fuchs <39280548+fucm@users.noreply.github.com> Date: Sun, 2 Dec 2018 16:01:18 +0100 Subject: [PATCH 208/325] Set sensor to unavailable if battery is dead. (#18802) --- homeassistant/components/binary_sensor/tahoma.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/binary_sensor/tahoma.py b/homeassistant/components/binary_sensor/tahoma.py index 7af5a730c43f3..73035a2da0d78 100644 --- a/homeassistant/components/binary_sensor/tahoma.py +++ b/homeassistant/components/binary_sensor/tahoma.py @@ -41,6 +41,7 @@ def __init__(self, tahoma_device, controller): self._state = None self._icon = None self._battery = None + self._available = False @property def is_on(self): @@ -71,6 +72,11 @@ def device_state_attributes(self): attr[ATTR_BATTERY_LEVEL] = self._battery return attr + @property + def available(self): + """Return True if entity is available.""" + return self._available + def update(self): """Update the state.""" self.controller.get_states([self.tahoma_device]) @@ -82,11 +88,13 @@ def update(self): self._state = STATE_ON if 'core:SensorDefectState' in self.tahoma_device.active_states: - # Set to 'lowBattery' for low battery warning. + # 'lowBattery' for low battery warning. 'dead' for not available. self._battery = self.tahoma_device.active_states[ 'core:SensorDefectState'] + self._available = bool(self._battery != 'dead') else: self._battery = None + self._available = True if self._state == STATE_ON: self._icon = "mdi:fire" From afa99915e35d864b5169d20d0c1ab6fe7b6c37e2 Mon Sep 17 00:00:00 2001 From: emontnemery Date: Sun, 2 Dec 2018 16:16:36 +0100 Subject: [PATCH 209/325] Reconfigure MQTT light component if discovery info is changed (#18176) --- .../components/light/mqtt/schema_basic.py | 210 +++++++++++------- .../components/light/mqtt/schema_json.py | 168 ++++++++------ .../components/light/mqtt/schema_template.py | 124 +++++++---- tests/components/light/test_mqtt.py | 38 +++- tests/components/light/test_mqtt_json.py | 40 +++- tests/components/light/test_mqtt_template.py | 44 +++- 6 files changed, 426 insertions(+), 198 deletions(-) diff --git a/homeassistant/components/light/mqtt/schema_basic.py b/homeassistant/components/light/mqtt/schema_basic.py index 6a151092ef0e1..4c648b5ddaead 100644 --- a/homeassistant/components/light/mqtt/schema_basic.py +++ b/homeassistant/components/light/mqtt/schema_basic.py @@ -21,7 +21,7 @@ from homeassistant.components.mqtt import ( CONF_AVAILABILITY_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, - MqttAvailability, MqttDiscoveryUpdate) + MqttAvailability, MqttDiscoveryUpdate, subscription) from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util @@ -114,11 +114,73 @@ async def async_setup_entity_basic(hass, config, async_add_entities, config.setdefault( CONF_STATE_VALUE_TEMPLATE, config.get(CONF_VALUE_TEMPLATE)) - async_add_entities([MqttLight( - config.get(CONF_NAME), - config.get(CONF_UNIQUE_ID), - config.get(CONF_EFFECT_LIST), - { + async_add_entities([MqttLight(config, discovery_hash)]) + + +class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): + """Representation of a MQTT light.""" + + def __init__(self, config, discovery_hash): + """Initialize MQTT light.""" + self._state = False + self._sub_state = None + self._brightness = None + self._hs = None + self._color_temp = None + self._effect = None + self._white_value = None + self._supported_features = 0 + + self._name = None + self._effect_list = None + self._topic = None + self._qos = None + self._retain = None + self._payload = None + self._templates = None + self._optimistic = False + self._optimistic_rgb = False + self._optimistic_brightness = False + self._optimistic_color_temp = False + self._optimistic_effect = False + self._optimistic_hs = False + self._optimistic_white_value = False + self._optimistic_xy = False + self._brightness_scale = None + self._white_value_scale = None + self._on_command_type = None + self._unique_id = config.get(CONF_UNIQUE_ID) + + # Load config + self._setup_from_config(config) + + availability_topic = config.get(CONF_AVAILABILITY_TOPIC) + payload_available = config.get(CONF_PAYLOAD_AVAILABLE) + payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) + + MqttAvailability.__init__(self, availability_topic, self._qos, + payload_available, payload_not_available) + MqttDiscoveryUpdate.__init__(self, discovery_hash, + self.discovery_update) + + async def async_added_to_hass(self): + """Subscribe to MQTT events.""" + await super().async_added_to_hass() + await self._subscribe_topics() + + async def discovery_update(self, discovery_payload): + """Handle updated discovery message.""" + config = PLATFORM_SCHEMA_BASIC(discovery_payload) + self._setup_from_config(config) + await self.availability_discovery_update(config) + await self._subscribe_topics() + self.async_schedule_update_ha_state() + + def _setup_from_config(self, config): + """(Re)Setup the entity.""" + self._name = config.get(CONF_NAME) + self._effect_list = config.get(CONF_EFFECT_LIST) + topic = { key: config.get(key) for key in ( CONF_BRIGHTNESS_COMMAND_TOPIC, CONF_BRIGHTNESS_STATE_TOPIC, @@ -137,8 +199,15 @@ async def async_setup_entity_basic(hass, config, async_add_entities, CONF_XY_COMMAND_TOPIC, CONF_XY_STATE_TOPIC, ) - }, - { + } + self._topic = topic + self._qos = config.get(CONF_QOS) + self._retain = config.get(CONF_RETAIN) + self._payload = { + 'on': config.get(CONF_PAYLOAD_ON), + 'off': config.get(CONF_PAYLOAD_OFF), + } + self._templates = { CONF_BRIGHTNESS: config.get(CONF_BRIGHTNESS_VALUE_TEMPLATE), CONF_COLOR_TEMP: config.get(CONF_COLOR_TEMP_VALUE_TEMPLATE), CONF_EFFECT: config.get(CONF_EFFECT_VALUE_TEMPLATE), @@ -148,43 +217,9 @@ async def async_setup_entity_basic(hass, config, async_add_entities, CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE), CONF_WHITE_VALUE: config.get(CONF_WHITE_VALUE_TEMPLATE), CONF_XY: config.get(CONF_XY_VALUE_TEMPLATE), - }, - config.get(CONF_QOS), - config.get(CONF_RETAIN), - { - 'on': config.get(CONF_PAYLOAD_ON), - 'off': config.get(CONF_PAYLOAD_OFF), - }, - config.get(CONF_OPTIMISTIC), - config.get(CONF_BRIGHTNESS_SCALE), - config.get(CONF_WHITE_VALUE_SCALE), - config.get(CONF_ON_COMMAND_TYPE), - config.get(CONF_AVAILABILITY_TOPIC), - config.get(CONF_PAYLOAD_AVAILABLE), - config.get(CONF_PAYLOAD_NOT_AVAILABLE), - discovery_hash, - )]) - - -class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): - """Representation of a MQTT light.""" + } - def __init__(self, name, unique_id, effect_list, topic, templates, - qos, retain, payload, optimistic, brightness_scale, - white_value_scale, on_command_type, availability_topic, - payload_available, payload_not_available, discovery_hash): - """Initialize MQTT light.""" - MqttAvailability.__init__(self, availability_topic, qos, - payload_available, payload_not_available) - MqttDiscoveryUpdate.__init__(self, discovery_hash) - self._name = name - self._unique_id = unique_id - self._effect_list = effect_list - self._topic = topic - self._qos = qos - self._retain = retain - self._payload = payload - self._templates = templates + optimistic = config.get(CONF_OPTIMISTIC) self._optimistic = optimistic or topic[CONF_STATE_TOPIC] is None self._optimistic_rgb = \ optimistic or topic[CONF_RGB_STATE_TOPIC] is None @@ -204,15 +239,11 @@ def __init__(self, name, unique_id, effect_list, topic, templates, optimistic or topic[CONF_WHITE_VALUE_STATE_TOPIC] is None) self._optimistic_xy = \ optimistic or topic[CONF_XY_STATE_TOPIC] is None - self._brightness_scale = brightness_scale - self._white_value_scale = white_value_scale - self._on_command_type = on_command_type - self._state = False - self._brightness = None - self._hs = None - self._color_temp = None - self._effect = None - self._white_value = None + + self._brightness_scale = config.get(CONF_BRIGHTNESS_SCALE) + self._white_value_scale = config.get(CONF_WHITE_VALUE_SCALE) + self._on_command_type = config.get(CONF_ON_COMMAND_TYPE) + self._supported_features = 0 self._supported_features |= ( topic[CONF_RGB_COMMAND_TOPIC] is not None and @@ -233,12 +264,10 @@ def __init__(self, name, unique_id, effect_list, topic, templates, SUPPORT_WHITE_VALUE) self._supported_features |= ( topic[CONF_XY_COMMAND_TOPIC] is not None and SUPPORT_COLOR) - self._discovery_hash = discovery_hash - - async def async_added_to_hass(self): - """Subscribe to MQTT events.""" - await super().async_added_to_hass() + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" + topics = {} templates = {} for key, tpl in list(self._templates.items()): if tpl is None: @@ -264,9 +293,10 @@ def state_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_STATE_TOPIC], state_received, - self._qos) + topics[CONF_STATE_TOPIC] = { + 'topic': self._topic[CONF_STATE_TOPIC], + 'msg_callback': state_received, + 'qos': self._qos} elif self._optimistic and last_state: self._state = last_state.state == STATE_ON @@ -285,9 +315,10 @@ def brightness_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_BRIGHTNESS_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_BRIGHTNESS_STATE_TOPIC], - brightness_received, self._qos) + topics[CONF_BRIGHTNESS_STATE_TOPIC] = { + 'topic': self._topic[CONF_BRIGHTNESS_STATE_TOPIC], + 'msg_callback': brightness_received, + 'qos': self._qos} self._brightness = 255 elif self._optimistic_brightness and last_state\ and last_state.attributes.get(ATTR_BRIGHTNESS): @@ -314,9 +345,10 @@ def rgb_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_RGB_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_RGB_STATE_TOPIC], rgb_received, - self._qos) + topics[CONF_RGB_STATE_TOPIC] = { + 'topic': self._topic[CONF_RGB_STATE_TOPIC], + 'msg_callback': rgb_received, + 'qos': self._qos} self._hs = (0, 0) if self._optimistic_rgb and last_state\ and last_state.attributes.get(ATTR_HS_COLOR): @@ -337,9 +369,10 @@ def color_temp_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_COLOR_TEMP_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_COLOR_TEMP_STATE_TOPIC], - color_temp_received, self._qos) + topics[CONF_COLOR_TEMP_STATE_TOPIC] = { + 'topic': self._topic[CONF_COLOR_TEMP_STATE_TOPIC], + 'msg_callback': color_temp_received, + 'qos': self._qos} self._color_temp = 150 if self._optimistic_color_temp and last_state\ and last_state.attributes.get(ATTR_COLOR_TEMP): @@ -361,9 +394,10 @@ def effect_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_EFFECT_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_EFFECT_STATE_TOPIC], - effect_received, self._qos) + topics[CONF_EFFECT_STATE_TOPIC] = { + 'topic': self._topic[CONF_EFFECT_STATE_TOPIC], + 'msg_callback': effect_received, + 'qos': self._qos} self._effect = 'none' if self._optimistic_effect and last_state\ and last_state.attributes.get(ATTR_EFFECT): @@ -390,9 +424,10 @@ def hs_received(topic, payload, qos): payload) if self._topic[CONF_HS_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_HS_STATE_TOPIC], hs_received, - self._qos) + topics[CONF_HS_STATE_TOPIC] = { + 'topic': self._topic[CONF_HS_STATE_TOPIC], + 'msg_callback': hs_received, + 'qos': self._qos} self._hs = (0, 0) if self._optimistic_hs and last_state\ and last_state.attributes.get(ATTR_HS_COLOR): @@ -415,9 +450,10 @@ def white_value_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_WHITE_VALUE_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_WHITE_VALUE_STATE_TOPIC], - white_value_received, self._qos) + topics[CONF_WHITE_VALUE_STATE_TOPIC] = { + 'topic': self._topic[CONF_WHITE_VALUE_STATE_TOPIC], + 'msg_callback': white_value_received, + 'qos': self._qos} self._white_value = 255 elif self._optimistic_white_value and last_state\ and last_state.attributes.get(ATTR_WHITE_VALUE): @@ -441,9 +477,10 @@ def xy_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_XY_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_XY_STATE_TOPIC], xy_received, - self._qos) + topics[CONF_XY_STATE_TOPIC] = { + 'topic': self._topic[CONF_XY_STATE_TOPIC], + 'msg_callback': xy_received, + 'qos': self._qos} self._hs = (0, 0) if self._optimistic_xy and last_state\ and last_state.attributes.get(ATTR_HS_COLOR): @@ -451,6 +488,15 @@ def xy_received(topic, payload, qos): elif self._topic[CONF_XY_COMMAND_TOPIC] is not None: self._hs = (0, 0) + self._sub_state = await subscription.async_subscribe_topics( + self.hass, self._sub_state, + topics) + + async def async_will_remove_from_hass(self): + """Unsubscribe when removed.""" + await subscription.async_unsubscribe_topics(self.hass, self._sub_state) + await MqttAvailability.async_will_remove_from_hass(self) + @property def brightness(self): """Return the brightness of this light between 0..255.""" diff --git a/homeassistant/components/light/mqtt/schema_json.py b/homeassistant/components/light/mqtt/schema_json.py index 55df6cbfd5ee7..dd3c896532f27 100644 --- a/homeassistant/components/light/mqtt/schema_json.py +++ b/homeassistant/components/light/mqtt/schema_json.py @@ -6,7 +6,6 @@ """ import json import logging -from typing import Optional import voluptuous as vol @@ -19,7 +18,7 @@ from homeassistant.components.mqtt import ( CONF_AVAILABILITY_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, - MqttAvailability, MqttDiscoveryUpdate) + MqttAvailability, MqttDiscoveryUpdate, subscription) from homeassistant.const import ( CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_EFFECT, CONF_NAME, CONF_OPTIMISTIC, CONF_RGB, CONF_WHITE_VALUE, CONF_XY, STATE_ON) @@ -87,105 +86,129 @@ async def async_setup_entity_json(hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_hash): """Set up a MQTT JSON Light.""" - async_add_entities([MqttLightJson( - config.get(CONF_NAME), - config.get(CONF_UNIQUE_ID), - config.get(CONF_EFFECT_LIST), - { - key: config.get(key) for key in ( - CONF_STATE_TOPIC, - CONF_COMMAND_TOPIC - ) - }, - config.get(CONF_QOS), - config.get(CONF_RETAIN), - config.get(CONF_OPTIMISTIC), - config.get(CONF_BRIGHTNESS), - config.get(CONF_COLOR_TEMP), - config.get(CONF_EFFECT), - config.get(CONF_RGB), - config.get(CONF_WHITE_VALUE), - config.get(CONF_XY), - config.get(CONF_HS), - { - key: config.get(key) for key in ( - CONF_FLASH_TIME_SHORT, - CONF_FLASH_TIME_LONG - ) - }, - config.get(CONF_AVAILABILITY_TOPIC), - config.get(CONF_PAYLOAD_AVAILABLE), - config.get(CONF_PAYLOAD_NOT_AVAILABLE), - config.get(CONF_BRIGHTNESS_SCALE), - discovery_hash, - )]) + async_add_entities([MqttLightJson(config, discovery_hash)]) class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): """Representation of a MQTT JSON light.""" - def __init__(self, name, unique_id, effect_list, topic, qos, retain, - optimistic, brightness, color_temp, effect, rgb, white_value, - xy, hs, flash_times, availability_topic, payload_available, - payload_not_available, brightness_scale, - discovery_hash: Optional[str]): + def __init__(self, config, discovery_hash): """Initialize MQTT JSON light.""" - MqttAvailability.__init__(self, availability_topic, qos, - payload_available, payload_not_available) - MqttDiscoveryUpdate.__init__(self, discovery_hash) - self._name = name - self._unique_id = unique_id - self._effect_list = effect_list - self._topic = topic - self._qos = qos - self._retain = retain - self._optimistic = optimistic or topic[CONF_STATE_TOPIC] is None self._state = False - self._rgb = rgb - self._xy = xy - self._hs_support = hs + self._sub_state = None + self._supported_features = 0 + + self._name = None + self._effect_list = None + self._topic = None + self._qos = None + self._retain = None + self._optimistic = False + self._rgb = False + self._xy = False + self._hs_support = False + self._brightness = None + self._color_temp = None + self._effect = None + self._hs = None + self._white_value = None + self._flash_times = None + self._brightness_scale = None + self._unique_id = config.get(CONF_UNIQUE_ID) + + # Load config + self._setup_from_config(config) + + availability_topic = config.get(CONF_AVAILABILITY_TOPIC) + payload_available = config.get(CONF_PAYLOAD_AVAILABLE) + payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) + + MqttAvailability.__init__(self, availability_topic, self._qos, + payload_available, payload_not_available) + MqttDiscoveryUpdate.__init__(self, discovery_hash, + self.discovery_update) + + async def async_added_to_hass(self): + """Subscribe to MQTT events.""" + await super().async_added_to_hass() + await self._subscribe_topics() + + async def discovery_update(self, discovery_payload): + """Handle updated discovery message.""" + config = PLATFORM_SCHEMA_JSON(discovery_payload) + self._setup_from_config(config) + await self.availability_discovery_update(config) + await self._subscribe_topics() + self.async_schedule_update_ha_state() + + def _setup_from_config(self, config): + """(Re)Setup the entity.""" + self._name = config.get(CONF_NAME) + self._effect_list = config.get(CONF_EFFECT_LIST) + self._topic = { + key: config.get(key) for key in ( + CONF_STATE_TOPIC, + CONF_COMMAND_TOPIC + ) + } + self._qos = config.get(CONF_QOS) + self._retain = config.get(CONF_RETAIN) + optimistic = config.get(CONF_OPTIMISTIC) + self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None + + brightness = config.get(CONF_BRIGHTNESS) if brightness: self._brightness = 255 else: self._brightness = None + color_temp = config.get(CONF_COLOR_TEMP) if color_temp: self._color_temp = 150 else: self._color_temp = None + effect = config.get(CONF_EFFECT) if effect: self._effect = 'none' else: self._effect = None - if hs or rgb or xy: - self._hs = [0, 0] - else: - self._hs = None - + white_value = config.get(CONF_WHITE_VALUE) if white_value: self._white_value = 255 else: self._white_value = None - self._flash_times = flash_times - self._brightness_scale = brightness_scale + self._rgb = config.get(CONF_RGB) + self._xy = config.get(CONF_XY) + self._hs_support = config.get(CONF_HS) + + if self._hs_support or self._rgb or self._xy: + self._hs = [0, 0] + else: + self._hs = None + + self._flash_times = { + key: config.get(key) for key in ( + CONF_FLASH_TIME_SHORT, + CONF_FLASH_TIME_LONG + ) + } + self._brightness_scale = config.get(CONF_BRIGHTNESS_SCALE) self._supported_features = (SUPPORT_TRANSITION | SUPPORT_FLASH) - self._supported_features |= (rgb and SUPPORT_COLOR) + self._supported_features |= (self._rgb and SUPPORT_COLOR) self._supported_features |= (brightness and SUPPORT_BRIGHTNESS) self._supported_features |= (color_temp and SUPPORT_COLOR_TEMP) self._supported_features |= (effect and SUPPORT_EFFECT) self._supported_features |= (white_value and SUPPORT_WHITE_VALUE) - self._supported_features |= (xy and SUPPORT_COLOR) - self._supported_features |= (hs and SUPPORT_COLOR) - - async def async_added_to_hass(self): - """Subscribe to MQTT events.""" - await super().async_added_to_hass() + self._supported_features |= (self._xy and SUPPORT_COLOR) + self._supported_features |= (self._hs_support and SUPPORT_COLOR) + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" last_state = await self.async_get_last_state() @callback @@ -267,9 +290,11 @@ def state_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_STATE_TOPIC], state_received, - self._qos) + self._sub_state = await subscription.async_subscribe_topics( + self.hass, self._sub_state, + {'state_topic': {'topic': self._topic[CONF_STATE_TOPIC], + 'msg_callback': state_received, + 'qos': self._qos}}) if self._optimistic and last_state: self._state = last_state.state == STATE_ON @@ -284,6 +309,11 @@ def state_received(topic, payload, qos): if last_state.attributes.get(ATTR_WHITE_VALUE): self._white_value = last_state.attributes.get(ATTR_WHITE_VALUE) + async def async_will_remove_from_hass(self): + """Unsubscribe when removed.""" + await subscription.async_unsubscribe_topics(self.hass, self._sub_state) + await MqttAvailability.async_will_remove_from_hass(self) + @property def brightness(self): """Return the brightness of this light between 0..255.""" diff --git a/homeassistant/components/light/mqtt/schema_template.py b/homeassistant/components/light/mqtt/schema_template.py index 81ef3e901dd31..e14e8e32be7b7 100644 --- a/homeassistant/components/light/mqtt/schema_template.py +++ b/homeassistant/components/light/mqtt/schema_template.py @@ -18,7 +18,7 @@ from homeassistant.components.mqtt import ( CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, - MqttAvailability) + MqttAvailability, MqttDiscoveryUpdate, subscription) import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util from homeassistant.helpers.restore_state import RestoreEntity @@ -69,17 +69,69 @@ async def async_setup_entity_template(hass, config, async_add_entities, discovery_hash): """Set up a MQTT Template light.""" - async_add_entities([MqttTemplate( - hass, - config.get(CONF_NAME), - config.get(CONF_EFFECT_LIST), - { + async_add_entities([MqttTemplate(config, discovery_hash)]) + + +class MqttTemplate(MqttAvailability, MqttDiscoveryUpdate, Light, + RestoreEntity): + """Representation of a MQTT Template light.""" + + def __init__(self, config, discovery_hash): + """Initialize a MQTT Template light.""" + self._state = False + self._sub_state = None + + self._name = None + self._effect_list = None + self._topics = None + self._templates = None + self._optimistic = False + self._qos = None + self._retain = None + + # features + self._brightness = None + self._color_temp = None + self._white_value = None + self._hs = None + self._effect = None + + # Load config + self._setup_from_config(config) + + availability_topic = config.get(CONF_AVAILABILITY_TOPIC) + payload_available = config.get(CONF_PAYLOAD_AVAILABLE) + payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) + + MqttAvailability.__init__(self, availability_topic, self._qos, + payload_available, payload_not_available) + MqttDiscoveryUpdate.__init__(self, discovery_hash, + self.discovery_update) + + async def async_added_to_hass(self): + """Subscribe to MQTT events.""" + await super().async_added_to_hass() + await self._subscribe_topics() + + async def discovery_update(self, discovery_payload): + """Handle updated discovery message.""" + config = PLATFORM_SCHEMA_TEMPLATE(discovery_payload) + self._setup_from_config(config) + await self.availability_discovery_update(config) + await self._subscribe_topics() + self.async_schedule_update_ha_state() + + def _setup_from_config(self, config): + """(Re)Setup the entity.""" + self._name = config.get(CONF_NAME) + self._effect_list = config.get(CONF_EFFECT_LIST) + self._topics = { key: config.get(key) for key in ( CONF_STATE_TOPIC, CONF_COMMAND_TOPIC ) - }, - { + } + self._templates = { key: config.get(key) for key in ( CONF_BLUE_TEMPLATE, CONF_BRIGHTNESS_TEMPLATE, @@ -92,36 +144,15 @@ async def async_setup_entity_template(hass, config, async_add_entities, CONF_STATE_TEMPLATE, CONF_WHITE_VALUE_TEMPLATE, ) - }, - config.get(CONF_OPTIMISTIC), - config.get(CONF_QOS), - config.get(CONF_RETAIN), - config.get(CONF_AVAILABILITY_TOPIC), - config.get(CONF_PAYLOAD_AVAILABLE), - config.get(CONF_PAYLOAD_NOT_AVAILABLE), - )]) - - -class MqttTemplate(MqttAvailability, Light, RestoreEntity): - """Representation of a MQTT Template light.""" - - def __init__(self, hass, name, effect_list, topics, templates, optimistic, - qos, retain, availability_topic, payload_available, - payload_not_available): - """Initialize a MQTT Template light.""" - super().__init__(availability_topic, qos, payload_available, - payload_not_available) - self._name = name - self._effect_list = effect_list - self._topics = topics - self._templates = templates - self._optimistic = optimistic or topics[CONF_STATE_TOPIC] is None \ - or templates[CONF_STATE_TEMPLATE] is None - self._qos = qos - self._retain = retain + } + optimistic = config.get(CONF_OPTIMISTIC) + self._optimistic = optimistic \ + or self._topics[CONF_STATE_TOPIC] is None \ + or self._templates[CONF_STATE_TEMPLATE] is None + self._qos = config.get(CONF_QOS) + self._retain = config.get(CONF_RETAIN) # features - self._state = False if self._templates[CONF_BRIGHTNESS_TEMPLATE] is not None: self._brightness = 255 else: @@ -145,13 +176,11 @@ def __init__(self, hass, name, effect_list, topics, templates, optimistic, self._hs = None self._effect = None + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" for tpl in self._templates.values(): if tpl is not None: - tpl.hass = hass - - async def async_added_to_hass(self): - """Subscribe to MQTT events.""" - await super().async_added_to_hass() + tpl.hass = self.hass last_state = await self.async_get_last_state() @@ -221,9 +250,11 @@ def state_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topics[CONF_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topics[CONF_STATE_TOPIC], state_received, - self._qos) + self._sub_state = await subscription.async_subscribe_topics( + self.hass, self._sub_state, + {'state_topic': {'topic': self._topics[CONF_STATE_TOPIC], + 'msg_callback': state_received, + 'qos': self._qos}}) if self._optimistic and last_state: self._state = last_state.state == STATE_ON @@ -238,6 +269,11 @@ def state_received(topic, payload, qos): if last_state.attributes.get(ATTR_WHITE_VALUE): self._white_value = last_state.attributes.get(ATTR_WHITE_VALUE) + async def async_will_remove_from_hass(self): + """Unsubscribe when removed.""" + await subscription.async_unsubscribe_topics(self.hass, self._sub_state) + await MqttAvailability.async_will_remove_from_hass(self) + @property def brightness(self): """Return the brightness of this light between 0..255.""" diff --git a/tests/components/light/test_mqtt.py b/tests/components/light/test_mqtt.py index 3b4ff586c94bc..9e4fa3ebc790f 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -1067,7 +1067,7 @@ async def test_discovery_removal_light(hass, mqtt_mock, caplog): async def test_discovery_deprecated(hass, mqtt_mock, caplog): - """Test removal of discovered mqtt_json lights.""" + """Test discovery of mqtt light with deprecated platform option.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) await async_start(hass, 'homeassistant', {'mqtt': {}}, entry) data = ( @@ -1081,3 +1081,39 @@ async def test_discovery_deprecated(hass, mqtt_mock, caplog): state = hass.states.get('light.beer') assert state is not None assert state.name == 'Beer' + + +async def test_discovery_update_light(hass, mqtt_mock, caplog): + """Test removal of discovered light.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer",' + ' "status_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "status_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('light.beer') + assert state is not None + assert state.name == 'Beer' + + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('light.beer') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('light.milk') + assert state is None diff --git a/tests/components/light/test_mqtt_json.py b/tests/components/light/test_mqtt_json.py index ae34cb6d82790..8567dfd792198 100644 --- a/tests/components/light/test_mqtt_json.py +++ b/tests/components/light/test_mqtt_json.py @@ -557,7 +557,7 @@ async def test_discovery_removal(hass, mqtt_mock, caplog): async def test_discovery_deprecated(hass, mqtt_mock, caplog): - """Test removal of discovered mqtt_json lights.""" + """Test discovery of mqtt_json light with deprecated platform option.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) await async_start(hass, 'homeassistant', {'mqtt': {}}, entry) data = ( @@ -571,3 +571,41 @@ async def test_discovery_deprecated(hass, mqtt_mock, caplog): state = hass.states.get('light.beer') assert state is not None assert state.name == 'Beer' + + +async def test_discovery_update_light(hass, mqtt_mock, caplog): + """Test removal of discovered light.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer",' + ' "schema": "json",' + ' "status_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "schema": "json",' + ' "status_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('light.beer') + assert state is not None + assert state.name == 'Beer' + + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('light.beer') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('light.milk') + assert state is None diff --git a/tests/components/light/test_mqtt_template.py b/tests/components/light/test_mqtt_template.py index 56030da43f222..ce4a5f5a2e639 100644 --- a/tests/components/light/test_mqtt_template.py +++ b/tests/components/light/test_mqtt_template.py @@ -504,7 +504,7 @@ async def test_discovery(hass, mqtt_mock, caplog): async def test_discovery_deprecated(hass, mqtt_mock, caplog): - """Test removal of discovered mqtt_json lights.""" + """Test discovery of mqtt template light with deprecated option.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) await async_start(hass, 'homeassistant', {'mqtt': {}}, entry) data = ( @@ -520,3 +520,45 @@ async def test_discovery_deprecated(hass, mqtt_mock, caplog): state = hass.states.get('light.beer') assert state is not None assert state.name == 'Beer' + + +async def test_discovery_update_light(hass, mqtt_mock, caplog): + """Test removal of discovered light.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer",' + ' "schema": "template",' + ' "status_topic": "test_topic",' + ' "command_topic": "test_topic",' + ' "command_on_template": "on",' + ' "command_off_template": "off"}' + ) + data2 = ( + '{ "name": "Milk",' + ' "schema": "template",' + ' "status_topic": "test_topic",' + ' "command_topic": "test_topic",' + ' "command_on_template": "on",' + ' "command_off_template": "off"}' + ) + + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('light.beer') + assert state is not None + assert state.name == 'Beer' + + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('light.beer') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('light.milk') + assert state is None From ae9e3d83d7aca41492d05ff44cec0badc0377860 Mon Sep 17 00:00:00 2001 From: emontnemery Date: Sun, 2 Dec 2018 16:16:46 +0100 Subject: [PATCH 210/325] Reconfigure MQTT switch component if discovery info is changed (#18179) --- homeassistant/components/switch/mqtt.py | 134 ++++++++++++++---------- tests/components/switch/test_mqtt.py | 36 +++++++ 2 files changed, 112 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/switch/mqtt.py b/homeassistant/components/switch/mqtt.py index 250fe36b7003d..75da1f4cf7425 100644 --- a/homeassistant/components/switch/mqtt.py +++ b/homeassistant/components/switch/mqtt.py @@ -5,7 +5,6 @@ https://home-assistant.io/components/switch.mqtt/ """ import logging -from typing import Optional import voluptuous as vol @@ -14,7 +13,7 @@ ATTR_DISCOVERY_HASH, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_AVAILABILITY_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, MqttAvailability, - MqttDiscoveryUpdate, MqttEntityDeviceInfo) + MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW from homeassistant.components.switch import SwitchDevice from homeassistant.const import ( @@ -54,7 +53,7 @@ async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None): """Set up MQTT switch through configuration.yaml.""" - await _async_setup_entity(hass, config, async_add_entities, + await _async_setup_entity(config, async_add_entities, discovery_info) @@ -63,7 +62,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add a MQTT switch.""" config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(hass, config, async_add_entities, + await _async_setup_entity(config, async_add_entities, discovery_payload[ATTR_DISCOVERY_HASH]) async_dispatcher_connect( @@ -71,35 +70,10 @@ async def async_discover(discovery_payload): async_discover) -async def _async_setup_entity(hass, config, async_add_entities, +async def _async_setup_entity(config, async_add_entities, discovery_hash=None): """Set up the MQTT switch.""" - value_template = config.get(CONF_VALUE_TEMPLATE) - if value_template is not None: - value_template.hass = hass - - newswitch = MqttSwitch( - config.get(CONF_NAME), - config.get(CONF_ICON), - config.get(CONF_STATE_TOPIC), - config.get(CONF_COMMAND_TOPIC), - config.get(CONF_AVAILABILITY_TOPIC), - config.get(CONF_QOS), - config.get(CONF_RETAIN), - config.get(CONF_PAYLOAD_ON), - config.get(CONF_PAYLOAD_OFF), - config.get(CONF_STATE_ON), - config.get(CONF_STATE_OFF), - config.get(CONF_OPTIMISTIC), - config.get(CONF_PAYLOAD_AVAILABLE), - config.get(CONF_PAYLOAD_NOT_AVAILABLE), - config.get(CONF_UNIQUE_ID), - value_template, - config.get(CONF_DEVICE), - discovery_hash, - ) - - async_add_entities([newswitch]) + async_add_entities([MqttSwitch(config, discovery_hash)]) # pylint: disable=too-many-ancestors @@ -107,37 +81,74 @@ class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, SwitchDevice, RestoreEntity): """Representation of a switch that can be toggled using MQTT.""" - def __init__(self, name, icon, - state_topic, command_topic, availability_topic, - qos, retain, payload_on, payload_off, state_on, - state_off, optimistic, payload_available, - payload_not_available, unique_id: Optional[str], - value_template, device_config: Optional[ConfigType], - discovery_hash): + def __init__(self, config, discovery_hash): """Initialize the MQTT switch.""" - MqttAvailability.__init__(self, availability_topic, qos, + self._state = False + self._sub_state = None + + self._name = None + self._icon = None + self._state_topic = None + self._command_topic = None + self._qos = None + self._retain = None + self._payload_on = None + self._payload_off = None + self._state_on = None + self._state_off = None + self._optimistic = None + self._template = None + self._unique_id = config.get(CONF_UNIQUE_ID) + + # Load config + self._setup_from_config(config) + + availability_topic = config.get(CONF_AVAILABILITY_TOPIC) + payload_available = config.get(CONF_PAYLOAD_AVAILABLE) + payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) + device_config = config.get(CONF_DEVICE) + + MqttAvailability.__init__(self, availability_topic, self._qos, payload_available, payload_not_available) - MqttDiscoveryUpdate.__init__(self, discovery_hash) + MqttDiscoveryUpdate.__init__(self, discovery_hash, + self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config) - self._state = False - self._name = name - self._icon = icon - self._state_topic = state_topic - self._command_topic = command_topic - self._qos = qos - self._retain = retain - self._payload_on = payload_on - self._payload_off = payload_off - self._state_on = state_on if state_on else self._payload_on - self._state_off = state_off if state_off else self._payload_off - self._optimistic = optimistic - self._template = value_template - self._unique_id = unique_id - self._discovery_hash = discovery_hash async def async_added_to_hass(self): """Subscribe to MQTT events.""" await super().async_added_to_hass() + await self._subscribe_topics() + + async def discovery_update(self, discovery_payload): + """Handle updated discovery message.""" + config = PLATFORM_SCHEMA(discovery_payload) + self._setup_from_config(config) + await self.availability_discovery_update(config) + await self._subscribe_topics() + self.async_schedule_update_ha_state() + + def _setup_from_config(self, config): + """(Re)Setup the entity.""" + self._name = config.get(CONF_NAME) + self._icon = config.get(CONF_ICON) + self._state_topic = config.get(CONF_STATE_TOPIC) + self._command_topic = config.get(CONF_COMMAND_TOPIC) + self._qos = config.get(CONF_QOS) + self._retain = config.get(CONF_RETAIN) + self._payload_on = config.get(CONF_PAYLOAD_ON) + self._payload_off = config.get(CONF_PAYLOAD_OFF) + state_on = config.get(CONF_STATE_ON) + self._state_on = state_on if state_on else self._payload_on + state_off = config.get(CONF_STATE_OFF) + self._state_off = state_off if state_off else self._payload_off + self._optimistic = config.get(CONF_OPTIMISTIC) + config.get(CONF_UNIQUE_ID) + self._template = config.get(CONF_VALUE_TEMPLATE) + + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" + if self._template is not None: + self._template.hass = self.hass @callback def state_message_received(topic, payload, qos): @@ -156,15 +167,22 @@ def state_message_received(topic, payload, qos): # Force into optimistic mode. self._optimistic = True else: - await mqtt.async_subscribe( - self.hass, self._state_topic, state_message_received, - self._qos) + self._sub_state = await subscription.async_subscribe_topics( + self.hass, self._sub_state, + {'state_topic': {'topic': self._state_topic, + 'msg_callback': state_message_received, + 'qos': self._qos}}) if self._optimistic: last_state = await self.async_get_last_state() if last_state: self._state = last_state.state == STATE_ON + async def async_will_remove_from_hass(self): + """Unsubscribe when removed.""" + await subscription.async_unsubscribe_topics(self.hass, self._sub_state) + await MqttAvailability.async_will_remove_from_hass(self) + @property def should_poll(self): """Return the polling state.""" diff --git a/tests/components/switch/test_mqtt.py b/tests/components/switch/test_mqtt.py index 5cfefd7a0c82a..a37572cc99228 100644 --- a/tests/components/switch/test_mqtt.py +++ b/tests/components/switch/test_mqtt.py @@ -361,6 +361,42 @@ async def test_discovery_removal_switch(hass, mqtt_mock, caplog): assert state is None +async def test_discovery_update_switch(hass, mqtt_mock, caplog): + """Test expansion of discovered switch.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer",' + ' "status_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "status_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + + async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('switch.beer') + assert state is not None + assert state.name == 'Beer' + + async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('switch.beer') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('switch.milk') + assert state is None + + async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT switch device registry integration.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) From d1a621601d145bd4cae5b09037485ac8a81ff54b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 2 Dec 2018 16:32:53 +0100 Subject: [PATCH 211/325] No more opt-out auth (#18854) * No more opt-out auth * Fix var --- homeassistant/auth/__init__.py | 5 - homeassistant/components/config/__init__.py | 6 +- homeassistant/components/frontend/__init__.py | 9 +- homeassistant/components/hassio/__init__.py | 8 +- homeassistant/components/http/__init__.py | 7 +- homeassistant/components/http/auth.py | 22 ++--- homeassistant/components/notify/html5.py | 2 +- .../components/onboarding/__init__.py | 4 - .../components/websocket_api/auth.py | 5 +- .../components/alexa/test_flash_briefings.py | 4 +- tests/components/auth/test_init.py | 3 +- tests/components/calendar/test_init.py | 8 +- tests/components/camera/test_generic.py | 20 ++-- tests/components/camera/test_local_file.py | 8 +- tests/components/cloud/test_http_api.py | 4 +- tests/components/config/test_auth.py | 27 +++-- tests/components/config/test_automation.py | 12 +-- .../components/config/test_config_entries.py | 4 +- tests/components/config/test_core.py | 4 +- tests/components/config/test_customize.py | 16 +-- tests/components/config/test_group.py | 20 ++-- tests/components/config/test_hassbian.py | 8 +- tests/components/config/test_zwave.py | 4 +- tests/components/conftest.py | 98 +------------------ .../device_tracker/test_locative.py | 4 +- .../components/device_tracker/test_meraki.py | 4 +- tests/components/frontend/test_init.py | 16 ++- tests/components/geofency/test_init.py | 4 +- tests/components/hassio/test_init.py | 26 +---- tests/components/http/test_auth.py | 46 ++------- tests/components/mailbox/test_init.py | 4 +- tests/components/notify/test_html5.py | 50 +++++----- tests/components/onboarding/test_init.py | 17 ++-- tests/components/test_logbook.py | 8 +- tests/components/test_prometheus.py | 6 +- tests/components/test_rss_feed_template.py | 4 +- tests/components/tts/test_init.py | 65 ++++++------ tests/components/websocket_api/test_auth.py | 78 +++++++-------- .../components/websocket_api/test_commands.py | 58 +---------- tests/conftest.py | 68 ++++++++++++- tests/helpers/test_aiohttp_client.py | 4 +- tests/test_config.py | 4 - 42 files changed, 304 insertions(+), 470 deletions(-) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 7d8ef13d2bb92..e53385880e5c3 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -78,11 +78,6 @@ def __init__(self, hass: HomeAssistant, store: auth_store.AuthStore, hass, self._async_create_login_flow, self._async_finish_login_flow) - @property - def active(self) -> bool: - """Return if any auth providers are registered.""" - return bool(self._providers) - @property def support_legacy(self) -> bool: """ diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index f2cfff1f34209..4154ca337a385 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -14,6 +14,8 @@ DOMAIN = 'config' DEPENDENCIES = ['http'] SECTIONS = ( + 'auth', + 'auth_provider_homeassistant', 'automation', 'config_entries', 'core', @@ -58,10 +60,6 @@ def component_loaded(event): tasks = [setup_panel(panel_name) for panel_name in SECTIONS] - if hass.auth.active: - tasks.append(setup_panel('auth')) - tasks.append(setup_panel('auth_provider_homeassistant')) - for panel_name in ON_DEMAND: if panel_name in hass.config.components: tasks.append(setup_panel(panel_name)) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index c16907007cf31..f8f7cb3b1edc8 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -238,7 +238,7 @@ async def async_setup(hass, config): if os.path.isdir(local): hass.http.register_static_path("/local", local, not is_dev) - index_view = IndexView(repo_path, js_version, hass.auth.active) + index_view = IndexView(repo_path, js_version) hass.http.register_view(index_view) hass.http.register_view(AuthorizeView(repo_path, js_version)) @@ -364,11 +364,10 @@ class IndexView(HomeAssistantView): requires_auth = False extra_urls = ['/states', '/states/{extra}'] - def __init__(self, repo_path, js_option, auth_active): + def __init__(self, repo_path, js_option): """Initialize the frontend view.""" self.repo_path = repo_path self.js_option = js_option - self.auth_active = auth_active self._template_cache = {} def get_template(self, latest): @@ -415,8 +414,6 @@ async def get(self, request, extra=None): # do not try to auto connect on load no_auth = '0' - use_oauth = '1' if self.auth_active else '0' - template = await hass.async_add_job(self.get_template, latest) extra_key = DATA_EXTRA_HTML_URL if latest else DATA_EXTRA_HTML_URL_ES5 @@ -425,7 +422,7 @@ async def get(self, request, extra=None): no_auth=no_auth, theme_color=MANIFEST_JSON['theme_color'], extra_urls=hass.data[extra_key], - use_oauth=use_oauth + use_oauth='1' ) return web.Response(text=template.render(**template_params), diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 6bfcaaa5d85ab..3c058281b0a78 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -213,13 +213,7 @@ async def async_setup(hass, config): embed_iframe=True, ) - # Temporary. No refresh token tells supervisor to use API password. - if hass.auth.active: - token = refresh_token.token - else: - token = None - - await hassio.update_hass_api(config.get('http', {}), token) + await hassio.update_hass_api(config.get('http', {}), refresh_token.token) if 'homeassistant' in config: await hassio.update_hass_timezone(config['homeassistant']) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 7180002430aad..a6b9588fce3d0 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -200,14 +200,13 @@ def __init__(self, hass, api_password, if is_ban_enabled: setup_bans(hass, app, login_threshold) - if hass.auth.active and hass.auth.support_legacy: + if hass.auth.support_legacy: _LOGGER.warning( "legacy_api_password support has been enabled. If you don't " "require it, remove the 'api_password' from your http config.") - setup_auth(app, trusted_networks, hass.auth.active, - support_legacy=hass.auth.support_legacy, - api_password=api_password) + setup_auth(app, trusted_networks, + api_password if hass.auth.support_legacy else None) setup_cors(app, cors_origins) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index ae6abf04c0285..6cd211613ce1e 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -41,29 +41,26 @@ def async_sign_path(hass, refresh_token_id, path, expiration): @callback -def setup_auth(app, trusted_networks, use_auth, - support_legacy=False, api_password=None): +def setup_auth(app, trusted_networks, api_password): """Create auth middleware for the app.""" old_auth_warning = set() - legacy_auth = (not use_auth or support_legacy) and api_password @middleware async def auth_middleware(request, handler): """Authenticate as middleware.""" authenticated = False - if use_auth and (HTTP_HEADER_HA_AUTH in request.headers or - DATA_API_PASSWORD in request.query): + if (HTTP_HEADER_HA_AUTH in request.headers or + DATA_API_PASSWORD in request.query): if request.path not in old_auth_warning: _LOGGER.log( - logging.INFO if support_legacy else logging.WARNING, + logging.INFO if api_password else logging.WARNING, 'You need to use a bearer token to access %s from %s', request.path, request[KEY_REAL_IP]) old_auth_warning.add(request.path) if (hdrs.AUTHORIZATION in request.headers and - await async_validate_auth_header( - request, api_password if legacy_auth else None)): + await async_validate_auth_header(request, api_password)): # it included both use_auth and api_password Basic auth authenticated = True @@ -73,7 +70,7 @@ async def auth_middleware(request, handler): await async_validate_signed_request(request)): authenticated = True - elif (legacy_auth and HTTP_HEADER_HA_AUTH in request.headers and + elif (api_password and HTTP_HEADER_HA_AUTH in request.headers and hmac.compare_digest( api_password.encode('utf-8'), request.headers[HTTP_HEADER_HA_AUTH].encode('utf-8'))): @@ -82,7 +79,7 @@ async def auth_middleware(request, handler): request['hass_user'] = await legacy_api_password.async_get_user( app['hass']) - elif (legacy_auth and DATA_API_PASSWORD in request.query and + elif (api_password and DATA_API_PASSWORD in request.query and hmac.compare_digest( api_password.encode('utf-8'), request.query[DATA_API_PASSWORD].encode('utf-8'))): @@ -98,11 +95,6 @@ async def auth_middleware(request, handler): break authenticated = True - elif not use_auth and api_password is None: - # If neither password nor auth_providers set, - # just always set authenticated=True - authenticated = True - request[KEY_AUTHENTICATED] = authenticated return await handler(request) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index fa93cc4ba4ddb..771606b935fe0 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -242,7 +242,7 @@ def decode_jwt(self, token): # 2b. If decode is unsuccessful, return a 401. target_check = jwt.decode(token, verify=False) - if target_check[ATTR_TARGET] in self.registrations: + if target_check.get(ATTR_TARGET) in self.registrations: possible_target = self.registrations[target_check[ATTR_TARGET]] key = possible_target[ATTR_SUBSCRIPTION][ATTR_KEYS][ATTR_AUTH] try: diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py index 376575e34408a..25aca9f8afaaf 100644 --- a/homeassistant/components/onboarding/__init__.py +++ b/homeassistant/components/onboarding/__init__.py @@ -14,10 +14,6 @@ @callback def async_is_onboarded(hass): """Return if Home Assistant has been onboarded.""" - # Temporarily: if auth not active, always set onboarded=True - if not hass.auth.active: - return True - return hass.data.get(DOMAIN, True) diff --git a/homeassistant/components/websocket_api/auth.py b/homeassistant/components/websocket_api/auth.py index db41f3df06d27..434775c9b9be8 100644 --- a/homeassistant/components/websocket_api/auth.py +++ b/homeassistant/components/websocket_api/auth.py @@ -69,7 +69,7 @@ async def async_handle(self, msg): self._send_message(auth_invalid_message(error_msg)) raise Disconnect - if self._hass.auth.active and 'access_token' in msg: + if 'access_token' in msg: self._logger.debug("Received access_token") refresh_token = \ await self._hass.auth.async_validate_access_token( @@ -78,8 +78,7 @@ async def async_handle(self, msg): return await self._async_finish_auth( refresh_token.user, refresh_token) - elif ((not self._hass.auth.active or self._hass.auth.support_legacy) - and 'api_password' in msg): + elif self._hass.auth.support_legacy and 'api_password' in msg: self._logger.debug("Received api_password") if validate_password(self._request, msg['api_password']): return await self._async_finish_auth(None, None) diff --git a/tests/components/alexa/test_flash_briefings.py b/tests/components/alexa/test_flash_briefings.py index d7871e82afc58..592ec5854211d 100644 --- a/tests/components/alexa/test_flash_briefings.py +++ b/tests/components/alexa/test_flash_briefings.py @@ -21,7 +21,7 @@ @pytest.fixture -def alexa_client(loop, hass, aiohttp_client): +def alexa_client(loop, hass, hass_client): """Initialize a Home Assistant server for testing this module.""" @callback def mock_service(call): @@ -49,7 +49,7 @@ def mock_service(call): }, } })) - return loop.run_until_complete(aiohttp_client(hass.http.app)) + return loop.run_until_complete(hass_client()) def _flash_briefing_req(client, briefing_id): diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index e28f7be43413a..1193526d2be44 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -114,8 +114,7 @@ async def test_ws_current_user(hass, hass_ws_client, hass_access_token): user.credentials.append(credential) assert len(user.credentials) == 1 - with patch('homeassistant.auth.AuthManager.active', return_value=True): - client = await hass_ws_client(hass, hass_access_token) + client = await hass_ws_client(hass, hass_access_token) await client.send_json({ 'id': 5, diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index a5f6a751b46f2..ff475376587ef 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -5,11 +5,11 @@ import homeassistant.util.dt as dt_util -async def test_events_http_api(hass, aiohttp_client): +async def test_events_http_api(hass, hass_client): """Test the calendar demo view.""" await async_setup_component(hass, 'calendar', {'calendar': {'platform': 'demo'}}) - client = await aiohttp_client(hass.http.app) + client = await hass_client() response = await client.get( '/api/calendars/calendar.calendar_2') assert response.status == 400 @@ -24,11 +24,11 @@ async def test_events_http_api(hass, aiohttp_client): assert events[0]['title'] == 'Future Event' -async def test_calendars_http_api(hass, aiohttp_client): +async def test_calendars_http_api(hass, hass_client): """Test the calendar demo view.""" await async_setup_component(hass, 'calendar', {'calendar': {'platform': 'demo'}}) - client = await aiohttp_client(hass.http.app) + client = await hass_client() response = await client.get('/api/calendars') assert response.status == 200 data = await response.json() diff --git a/tests/components/camera/test_generic.py b/tests/components/camera/test_generic.py index b981fced32020..843bda0656c43 100644 --- a/tests/components/camera/test_generic.py +++ b/tests/components/camera/test_generic.py @@ -7,7 +7,7 @@ @asyncio.coroutine -def test_fetching_url(aioclient_mock, hass, aiohttp_client): +def test_fetching_url(aioclient_mock, hass, hass_client): """Test that it fetches the given url.""" aioclient_mock.get('http://example.com', text='hello world') @@ -20,7 +20,7 @@ def test_fetching_url(aioclient_mock, hass, aiohttp_client): 'password': 'pass' }}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.get('/api/camera_proxy/camera.config_test') @@ -34,7 +34,7 @@ def test_fetching_url(aioclient_mock, hass, aiohttp_client): @asyncio.coroutine -def test_fetching_without_verify_ssl(aioclient_mock, hass, aiohttp_client): +def test_fetching_without_verify_ssl(aioclient_mock, hass, hass_client): """Test that it fetches the given url when ssl verify is off.""" aioclient_mock.get('https://example.com', text='hello world') @@ -48,7 +48,7 @@ def test_fetching_without_verify_ssl(aioclient_mock, hass, aiohttp_client): 'verify_ssl': 'false', }}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.get('/api/camera_proxy/camera.config_test') @@ -56,7 +56,7 @@ def test_fetching_without_verify_ssl(aioclient_mock, hass, aiohttp_client): @asyncio.coroutine -def test_fetching_url_with_verify_ssl(aioclient_mock, hass, aiohttp_client): +def test_fetching_url_with_verify_ssl(aioclient_mock, hass, hass_client): """Test that it fetches the given url when ssl verify is explicitly on.""" aioclient_mock.get('https://example.com', text='hello world') @@ -70,7 +70,7 @@ def test_fetching_url_with_verify_ssl(aioclient_mock, hass, aiohttp_client): 'verify_ssl': 'true', }}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.get('/api/camera_proxy/camera.config_test') @@ -78,7 +78,7 @@ def test_fetching_url_with_verify_ssl(aioclient_mock, hass, aiohttp_client): @asyncio.coroutine -def test_limit_refetch(aioclient_mock, hass, aiohttp_client): +def test_limit_refetch(aioclient_mock, hass, hass_client): """Test that it fetches the given url.""" aioclient_mock.get('http://example.com/5a', text='hello world') aioclient_mock.get('http://example.com/10a', text='hello world') @@ -94,7 +94,7 @@ def test_limit_refetch(aioclient_mock, hass, aiohttp_client): 'limit_refetch_to_url_change': True, }}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.get('/api/camera_proxy/camera.config_test') @@ -139,7 +139,7 @@ def test_limit_refetch(aioclient_mock, hass, aiohttp_client): @asyncio.coroutine -def test_camera_content_type(aioclient_mock, hass, aiohttp_client): +def test_camera_content_type(aioclient_mock, hass, hass_client): """Test generic camera with custom content_type.""" svg_image = '' urlsvg = 'https://upload.wikimedia.org/wikipedia/commons/0/02/SVG_logo.svg' @@ -158,7 +158,7 @@ def test_camera_content_type(aioclient_mock, hass, aiohttp_client): yield from async_setup_component(hass, 'camera', { 'camera': [cam_config_svg, cam_config_normal]}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp_1 = yield from client.get('/api/camera_proxy/camera.config_test_svg') assert aioclient_mock.call_count == 1 diff --git a/tests/components/camera/test_local_file.py b/tests/components/camera/test_local_file.py index 0a57512aabd55..f2dbb2941360f 100644 --- a/tests/components/camera/test_local_file.py +++ b/tests/components/camera/test_local_file.py @@ -11,7 +11,7 @@ @asyncio.coroutine -def test_loading_file(hass, aiohttp_client): +def test_loading_file(hass, hass_client): """Test that it loads image from disk.""" mock_registry(hass) @@ -24,7 +24,7 @@ def test_loading_file(hass, aiohttp_client): 'file_path': 'mock.file', }}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() m_open = mock.mock_open(read_data=b'hello') with mock.patch( @@ -56,7 +56,7 @@ def test_file_not_readable(hass, caplog): @asyncio.coroutine -def test_camera_content_type(hass, aiohttp_client): +def test_camera_content_type(hass, hass_client): """Test local_file camera content_type.""" cam_config_jpg = { 'name': 'test_jpg', @@ -83,7 +83,7 @@ def test_camera_content_type(hass, aiohttp_client): 'camera': [cam_config_jpg, cam_config_png, cam_config_svg, cam_config_noext]}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() image = 'hello' m_open = mock.mock_open(read_data=image.encode()) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 57e92ba762820..84d35f4bdd834 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -52,10 +52,10 @@ def setup_api(hass): @pytest.fixture -def cloud_client(hass, aiohttp_client): +def cloud_client(hass, hass_client): """Fixture that can fetch from the cloud client.""" with patch('homeassistant.components.cloud.Cloud.write_user_info'): - yield hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + yield hass.loop.run_until_complete(hass_client()) @pytest.fixture diff --git a/tests/components/config/test_auth.py b/tests/components/config/test_auth.py index f7e348e847665..b5e0a8c91977c 100644 --- a/tests/components/config/test_auth.py +++ b/tests/components/config/test_auth.py @@ -1,6 +1,4 @@ """Test config entries API.""" -from unittest.mock import PropertyMock, patch - import pytest from homeassistant.auth import models as auth_models @@ -9,14 +7,6 @@ from tests.common import MockGroup, MockUser, CLIENT_ID -@pytest.fixture(autouse=True) -def auth_active(hass): - """Mock that auth is active.""" - with patch('homeassistant.auth.AuthManager.active', - PropertyMock(return_value=True)): - yield - - @pytest.fixture(autouse=True) def setup_config(hass, aiohttp_client): """Fixture that sets up the auth provider homeassistant module.""" @@ -37,7 +27,7 @@ async def test_list_requires_owner(hass, hass_ws_client, hass_access_token): assert result['error']['code'] == 'unauthorized' -async def test_list(hass, hass_ws_client): +async def test_list(hass, hass_ws_client, hass_admin_user): """Test get users.""" group = MockGroup().add_to_hass(hass) @@ -80,8 +70,17 @@ async def test_list(hass, hass_ws_client): result = await client.receive_json() assert result['success'], result data = result['result'] - assert len(data) == 3 + assert len(data) == 4 assert data[0] == { + 'id': hass_admin_user.id, + 'name': 'Mock User', + 'is_owner': False, + 'is_active': True, + 'system_generated': False, + 'group_ids': [group.id for group in hass_admin_user.groups], + 'credentials': [] + } + assert data[1] == { 'id': owner.id, 'name': 'Test Owner', 'is_owner': True, @@ -90,7 +89,7 @@ async def test_list(hass, hass_ws_client): 'group_ids': [group.id for group in owner.groups], 'credentials': [{'type': 'homeassistant'}] } - assert data[1] == { + assert data[2] == { 'id': system.id, 'name': 'Test Hass.io', 'is_owner': False, @@ -99,7 +98,7 @@ async def test_list(hass, hass_ws_client): 'group_ids': [], 'credentials': [], } - assert data[2] == { + assert data[3] == { 'id': inactive.id, 'name': 'Inactive User', 'is_owner': False, diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py index 2c888dd2dd25d..f97559a224f66 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -6,12 +6,12 @@ from homeassistant.components import config -async def test_get_device_config(hass, aiohttp_client): +async def test_get_device_config(hass, hass_client): """Test getting device config.""" with patch.object(config, 'SECTIONS', ['automation']): await async_setup_component(hass, 'config', {}) - client = await aiohttp_client(hass.http.app) + client = await hass_client() def mock_read(path): """Mock reading data.""" @@ -34,12 +34,12 @@ def mock_read(path): assert result == {'id': 'moon'} -async def test_update_device_config(hass, aiohttp_client): +async def test_update_device_config(hass, hass_client): """Test updating device config.""" with patch.object(config, 'SECTIONS', ['automation']): await async_setup_component(hass, 'config', {}) - client = await aiohttp_client(hass.http.app) + client = await hass_client() orig_data = [ { @@ -83,12 +83,12 @@ def mock_write(path, data): assert written[0] == orig_data -async def test_bad_formatted_automations(hass, aiohttp_client): +async def test_bad_formatted_automations(hass, hass_client): """Test that we handle automations without ID.""" with patch.object(config, 'SECTIONS', ['automation']): await async_setup_component(hass, 'config', {}) - client = await aiohttp_client(hass.http.app) + client = await hass_client() orig_data = [ { diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 67d7eebbfecab..0b36cc6bc874b 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -23,11 +23,11 @@ def mock_test_component(hass): @pytest.fixture -def client(hass, aiohttp_client): +def client(hass, hass_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(aiohttp_client(hass.http.app)) + yield hass.loop.run_until_complete(hass_client()) @asyncio.coroutine diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py index 5b52b3d571111..4d9063d774bcc 100644 --- a/tests/components/config/test_core.py +++ b/tests/components/config/test_core.py @@ -8,14 +8,14 @@ @asyncio.coroutine -def test_validate_config_ok(hass, aiohttp_client): +def test_validate_config_ok(hass, hass_client): """Test checking config.""" with patch.object(config, 'SECTIONS', ['core']): yield from async_setup_component(hass, 'config', {}) yield from asyncio.sleep(0.1, loop=hass.loop) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() with patch( 'homeassistant.components.config.core.async_check_ha_config_file', diff --git a/tests/components/config/test_customize.py b/tests/components/config/test_customize.py index 100a18618e69d..7f81b65540fd3 100644 --- a/tests/components/config/test_customize.py +++ b/tests/components/config/test_customize.py @@ -9,12 +9,12 @@ @asyncio.coroutine -def test_get_entity(hass, aiohttp_client): +def test_get_entity(hass, hass_client): """Test getting entity.""" with patch.object(config, 'SECTIONS', ['customize']): yield from async_setup_component(hass, 'config', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() def mock_read(path): """Mock reading data.""" @@ -38,12 +38,12 @@ def mock_read(path): @asyncio.coroutine -def test_update_entity(hass, aiohttp_client): +def test_update_entity(hass, hass_client): """Test updating entity.""" with patch.object(config, 'SECTIONS', ['customize']): yield from async_setup_component(hass, 'config', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() orig_data = { 'hello.beer': { @@ -89,12 +89,12 @@ def mock_write(path, data): @asyncio.coroutine -def test_update_entity_invalid_key(hass, aiohttp_client): +def test_update_entity_invalid_key(hass, hass_client): """Test updating entity.""" with patch.object(config, 'SECTIONS', ['customize']): yield from async_setup_component(hass, 'config', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.post( '/api/config/customize/config/not_entity', data=json.dumps({ @@ -105,12 +105,12 @@ def test_update_entity_invalid_key(hass, aiohttp_client): @asyncio.coroutine -def test_update_entity_invalid_json(hass, aiohttp_client): +def test_update_entity_invalid_json(hass, hass_client): """Test updating entity.""" with patch.object(config, 'SECTIONS', ['customize']): yield from async_setup_component(hass, 'config', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.post( '/api/config/customize/config/hello.beer', data='not json') diff --git a/tests/components/config/test_group.py b/tests/components/config/test_group.py index 06ba2ff110501..52c72c60860b2 100644 --- a/tests/components/config/test_group.py +++ b/tests/components/config/test_group.py @@ -11,12 +11,12 @@ @asyncio.coroutine -def test_get_device_config(hass, aiohttp_client): +def test_get_device_config(hass, hass_client): """Test getting device config.""" with patch.object(config, 'SECTIONS', ['group']): yield from async_setup_component(hass, 'config', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() def mock_read(path): """Mock reading data.""" @@ -40,12 +40,12 @@ def mock_read(path): @asyncio.coroutine -def test_update_device_config(hass, aiohttp_client): +def test_update_device_config(hass, hass_client): """Test updating device config.""" with patch.object(config, 'SECTIONS', ['group']): yield from async_setup_component(hass, 'config', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() orig_data = { 'hello.beer': { @@ -89,12 +89,12 @@ def mock_write(path, data): @asyncio.coroutine -def test_update_device_config_invalid_key(hass, aiohttp_client): +def test_update_device_config_invalid_key(hass, hass_client): """Test updating device config.""" with patch.object(config, 'SECTIONS', ['group']): yield from async_setup_component(hass, 'config', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.post( '/api/config/group/config/not a slug', data=json.dumps({ @@ -105,12 +105,12 @@ def test_update_device_config_invalid_key(hass, aiohttp_client): @asyncio.coroutine -def test_update_device_config_invalid_data(hass, aiohttp_client): +def test_update_device_config_invalid_data(hass, hass_client): """Test updating device config.""" with patch.object(config, 'SECTIONS', ['group']): yield from async_setup_component(hass, 'config', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.post( '/api/config/group/config/hello_beer', data=json.dumps({ @@ -121,12 +121,12 @@ def test_update_device_config_invalid_data(hass, aiohttp_client): @asyncio.coroutine -def test_update_device_config_invalid_json(hass, aiohttp_client): +def test_update_device_config_invalid_json(hass, hass_client): """Test updating device config.""" with patch.object(config, 'SECTIONS', ['group']): yield from async_setup_component(hass, 'config', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.post( '/api/config/group/config/hello_beer', data='not json') diff --git a/tests/components/config/test_hassbian.py b/tests/components/config/test_hassbian.py index 85fbf0c2e5a89..547bb612ee46c 100644 --- a/tests/components/config/test_hassbian.py +++ b/tests/components/config/test_hassbian.py @@ -34,13 +34,13 @@ def test_setup_check_env_works(hass, loop): @asyncio.coroutine -def test_get_suites(hass, aiohttp_client): +def test_get_suites(hass, hass_client): """Test getting suites.""" with patch.dict(os.environ, {'FORCE_HASSBIAN': '1'}), \ patch.object(config, 'SECTIONS', ['hassbian']): yield from async_setup_component(hass, 'config', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.get('/api/config/hassbian/suites') assert resp.status == 200 result = yield from resp.json() @@ -53,13 +53,13 @@ def test_get_suites(hass, aiohttp_client): @asyncio.coroutine -def test_install_suite(hass, aiohttp_client): +def test_install_suite(hass, hass_client): """Test getting suites.""" with patch.dict(os.environ, {'FORCE_HASSBIAN': '1'}), \ patch.object(config, 'SECTIONS', ['hassbian']): yield from async_setup_component(hass, 'config', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.post( '/api/config/hassbian/suites/openzwave/install') assert resp.status == 200 diff --git a/tests/components/config/test_zwave.py b/tests/components/config/test_zwave.py index 8aae5c0a28b92..71ced80eac95b 100644 --- a/tests/components/config/test_zwave.py +++ b/tests/components/config/test_zwave.py @@ -16,12 +16,12 @@ @pytest.fixture -def client(loop, hass, aiohttp_client): +def client(loop, hass, hass_client): """Client to communicate with Z-Wave config views.""" with patch.object(config, 'SECTIONS', ['zwave']): loop.run_until_complete(async_setup_component(hass, 'config', {})) - return loop.run_until_complete(aiohttp_client(hass.http.app)) + return loop.run_until_complete(hass_client()) @asyncio.coroutine diff --git a/tests/components/conftest.py b/tests/components/conftest.py index d3cbdba63b4dc..4903e8c645515 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -3,14 +3,12 @@ import pytest -from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY -from homeassistant.auth.providers import legacy_api_password, homeassistant from homeassistant.setup import async_setup_component from homeassistant.components.websocket_api.http import URL from homeassistant.components.websocket_api.auth import ( TYPE_AUTH, TYPE_AUTH_OK, TYPE_AUTH_REQUIRED) -from tests.common import MockUser, CLIENT_ID, mock_coro +from tests.common import mock_coro @pytest.fixture(autouse=True) @@ -22,35 +20,15 @@ def prevent_io(): @pytest.fixture -def hass_ws_client(aiohttp_client): +def hass_ws_client(aiohttp_client, hass_access_token): """Websocket client fixture connected to websocket server.""" - async def create_client(hass, access_token=None): + async def create_client(hass, access_token=hass_access_token): """Create a websocket client.""" assert await async_setup_component(hass, 'websocket_api') client = await aiohttp_client(hass.http.app) - patches = [] - - if access_token is None: - patches.append(patch( - 'homeassistant.auth.AuthManager.active', return_value=False)) - patches.append(patch( - 'homeassistant.auth.AuthManager.support_legacy', - return_value=True)) - patches.append(patch( - 'homeassistant.components.websocket_api.auth.' - 'validate_password', return_value=True)) - else: - patches.append(patch( - 'homeassistant.auth.AuthManager.active', return_value=True)) - patches.append(patch( - 'homeassistant.components.http.auth.setup_auth')) - - for p in patches: - p.start() - - try: + with patch('homeassistant.components.http.auth.setup_auth'): websocket = await client.ws_connect(URL) auth_resp = await websocket.receive_json() assert auth_resp['type'] == TYPE_AUTH_REQUIRED @@ -69,76 +47,8 @@ async def create_client(hass, access_token=None): auth_ok = await websocket.receive_json() assert auth_ok['type'] == TYPE_AUTH_OK - finally: - for p in patches: - p.stop() - # wrap in client websocket.client = client return websocket return create_client - - -@pytest.fixture -def hass_access_token(hass, hass_admin_user): - """Return an access token to access Home Assistant.""" - refresh_token = hass.loop.run_until_complete( - hass.auth.async_create_refresh_token(hass_admin_user, CLIENT_ID)) - yield hass.auth.async_create_access_token(refresh_token) - - -@pytest.fixture -def hass_owner_user(hass, local_auth): - """Return a Home Assistant admin user.""" - return MockUser(is_owner=True).add_to_hass(hass) - - -@pytest.fixture -def hass_admin_user(hass, local_auth): - """Return a Home Assistant admin user.""" - admin_group = hass.loop.run_until_complete(hass.auth.async_get_group( - GROUP_ID_ADMIN)) - return MockUser(groups=[admin_group]).add_to_hass(hass) - - -@pytest.fixture -def hass_read_only_user(hass, local_auth): - """Return a Home Assistant read only user.""" - read_only_group = hass.loop.run_until_complete(hass.auth.async_get_group( - GROUP_ID_READ_ONLY)) - return MockUser(groups=[read_only_group]).add_to_hass(hass) - - -@pytest.fixture -def legacy_auth(hass): - """Load legacy API password provider.""" - prv = legacy_api_password.LegacyApiPasswordAuthProvider( - hass, hass.auth._store, { - 'type': 'legacy_api_password' - } - ) - hass.auth._providers[(prv.type, prv.id)] = prv - - -@pytest.fixture -def local_auth(hass): - """Load local auth provider.""" - prv = homeassistant.HassAuthProvider( - hass, hass.auth._store, { - 'type': 'homeassistant' - } - ) - hass.auth._providers[(prv.type, prv.id)] = prv - - -@pytest.fixture -def hass_client(hass, aiohttp_client, hass_access_token): - """Return an authenticated HTTP client.""" - async def auth_client(): - """Return an authenticated client.""" - return await aiohttp_client(hass.http.app, headers={ - 'Authorization': "Bearer {}".format(hass_access_token) - }) - - return auth_client diff --git a/tests/components/device_tracker/test_locative.py b/tests/components/device_tracker/test_locative.py index 7cfef8f52197e..a167a1e9fd4c2 100644 --- a/tests/components/device_tracker/test_locative.py +++ b/tests/components/device_tracker/test_locative.py @@ -19,7 +19,7 @@ def _url(data=None): @pytest.fixture -def locative_client(loop, hass, aiohttp_client): +def locative_client(loop, hass, hass_client): """Locative mock client.""" assert loop.run_until_complete(async_setup_component( hass, device_tracker.DOMAIN, { @@ -29,7 +29,7 @@ def locative_client(loop, hass, aiohttp_client): })) with patch('homeassistant.components.device_tracker.update_config'): - yield loop.run_until_complete(aiohttp_client(hass.http.app)) + yield loop.run_until_complete(hass_client()) @asyncio.coroutine diff --git a/tests/components/device_tracker/test_meraki.py b/tests/components/device_tracker/test_meraki.py index 925ba6d66db52..582f112f69c36 100644 --- a/tests/components/device_tracker/test_meraki.py +++ b/tests/components/device_tracker/test_meraki.py @@ -13,7 +13,7 @@ @pytest.fixture -def meraki_client(loop, hass, aiohttp_client): +def meraki_client(loop, hass, hass_client): """Meraki mock client.""" assert loop.run_until_complete(async_setup_component( hass, device_tracker.DOMAIN, { @@ -25,7 +25,7 @@ def meraki_client(loop, hass, aiohttp_client): } })) - yield loop.run_until_complete(aiohttp_client(hass.http.app)) + yield loop.run_until_complete(hass_client()) @asyncio.coroutine diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 2e78e0441a3ef..9f386ceb90438 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -59,8 +59,16 @@ def mock_http_client_with_urls(hass, aiohttp_client): return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) +@pytest.fixture +def mock_onboarded(): + """Mock that we're onboarded.""" + with patch('homeassistant.components.onboarding.async_is_onboarded', + return_value=True): + yield + + @asyncio.coroutine -def test_frontend_and_static(mock_http_client): +def test_frontend_and_static(mock_http_client, mock_onboarded): """Test if we can get the frontend.""" resp = yield from mock_http_client.get('') assert resp.status == 200 @@ -220,7 +228,7 @@ async def test_missing_themes(hass, hass_ws_client): @asyncio.coroutine -def test_extra_urls(mock_http_client_with_urls): +def test_extra_urls(mock_http_client_with_urls, mock_onboarded): """Test that extra urls are loaded.""" resp = yield from mock_http_client_with_urls.get('/states?latest') assert resp.status == 200 @@ -229,7 +237,7 @@ def test_extra_urls(mock_http_client_with_urls): @asyncio.coroutine -def test_extra_urls_es5(mock_http_client_with_urls): +def test_extra_urls_es5(mock_http_client_with_urls, mock_onboarded): """Test that es5 extra urls are loaded.""" resp = yield from mock_http_client_with_urls.get('/states?es5') assert resp.status == 200 @@ -280,7 +288,7 @@ async def test_get_translations(hass, hass_ws_client): assert msg['result'] == {'resources': {'lang': 'nl'}} -async def test_auth_load(mock_http_client): +async def test_auth_load(mock_http_client, mock_onboarded): """Test auth component loaded by default.""" resp = await mock_http_client.get('/auth/providers') assert resp.status == 200 diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py index 6f6d78ba73c86..c8044b1ad5e29 100644 --- a/tests/components/geofency/test_init.py +++ b/tests/components/geofency/test_init.py @@ -105,7 +105,7 @@ @pytest.fixture -def geofency_client(loop, hass, aiohttp_client): +def geofency_client(loop, hass, hass_client): """Geofency mock client.""" assert loop.run_until_complete(async_setup_component( hass, DOMAIN, { @@ -116,7 +116,7 @@ def geofency_client(loop, hass, aiohttp_client): loop.run_until_complete(hass.async_block_till_done()) with patch('homeassistant.components.device_tracker.update_config'): - yield loop.run_until_complete(aiohttp_client(hass.http.app)) + yield loop.run_until_complete(hass_client()) @pytest.fixture(autouse=True) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 51fca931faaaf..62e7278ba1fdd 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -89,8 +89,7 @@ def test_setup_api_push_api_data_server_host(hass, aioclient_mock): async def test_setup_api_push_api_data_default(hass, aioclient_mock, hass_storage): """Test setup with API push default data.""" - with patch.dict(os.environ, MOCK_ENVIRON), \ - patch('homeassistant.auth.AuthManager.active', return_value=True): + with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component(hass, 'hassio', { 'http': {}, 'hassio': {} @@ -130,20 +129,6 @@ async def test_setup_adds_admin_group_to_user(hass, aioclient_mock, 'version': 1 } - with patch.dict(os.environ, MOCK_ENVIRON), \ - patch('homeassistant.auth.AuthManager.active', return_value=True): - result = await async_setup_component(hass, 'hassio', { - 'http': {}, - 'hassio': {} - }) - assert result - - assert user.is_admin - - -async def test_setup_api_push_api_data_no_auth(hass, aioclient_mock, - hass_storage): - """Test setup with API push default data.""" with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component(hass, 'hassio', { 'http': {}, @@ -151,11 +136,7 @@ async def test_setup_api_push_api_data_no_auth(hass, aioclient_mock, }) 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 - assert aioclient_mock.mock_calls[1][2]['refresh_token'] is None + assert user.is_admin async def test_setup_api_existing_hassio_user(hass, aioclient_mock, @@ -169,8 +150,7 @@ async def test_setup_api_existing_hassio_user(hass, aioclient_mock, 'hassio_user': user.id } } - with patch.dict(os.environ, MOCK_ENVIRON), \ - patch('homeassistant.auth.AuthManager.active', return_value=True): + with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component(hass, 'hassio', { 'http': {}, 'hassio': {} diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 222e8ced6e7aa..304bb4de997e0 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -75,19 +75,10 @@ async def test_auth_middleware_loaded_by_default(hass): assert len(mock_setup.mock_calls) == 1 -async def test_access_without_password(app, aiohttp_client): - """Test access without password.""" - setup_auth(app, [], False, api_password=None) - client = await aiohttp_client(app) - - resp = await client.get('/') - assert resp.status == 200 - - async def test_access_with_password_in_header(app, aiohttp_client, legacy_auth, hass): """Test access with password in header.""" - setup_auth(app, [], False, api_password=API_PASSWORD) + setup_auth(app, [], api_password=API_PASSWORD) client = await aiohttp_client(app) user = await legacy_api_password.async_get_user(hass) @@ -107,7 +98,7 @@ async def test_access_with_password_in_header(app, aiohttp_client, async def test_access_with_password_in_query(app, aiohttp_client, legacy_auth, hass): """Test access with password in URL.""" - setup_auth(app, [], False, api_password=API_PASSWORD) + setup_auth(app, [], api_password=API_PASSWORD) client = await aiohttp_client(app) user = await legacy_api_password.async_get_user(hass) @@ -131,7 +122,7 @@ async def test_access_with_password_in_query(app, aiohttp_client, legacy_auth, async def test_basic_auth_works(app, aiohttp_client, hass, legacy_auth): """Test access with basic authentication.""" - setup_auth(app, [], False, api_password=API_PASSWORD) + setup_auth(app, [], api_password=API_PASSWORD) client = await aiohttp_client(app) user = await legacy_api_password.async_get_user(hass) @@ -164,7 +155,7 @@ async def test_basic_auth_works(app, aiohttp_client, hass, legacy_auth): async def test_access_with_trusted_ip(app2, aiohttp_client, hass_owner_user): """Test access with an untrusted ip address.""" - setup_auth(app2, TRUSTED_NETWORKS, False, api_password='some-pass') + setup_auth(app2, TRUSTED_NETWORKS, api_password='some-pass') set_mock_ip = mock_real_ip(app2) client = await aiohttp_client(app2) @@ -190,7 +181,7 @@ async def test_auth_active_access_with_access_token_in_header( hass, app, aiohttp_client, hass_access_token): """Test access with access token in header.""" token = hass_access_token - setup_auth(app, [], True, api_password=None) + setup_auth(app, [], api_password=None) client = await aiohttp_client(app) refresh_token = await hass.auth.async_validate_access_token( hass_access_token) @@ -238,7 +229,7 @@ async def test_auth_active_access_with_access_token_in_header( async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client, hass_owner_user): """Test access with an untrusted ip address.""" - setup_auth(app2, TRUSTED_NETWORKS, True, api_password=None) + setup_auth(app2, TRUSTED_NETWORKS, None) set_mock_ip = mock_real_ip(app2) client = await aiohttp_client(app2) @@ -260,31 +251,10 @@ async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client, } -async def test_auth_active_blocked_api_password_access( - app, aiohttp_client, legacy_auth): - """Test access using api_password should be blocked when auth.active.""" - setup_auth(app, [], True, api_password=API_PASSWORD) - client = await aiohttp_client(app) - - req = await client.get( - '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) - assert req.status == 401 - - resp = await client.get('/', params={ - 'api_password': API_PASSWORD - }) - assert resp.status == 401 - - req = await client.get( - '/', - auth=BasicAuth('homeassistant', API_PASSWORD)) - assert req.status == 401 - - async def test_auth_legacy_support_api_password_access( app, aiohttp_client, legacy_auth, hass): """Test access using api_password if auth.support_legacy.""" - setup_auth(app, [], True, support_legacy=True, api_password=API_PASSWORD) + setup_auth(app, [], API_PASSWORD) client = await aiohttp_client(app) user = await legacy_api_password.async_get_user(hass) @@ -320,7 +290,7 @@ async def test_auth_access_signed_path( """Test access with signed url.""" app.router.add_post('/', mock_handler) app.router.add_get('/another_path', mock_handler) - setup_auth(app, [], True, api_password=None) + setup_auth(app, [], None) client = await aiohttp_client(app) refresh_token = await hass.auth.async_validate_access_token( diff --git a/tests/components/mailbox/test_init.py b/tests/components/mailbox/test_init.py index 2c69a5effa7ef..de0ee2f0b3ea5 100644 --- a/tests/components/mailbox/test_init.py +++ b/tests/components/mailbox/test_init.py @@ -9,7 +9,7 @@ @pytest.fixture -def mock_http_client(hass, aiohttp_client): +def mock_http_client(hass, hass_client): """Start the Hass HTTP component.""" config = { mailbox.DOMAIN: { @@ -18,7 +18,7 @@ def mock_http_client(hass, aiohttp_client): } hass.loop.run_until_complete( async_setup_component(hass, mailbox.DOMAIN, config)) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + return hass.loop.run_until_complete(hass_client()) @asyncio.coroutine diff --git a/tests/components/notify/test_html5.py b/tests/components/notify/test_html5.py index 486300679b741..08210ecd9a2e9 100644 --- a/tests/components/notify/test_html5.py +++ b/tests/components/notify/test_html5.py @@ -49,7 +49,7 @@ PUBLISH_URL = '/api/notify.html5/callback' -async def mock_client(hass, aiohttp_client, registrations=None): +async def mock_client(hass, hass_client, registrations=None): """Create a test client for HTML5 views.""" if registrations is None: registrations = {} @@ -62,7 +62,7 @@ async def mock_client(hass, aiohttp_client, registrations=None): } }) - return await aiohttp_client(hass.http.app) + return await hass_client() class TestHtml5Notify: @@ -151,9 +151,9 @@ def test_gcm_key_include(self, mock_wp): assert mock_wp.mock_calls[4][2]['gcm_key'] is None -async def test_registering_new_device_view(hass, aiohttp_client): +async def test_registering_new_device_view(hass, hass_client): """Test that the HTML view works.""" - client = await mock_client(hass, aiohttp_client) + client = await mock_client(hass, hass_client) with patch('homeassistant.components.notify.html5.save_json') as mock_save: resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) @@ -165,9 +165,9 @@ async def test_registering_new_device_view(hass, aiohttp_client): } -async def test_registering_new_device_expiration_view(hass, aiohttp_client): +async def test_registering_new_device_expiration_view(hass, hass_client): """Test that the HTML view works.""" - client = await mock_client(hass, aiohttp_client) + client = await mock_client(hass, hass_client) with patch('homeassistant.components.notify.html5.save_json') as mock_save: resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) @@ -178,10 +178,10 @@ async def test_registering_new_device_expiration_view(hass, aiohttp_client): } -async def test_registering_new_device_fails_view(hass, aiohttp_client): +async def test_registering_new_device_fails_view(hass, hass_client): """Test subs. are not altered when registering a new device fails.""" registrations = {} - client = await mock_client(hass, aiohttp_client, registrations) + client = await mock_client(hass, hass_client, registrations) with patch('homeassistant.components.notify.html5.save_json', side_effect=HomeAssistantError()): @@ -191,10 +191,10 @@ async def test_registering_new_device_fails_view(hass, aiohttp_client): assert registrations == {} -async def test_registering_existing_device_view(hass, aiohttp_client): +async def test_registering_existing_device_view(hass, hass_client): """Test subscription is updated when registering existing device.""" registrations = {} - client = await mock_client(hass, aiohttp_client, registrations) + client = await mock_client(hass, hass_client, registrations) with patch('homeassistant.components.notify.html5.save_json') as mock_save: await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) @@ -209,10 +209,10 @@ async def test_registering_existing_device_view(hass, aiohttp_client): } -async def test_registering_existing_device_fails_view(hass, aiohttp_client): +async def test_registering_existing_device_fails_view(hass, hass_client): """Test sub. is not updated when registering existing device fails.""" registrations = {} - client = await mock_client(hass, aiohttp_client, registrations) + client = await mock_client(hass, hass_client, registrations) with patch('homeassistant.components.notify.html5.save_json') as mock_save: await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) @@ -225,9 +225,9 @@ async def test_registering_existing_device_fails_view(hass, aiohttp_client): } -async def test_registering_new_device_validation(hass, aiohttp_client): +async def test_registering_new_device_validation(hass, hass_client): """Test various errors when registering a new device.""" - client = await mock_client(hass, aiohttp_client) + client = await mock_client(hass, hass_client) resp = await client.post(REGISTER_URL, data=json.dumps({ 'browser': 'invalid browser', @@ -249,13 +249,13 @@ async def test_registering_new_device_validation(hass, aiohttp_client): assert resp.status == 400 -async def test_unregistering_device_view(hass, aiohttp_client): +async def test_unregistering_device_view(hass, hass_client): """Test that the HTML unregister view works.""" registrations = { 'some device': SUBSCRIPTION_1, 'other device': SUBSCRIPTION_2, } - client = await mock_client(hass, aiohttp_client, registrations) + client = await mock_client(hass, hass_client, registrations) with patch('homeassistant.components.notify.html5.save_json') as mock_save: resp = await client.delete(REGISTER_URL, data=json.dumps({ @@ -270,10 +270,10 @@ async def test_unregistering_device_view(hass, aiohttp_client): async def test_unregister_device_view_handle_unknown_subscription( - hass, aiohttp_client): + hass, hass_client): """Test that the HTML unregister view handles unknown subscriptions.""" registrations = {} - client = await mock_client(hass, aiohttp_client, registrations) + client = await mock_client(hass, hass_client, registrations) with patch('homeassistant.components.notify.html5.save_json') as mock_save: resp = await client.delete(REGISTER_URL, data=json.dumps({ @@ -286,13 +286,13 @@ async def test_unregister_device_view_handle_unknown_subscription( async def test_unregistering_device_view_handles_save_error( - hass, aiohttp_client): + hass, hass_client): """Test that the HTML unregister view handles save errors.""" registrations = { 'some device': SUBSCRIPTION_1, 'other device': SUBSCRIPTION_2, } - client = await mock_client(hass, aiohttp_client, registrations) + client = await mock_client(hass, hass_client, registrations) with patch('homeassistant.components.notify.html5.save_json', side_effect=HomeAssistantError()): @@ -307,23 +307,23 @@ async def test_unregistering_device_view_handles_save_error( } -async def test_callback_view_no_jwt(hass, aiohttp_client): +async def test_callback_view_no_jwt(hass, hass_client): """Test that the notification callback view works without JWT.""" - client = await mock_client(hass, aiohttp_client) + client = await mock_client(hass, hass_client) resp = await client.post(PUBLISH_URL, data=json.dumps({ 'type': 'push', 'tag': '3bc28d69-0921-41f1-ac6a-7a627ba0aa72' })) - assert resp.status == 401, resp.response + assert resp.status == 401 -async def test_callback_view_with_jwt(hass, aiohttp_client): +async def test_callback_view_with_jwt(hass, hass_client): """Test that the notification callback view works with JWT.""" registrations = { 'device': SUBSCRIPTION_1 } - client = await mock_client(hass, aiohttp_client, registrations) + client = await mock_client(hass, hass_client, registrations) with patch('pywebpush.WebPusher') as mock_wp: await hass.services.async_call('notify', 'notify', { diff --git a/tests/components/onboarding/test_init.py b/tests/components/onboarding/test_init.py index 57a81a78da34b..483b917a63e41 100644 --- a/tests/components/onboarding/test_init.py +++ b/tests/components/onboarding/test_init.py @@ -38,8 +38,7 @@ async def test_setup_views_if_not_onboarded(hass): assert len(mock_setup.mock_calls) == 1 assert onboarding.DOMAIN in hass.data - with patch('homeassistant.auth.AuthManager.active', return_value=True): - assert not onboarding.async_is_onboarded(hass) + assert not onboarding.async_is_onboarded(hass) async def test_is_onboarded(): @@ -47,17 +46,13 @@ async def test_is_onboarded(): hass = Mock() hass.data = {} - with patch('homeassistant.auth.AuthManager.active', return_value=False): - assert onboarding.async_is_onboarded(hass) - - with patch('homeassistant.auth.AuthManager.active', return_value=True): - assert onboarding.async_is_onboarded(hass) + assert onboarding.async_is_onboarded(hass) - hass.data[onboarding.DOMAIN] = True - assert onboarding.async_is_onboarded(hass) + hass.data[onboarding.DOMAIN] = True + assert onboarding.async_is_onboarded(hass) - hass.data[onboarding.DOMAIN] = False - assert not onboarding.async_is_onboarded(hass) + hass.data[onboarding.DOMAIN] = False + assert not onboarding.async_is_onboarded(hass) async def test_having_owner_finishes_user_step(hass, hass_storage): diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index 6a272991798c5..b530c3dac3c57 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -591,18 +591,18 @@ def create_state_changed_event(self, event_time_fired, entity_id, state, }, time_fired=event_time_fired) -async def test_logbook_view(hass, aiohttp_client): +async def test_logbook_view(hass, hass_client): """Test the logbook view.""" await hass.async_add_job(init_recorder_component, hass) await async_setup_component(hass, 'logbook', {}) await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) - client = await aiohttp_client(hass.http.app) + client = await hass_client() response = await client.get( '/api/logbook/{}'.format(dt_util.utcnow().isoformat())) assert response.status == 200 -async def test_logbook_view_period_entity(hass, aiohttp_client): +async def test_logbook_view_period_entity(hass, hass_client): """Test the logbook view with period and entity.""" await hass.async_add_job(init_recorder_component, hass) await async_setup_component(hass, 'logbook', {}) @@ -617,7 +617,7 @@ async def test_logbook_view_period_entity(hass, aiohttp_client): await hass.async_block_till_done() await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) - client = await aiohttp_client(hass.http.app) + client = await hass_client() # Today time 00:00:00 start = dt_util.utcnow().date() diff --git a/tests/components/test_prometheus.py b/tests/components/test_prometheus.py index 49744421c726e..68e7602b22822 100644 --- a/tests/components/test_prometheus.py +++ b/tests/components/test_prometheus.py @@ -7,14 +7,14 @@ @pytest.fixture -def prometheus_client(loop, hass, aiohttp_client): - """Initialize an aiohttp_client with Prometheus component.""" +def prometheus_client(loop, hass, hass_client): + """Initialize an hass_client with Prometheus component.""" assert loop.run_until_complete(async_setup_component( hass, prometheus.DOMAIN, {prometheus.DOMAIN: {}}, )) - return loop.run_until_complete(aiohttp_client(hass.http.app)) + return loop.run_until_complete(hass_client()) @asyncio.coroutine diff --git a/tests/components/test_rss_feed_template.py b/tests/components/test_rss_feed_template.py index 64876dbea44a6..391004598e77f 100644 --- a/tests/components/test_rss_feed_template.py +++ b/tests/components/test_rss_feed_template.py @@ -8,7 +8,7 @@ @pytest.fixture -def mock_http_client(loop, hass, aiohttp_client): +def mock_http_client(loop, hass, hass_client): """Set up test fixture.""" config = { 'rss_feed_template': { @@ -21,7 +21,7 @@ def mock_http_client(loop, hass, aiohttp_client): loop.run_until_complete(async_setup_component(hass, 'rss_feed_template', config)) - return loop.run_until_complete(aiohttp_client(hass.http.app)) + return loop.run_until_complete(hass_client()) @asyncio.coroutine diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 70cbbc15c91bf..977b0669880c9 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -2,7 +2,6 @@ import ctypes import os import shutil -import json from unittest.mock import patch, PropertyMock import pytest @@ -14,7 +13,7 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, MEDIA_TYPE_MUSIC, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, DOMAIN as DOMAIN_MP) -from homeassistant.setup import setup_component +from homeassistant.setup import setup_component, async_setup_component from tests.common import ( get_test_home_assistant, get_test_instance_port, assert_setup_component, @@ -584,45 +583,45 @@ def test_setup_component_load_cache_retrieve_without_mem_cache(self): assert req.status_code == 200 assert req.content == demo_data - def test_setup_component_and_web_get_url(self): - """Set up the demo platform and receive wrong file from web.""" - config = { - tts.DOMAIN: { - 'platform': 'demo', - } + +async def test_setup_component_and_web_get_url(hass, hass_client): + """Set up the demo platform and receive file from web.""" + config = { + tts.DOMAIN: { + 'platform': 'demo', } + } - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) + await async_setup_component(hass, tts.DOMAIN, config) - self.hass.start() + client = await hass_client() - url = ("{}/api/tts_get_url").format(self.hass.config.api.base_url) - data = {'platform': 'demo', - 'message': "I person is on front of your door."} + url = "/api/tts_get_url" + data = {'platform': 'demo', + 'message': "I person is on front of your door."} - req = requests.post(url, data=json.dumps(data)) - assert req.status_code == 200 - response = json.loads(req.text) - assert response.get('url') == (("{}/api/tts_proxy/265944c108cbb00b2a62" - "1be5930513e03a0bb2cd_en_-_demo.mp3") - .format(self.hass.config.api.base_url)) + req = await client.post(url, json=data) + assert req.status == 200 + response = await req.json() + assert response.get('url') == \ + ("{}/api/tts_proxy/265944c108cbb00b2a62" + "1be5930513e03a0bb2cd_en_-_demo.mp3".format(hass.config.api.base_url)) - def test_setup_component_and_web_get_url_bad_config(self): - """Set up the demo platform and receive wrong file from web.""" - config = { - tts.DOMAIN: { - 'platform': 'demo', - } + +async def test_setup_component_and_web_get_url_bad_config(hass, hass_client): + """Set up the demo platform and receive wrong file from web.""" + config = { + tts.DOMAIN: { + 'platform': 'demo', } + } - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) + await async_setup_component(hass, tts.DOMAIN, config) - self.hass.start() + client = await hass_client() - url = ("{}/api/tts_get_url").format(self.hass.config.api.base_url) - data = {'message': "I person is on front of your door."} + url = "/api/tts_get_url" + data = {'message': "I person is on front of your door."} - req = requests.post(url, data=data) - assert req.status_code == 400 + req = await client.post(url, json=data) + assert req.status == 400 diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index ed54b509aaa3a..4c0014e478390 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -13,7 +13,7 @@ from . import API_PASSWORD -async def test_auth_via_msg(no_auth_websocket_client): +async def test_auth_via_msg(no_auth_websocket_client, legacy_auth): """Test authenticating.""" await no_auth_websocket_client.send_json({ 'type': TYPE_AUTH, @@ -70,18 +70,16 @@ async def test_auth_active_with_token(hass, aiohttp_client, hass_access_token): client = await aiohttp_client(hass.http.app) async with client.ws_connect(URL) as ws: - with patch('homeassistant.auth.AuthManager.active') as auth_active: - auth_active.return_value = True - auth_msg = await ws.receive_json() - assert auth_msg['type'] == TYPE_AUTH_REQUIRED + auth_msg = await ws.receive_json() + assert auth_msg['type'] == TYPE_AUTH_REQUIRED - await ws.send_json({ - 'type': TYPE_AUTH, - 'access_token': hass_access_token - }) + await ws.send_json({ + 'type': TYPE_AUTH, + 'access_token': hass_access_token + }) - auth_msg = await ws.receive_json() - assert auth_msg['type'] == TYPE_AUTH_OK + auth_msg = await ws.receive_json() + assert auth_msg['type'] == TYPE_AUTH_OK async def test_auth_active_user_inactive(hass, aiohttp_client, @@ -99,18 +97,16 @@ async def test_auth_active_user_inactive(hass, aiohttp_client, client = await aiohttp_client(hass.http.app) async with client.ws_connect(URL) as ws: - with patch('homeassistant.auth.AuthManager.active') as auth_active: - auth_active.return_value = True - auth_msg = await ws.receive_json() - assert auth_msg['type'] == TYPE_AUTH_REQUIRED + auth_msg = await ws.receive_json() + assert auth_msg['type'] == TYPE_AUTH_REQUIRED - await ws.send_json({ - 'type': TYPE_AUTH, - 'access_token': hass_access_token - }) + await ws.send_json({ + 'type': TYPE_AUTH, + 'access_token': hass_access_token + }) - auth_msg = await ws.receive_json() - assert auth_msg['type'] == TYPE_AUTH_INVALID + auth_msg = await ws.receive_json() + assert auth_msg['type'] == TYPE_AUTH_INVALID async def test_auth_active_with_password_not_allow(hass, aiohttp_client): @@ -124,18 +120,16 @@ async def test_auth_active_with_password_not_allow(hass, aiohttp_client): client = await aiohttp_client(hass.http.app) async with client.ws_connect(URL) as ws: - with patch('homeassistant.auth.AuthManager.active', - return_value=True): - auth_msg = await ws.receive_json() - assert auth_msg['type'] == TYPE_AUTH_REQUIRED + auth_msg = await ws.receive_json() + assert auth_msg['type'] == TYPE_AUTH_REQUIRED - await ws.send_json({ - 'type': TYPE_AUTH, - 'api_password': API_PASSWORD - }) + await ws.send_json({ + 'type': TYPE_AUTH, + 'api_password': API_PASSWORD + }) - auth_msg = await ws.receive_json() - assert auth_msg['type'] == TYPE_AUTH_INVALID + auth_msg = await ws.receive_json() + assert auth_msg['type'] == TYPE_AUTH_INVALID async def test_auth_legacy_support_with_password(hass, aiohttp_client): @@ -149,9 +143,7 @@ async def test_auth_legacy_support_with_password(hass, aiohttp_client): client = await aiohttp_client(hass.http.app) async with client.ws_connect(URL) as ws: - with patch('homeassistant.auth.AuthManager.active', - return_value=True),\ - patch('homeassistant.auth.AuthManager.support_legacy', + with patch('homeassistant.auth.AuthManager.support_legacy', return_value=True): auth_msg = await ws.receive_json() assert auth_msg['type'] == TYPE_AUTH_REQUIRED @@ -176,15 +168,13 @@ async def test_auth_with_invalid_token(hass, aiohttp_client): client = await aiohttp_client(hass.http.app) async with client.ws_connect(URL) as ws: - with patch('homeassistant.auth.AuthManager.active') as auth_active: - auth_active.return_value = True - auth_msg = await ws.receive_json() - assert auth_msg['type'] == TYPE_AUTH_REQUIRED + auth_msg = await ws.receive_json() + assert auth_msg['type'] == TYPE_AUTH_REQUIRED - await ws.send_json({ - 'type': TYPE_AUTH, - 'access_token': 'incorrect' - }) + await ws.send_json({ + 'type': TYPE_AUTH, + 'access_token': 'incorrect' + }) - auth_msg = await ws.receive_json() - assert auth_msg['type'] == TYPE_AUTH_INVALID + auth_msg = await ws.receive_json() + assert auth_msg['type'] == TYPE_AUTH_INVALID diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 2406eefe08e40..78a5bf6d57ea9 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -1,6 +1,4 @@ """Tests for WebSocket API commands.""" -from unittest.mock import patch - from async_timeout import timeout from homeassistant.core import callback @@ -200,62 +198,13 @@ async def test_call_service_context_with_user(hass, aiohttp_client, calls = async_mock_service(hass, 'domain_test', 'test_service') client = await aiohttp_client(hass.http.app) - async with client.ws_connect(URL) as ws: - with patch('homeassistant.auth.AuthManager.active') as auth_active: - auth_active.return_value = True - auth_msg = await ws.receive_json() - assert auth_msg['type'] == TYPE_AUTH_REQUIRED - - await ws.send_json({ - 'type': TYPE_AUTH, - 'access_token': hass_access_token - }) - - auth_msg = await ws.receive_json() - assert auth_msg['type'] == TYPE_AUTH_OK - - await ws.send_json({ - 'id': 5, - 'type': commands.TYPE_CALL_SERVICE, - 'domain': 'domain_test', - 'service': 'test_service', - 'service_data': { - 'hello': 'world' - } - }) - - msg = await ws.receive_json() - assert msg['success'] - - refresh_token = await hass.auth.async_validate_access_token( - hass_access_token) - - assert len(calls) == 1 - call = calls[0] - assert call.domain == 'domain_test' - assert call.service == 'test_service' - assert call.data == {'hello': 'world'} - assert call.context.user_id == refresh_token.user.id - - -async def test_call_service_context_no_user(hass, aiohttp_client): - """Test that connection without user sets context.""" - assert await async_setup_component(hass, 'websocket_api', { - 'http': { - 'api_password': API_PASSWORD - } - }) - - calls = async_mock_service(hass, 'domain_test', 'test_service') - client = await aiohttp_client(hass.http.app) - async with client.ws_connect(URL) as ws: auth_msg = await ws.receive_json() assert auth_msg['type'] == TYPE_AUTH_REQUIRED await ws.send_json({ 'type': TYPE_AUTH, - 'api_password': API_PASSWORD + 'access_token': hass_access_token }) auth_msg = await ws.receive_json() @@ -274,12 +223,15 @@ async def test_call_service_context_no_user(hass, aiohttp_client): msg = await ws.receive_json() assert msg['success'] + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + assert len(calls) == 1 call = calls[0] assert call.domain == 'domain_test' assert call.service == 'test_service' assert call.data == {'hello': 'world'} - assert call.context.user_id is None + assert call.context.user_id == refresh_token.user.id async def test_subscribe_requires_admin(websocket_client, hass_admin_user): diff --git a/tests/conftest.py b/tests/conftest.py index 84b72189a8d2f..82ae596fb48e3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,10 +10,12 @@ from homeassistant import util from homeassistant.util import location +from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY +from homeassistant.auth.providers import legacy_api_password, homeassistant from tests.common import ( async_test_home_assistant, INSTANCES, async_mock_mqtt_component, mock_coro, - mock_storage as mock_storage) + mock_storage as mock_storage, MockUser, CLIENT_ID) from tests.test_util.aiohttp import mock_aiohttp_client from tests.mock.zwave import MockNetwork, MockOption @@ -133,3 +135,67 @@ async def mock_update_config(path, id, entity): side_effect=lambda *args: mock_coro(devices) ): yield devices + + +@pytest.fixture +def hass_access_token(hass, hass_admin_user): + """Return an access token to access Home Assistant.""" + refresh_token = hass.loop.run_until_complete( + hass.auth.async_create_refresh_token(hass_admin_user, CLIENT_ID)) + yield hass.auth.async_create_access_token(refresh_token) + + +@pytest.fixture +def hass_owner_user(hass, local_auth): + """Return a Home Assistant admin user.""" + return MockUser(is_owner=True).add_to_hass(hass) + + +@pytest.fixture +def hass_admin_user(hass, local_auth): + """Return a Home Assistant admin user.""" + admin_group = hass.loop.run_until_complete(hass.auth.async_get_group( + GROUP_ID_ADMIN)) + return MockUser(groups=[admin_group]).add_to_hass(hass) + + +@pytest.fixture +def hass_read_only_user(hass, local_auth): + """Return a Home Assistant read only user.""" + read_only_group = hass.loop.run_until_complete(hass.auth.async_get_group( + GROUP_ID_READ_ONLY)) + return MockUser(groups=[read_only_group]).add_to_hass(hass) + + +@pytest.fixture +def legacy_auth(hass): + """Load legacy API password provider.""" + prv = legacy_api_password.LegacyApiPasswordAuthProvider( + hass, hass.auth._store, { + 'type': 'legacy_api_password' + } + ) + hass.auth._providers[(prv.type, prv.id)] = prv + + +@pytest.fixture +def local_auth(hass): + """Load local auth provider.""" + prv = homeassistant.HassAuthProvider( + hass, hass.auth._store, { + 'type': 'homeassistant' + } + ) + hass.auth._providers[(prv.type, prv.id)] = prv + + +@pytest.fixture +def hass_client(hass, aiohttp_client, hass_access_token): + """Return an authenticated HTTP client.""" + async def auth_client(): + """Return an authenticated client.""" + return await aiohttp_client(hass.http.app, headers={ + 'Authorization': "Bearer {}".format(hass_access_token) + }) + + return auth_client diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index 699342381f953..5cd77eee70736 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -14,7 +14,7 @@ @pytest.fixture -def camera_client(hass, aiohttp_client): +def camera_client(hass, hass_client): """Fixture to fetch camera streams.""" assert hass.loop.run_until_complete(async_setup_component(hass, 'camera', { 'camera': { @@ -23,7 +23,7 @@ def camera_client(hass, aiohttp_client): 'mjpeg_url': 'http://example.com/mjpeg_stream', }})) - yield hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + yield hass.loop.run_until_complete(hass_client()) class TestHelpersAiohttpClient(unittest.TestCase): diff --git a/tests/test_config.py b/tests/test_config.py index 056bf30efe58f..0d248e2b170fd 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -808,7 +808,6 @@ async def test_auth_provider_config(hass): assert len(hass.auth.auth_providers) == 2 assert hass.auth.auth_providers[0].type == 'homeassistant' assert hass.auth.auth_providers[1].type == 'legacy_api_password' - assert hass.auth.active is True assert len(hass.auth.auth_mfa_modules) == 2 assert hass.auth.auth_mfa_modules[0].id == 'totp' assert hass.auth.auth_mfa_modules[1].id == 'second' @@ -830,7 +829,6 @@ async def test_auth_provider_config_default(hass): assert len(hass.auth.auth_providers) == 1 assert hass.auth.auth_providers[0].type == 'homeassistant' - assert hass.auth.active is True assert len(hass.auth.auth_mfa_modules) == 1 assert hass.auth.auth_mfa_modules[0].id == 'totp' @@ -852,7 +850,6 @@ async def test_auth_provider_config_default_api_password(hass): assert len(hass.auth.auth_providers) == 2 assert hass.auth.auth_providers[0].type == 'homeassistant' assert hass.auth.auth_providers[1].type == 'legacy_api_password' - assert hass.auth.active is True async def test_auth_provider_config_default_trusted_networks(hass): @@ -873,7 +870,6 @@ async def test_auth_provider_config_default_trusted_networks(hass): assert len(hass.auth.auth_providers) == 2 assert hass.auth.auth_providers[0].type == 'homeassistant' assert hass.auth.auth_providers[1].type == 'trusted_networks' - assert hass.auth.active is True async def test_disallowed_auth_provider_config(hass): From b9ad19acbf15ca93e1646f47720ac4ee0208dab3 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 2 Dec 2018 17:00:31 +0100 Subject: [PATCH 212/325] Add JSON attribute topic to MQTT binary sensor Add MqttAttributes mixin --- .../components/binary_sensor/mqtt.py | 14 ++-- homeassistant/components/mqtt/__init__.py | 65 +++++++++++++++++++ tests/components/binary_sensor/test_mqtt.py | 63 +++++++++++++++++- 3 files changed, 136 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index acbad0d041903..d2a2be8817253 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -16,10 +16,10 @@ CONF_FORCE_UPDATE, CONF_NAME, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_ON, CONF_PAYLOAD_OFF, CONF_DEVICE_CLASS, CONF_DEVICE) from homeassistant.components.mqtt import ( - ATTR_DISCOVERY_HASH, CONF_STATE_TOPIC, CONF_AVAILABILITY_TOPIC, + ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, - MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, - subscription) + MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, + MqttEntityDeviceInfo, subscription) from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -49,7 +49,8 @@ # This is an exception because MQTT is a message transport, not a protocol vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, -}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) +}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend( + mqtt.MQTT_JSON_ATTRS_SCHEMA.schema) async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, @@ -76,7 +77,7 @@ async def _async_setup_entity(config, async_add_entities, discovery_hash=None): async_add_entities([MqttBinarySensor(config, discovery_hash)]) -class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, +class MqttBinarySensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, BinarySensorDevice): """Representation a binary sensor that is updated by MQTT.""" @@ -94,6 +95,7 @@ def __init__(self, config, discovery_hash): qos = config.get(CONF_QOS) device_config = config.get(CONF_DEVICE) + MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, availability_topic, qos, payload_available, payload_not_available) MqttDiscoveryUpdate.__init__(self, discovery_hash, @@ -109,6 +111,7 @@ async def discovery_update(self, discovery_payload): """Handle updated discovery message.""" config = PLATFORM_SCHEMA(discovery_payload) self._config = config + await self.attributes_discovery_update(config) await self.availability_discovery_update(config) await self._subscribe_topics() self.async_schedule_update_ha_state() @@ -164,6 +167,7 @@ def state_message_received(_topic, payload, _qos): async def async_will_remove_from_hass(self): """Unsubscribe when removed.""" await subscription.async_unsubscribe_topics(self.hass, self._sub_state) + await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) @property diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 7ff32a7914270..11b837113c508 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -6,6 +6,7 @@ """ import asyncio from itertools import groupby +import json import logging from operator import attrgetter import os @@ -70,6 +71,7 @@ CONF_AVAILABILITY_TOPIC = 'availability_topic' CONF_PAYLOAD_AVAILABLE = 'payload_available' CONF_PAYLOAD_NOT_AVAILABLE = 'payload_not_available' +CONF_JSON_ATTRS_TOPIC = 'json_attributes_topic' CONF_QOS = 'qos' CONF_RETAIN = 'retain' @@ -224,6 +226,10 @@ def validate_device_has_at_least_one_identifier(value: ConfigType) -> \ vol.Optional(CONF_SW_VERSION): cv.string, }), validate_device_has_at_least_one_identifier) +MQTT_JSON_ATTRS_SCHEMA = vol.Schema({ + vol.Optional(CONF_JSON_ATTRS_TOPIC): valid_subscribe_topic, +}) + MQTT_BASE_PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(SCHEMA_BASE) # Sensor type platforms subscribe to MQTT events @@ -820,6 +826,65 @@ def _match_topic(subscription: str, topic: str) -> bool: return False +class MqttAttributes(Entity): + """Mixin used for platforms that support JSON attributes.""" + + def __init__(self, config: dict) -> None: + """Initialize the JSON attributes mixin.""" + self._attributes = None + self._attributes_sub_state = None + self._attributes_config = config + + async def async_added_to_hass(self) -> None: + """Subscribe MQTT events. + + This method must be run in the event loop and returns a coroutine. + """ + await self._attributes_subscribe_topics() + + async def attributes_discovery_update(self, config: dict): + """Handle updated discovery message.""" + self._attributes_config = config + await self._attributes_subscribe_topics() + + async def _attributes_subscribe_topics(self): + """(Re)Subscribe to topics.""" + from .subscription import async_subscribe_topics + + @callback + def attributes_message_received(topic: str, + payload: SubscribePayloadType, + qos: int) -> None: + try: + json_dict = json.loads(payload) + if isinstance(json_dict, dict): + self._attributes = json_dict + self.async_schedule_update_ha_state() + else: + _LOGGER.debug("JSON result was not a dictionary") + self._attributes = None + except ValueError: + _LOGGER.debug("Erroneous JSON: %s", payload) + self._attributes = None + + self._attributes_sub_state = await async_subscribe_topics( + self.hass, self._attributes_sub_state, + {'attributes_topic': { + 'topic': self._attributes_config.get(CONF_JSON_ATTRS_TOPIC), + 'msg_callback': attributes_message_received, + 'qos': self._attributes_config.get(CONF_QOS)}}) + + async def async_will_remove_from_hass(self): + """Unsubscribe when removed.""" + from .subscription import async_unsubscribe_topics + await async_unsubscribe_topics(self.hass, self._attributes_sub_state) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + + class MqttAvailability(Entity): """Mixin used for platforms that report availability.""" diff --git a/tests/components/binary_sensor/test_mqtt.py b/tests/components/binary_sensor/test_mqtt.py index 71d179211a231..a4357eefed830 100644 --- a/tests/components/binary_sensor/test_mqtt.py +++ b/tests/components/binary_sensor/test_mqtt.py @@ -1,7 +1,7 @@ """The tests for the MQTT binary sensor platform.""" import json import unittest -from unittest.mock import Mock +from unittest.mock import Mock, patch from datetime import timedelta import homeassistant.core as ha @@ -256,6 +256,67 @@ def callback(event): assert STATE_OFF == state.state assert 3 == len(events) + def test_setting_sensor_attribute_via_mqtt_json_message(self): + """Test the setting of attribute via MQTT with JSON payload.""" + mock_component(self.hass, 'mqtt') + assert setup_component(self.hass, binary_sensor.DOMAIN, { + binary_sensor.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + fire_mqtt_message(self.hass, 'attr-topic', '{ "val": "100" }') + self.hass.block_till_done() + state = self.hass.states.get('binary_sensor.test') + + assert '100' == \ + state.attributes.get('val') + + @patch('homeassistant.components.mqtt._LOGGER') + def test_update_with_json_attrs_not_dict(self, mock_logger): + """Test attributes get extracted from a JSON result.""" + mock_component(self.hass, 'mqtt') + assert setup_component(self.hass, binary_sensor.DOMAIN, { + binary_sensor.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + fire_mqtt_message(self.hass, 'attr-topic', '[ "list", "of", "things"]') + self.hass.block_till_done() + state = self.hass.states.get('binary_sensor.test') + + assert state.attributes.get('val') is None + mock_logger.debug.assert_called_with( + 'JSON result was not a dictionary') + + @patch('homeassistant.components.mqtt._LOGGER') + def test_update_with_json_attrs_bad_JSON(self, mock_logger): + """Test attributes get extracted from a JSON result.""" + mock_component(self.hass, 'mqtt') + assert setup_component(self.hass, binary_sensor.DOMAIN, { + binary_sensor.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'test-topic', + 'json_attributes_topic': 'attr-topic' + } + }) + + fire_mqtt_message(self.hass, 'attr-topic', 'This is not JSON') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test') + assert state.attributes.get('val') is None + mock_logger.debug.assert_called_with( + 'Erroneous JSON: %s', 'This is not JSON') + async def test_unique_id(hass): """Test unique id option only creates one sensor per unique_id.""" From 87fb492b1435897c4a4341aaf1cb42f5907ebf4d Mon Sep 17 00:00:00 2001 From: emontnemery Date: Sun, 2 Dec 2018 19:12:03 +0100 Subject: [PATCH 213/325] Remove commented out code (#18925) --- homeassistant/components/climate/mqtt.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/climate/mqtt.py b/homeassistant/components/climate/mqtt.py index 4995fa13b3a25..098ff2867daa8 100644 --- a/homeassistant/components/climate/mqtt.py +++ b/homeassistant/components/climate/mqtt.py @@ -223,7 +223,6 @@ async def discovery_update(self, discovery_payload): def _setup_from_config(self, config): """(Re)Setup the entity.""" - # self._name = config.get(CONF_NAME) self._topic = { key: config.get(key) for key in ( CONF_POWER_COMMAND_TOPIC, From eb584a26e29c3b7998f2ef89a1dcd8bd41e9bb21 Mon Sep 17 00:00:00 2001 From: GeoffAtHome Date: Sun, 2 Dec 2018 19:58:31 +0000 Subject: [PATCH 214/325] Add lightwave components for switches and lights (#18026) * Added lightwave components for switches and lights. * Address warnings raised by Hound * Correcting lint messages and major typo. This time tested before commit. * Trying to fix author * Minor lint changes * Attempt to correct other lint error. * Another lint attempt. * More lint issues. * Last two lint errors! Hurrah. * Changes after review from fabaff. * Moved device dependent code to PyPi. * Replaced DEPENDENCIES with REQUIREMENTS * Updated following code review from Martin Hjelmare. * Added lightwave to requirements_all.txt * Omit lightwave from tests. * Updated requirements_all.txt * Refactored how lightwave lights and switches load. * Removed imports that were no longer required. * Add guard for no discovery_info. * Make it a guard clause and save indentation. Rename LRFxxx to LWRFxxx. * Sorted imports to match style guidelines. * Correct return value. * Update requirements_all.txt * Catch case where we have no lights or switches configured. * Improve configuration validation. --- .coveragerc | 3 + homeassistant/components/light/lightwave.py | 88 ++++++++++++++++++++ homeassistant/components/lightwave.py | 49 +++++++++++ homeassistant/components/switch/lightwave.py | 65 +++++++++++++++ requirements_all.txt | 3 + 5 files changed, 208 insertions(+) create mode 100644 homeassistant/components/light/lightwave.py create mode 100644 homeassistant/components/lightwave.py create mode 100644 homeassistant/components/switch/lightwave.py diff --git a/.coveragerc b/.coveragerc index f894d1edd4a6b..9463e85c2a083 100644 --- a/.coveragerc +++ b/.coveragerc @@ -203,6 +203,9 @@ omit = homeassistant/components/linode.py homeassistant/components/*/linode.py + homeassistant/components/lightwave.py + homeassistant/components/*/lightwave.py + homeassistant/components/logi_circle.py homeassistant/components/*/logi_circle.py diff --git a/homeassistant/components/light/lightwave.py b/homeassistant/components/light/lightwave.py new file mode 100644 index 0000000000000..50c664d90463d --- /dev/null +++ b/homeassistant/components/light/lightwave.py @@ -0,0 +1,88 @@ +""" +Implements LightwaveRF lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.lightwave/ +""" +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) +from homeassistant.components.lightwave import LIGHTWAVE_LINK +from homeassistant.const import CONF_NAME + +DEPENDENCIES = ['lightwave'] + +MAX_BRIGHTNESS = 255 + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Find and return LightWave lights.""" + if not discovery_info: + return + + lights = [] + lwlink = hass.data[LIGHTWAVE_LINK] + + for device_id, device_config in discovery_info.items(): + name = device_config[CONF_NAME] + lights.append(LWRFLight(name, device_id, lwlink)) + + async_add_entities(lights) + + +class LWRFLight(Light): + """Representation of a LightWaveRF light.""" + + def __init__(self, name, device_id, lwlink): + """Initialize LWRFLight entity.""" + self._name = name + self._device_id = device_id + self._state = None + self._brightness = MAX_BRIGHTNESS + self._lwlink = lwlink + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_BRIGHTNESS + + @property + def should_poll(self): + """No polling needed for a LightWave light.""" + return False + + @property + def name(self): + """Lightwave light name.""" + return self._name + + @property + def brightness(self): + """Brightness of this light between 0..MAX_BRIGHTNESS.""" + return self._brightness + + @property + def is_on(self): + """Lightwave light is on state.""" + return self._state + + async def async_turn_on(self, **kwargs): + """Turn the LightWave light on.""" + self._state = True + + if ATTR_BRIGHTNESS in kwargs: + self._brightness = kwargs[ATTR_BRIGHTNESS] + + if self._brightness != MAX_BRIGHTNESS: + self._lwlink.turn_on_with_brightness( + self._device_id, self._name, self._brightness) + else: + self._lwlink.turn_on_light(self._device_id, self._name) + + self.async_schedule_update_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn the LightWave light off.""" + self._state = False + self._lwlink.turn_off(self._device_id, self._name) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/lightwave.py b/homeassistant/components/lightwave.py new file mode 100644 index 0000000000000..e1aa1664eba49 --- /dev/null +++ b/homeassistant/components/lightwave.py @@ -0,0 +1,49 @@ +""" +Support for device connected via Lightwave WiFi-link hub. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lightwave/ +""" +import voluptuous as vol +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (CONF_HOST, CONF_LIGHTS, CONF_NAME, + CONF_SWITCHES) +from homeassistant.helpers.discovery import async_load_platform + +REQUIREMENTS = ['lightwave==0.15'] +LIGHTWAVE_LINK = 'lightwave_link' +DOMAIN = 'lightwave' + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema( + cv.has_at_least_one_key(CONF_LIGHTS, CONF_SWITCHES), { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_LIGHTS, default={}): { + cv.string: vol.Schema({vol.Required(CONF_NAME): cv.string}), + }, + vol.Optional(CONF_SWITCHES, default={}): { + cv.string: vol.Schema({vol.Required(CONF_NAME): cv.string}), + } + }) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Try to start embedded Lightwave broker.""" + from lightwave.lightwave import LWLink + + host = config[DOMAIN][CONF_HOST] + hass.data[LIGHTWAVE_LINK] = LWLink(host) + + lights = config[DOMAIN][CONF_LIGHTS] + if lights: + hass.async_create_task(async_load_platform( + hass, 'light', DOMAIN, lights, config)) + + switches = config[DOMAIN][CONF_SWITCHES] + if switches: + hass.async_create_task(async_load_platform( + hass, 'switch', DOMAIN, switches, config)) + + return True diff --git a/homeassistant/components/switch/lightwave.py b/homeassistant/components/switch/lightwave.py new file mode 100644 index 0000000000000..b612cd8dec74c --- /dev/null +++ b/homeassistant/components/switch/lightwave.py @@ -0,0 +1,65 @@ +""" +Implements LightwaveRF switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.lightwave/ +""" +from homeassistant.components.lightwave import LIGHTWAVE_LINK +from homeassistant.components.switch import SwitchDevice +from homeassistant.const import CONF_NAME + +DEPENDENCIES = ['lightwave'] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Find and return LightWave switches.""" + if not discovery_info: + return + + switches = [] + lwlink = hass.data[LIGHTWAVE_LINK] + + for device_id, device_config in discovery_info.items(): + name = device_config[CONF_NAME] + switches.append(LWRFSwitch(name, device_id, lwlink)) + + async_add_entities(switches) + + +class LWRFSwitch(SwitchDevice): + """Representation of a LightWaveRF switch.""" + + def __init__(self, name, device_id, lwlink): + """Initialize LWRFSwitch entity.""" + self._name = name + self._device_id = device_id + self._state = None + self._lwlink = lwlink + + @property + def should_poll(self): + """No polling needed for a LightWave light.""" + return False + + @property + def name(self): + """Lightwave switch name.""" + return self._name + + @property + def is_on(self): + """Lightwave switch is on state.""" + return self._state + + async def async_turn_on(self, **kwargs): + """Turn the LightWave switch on.""" + self._state = True + self._lwlink.turn_on_switch(self._device_id, self._name) + self.async_schedule_update_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn the LightWave switch off.""" + self._state = False + self._lwlink.turn_off(self._device_id, self._name) + self.async_schedule_update_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index 5f439e0dd0783..89d011f0927b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -583,6 +583,9 @@ liffylights==0.9.4 # homeassistant.components.light.osramlightify lightify==1.0.6.1 +# homeassistant.components.lightwave +lightwave==0.15 + # homeassistant.components.light.limitlessled limitlessled==1.1.3 From 5ae65142b87efd74106012a111da73accbeb3871 Mon Sep 17 00:00:00 2001 From: Andrew Hayworth Date: Mon, 3 Dec 2018 00:25:54 -0600 Subject: [PATCH 215/325] Allow verisure locks to be configured with a default code (#18873) * Allow verisure locks to be configured with a default code * linting fix * PR feedback * PR feedback - try harder to prevent future typos A python mock is a magical thing, and will respond to basicaly any method you call on it. It's somewhat better to assert against an explicit variable named 'mock', rather than to assert on the method name you wanted to mock... could prevent a typo from messing up tests. * PR feedback: convert tests to integration-style tests Set up a fake verisure hub, stub out a _lot_ of calls, then test after platform discovery and service calls. It should be noted that we're overriding the `update()` calls in these tests. This was done to prevent even further mocking of the verisure hub's responses. Hopefully, this'll be a foundation for people to write more tests. * more pr feedback --- homeassistant/components/lock/verisure.py | 20 ++- homeassistant/components/verisure.py | 2 + requirements_test_all.txt | 6 + script/gen_requirements_all.py | 2 + tests/components/lock/test_verisure.py | 141 ++++++++++++++++++++++ 5 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 tests/components/lock/test_verisure.py diff --git a/homeassistant/components/lock/verisure.py b/homeassistant/components/lock/verisure.py index 877c8a1ddf6c5..25c7e1aa8ea20 100644 --- a/homeassistant/components/lock/verisure.py +++ b/homeassistant/components/lock/verisure.py @@ -8,7 +8,8 @@ from time import sleep from time import time from homeassistant.components.verisure import HUB as hub -from homeassistant.components.verisure import (CONF_LOCKS, CONF_CODE_DIGITS) +from homeassistant.components.verisure import ( + CONF_LOCKS, CONF_DEFAULT_LOCK_CODE, CONF_CODE_DIGITS) from homeassistant.components.lock import LockDevice from homeassistant.const import ( ATTR_CODE, STATE_LOCKED, STATE_UNKNOWN, STATE_UNLOCKED) @@ -39,6 +40,7 @@ def __init__(self, device_label): self._digits = hub.config.get(CONF_CODE_DIGITS) self._changed_by = None self._change_timestamp = 0 + self._default_lock_code = hub.config.get(CONF_DEFAULT_LOCK_CODE) @property def name(self): @@ -96,13 +98,25 @@ def unlock(self, **kwargs): """Send unlock command.""" if self._state == STATE_UNLOCKED: return - self.set_lock_state(kwargs[ATTR_CODE], STATE_UNLOCKED) + + code = kwargs.get(ATTR_CODE, self._default_lock_code) + if code is None: + _LOGGER.error("Code required but none provided") + return + + self.set_lock_state(code, STATE_UNLOCKED) def lock(self, **kwargs): """Send lock command.""" if self._state == STATE_LOCKED: return - self.set_lock_state(kwargs[ATTR_CODE], STATE_LOCKED) + + code = kwargs.get(ATTR_CODE, self._default_lock_code) + if code is None: + _LOGGER.error("Code required but none provided") + return + + self.set_lock_state(code, STATE_LOCKED) def set_lock_state(self, code, state): """Send set lock state command.""" diff --git a/homeassistant/components/verisure.py b/homeassistant/components/verisure.py index 2f2fa19484684..481aa331e41f3 100644 --- a/homeassistant/components/verisure.py +++ b/homeassistant/components/verisure.py @@ -28,6 +28,7 @@ CONF_GIID = 'giid' CONF_HYDROMETERS = 'hygrometers' CONF_LOCKS = 'locks' +CONF_DEFAULT_LOCK_CODE = 'default_lock_code' CONF_MOUSE = 'mouse' CONF_SMARTPLUGS = 'smartplugs' CONF_THERMOMETERS = 'thermometers' @@ -52,6 +53,7 @@ vol.Optional(CONF_GIID): cv.string, vol.Optional(CONF_HYDROMETERS, default=True): cv.boolean, vol.Optional(CONF_LOCKS, default=True): cv.boolean, + vol.Optional(CONF_DEFAULT_LOCK_CODE): cv.string, vol.Optional(CONF_MOUSE, default=True): cv.boolean, vol.Optional(CONF_SMARTPLUGS, default=True): cv.boolean, vol.Optional(CONF_THERMOMETERS, default=True): cv.boolean, diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5707847a78943..f62bb98fa887e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -110,6 +110,9 @@ homematicip==0.9.8 # homeassistant.components.sensor.influxdb influxdb==5.2.0 +# homeassistant.components.verisure +jsonpath==0.75 + # homeassistant.components.dyson libpurecoollink==0.4.2 @@ -257,6 +260,9 @@ statsd==3.2.1 # homeassistant.components.camera.uvc uvcclient==0.11.0 +# homeassistant.components.verisure +vsure==1.5.2 + # homeassistant.components.vultr vultr==0.1.2 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index e5840d62e17e9..82dab374e42ca 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -64,6 +64,7 @@ 'home-assistant-frontend', 'homematicip', 'influxdb', + 'jsonpath', 'libpurecoollink', 'libsoundtouch', 'luftdaten', @@ -110,6 +111,7 @@ 'srpenergy', 'statsd', 'uvcclient', + 'vsure', 'warrant', 'pythonwhois', 'wakeonlan', diff --git a/tests/components/lock/test_verisure.py b/tests/components/lock/test_verisure.py new file mode 100644 index 0000000000000..03dd202e8381c --- /dev/null +++ b/tests/components/lock/test_verisure.py @@ -0,0 +1,141 @@ +"""Tests for the Verisure platform.""" + +from contextlib import contextmanager +from unittest.mock import patch, call +from homeassistant.const import STATE_UNLOCKED +from homeassistant.setup import async_setup_component +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, SERVICE_LOCK, SERVICE_UNLOCK) +from homeassistant.components.verisure import DOMAIN as VERISURE_DOMAIN + + +NO_DEFAULT_LOCK_CODE_CONFIG = { + 'verisure': { + 'username': 'test', + 'password': 'test', + 'locks': True, + 'alarm': False, + 'door_window': False, + 'hygrometers': False, + 'mouse': False, + 'smartplugs': False, + 'thermometers': False, + 'smartcam': False, + } +} + +DEFAULT_LOCK_CODE_CONFIG = { + 'verisure': { + 'username': 'test', + 'password': 'test', + 'locks': True, + 'default_lock_code': '9999', + 'alarm': False, + 'door_window': False, + 'hygrometers': False, + 'mouse': False, + 'smartplugs': False, + 'thermometers': False, + 'smartcam': False, + } +} + +LOCKS = ['door_lock'] + + +@contextmanager +def mock_hub(config, get_response=LOCKS[0]): + """Extensively mock out a verisure hub.""" + hub_prefix = 'homeassistant.components.lock.verisure.hub' + verisure_prefix = 'verisure.Session' + with patch(verisure_prefix) as session, \ + patch(hub_prefix) as hub: + session.login.return_value = True + + hub.config = config['verisure'] + hub.get.return_value = LOCKS + hub.get_first.return_value = get_response.upper() + hub.session.set_lock_state.return_value = { + 'doorLockStateChangeTransactionId': 'test', + } + hub.session.get_lock_state_transaction.return_value = { + 'result': 'OK', + } + + yield hub + + +async def setup_verisure_locks(hass, config): + """Set up mock verisure locks.""" + with mock_hub(config): + await async_setup_component(hass, VERISURE_DOMAIN, config) + await hass.async_block_till_done() + # lock.door_lock, group.all_locks + assert len(hass.states.async_all()) == 2 + + +async def test_verisure_no_default_code(hass): + """Test configs without a default lock code.""" + await setup_verisure_locks(hass, NO_DEFAULT_LOCK_CODE_CONFIG) + with mock_hub(NO_DEFAULT_LOCK_CODE_CONFIG, + STATE_UNLOCKED) as hub: + + mock = hub.session.set_lock_state + await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, { + 'entity_id': 'lock.door_lock', + }) + await hass.async_block_till_done() + assert mock.call_count == 0 + + await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, { + 'entity_id': 'lock.door_lock', + 'code': '12345', + }) + await hass.async_block_till_done() + assert mock.call_args == call('12345', LOCKS[0], 'lock') + + mock.reset_mock() + await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, { + 'entity_id': 'lock.door_lock', + }) + await hass.async_block_till_done() + assert mock.call_count == 0 + + await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, { + 'entity_id': 'lock.door_lock', + 'code': '12345', + }) + await hass.async_block_till_done() + assert mock.call_args == call('12345', LOCKS[0], 'unlock') + + +async def test_verisure_default_code(hass): + """Test configs with a default lock code.""" + await setup_verisure_locks(hass, DEFAULT_LOCK_CODE_CONFIG) + with mock_hub(DEFAULT_LOCK_CODE_CONFIG, STATE_UNLOCKED) as hub: + mock = hub.session.set_lock_state + await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, { + 'entity_id': 'lock.door_lock', + }) + await hass.async_block_till_done() + assert mock.call_args == call('9999', LOCKS[0], 'lock') + + await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, { + 'entity_id': 'lock.door_lock', + }) + await hass.async_block_till_done() + assert mock.call_args == call('9999', LOCKS[0], 'unlock') + + await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, { + 'entity_id': 'lock.door_lock', + 'code': '12345', + }) + await hass.async_block_till_done() + assert mock.call_args == call('12345', LOCKS[0], 'lock') + + await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, { + 'entity_id': 'lock.door_lock', + 'code': '12345', + }) + await hass.async_block_till_done() + assert mock.call_args == call('12345', LOCKS[0], 'unlock') From 832fa61477219ea02b2ee5db35b01a05f12652c5 Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Mon, 3 Dec 2018 01:31:53 -0700 Subject: [PATCH 216/325] Initial hlk-sw16 relay switch support (#17855) * Initial hlk-sw16 relay switch support * remove entity_id and validate relay id's * Bump hlk-sw16 library version and cleanup component * refactor hlk-sw16 switch platform loading * Use voluptuous to coerce relay id to string * remove force_update for SW16Switch * Move to callback based hlk-sw16 relay state changes * fix hlk-sw16 default port and cleanup some unused variables * Refactor to allow registration of multiple HLK-SW16 device * Store protocol in instance variable instead of class variable * remove is_connected * flake8 style fix * Move reconnect logic into HLK-SW16 client library * Cleanup and improve logging * Load hlk-sw16 platform entities at same time per device * scope SIGNAL_AVAILABILITY to device_id * Fixes for connection resume * move device_client out of switches loop * Add timeout for commands and keep alive * remove unused variables --- .coveragerc | 3 + homeassistant/components/hlk_sw16.py | 163 ++++++++++++++++++++ homeassistant/components/switch/hlk_sw16.py | 54 +++++++ requirements_all.txt | 3 + 4 files changed, 223 insertions(+) create mode 100644 homeassistant/components/hlk_sw16.py create mode 100644 homeassistant/components/switch/hlk_sw16.py diff --git a/.coveragerc b/.coveragerc index 9463e85c2a083..ecfafa916e4f2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -148,6 +148,9 @@ omit = homeassistant/components/hive.py homeassistant/components/*/hive.py + homeassistant/components/hlk_sw16.py + homeassistant/components/*/hlk_sw16.py + homeassistant/components/homekit_controller/__init__.py homeassistant/components/*/homekit_controller.py diff --git a/homeassistant/components/hlk_sw16.py b/homeassistant/components/hlk_sw16.py new file mode 100644 index 0000000000000..cfbb8ac010c7d --- /dev/null +++ b/homeassistant/components/hlk_sw16.py @@ -0,0 +1,163 @@ +""" +Support for HLK-SW16 relay switch. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/hlk_sw16/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import ( + CONF_HOST, CONF_PORT, + EVENT_HOMEASSISTANT_STOP, CONF_SWITCHES, CONF_NAME) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import ( + async_dispatcher_send, async_dispatcher_connect) + +REQUIREMENTS = ['hlk-sw16==0.0.6'] + +_LOGGER = logging.getLogger(__name__) + +DATA_DEVICE_REGISTER = 'hlk_sw16_device_register' +DEFAULT_RECONNECT_INTERVAL = 10 +CONNECTION_TIMEOUT = 10 +DEFAULT_PORT = 8080 + +DOMAIN = 'hlk_sw16' + +SIGNAL_AVAILABILITY = 'hlk_sw16_device_available_{}' + +SWITCH_SCHEMA = vol.Schema({ + vol.Optional(CONF_NAME): cv.string, +}) + +RELAY_ID = vol.All( + vol.Any(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 'a', 'b', 'c', 'd', 'e', 'f'), + vol.Coerce(str)) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + cv.string: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_SWITCHES): vol.Schema({RELAY_ID: SWITCH_SCHEMA}), + }), + }), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the HLK-SW16 switch.""" + # Allow platform to specify function to register new unknown devices + from hlk_sw16 import create_hlk_sw16_connection + hass.data[DATA_DEVICE_REGISTER] = {} + + def add_device(device): + switches = config[DOMAIN][device][CONF_SWITCHES] + + host = config[DOMAIN][device][CONF_HOST] + port = config[DOMAIN][device][CONF_PORT] + + @callback + def disconnected(): + """Schedule reconnect after connection has been lost.""" + _LOGGER.warning('HLK-SW16 %s disconnected', device) + async_dispatcher_send(hass, SIGNAL_AVAILABILITY.format(device), + False) + + @callback + def reconnected(): + """Schedule reconnect after connection has been lost.""" + _LOGGER.warning('HLK-SW16 %s connected', device) + async_dispatcher_send(hass, SIGNAL_AVAILABILITY.format(device), + True) + + async def connect(): + """Set up connection and hook it into HA for reconnect/shutdown.""" + _LOGGER.info('Initiating HLK-SW16 connection to %s', device) + + client = await create_hlk_sw16_connection( + host=host, + port=port, + disconnect_callback=disconnected, + reconnect_callback=reconnected, + loop=hass.loop, + timeout=CONNECTION_TIMEOUT, + reconnect_interval=DEFAULT_RECONNECT_INTERVAL) + + hass.data[DATA_DEVICE_REGISTER][device] = client + + # Load platforms + hass.async_create_task( + async_load_platform(hass, 'switch', DOMAIN, + (switches, device), + config)) + + # handle shutdown of HLK-SW16 asyncio transport + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, + lambda x: client.stop()) + + _LOGGER.info('Connected to HLK-SW16 device: %s', device) + + hass.loop.create_task(connect()) + + for device in config[DOMAIN]: + add_device(device) + return True + + +class SW16Device(Entity): + """Representation of a HLK-SW16 device. + + Contains the common logic for HLK-SW16 entities. + """ + + def __init__(self, relay_name, device_port, device_id, client): + """Initialize the device.""" + # HLK-SW16 specific attributes for every component type + self._device_id = device_id + self._device_port = device_port + self._is_on = None + self._client = client + self._name = relay_name + + @callback + def handle_event_callback(self, event): + """Propagate changes through ha.""" + _LOGGER.debug("Relay %s new state callback: %r", + self._device_port, event) + self._is_on = event + self.async_schedule_update_ha_state() + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return a name for the device.""" + return self._name + + @property + def available(self): + """Return True if entity is available.""" + return bool(self._client.is_connected) + + @callback + def _availability_callback(self, availability): + """Update availability state.""" + self.async_schedule_update_ha_state() + + async def async_added_to_hass(self): + """Register update callback.""" + self._client.register_status_callback(self.handle_event_callback, + self._device_port) + self._is_on = await self._client.status(self._device_port) + async_dispatcher_connect(self.hass, + SIGNAL_AVAILABILITY.format(self._device_id), + self._availability_callback) diff --git a/homeassistant/components/switch/hlk_sw16.py b/homeassistant/components/switch/hlk_sw16.py new file mode 100644 index 0000000000000..d76528c56f06a --- /dev/null +++ b/homeassistant/components/switch/hlk_sw16.py @@ -0,0 +1,54 @@ +""" +Support for HLK-SW16 switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.hlk_sw16/ +""" +import logging + +from homeassistant.components.hlk_sw16 import ( + SW16Device, DOMAIN as HLK_SW16, + DATA_DEVICE_REGISTER) +from homeassistant.components.switch import ( + ToggleEntity) +from homeassistant.const import CONF_NAME + +DEPENDENCIES = [HLK_SW16] + +_LOGGER = logging.getLogger(__name__) + + +def devices_from_config(hass, domain_config): + """Parse configuration and add HLK-SW16 switch devices.""" + switches = domain_config[0] + device_id = domain_config[1] + device_client = hass.data[DATA_DEVICE_REGISTER][device_id] + devices = [] + for device_port, device_config in switches.items(): + device_name = device_config.get(CONF_NAME, device_port) + device = SW16Switch(device_name, device_port, device_id, device_client) + devices.append(device) + return devices + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the HLK-SW16 platform.""" + async_add_entities(devices_from_config(hass, discovery_info)) + + +class SW16Switch(SW16Device, ToggleEntity): + """Representation of a HLK-SW16 switch.""" + + @property + def is_on(self): + """Return true if device is on.""" + return self._is_on + + async def async_turn_on(self, **kwargs): + """Turn the device on.""" + await self._client.turn_on(self._device_port) + + async def async_turn_off(self, **kwargs): + """Turn the device off.""" + await self._client.turn_off(self._device_port) diff --git a/requirements_all.txt b/requirements_all.txt index 89d011f0927b3..af32ab534d792 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -482,6 +482,9 @@ hikvision==0.4 # homeassistant.components.notify.hipchat hipnotify==1.0.8 +# homeassistant.components.hlk_sw16 +hlk-sw16==0.0.6 + # homeassistant.components.sensor.pi_hole hole==0.3.0 From f3946cb54f3211d21fbd1a6b84859116ed68dcb4 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 3 Dec 2018 10:07:43 +0100 Subject: [PATCH 217/325] Push to version 0.7.7 of denonavr (#18917) --- 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 bf93431130367..c565a161b101e 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -21,7 +21,7 @@ STATE_PAUSED, STATE_PLAYING) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['denonavr==0.7.6'] +REQUIREMENTS = ['denonavr==0.7.7'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index af32ab534d792..3fa9a265a62a2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -293,7 +293,7 @@ defusedxml==0.5.0 deluge-client==1.4.0 # homeassistant.components.media_player.denonavr -denonavr==0.7.6 +denonavr==0.7.7 # homeassistant.components.media_player.directv directpy==0.5 From 3904d83c32f55f038e33909c74dadcb6606bff1d Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Mon, 3 Dec 2018 10:56:26 +0100 Subject: [PATCH 218/325] Extend partial reload to include packages (#18884) * Merge packages after partial reload * Remove merge from core reload & test * Integrate merge in 'async_hass_config_yaml' * Merge executors --- homeassistant/bootstrap.py | 5 ----- homeassistant/config.py | 7 +++++-- homeassistant/scripts/check_config.py | 5 ----- tests/test_config.py | 29 ++++++++++++++++++++++++++- 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 0676cec7fad5d..c764bfe8c21b5 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -115,11 +115,6 @@ async def async_from_config_dict(config: Dict[str, Any], conf_util.merge_packages_config( hass, config, core_config.get(conf_util.CONF_PACKAGES, {})) - # Ensure we have no None values after merge - for key, value in config.items(): - if not value: - config[key] = {} - hass.config_entries = config_entries.ConfigEntries(hass, config) await hass.config_entries.async_load() diff --git a/homeassistant/config.py b/homeassistant/config.py index 5f7107f95ae62..4fc77bd81cdce 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -332,7 +332,7 @@ async def async_hass_config_yaml(hass: HomeAssistant) -> Dict: """Load YAML from a Home Assistant configuration file. This function allow a component inside the asyncio loop to reload its - configuration by itself. + configuration by itself. Include package merge. This method is a coroutine. """ @@ -341,7 +341,10 @@ def _load_hass_yaml_config() -> Dict: if path is None: raise HomeAssistantError( "Config file not found in: {}".format(hass.config.config_dir)) - return load_yaml_config_file(path) + config = load_yaml_config_file(path) + core_config = config.get(CONF_CORE, {}) + merge_packages_config(hass, config, core_config.get(CONF_PACKAGES, {})) + return config return await hass.async_add_executor_job(_load_hass_yaml_config) diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 1e77454a8d57a..ac341e8f58a3d 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -327,11 +327,6 @@ def _comp_error(ex, domain, config): hass, config, core_config.get(CONF_PACKAGES, {}), _pack_error) core_config.pop(CONF_PACKAGES, None) - # Ensure we have no None values after merge - for key, value in config.items(): - if not value: - config[key] = {} - # Filter out repeating config sections components = set(key.split(' ')[0] for key in config.keys()) diff --git a/tests/test_config.py b/tests/test_config.py index 0d248e2b170fd..212fc247eb9a8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,8 +6,10 @@ import unittest.mock as mock from collections import OrderedDict +import asynctest import pytest from voluptuous import MultipleInvalid, Invalid +import yaml from homeassistant.core import DOMAIN, HomeAssistantError, Config import homeassistant.config as config_util @@ -31,7 +33,8 @@ CONFIG_PATH as CUSTOMIZE_CONFIG_PATH) import homeassistant.scripts.check_config as check_config -from tests.common import get_test_config_dir, get_test_home_assistant +from tests.common import ( + get_test_config_dir, get_test_home_assistant, patch_yaml_files) CONFIG_DIR = get_test_config_dir() YAML_PATH = os.path.join(CONFIG_DIR, config_util.YAML_CONFIG_FILE) @@ -550,6 +553,30 @@ def test_check_ha_config_file_wrong(self, mock_check): ).result() == 'bad' +@asynctest.mock.patch('homeassistant.config.os.path.isfile', + mock.Mock(return_value=True)) +async def test_async_hass_config_yaml_merge(merge_log_err, hass): + """Test merge during async config reload.""" + config = { + config_util.CONF_CORE: {config_util.CONF_PACKAGES: { + 'pack_dict': { + 'input_boolean': {'ib1': None}}}}, + 'input_boolean': {'ib2': None}, + 'light': {'platform': 'test'} + } + + files = {config_util.YAML_CONFIG_FILE: yaml.dump(config)} + with patch_yaml_files(files, True): + conf = await config_util.async_hass_config_yaml(hass) + + assert merge_log_err.call_count == 0 + assert conf[config_util.CONF_CORE].get(config_util.CONF_PACKAGES) \ + is not None + assert len(conf) == 3 + assert len(conf['input_boolean']) == 2 + assert len(conf['light']) == 1 + + # pylint: disable=redefined-outer-name @pytest.fixture def merge_log_err(hass): From 17c6ef5d540a0851fbddfc5d9c083b5aac95c5ee Mon Sep 17 00:00:00 2001 From: kennedyshead Date: Mon, 3 Dec 2018 11:13:06 +0100 Subject: [PATCH 219/325] bump aioasuswrt version (#18955) --- homeassistant/components/asuswrt.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/asuswrt.py b/homeassistant/components/asuswrt.py index d72c8d77a2bbd..719e857c75194 100644 --- a/homeassistant/components/asuswrt.py +++ b/homeassistant/components/asuswrt.py @@ -14,7 +14,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform -REQUIREMENTS = ['aioasuswrt==1.1.12'] +REQUIREMENTS = ['aioasuswrt==1.1.13'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 3fa9a265a62a2..2a1649e9b9a76 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -86,7 +86,7 @@ abodepy==0.14.0 afsapi==0.0.4 # homeassistant.components.asuswrt -aioasuswrt==1.1.12 +aioasuswrt==1.1.13 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.5 From d2b62840f2d160badaa588ce9e6f92b2b7c5c752 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Dec 2018 11:34:01 +0100 Subject: [PATCH 220/325] Add users added via credentials to admin group too (#18922) * Add users added via credentials to admin group too * Update test_init.py --- homeassistant/auth/__init__.py | 1 + tests/auth/test_init.py | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index e53385880e5c3..3377bb2a6aa0d 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -185,6 +185,7 @@ async def async_get_or_create_user(self, credentials: models.Credentials) \ credentials=credentials, name=info.name, is_active=info.is_active, + group_ids=[GROUP_ID_ADMIN], ) self.hass.bus.async_fire(EVENT_USER_ADDED, { diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 4357ba1b1dea5..e950230f10ab3 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -870,3 +870,28 @@ def user_removed(event): await hass.async_block_till_done() assert len(events) == 1 assert events[0].data['user_id'] == user.id + + +async def test_new_users_admin(mock_hass): + """Test newly created users are admin.""" + manager = await auth.auth_manager_from_config(mock_hass, [{ + 'type': 'insecure_example', + 'users': [{ + 'username': 'test-user', + 'password': 'test-pass', + 'name': 'Test Name' + }] + }], []) + ensure_auth_manager_loaded(manager) + + user = await manager.async_create_user('Hello') + assert user.is_admin + + user_cred = await manager.async_get_or_create_user(auth_models.Credentials( + id='mock-id', + auth_provider_type='insecure_example', + auth_provider_id=None, + data={'username': 'test-user'}, + is_new=True, + )) + assert user_cred.is_admin From 85c0de550cbf3d6d6e4cae25b3c8519d32a1017b Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Mon, 3 Dec 2018 05:34:22 -0500 Subject: [PATCH 221/325] Use capability of sensor if present to fix multisensor Wink devices (#18907) --- homeassistant/components/wink/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/wink/__init__.py b/homeassistant/components/wink/__init__.py index a94f8c3bdf29f..c4cefa2c2d175 100644 --- a/homeassistant/components/wink/__init__.py +++ b/homeassistant/components/wink/__init__.py @@ -690,6 +690,10 @@ def name(self): @property def unique_id(self): """Return the unique id of the Wink device.""" + if hasattr(self.wink, 'capability') and \ + self.wink.capability() is not None: + return "{}_{}".format(self.wink.object_id(), + self.wink.capability()) return self.wink.object_id() @property From 1d717b768d4ce59ee03b904f093460626b50c3d4 Mon Sep 17 00:00:00 2001 From: Andrew Hayworth Date: Sun, 2 Dec 2018 04:14:46 -0600 Subject: [PATCH 222/325] bugfix: ensure the `google_assistant` component respects `allow_unlock` (#18874) The `Config` object specific to the `google_assistant` component had a default value for `allow_unlock`. We were not overriding this default when constructing the Config object during `google_assistant` component setup, whereas we do when setting up the `cloud` component. To fix, we thread the `allow_unlock` parameter down through http setup, and ensure that it's set correctly. Moreover, we also change the ordering of the `Config` parameters, and remove the default. Future refactoring should not miss it, as it is now a required parameter. --- homeassistant/components/cloud/__init__.py | 2 +- homeassistant/components/google_assistant/helpers.py | 4 ++-- homeassistant/components/google_assistant/http.py | 8 ++++++-- tests/components/google_assistant/test_smart_home.py | 2 ++ tests/components/google_assistant/test_trait.py | 1 + 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 4f4b0c582fc9c..ba5621b1f8dd9 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -186,9 +186,9 @@ def should_expose(entity): self._gactions_config = ga_h.Config( should_expose=should_expose, + allow_unlock=self.prefs.google_allow_unlock, agent_user_id=self.claims['cognito:username'], entity_config=conf.get(CONF_ENTITY_CONFIG), - allow_unlock=self.prefs.google_allow_unlock, ) return self._gactions_config diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index e71756d9fee24..f20a4106a161b 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -16,8 +16,8 @@ def __init__(self, code, msg): class Config: """Hold the configuration for Google Assistant.""" - def __init__(self, should_expose, agent_user_id, entity_config=None, - allow_unlock=False): + def __init__(self, should_expose, allow_unlock, agent_user_id, + entity_config=None): """Initialize the configuration.""" self.should_expose = should_expose self.agent_user_id = agent_user_id diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index f29e8bbae12b8..d688491fe8925 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -15,6 +15,7 @@ from .const import ( GOOGLE_ASSISTANT_API_ENDPOINT, + CONF_ALLOW_UNLOCK, CONF_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS, CONF_ENTITY_CONFIG, @@ -32,6 +33,7 @@ def async_register_http(hass, cfg): expose_by_default = cfg.get(CONF_EXPOSE_BY_DEFAULT) exposed_domains = cfg.get(CONF_EXPOSED_DOMAINS) entity_config = cfg.get(CONF_ENTITY_CONFIG) or {} + allow_unlock = cfg.get(CONF_ALLOW_UNLOCK, False) def is_exposed(entity) -> bool: """Determine if an entity should be exposed to Google Assistant.""" @@ -57,7 +59,7 @@ def is_exposed(entity) -> bool: return is_default_exposed or explicit_expose hass.http.register_view( - GoogleAssistantView(is_exposed, entity_config)) + GoogleAssistantView(is_exposed, entity_config, allow_unlock)) class GoogleAssistantView(HomeAssistantView): @@ -67,15 +69,17 @@ class GoogleAssistantView(HomeAssistantView): name = 'api:google_assistant' requires_auth = True - def __init__(self, is_exposed, entity_config): + def __init__(self, is_exposed, entity_config, allow_unlock): """Initialize the Google Assistant request handler.""" self.is_exposed = is_exposed self.entity_config = entity_config + self.allow_unlock = allow_unlock async def post(self, request: Request) -> Response: """Handle Google Assistant requests.""" message = await request.json() # type: dict config = Config(self.is_exposed, + self.allow_unlock, request['hass_user'].id, self.entity_config) result = await async_handle_message( diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 66e7747e06a77..36971224f92ed 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -11,6 +11,7 @@ BASIC_CONFIG = helpers.Config( should_expose=lambda state: True, + allow_unlock=False, agent_user_id='test-agent', ) REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf' @@ -35,6 +36,7 @@ async def test_sync_message(hass): config = helpers.Config( should_expose=lambda state: state.entity_id != 'light.not_expose', + allow_unlock=False, agent_user_id='test-agent', entity_config={ 'light.demo_light': { diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 42af1230eed79..616c43464a627 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -25,6 +25,7 @@ BASIC_CONFIG = helpers.Config( should_expose=lambda state: True, + allow_unlock=False, agent_user_id='test-agent', ) From 6de0ed3f0a016e30c65e8404c21a7762ebc0a9f3 Mon Sep 17 00:00:00 2001 From: ludeeus Date: Sat, 1 Dec 2018 11:24:32 +0100 Subject: [PATCH 223/325] Fix stability issues with multiple units --- homeassistant/components/device_tracker/googlehome.py | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/device_tracker/googlehome.py b/homeassistant/components/device_tracker/googlehome.py index 575d9688493b2..e700301d5798b 100644 --- a/homeassistant/components/device_tracker/googlehome.py +++ b/homeassistant/components/device_tracker/googlehome.py @@ -14,7 +14,7 @@ DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST -REQUIREMENTS = ['ghlocalapi==0.1.0'] +REQUIREMENTS = ['ghlocalapi==0.3.4'] _LOGGER = logging.getLogger(__name__) @@ -77,8 +77,8 @@ async def get_extra_attributes(self, device): async def async_update_info(self): """Ensure the information from Google Home is up to date.""" _LOGGER.debug('Checking Devices...') - await self.scanner.scan_for_devices() await self.scanner.get_scan_result() + await self.scanner.scan_for_devices() ghname = self.deviceinfo.device_info['name'] devices = {} for device in self.scanner.devices: diff --git a/requirements_all.txt b/requirements_all.txt index e4020ca41e75e..8f4f24f45edb1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ geojson_client==0.3 georss_client==0.4 # homeassistant.components.device_tracker.googlehome -ghlocalapi==0.1.0 +ghlocalapi==0.3.4 # homeassistant.components.sensor.gitter gitterpy==0.1.7 From 79a9c1af9ea1304ae987833220b79c6bee214da0 Mon Sep 17 00:00:00 2001 From: ludeeus Date: Sat, 1 Dec 2018 16:28:22 +0100 Subject: [PATCH 224/325] bump ghlocalapi to use clear_scan_result --- homeassistant/components/device_tracker/googlehome.py | 5 +++-- requirements_all.txt | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/device_tracker/googlehome.py b/homeassistant/components/device_tracker/googlehome.py index e700301d5798b..dabb92a075114 100644 --- a/homeassistant/components/device_tracker/googlehome.py +++ b/homeassistant/components/device_tracker/googlehome.py @@ -14,7 +14,7 @@ DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST -REQUIREMENTS = ['ghlocalapi==0.3.4'] +REQUIREMENTS = ['ghlocalapi==0.3.5'] _LOGGER = logging.getLogger(__name__) @@ -77,8 +77,8 @@ async def get_extra_attributes(self, device): async def async_update_info(self): """Ensure the information from Google Home is up to date.""" _LOGGER.debug('Checking Devices...') - await self.scanner.get_scan_result() await self.scanner.scan_for_devices() + await self.scanner.get_scan_result() ghname = self.deviceinfo.device_info['name'] devices = {} for device in self.scanner.devices: @@ -89,4 +89,5 @@ async def async_update_info(self): devices[uuid]['btle_mac_address'] = device['mac_address'] devices[uuid]['ghname'] = ghname devices[uuid]['source_type'] = 'bluetooth' + await self.scanner.clear_scan_result() self.last_results = devices diff --git a/requirements_all.txt b/requirements_all.txt index 8f4f24f45edb1..c016d4596ada4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ geojson_client==0.3 georss_client==0.4 # homeassistant.components.device_tracker.googlehome -ghlocalapi==0.3.4 +ghlocalapi==0.3.5 # homeassistant.components.sensor.gitter gitterpy==0.1.7 From f8218b5e01bc67ad1e8e01f032d7a9f7aba27ac3 Mon Sep 17 00:00:00 2001 From: ludeeus Date: Sat, 1 Dec 2018 13:02:15 +0100 Subject: [PATCH 225/325] Fix IndexError for home stats --- homeassistant/components/sensor/tautulli.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/tautulli.py b/homeassistant/components/sensor/tautulli.py index 7b0d8e491d227..419ef6a11a12d 100644 --- a/homeassistant/components/sensor/tautulli.py +++ b/homeassistant/components/sensor/tautulli.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['pytautulli==0.4.0'] +REQUIREMENTS = ['pytautulli==0.4.1'] _LOGGER = logging.getLogger(__name__) @@ -90,9 +90,9 @@ async def async_update(self): await self.tautulli.async_update() self.home = self.tautulli.api.home_data self.sessions = self.tautulli.api.session_data - self._attributes['Top Movie'] = self.home[0]['rows'][0]['title'] - self._attributes['Top TV Show'] = self.home[3]['rows'][0]['title'] - self._attributes['Top User'] = self.home[7]['rows'][0]['user'] + self._attributes['Top Movie'] = self.home['movie'] + self._attributes['Top TV Show'] = self.home['tv'] + self._attributes['Top User'] = self.home['user'] for key in self.sessions: if 'sessions' not in key: self._attributes[key] = self.sessions[key] From 475be636d6d1fda3349dd444cb085f0000ea5b1a Mon Sep 17 00:00:00 2001 From: ludeeus Date: Sat, 1 Dec 2018 13:07:32 +0100 Subject: [PATCH 226/325] Fix requirements_all --- requirements_all.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_all.txt b/requirements_all.txt index c016d4596ada4..50a81aa6c2c55 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1154,7 +1154,7 @@ pystride==0.1.7 pysyncthru==0.3.1 # homeassistant.components.sensor.tautulli -pytautulli==0.4.0 +pytautulli==0.4.1 # homeassistant.components.media_player.liveboxplaytv pyteleloisirs==3.4 From 82d89edb4f2cd82b785c955e03ccdd1d95c6073b Mon Sep 17 00:00:00 2001 From: ludeeus Date: Sat, 1 Dec 2018 21:32:31 +0100 Subject: [PATCH 227/325] Use dict.get('key') instead of dict['key'] --- homeassistant/components/sensor/tautulli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/tautulli.py b/homeassistant/components/sensor/tautulli.py index 419ef6a11a12d..29c11c934f9e6 100644 --- a/homeassistant/components/sensor/tautulli.py +++ b/homeassistant/components/sensor/tautulli.py @@ -90,9 +90,9 @@ async def async_update(self): await self.tautulli.async_update() self.home = self.tautulli.api.home_data self.sessions = self.tautulli.api.session_data - self._attributes['Top Movie'] = self.home['movie'] - self._attributes['Top TV Show'] = self.home['tv'] - self._attributes['Top User'] = self.home['user'] + self._attributes['Top Movie'] = self.home.get('movie') + self._attributes['Top TV Show'] = self.home,get('tv') + self._attributes['Top User'] = self.home.get('user') for key in self.sessions: if 'sessions' not in key: self._attributes[key] = self.sessions[key] From ee1c29b392ac0734c4fd0d1ee15fa44547612893 Mon Sep 17 00:00:00 2001 From: ludeeus Date: Sat, 1 Dec 2018 21:34:31 +0100 Subject: [PATCH 228/325] corrects , -> . typo --- homeassistant/components/sensor/tautulli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/tautulli.py b/homeassistant/components/sensor/tautulli.py index 29c11c934f9e6..f47f0e5c38291 100644 --- a/homeassistant/components/sensor/tautulli.py +++ b/homeassistant/components/sensor/tautulli.py @@ -91,7 +91,7 @@ async def async_update(self): self.home = self.tautulli.api.home_data self.sessions = self.tautulli.api.session_data self._attributes['Top Movie'] = self.home.get('movie') - self._attributes['Top TV Show'] = self.home,get('tv') + self._attributes['Top TV Show'] = self.home.get('tv') self._attributes['Top User'] = self.home.get('user') for key in self.sessions: if 'sessions' not in key: From 3575c34f77aecaaacace741d66c33ba8201dd4a0 Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Mon, 3 Dec 2018 05:34:22 -0500 Subject: [PATCH 229/325] Use capability of sensor if present to fix multisensor Wink devices (#18907) --- homeassistant/components/wink/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/wink/__init__.py b/homeassistant/components/wink/__init__.py index a94f8c3bdf29f..c4cefa2c2d175 100644 --- a/homeassistant/components/wink/__init__.py +++ b/homeassistant/components/wink/__init__.py @@ -690,6 +690,10 @@ def name(self): @property def unique_id(self): """Return the unique id of the Wink device.""" + if hasattr(self.wink, 'capability') and \ + self.wink.capability() is not None: + return "{}_{}".format(self.wink.object_id(), + self.wink.capability()) return self.wink.object_id() @property From 35690d5b29b1f54ac7cadf3ee9777de15ad23061 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Dec 2018 11:34:01 +0100 Subject: [PATCH 230/325] Add users added via credentials to admin group too (#18922) * Add users added via credentials to admin group too * Update test_init.py --- homeassistant/auth/__init__.py | 1 + tests/auth/test_init.py | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 7d8ef13d2bb92..49f01211e5a20 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -190,6 +190,7 @@ async def async_get_or_create_user(self, credentials: models.Credentials) \ credentials=credentials, name=info.name, is_active=info.is_active, + group_ids=[GROUP_ID_ADMIN], ) self.hass.bus.async_fire(EVENT_USER_ADDED, { diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 4357ba1b1dea5..e950230f10ab3 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -870,3 +870,28 @@ def user_removed(event): await hass.async_block_till_done() assert len(events) == 1 assert events[0].data['user_id'] == user.id + + +async def test_new_users_admin(mock_hass): + """Test newly created users are admin.""" + manager = await auth.auth_manager_from_config(mock_hass, [{ + 'type': 'insecure_example', + 'users': [{ + 'username': 'test-user', + 'password': 'test-pass', + 'name': 'Test Name' + }] + }], []) + ensure_auth_manager_loaded(manager) + + user = await manager.async_create_user('Hello') + assert user.is_admin + + user_cred = await manager.async_get_or_create_user(auth_models.Credentials( + id='mock-id', + auth_provider_type='insecure_example', + auth_provider_id=None, + data={'username': 'test-user'}, + is_new=True, + )) + assert user_cred.is_admin From f6a79059e5a240540f8185cd6c419135f8e5c90c Mon Sep 17 00:00:00 2001 From: kennedyshead Date: Tue, 27 Nov 2018 14:20:25 +0100 Subject: [PATCH 231/325] fix aioasuswrt sometimes return empty lists (#18742) * aioasuswrt sometimes return empty lists * Bumping aioasuswrt to 1.1.12 --- homeassistant/components/asuswrt.py | 2 +- homeassistant/components/sensor/asuswrt.py | 8 ++++---- requirements_all.txt | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/asuswrt.py b/homeassistant/components/asuswrt.py index c653c1d03fd0e..d72c8d77a2bbd 100644 --- a/homeassistant/components/asuswrt.py +++ b/homeassistant/components/asuswrt.py @@ -14,7 +14,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform -REQUIREMENTS = ['aioasuswrt==1.1.11'] +REQUIREMENTS = ['aioasuswrt==1.1.12'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/asuswrt.py b/homeassistant/components/sensor/asuswrt.py index 4ca088fb1e2c5..876f0dfd55949 100644 --- a/homeassistant/components/sensor/asuswrt.py +++ b/homeassistant/components/sensor/asuswrt.py @@ -68,7 +68,7 @@ def unit_of_measurement(self): async def async_update(self): """Fetch new state data for the sensor.""" await super().async_update() - if self._speed is not None: + if self._speed: self._state = round(self._speed[0] / 125000, 2) @@ -86,7 +86,7 @@ def unit_of_measurement(self): async def async_update(self): """Fetch new state data for the sensor.""" await super().async_update() - if self._speed is not None: + if self._speed: self._state = round(self._speed[1] / 125000, 2) @@ -104,7 +104,7 @@ def unit_of_measurement(self): async def async_update(self): """Fetch new state data for the sensor.""" await super().async_update() - if self._rates is not None: + if self._rates: self._state = round(self._rates[0] / 1000000000, 1) @@ -122,5 +122,5 @@ def unit_of_measurement(self): async def async_update(self): """Fetch new state data for the sensor.""" await super().async_update() - if self._rates is not None: + if self._rates: self._state = round(self._rates[1] / 1000000000, 1) diff --git a/requirements_all.txt b/requirements_all.txt index 50a81aa6c2c55..e939f1e464607 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -86,7 +86,7 @@ abodepy==0.14.0 afsapi==0.0.4 # homeassistant.components.asuswrt -aioasuswrt==1.1.11 +aioasuswrt==1.1.12 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.5 From 106cb63922955ef8af0ca64fbf8133e76a2e1483 Mon Sep 17 00:00:00 2001 From: kennedyshead Date: Mon, 3 Dec 2018 11:13:06 +0100 Subject: [PATCH 232/325] bump aioasuswrt version (#18955) --- homeassistant/components/asuswrt.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/asuswrt.py b/homeassistant/components/asuswrt.py index d72c8d77a2bbd..719e857c75194 100644 --- a/homeassistant/components/asuswrt.py +++ b/homeassistant/components/asuswrt.py @@ -14,7 +14,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform -REQUIREMENTS = ['aioasuswrt==1.1.12'] +REQUIREMENTS = ['aioasuswrt==1.1.13'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index e939f1e464607..169c54aa71317 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -86,7 +86,7 @@ abodepy==0.14.0 afsapi==0.0.4 # homeassistant.components.asuswrt -aioasuswrt==1.1.12 +aioasuswrt==1.1.13 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.5 From 4ef1bf21570f15fca37262ce76deb08d61627204 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Dec 2018 11:42:49 +0100 Subject: [PATCH 233/325] Bumped version to 0.83.3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1a1d45396f016..63d4e9f00f526 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 83 -PATCH_VERSION = '2' +PATCH_VERSION = '3' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 149eddaf4615e131e7c40d69ce89757b86b7b057 Mon Sep 17 00:00:00 2001 From: pbalogh77 Date: Mon, 3 Dec 2018 14:57:55 +0100 Subject: [PATCH 234/325] Initial scene support for Fibaro hubs (#18779) * Initial scene support Added initial support for fibaro scenes * removed comments * cleanup based on code review * Removed unused functions * grrr, my mistake. My local pylint and flake8 are playing tricks with me * Update homeassistant/components/scene/fibaro.py * fixes based on code review ABC ordered the list of platforms changed setup platform to async removed overloaded name property as the FibaroDevice parent class already provides this Changed to new style string formatting * Update homeassistant/components/scene/fibaro.py Co-Authored-By: pbalogh77 --- homeassistant/components/fibaro.py | 21 +++++++++++++- homeassistant/components/scene/fibaro.py | 35 ++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/scene/fibaro.py diff --git a/homeassistant/components/fibaro.py b/homeassistant/components/fibaro.py index 51d7dd2ef7ed9..55f6f528622d9 100644 --- a/homeassistant/components/fibaro.py +++ b/homeassistant/components/fibaro.py @@ -27,7 +27,8 @@ ATTR_CURRENT_ENERGY_KWH = "current_energy_kwh" CONF_PLUGINS = "plugins" -FIBARO_COMPONENTS = ['binary_sensor', 'cover', 'light', 'sensor', 'switch'] +FIBARO_COMPONENTS = ['binary_sensor', 'cover', 'light', + 'scene', 'sensor', 'switch'] FIBARO_TYPEMAP = { 'com.fibaro.multilevelSensor': "sensor", @@ -72,6 +73,7 @@ def __init__(self, username, password, url, import_plugins): """Initialize the Fibaro controller.""" from fiblary3.client.v4.client import Client as FibaroClient self._client = FibaroClient(url, username, password) + self._scene_map = None def connect(self): """Start the communication with the Fibaro controller.""" @@ -88,6 +90,7 @@ def connect(self): self._room_map = {room.id: room for room in self._client.rooms.list()} self._read_devices() + self._read_scenes() return True def enable_state_handler(self): @@ -167,6 +170,22 @@ def _map_device_to_type(device): device_type = 'light' return device_type + def _read_scenes(self): + scenes = self._client.scenes.list() + self._scene_map = {} + for device in scenes: + if not device.visible: + continue + if device.roomID == 0: + room_name = 'Unknown' + else: + room_name = self._room_map[device.roomID].name + device.friendly_name = '{} {}'.format(room_name, device.name) + device.ha_id = '{}_{}_{}'.format( + slugify(room_name), slugify(device.name), device.id) + self._scene_map[device.id] = device + self.fibaro_devices['scene'].append(device) + def _read_devices(self): """Read and process the device list.""" devices = self._client.devices.list() diff --git a/homeassistant/components/scene/fibaro.py b/homeassistant/components/scene/fibaro.py new file mode 100644 index 0000000000000..7a36900f8842d --- /dev/null +++ b/homeassistant/components/scene/fibaro.py @@ -0,0 +1,35 @@ +""" +Support for Fibaro scenes. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/scene.fibaro/ +""" +import logging + +from homeassistant.components.scene import ( + Scene) +from homeassistant.components.fibaro import ( + FIBARO_CONTROLLER, FIBARO_DEVICES, FibaroDevice) + +DEPENDENCIES = ['fibaro'] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Perform the setup for Fibaro scenes.""" + if discovery_info is None: + return + + async_add_entities( + [FibaroScene(scene, hass.data[FIBARO_CONTROLLER]) + for scene in hass.data[FIBARO_DEVICES]['scene']], True) + + +class FibaroScene(FibaroDevice, Scene): + """Representation of a Fibaro scene entity.""" + + def activate(self): + """Activate the scene.""" + self.fibaro_device.start() From d0751ffd91b993c194826cf0d554222b26832b55 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 3 Dec 2018 15:44:04 +0100 Subject: [PATCH 235/325] Add id when not exist and fix dup id check (#18960) * Add id when not exist and fix dup id check * config possibly not be a yaml dict --- homeassistant/components/lovelace/__init__.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 3e6958f35e271..49992bc6e393d 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -159,15 +159,17 @@ def load_config(hass) -> JSON_TYPE: seen_card_ids = set() seen_view_ids = set() for view in config.get('views', []): - view_id = str(view.get('id', '')) + view_id = view.get('id') if view_id: + view_id = str(view_id) if view_id in seen_view_ids: raise DuplicateIdError( 'ID `{}` has multiple occurances in views'.format(view_id)) seen_view_ids.add(view_id) for card in view.get('cards', []): - card_id = str(card.get('id', '')) + card_id = card.get('id') if card_id: + card_id = str(card_id) if card_id in seen_card_ids: raise DuplicateIdError( 'ID `{}` has multiple occurances in cards' @@ -267,6 +269,9 @@ def add_card(fname: str, view_id: str, card_config: str, cards = view.get('cards', []) if data_format == FORMAT_YAML: card_config = yaml.yaml_to_object(card_config) + if 'id' not in card_config: + card_config['id'] = uuid.uuid4().hex + card_config.move_to_end('id', last=False) if position is None: cards.append(card_config) else: @@ -389,6 +394,9 @@ def add_view(fname: str, view_config: str, views = config.get('views', []) if data_format == FORMAT_YAML: view_config = yaml.yaml_to_object(view_config) + if 'id' not in view_config: + view_config['id'] = uuid.uuid4().hex + view_config.move_to_end('id', last=False) if position is None: views.append(view_config) else: From d028236bf210e668f4151ba2dc9514833e52cda8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Dec 2018 15:46:25 +0100 Subject: [PATCH 236/325] Refactor script helper actions into their own methods (#18962) * Refactor script helper actions into their own methods * Lint * Lint --- homeassistant/helpers/script.py | 236 +++++++++++++++++++------------- tests/helpers/test_script.py | 85 ++++++++++++ 2 files changed, 228 insertions(+), 93 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 80d66f4fac881..088882df608f7 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant, Context, callback from homeassistant.const import CONF_CONDITION, CONF_TIMEOUT -from homeassistant.exceptions import TemplateError +from homeassistant import exceptions from homeassistant.helpers import ( service, condition, template as template, config_validation as cv) @@ -34,6 +34,30 @@ CONF_CONTINUE = 'continue_on_timeout' +ACTION_DELAY = 'delay' +ACTION_WAIT_TEMPLATE = 'wait_template' +ACTION_CHECK_CONDITION = 'condition' +ACTION_FIRE_EVENT = 'event' +ACTION_CALL_SERVICE = 'call_service' + + +def _determine_action(action): + """Determine action type.""" + if CONF_DELAY in action: + return ACTION_DELAY + + if CONF_WAIT_TEMPLATE in action: + return ACTION_WAIT_TEMPLATE + + if CONF_CONDITION in action: + return ACTION_CHECK_CONDITION + + if CONF_EVENT in action: + return ACTION_FIRE_EVENT + + return ACTION_CALL_SERVICE + + def call_from_config(hass: HomeAssistant, config: ConfigType, variables: Optional[Sequence] = None, context: Optional[Context] = None) -> None: @@ -41,6 +65,14 @@ def call_from_config(hass: HomeAssistant, config: ConfigType, Script(hass, cv.SCRIPT_SCHEMA(config)).run(variables, context) +class _StopScript(Exception): + """Throw if script needs to stop.""" + + +class _SuspendScript(Exception): + """Throw if script needs to suspend.""" + + class Script(): """Representation of a script.""" @@ -60,6 +92,13 @@ def __init__(self, hass: HomeAssistant, sequence, name: str = None, self._async_listener = [] self._template_cache = {} self._config_cache = {} + self._actions = { + ACTION_DELAY: self._async_delay, + ACTION_WAIT_TEMPLATE: self._async_wait_template, + ACTION_CHECK_CONDITION: self._async_check_condition, + ACTION_FIRE_EVENT: self._async_fire_event, + ACTION_CALL_SERVICE: self._async_call_service, + } @property def is_running(self) -> bool: @@ -87,98 +126,27 @@ async def async_run(self, variables: Optional[Sequence] = None, self._async_remove_listener() for cur, action in islice(enumerate(self.sequence), self._cur, None): - - if CONF_DELAY in action: - # Call ourselves in the future to continue work - unsub = None - - @callback - def async_script_delay(now): - """Handle delay.""" - # pylint: disable=cell-var-from-loop - with suppress(ValueError): - self._async_listener.remove(unsub) - - self.hass.async_create_task( - self.async_run(variables, context)) - - delay = action[CONF_DELAY] - - try: - if isinstance(delay, template.Template): - delay = vol.All( - cv.time_period, - cv.positive_timedelta)( - delay.async_render(variables)) - elif isinstance(delay, dict): - delay_data = {} - delay_data.update( - template.render_complex(delay, variables)) - delay = cv.time_period(delay_data) - except (TemplateError, vol.Invalid) as ex: - _LOGGER.error("Error rendering '%s' delay template: %s", - self.name, ex) - break - - self.last_action = action.get( - CONF_ALIAS, 'delay {}'.format(delay)) - self._log("Executing step %s" % self.last_action) - - unsub = async_track_point_in_utc_time( - self.hass, async_script_delay, - date_util.utcnow() + delay - ) - self._async_listener.append(unsub) - - self._cur = cur + 1 - if self._change_listener: - self.hass.async_add_job(self._change_listener) - return - - if CONF_WAIT_TEMPLATE in action: - # Call ourselves in the future to continue work - wait_template = action[CONF_WAIT_TEMPLATE] - wait_template.hass = self.hass - - self.last_action = action.get(CONF_ALIAS, 'wait template') - self._log("Executing step %s" % self.last_action) - - # check if condition already okay - if condition.async_template( - self.hass, wait_template, variables): - continue - - @callback - def async_script_wait(entity_id, from_s, to_s): - """Handle script after template condition is true.""" - self._async_remove_listener() - self.hass.async_create_task( - self.async_run(variables, context)) - - self._async_listener.append(async_track_template( - self.hass, wait_template, async_script_wait, variables)) - + try: + await self._handle_action(action, variables, context) + except _SuspendScript: + # Store next step to take and notify change listeners self._cur = cur + 1 if self._change_listener: self.hass.async_add_job(self._change_listener) - - if CONF_TIMEOUT in action: - self._async_set_timeout( - action, variables, context, - action.get(CONF_CONTINUE, True)) - return - - if CONF_CONDITION in action: - if not self._async_check_condition(action, variables): - break - - elif CONF_EVENT in action: - self._async_fire_event(action, variables, context) - - else: - await self._async_call_service(action, variables, context) - + except _StopScript: + break + except Exception as err: + # Store the step that had an exception + # pylint: disable=protected-access + err._script_step = cur + # Set script to not running + self._cur = -1 + self.last_action = None + # Pass exception on. + raise + + # Set script to not-running. self._cur = -1 self.last_action = None if self._change_listener: @@ -198,6 +166,86 @@ def async_stop(self) -> None: if self._change_listener: self.hass.async_add_job(self._change_listener) + async def _handle_action(self, action, variables, context): + """Handle an action.""" + await self._actions[_determine_action(action)]( + action, variables, context) + + async def _async_delay(self, action, variables, context): + """Handle delay.""" + # Call ourselves in the future to continue work + unsub = None + + @callback + def async_script_delay(now): + """Handle delay.""" + # pylint: disable=cell-var-from-loop + with suppress(ValueError): + self._async_listener.remove(unsub) + + self.hass.async_create_task( + self.async_run(variables, context)) + + delay = action[CONF_DELAY] + + try: + if isinstance(delay, template.Template): + delay = vol.All( + cv.time_period, + cv.positive_timedelta)( + delay.async_render(variables)) + elif isinstance(delay, dict): + delay_data = {} + delay_data.update( + template.render_complex(delay, variables)) + delay = cv.time_period(delay_data) + except (exceptions.TemplateError, vol.Invalid) as ex: + _LOGGER.error("Error rendering '%s' delay template: %s", + self.name, ex) + raise _StopScript + + self.last_action = action.get( + CONF_ALIAS, 'delay {}'.format(delay)) + self._log("Executing step %s" % self.last_action) + + unsub = async_track_point_in_utc_time( + self.hass, async_script_delay, + date_util.utcnow() + delay + ) + self._async_listener.append(unsub) + raise _SuspendScript + + async def _async_wait_template(self, action, variables, context): + """Handle a wait template.""" + # Call ourselves in the future to continue work + wait_template = action[CONF_WAIT_TEMPLATE] + wait_template.hass = self.hass + + self.last_action = action.get(CONF_ALIAS, 'wait template') + self._log("Executing step %s" % self.last_action) + + # check if condition already okay + if condition.async_template( + self.hass, wait_template, variables): + return + + @callback + def async_script_wait(entity_id, from_s, to_s): + """Handle script after template condition is true.""" + self._async_remove_listener() + self.hass.async_create_task( + self.async_run(variables, context)) + + self._async_listener.append(async_track_template( + self.hass, wait_template, async_script_wait, variables)) + + if CONF_TIMEOUT in action: + self._async_set_timeout( + action, variables, context, + action.get(CONF_CONTINUE, True)) + + raise _SuspendScript + async def _async_call_service(self, action, variables, context): """Call the service specified in the action. @@ -213,7 +261,7 @@ async def _async_call_service(self, action, variables, context): context=context ) - def _async_fire_event(self, action, variables, context): + async def _async_fire_event(self, action, variables, context): """Fire an event.""" self.last_action = action.get(CONF_ALIAS, action[CONF_EVENT]) self._log("Executing step %s" % self.last_action) @@ -222,13 +270,13 @@ def _async_fire_event(self, action, variables, context): try: event_data.update(template.render_complex( action[CONF_EVENT_DATA_TEMPLATE], variables)) - except TemplateError as ex: + except exceptions.TemplateError as ex: _LOGGER.error('Error rendering event data template: %s', ex) self.hass.bus.async_fire(action[CONF_EVENT], event_data, context=context) - def _async_check_condition(self, action, variables): + async def _async_check_condition(self, action, variables, context): """Test if condition is matching.""" config_cache_key = frozenset((k, str(v)) for k, v in action.items()) config = self._config_cache.get(config_cache_key) @@ -239,7 +287,9 @@ def _async_check_condition(self, action, variables): self.last_action = action.get(CONF_ALIAS, action[CONF_CONDITION]) check = config(self.hass, variables) self._log("Test condition {}: {}".format(self.last_action, check)) - return check + + if not check: + raise _StopScript def _async_set_timeout(self, action, variables, context, continue_on_timeout): diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index e5e62d2aed37e..887a147c41750 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -4,6 +4,10 @@ from unittest import mock import unittest +import voluptuous as vol +import pytest + +from homeassistant import exceptions from homeassistant.core import Context, callback # Otherwise can't test just this file (import order issue) import homeassistant.components # noqa @@ -774,3 +778,84 @@ def test_last_triggered(self): self.hass.block_till_done() assert script_obj.last_triggered == time + + +async def test_propagate_error_service_not_found(hass): + """Test that a script aborts when a service is not found.""" + events = [] + + @callback + def record_event(event): + events.append(event) + + hass.bus.async_listen('test_event', record_event) + + script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([ + {'service': 'test.script'}, + {'event': 'test_event'}])) + + with pytest.raises(exceptions.ServiceNotFound): + await script_obj.async_run() + + assert len(events) == 0 + + +async def test_propagate_error_invalid_service_data(hass): + """Test that a script aborts when we send invalid service data.""" + events = [] + + @callback + def record_event(event): + events.append(event) + + hass.bus.async_listen('test_event', record_event) + + calls = [] + + @callback + def record_call(service): + """Add recorded event to set.""" + calls.append(service) + + hass.services.async_register('test', 'script', record_call, + schema=vol.Schema({'text': str})) + + script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([ + {'service': 'test.script', 'data': {'text': 1}}, + {'event': 'test_event'}])) + + with pytest.raises(vol.Invalid): + await script_obj.async_run() + + assert len(events) == 0 + assert len(calls) == 0 + + +async def test_propagate_error_service_exception(hass): + """Test that a script aborts when a service throws an exception.""" + events = [] + + @callback + def record_event(event): + events.append(event) + + hass.bus.async_listen('test_event', record_event) + + calls = [] + + @callback + def record_call(service): + """Add recorded event to set.""" + raise ValueError("BROKEN") + + hass.services.async_register('test', 'script', record_call) + + script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([ + {'service': 'test.script'}, + {'event': 'test_event'}])) + + with pytest.raises(ValueError): + await script_obj.async_run() + + assert len(events) == 0 + assert len(calls) == 0 From 111a3254fbc93d27d0c298c11ebed34a01096994 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Mon, 3 Dec 2018 16:50:05 +0100 Subject: [PATCH 237/325] Point fix for multiple devices (#18959) * fix for multiple devices closes, #18956 * Point API finally supports "all" events --- .../components/binary_sensor/point.py | 19 ++++++--- homeassistant/components/point/__init__.py | 42 +++++++++++++------ homeassistant/components/point/const.py | 3 +- homeassistant/components/sensor/point.py | 19 ++++++--- 4 files changed, 57 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/binary_sensor/point.py b/homeassistant/components/binary_sensor/point.py index 90a8b0b581348..29488d081305c 100644 --- a/homeassistant/components/binary_sensor/point.py +++ b/homeassistant/components/binary_sensor/point.py @@ -7,10 +7,11 @@ import logging -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import ( + DOMAIN as PARENT_DOMAIN, BinarySensorDevice) from homeassistant.components.point import MinutPointEntity from homeassistant.components.point.const import ( - DOMAIN as POINT_DOMAIN, NEW_DEVICE, SIGNAL_WEBHOOK) + DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -40,10 +41,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up a Point's binary sensors based on a config entry.""" - device_id = config_entry.data[NEW_DEVICE] - client = hass.data[POINT_DOMAIN][config_entry.entry_id] - async_add_entities((MinutPointBinarySensor(client, device_id, device_class) - for device_class in EVENTS), True) + async def async_discover_sensor(device_id): + """Discover and add a discovered sensor.""" + client = hass.data[POINT_DOMAIN][config_entry.entry_id] + async_add_entities( + (MinutPointBinarySensor(client, device_id, device_class) + for device_class in EVENTS), True) + + async_dispatcher_connect( + hass, POINT_DISCOVERY_NEW.format(PARENT_DOMAIN, POINT_DOMAIN), + async_discover_sensor) class MinutPointBinarySensor(MinutPointEntity, BinarySensorDevice): diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index 36215da78935d..6616d6b24ec10 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -4,6 +4,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/point/ """ +import asyncio import logging import voluptuous as vol @@ -22,8 +23,8 @@ from . import config_flow # noqa pylint_disable=unused-import from .const import ( - CONF_WEBHOOK_URL, DOMAIN, EVENT_RECEIVED, NEW_DEVICE, SCAN_INTERVAL, - SIGNAL_UPDATE_ENTITY, SIGNAL_WEBHOOK) + CONF_WEBHOOK_URL, DOMAIN, EVENT_RECEIVED, POINT_DISCOVERY_NEW, + SCAN_INTERVAL, SIGNAL_UPDATE_ENTITY, SIGNAL_WEBHOOK) REQUIREMENTS = ['pypoint==1.0.6'] DEPENDENCIES = ['webhook'] @@ -33,6 +34,9 @@ CONF_CLIENT_ID = 'client_id' CONF_CLIENT_SECRET = 'client_secret' +DATA_CONFIG_ENTRY_LOCK = 'point_config_entry_lock' +CONFIG_ENTRY_IS_SETUP = 'point_config_entry_is_setup' + CONFIG_SCHEMA = vol.Schema( { DOMAIN: @@ -87,6 +91,9 @@ def token_saver(token): _LOGGER.error('Authentication Error') return False + hass.data[DATA_CONFIG_ENTRY_LOCK] = asyncio.Lock() + hass.data[CONFIG_ENTRY_IS_SETUP] = set() + await async_setup_webhook(hass, entry, session) client = MinutPointClient(hass, entry, session) hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: client}) @@ -111,7 +118,7 @@ async def async_setup_webhook(hass: HomeAssistantType, entry: ConfigEntry, **entry.data, }) session.update_webhook(entry.data[CONF_WEBHOOK_URL], - entry.data[CONF_WEBHOOK_ID]) + entry.data[CONF_WEBHOOK_ID], events=['*']) hass.components.webhook.async_register( DOMAIN, 'Point', entry.data[CONF_WEBHOOK_ID], handle_webhook) @@ -153,7 +160,7 @@ class MinutPointClient(): def __init__(self, hass: HomeAssistantType, config_entry: ConfigEntry, session): """Initialize the Minut data object.""" - self._known_devices = [] + self._known_devices = set() self._hass = hass self._config_entry = config_entry self._is_available = True @@ -172,18 +179,27 @@ async def _sync(self): _LOGGER.warning("Device is unavailable") return + async def new_device(device_id, component): + """Load new device.""" + config_entries_key = '{}.{}'.format(component, DOMAIN) + async with self._hass.data[DATA_CONFIG_ENTRY_LOCK]: + if config_entries_key not in self._hass.data[ + CONFIG_ENTRY_IS_SETUP]: + await self._hass.config_entries.async_forward_entry_setup( + self._config_entry, component) + self._hass.data[CONFIG_ENTRY_IS_SETUP].add( + config_entries_key) + + async_dispatcher_send( + self._hass, POINT_DISCOVERY_NEW.format(component, DOMAIN), + device_id) + self._is_available = True for device in self._client.devices: if device.device_id not in self._known_devices: - # A way to communicate the device_id to entry_setup, - # can this be done nicer? - self._config_entry.data[NEW_DEVICE] = device.device_id - await self._hass.config_entries.async_forward_entry_setup( - self._config_entry, 'sensor') - await self._hass.config_entries.async_forward_entry_setup( - self._config_entry, 'binary_sensor') - self._known_devices.append(device.device_id) - del self._config_entry.data[NEW_DEVICE] + for component in ('sensor', 'binary_sensor'): + await new_device(device.device_id, component) + self._known_devices.add(device.device_id) async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY) def device(self, device_id): diff --git a/homeassistant/components/point/const.py b/homeassistant/components/point/const.py index 4ef21b57cd9f5..c6ba69a80831e 100644 --- a/homeassistant/components/point/const.py +++ b/homeassistant/components/point/const.py @@ -12,4 +12,5 @@ EVENT_RECEIVED = 'point_webhook_received' SIGNAL_UPDATE_ENTITY = 'point_update' SIGNAL_WEBHOOK = 'point_webhook' -NEW_DEVICE = 'new_device' + +POINT_DISCOVERY_NEW = 'point_new_{}_{}' diff --git a/homeassistant/components/sensor/point.py b/homeassistant/components/sensor/point.py index 0c099c8873e72..1bb46827602da 100644 --- a/homeassistant/components/sensor/point.py +++ b/homeassistant/components/sensor/point.py @@ -6,13 +6,15 @@ """ import logging -from homeassistant.components.point import MinutPointEntity +from homeassistant.components.point import ( + DOMAIN as PARENT_DOMAIN, MinutPointEntity) from homeassistant.components.point.const import ( - DOMAIN as POINT_DOMAIN, NEW_DEVICE) + DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW) from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS) from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.dt import parse_datetime _LOGGER = logging.getLogger(__name__) @@ -29,10 +31,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up a Point's sensors based on a config entry.""" - device_id = config_entry.data[NEW_DEVICE] - client = hass.data[POINT_DOMAIN][config_entry.entry_id] - async_add_entities((MinutPointSensor(client, device_id, sensor_type) - for sensor_type in SENSOR_TYPES), True) + async def async_discover_sensor(device_id): + """Discover and add a discovered sensor.""" + client = hass.data[POINT_DOMAIN][config_entry.entry_id] + async_add_entities((MinutPointSensor(client, device_id, sensor_type) + for sensor_type in SENSOR_TYPES), True) + + async_dispatcher_connect( + hass, POINT_DISCOVERY_NEW.format(PARENT_DOMAIN, POINT_DOMAIN), + async_discover_sensor) class MinutPointSensor(MinutPointEntity): From c8d92ce90731548b506c2c5213882fc7d7fda78a Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Mon, 3 Dec 2018 19:16:58 +0100 Subject: [PATCH 238/325] Fix MQTT re-subscription logic (#18953) * Fix MQTT re-subscription logic * Cleanup * Lint * Fix --- homeassistant/components/mqtt/subscription.py | 99 ++++++++++++++----- 1 file changed, 72 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py index 8be8d311d9b53..26101f32f8989 100644 --- a/homeassistant/components/mqtt/subscription.py +++ b/homeassistant/components/mqtt/subscription.py @@ -5,45 +5,90 @@ https://home-assistant.io/components/mqtt/ """ import logging +from typing import Any, Callable, Dict, Optional + +import attr from homeassistant.components import mqtt -from homeassistant.components.mqtt import DEFAULT_QOS -from homeassistant.loader import bind_hass +from homeassistant.components.mqtt import DEFAULT_QOS, MessageCallbackType from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.loader import bind_hass _LOGGER = logging.getLogger(__name__) +@attr.s(slots=True) +class EntitySubscription: + """Class to hold data about an active entity topic subscription.""" + + topic = attr.ib(type=str) + message_callback = attr.ib(type=MessageCallbackType) + unsubscribe_callback = attr.ib(type=Optional[Callable[[], None]]) + qos = attr.ib(type=int, default=0) + encoding = attr.ib(type=str, default='utf-8') + + async def resubscribe_if_necessary(self, hass, other): + """Re-subscribe to the new topic if necessary.""" + if not self._should_resubscribe(other): + return + + if other is not None and other.unsubscribe_callback is not None: + other.unsubscribe_callback() + + if self.topic is None: + # We were asked to remove the subscription or not to create it + return + + self.unsubscribe_callback = await mqtt.async_subscribe( + hass, self.topic, self.message_callback, + self.qos, self.encoding + ) + + def _should_resubscribe(self, other): + """Check if we should re-subscribe to the topic using the old state.""" + if other is None: + return True + + return (self.topic, self.qos, self.encoding) != \ + (other.topic, other.qos, other.encoding) + + @bind_hass -async def async_subscribe_topics(hass: HomeAssistantType, sub_state: dict, - topics: dict): +async def async_subscribe_topics(hass: HomeAssistantType, + new_state: Optional[Dict[str, + EntitySubscription]], + topics: Dict[str, Any]): """(Re)Subscribe to a set of MQTT topics. - State is kept in sub_state. + State is kept in sub_state and a dictionary mapping from the subscription + key to the subscription state. + + Please note that the sub state must not be shared between multiple + sets of topics. Every call to async_subscribe_topics must always + contain _all_ the topics the subscription state should manage. """ - cur_state = sub_state if sub_state is not None else {} - sub_state = {} - for key in topics: - topic = topics[key].get('topic', None) - msg_callback = topics[key].get('msg_callback', None) - qos = topics[key].get('qos', DEFAULT_QOS) - encoding = topics[key].get('encoding', 'utf-8') - topic = (topic, msg_callback, qos, encoding) - (cur_topic, unsub) = cur_state.pop( - key, ((None, None, None, None), None)) - - if topic != cur_topic and topic[0] is not None: - if unsub is not None: - unsub() - unsub = await mqtt.async_subscribe( - hass, topic[0], topic[1], topic[2], topic[3]) - sub_state[key] = (topic, unsub) - - for key, (topic, unsub) in list(cur_state.items()): - if unsub is not None: - unsub() + current_subscriptions = new_state if new_state is not None else {} + new_state = {} + for key, value in topics.items(): + # Extract the new requested subscription + requested = EntitySubscription( + topic=value.get('topic', None), + message_callback=value.get('msg_callback', None), + unsubscribe_callback=None, + qos=value.get('qos', DEFAULT_QOS), + encoding=value.get('encoding', 'utf-8'), + ) + # Get the current subscription state + current = current_subscriptions.pop(key, None) + await requested.resubscribe_if_necessary(hass, current) + new_state[key] = requested - return sub_state + # Go through all remaining subscriptions and unsubscribe them + for remaining in current_subscriptions.values(): + if remaining.unsubscribe_callback is not None: + remaining.unsubscribe_callback() + + return new_state @bind_hass From d7a10136dff54b6e92c359fa6d78431966610f44 Mon Sep 17 00:00:00 2001 From: Erik Eriksson <8228319+molobrakos@users.noreply.github.com> Date: Mon, 3 Dec 2018 19:52:50 +0100 Subject: [PATCH 239/325] VOC: Update library version. Moved method one step out. Instruments can be a set as well (#18967) --- homeassistant/components/volvooncall.py | 14 +++++++------- requirements_all.txt | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/volvooncall.py b/homeassistant/components/volvooncall.py index fe7ec46067419..46c22c65e8537 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -24,7 +24,7 @@ DATA_KEY = DOMAIN -REQUIREMENTS = ['volvooncall==0.7.4'] +REQUIREMENTS = ['volvooncall==0.7.9'] _LOGGER = logging.getLogger(__name__) @@ -117,6 +117,10 @@ async def async_setup(hass, config): data = hass.data[DATA_KEY] = VolvoData(config) + def is_enabled(attr): + """Return true if the user has enabled the resource.""" + return attr in config[DOMAIN].get(CONF_RESOURCES, [attr]) + def discover_vehicle(vehicle): """Load relevant platforms.""" data.vehicles.add(vehicle.vin) @@ -125,17 +129,13 @@ def discover_vehicle(vehicle): mutable=config[DOMAIN][CONF_MUTABLE], scandinavian_miles=config[DOMAIN][CONF_SCANDINAVIAN_MILES]) - def is_enabled(attr): - """Return true if the user has enabled the resource.""" - return attr in config[DOMAIN].get(CONF_RESOURCES, [attr]) - for instrument in ( instrument for instrument in dashboard.instruments if instrument.component in COMPONENTS and is_enabled(instrument.slug_attr)): - data.instruments.append(instrument) + data.instruments.add(instrument) hass.async_create_task( discovery.async_load_platform( @@ -174,7 +174,7 @@ class VolvoData: def __init__(self, config): """Initialize the component state.""" self.vehicles = set() - self.instruments = [] + self.instruments = set() self.config = config[DOMAIN] self.names = self.config.get(CONF_NAME) diff --git a/requirements_all.txt b/requirements_all.txt index 2a1649e9b9a76..fadfe6491b66b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1590,7 +1590,7 @@ venstarcolortouch==0.6 volkszaehler==0.1.2 # homeassistant.components.volvooncall -volvooncall==0.7.4 +volvooncall==0.7.9 # homeassistant.components.verisure vsure==1.5.2 From df3c683023e85f745e0b14be2eebe91204df312a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Mon, 3 Dec 2018 20:53:18 +0100 Subject: [PATCH 240/325] Improve err handling --- homeassistant/components/sensor/tibber.py | 11 ++++++++--- homeassistant/components/tibber/__init__.py | 8 ++++++-- requirements_all.txt | 2 +- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index d900067f98b2c..2c921e95863f4 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -12,6 +12,7 @@ import aiohttp from homeassistant.components.tibber import DOMAIN as TIBBER_DOMAIN +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity import Entity from homeassistant.util import dt as dt_util from homeassistant.util import Throttle @@ -38,13 +39,17 @@ async def async_setup_platform(hass, config, async_add_entities, for home in tibber_connection.get_homes(): try: await home.update_info() - except (asyncio.TimeoutError, aiohttp.ClientError): - pass + except asyncio.TimeoutError as err: + _LOGGER.error("Timeout connecting to Tibber home: %s ", err) + raise PlatformNotReady() + except aiohttp.ClientError as err: + _LOGGER.error("Error connecting to Tibber home: %s ", err) + raise PlatformNotReady() dev.append(TibberSensorElPrice(home)) if home.has_real_time_consumption: dev.append(TibberSensorRT(home)) - async_add_entities(dev, True) + async_add_entities(dev, False) class TibberSensorElPrice(Entity): diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 4f6761f0b402a..27595dc09c766 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -16,7 +16,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['pyTibber==0.8.3'] +REQUIREMENTS = ['pyTibber==0.8.4'] DOMAIN = 'tibber' @@ -45,7 +45,11 @@ async def _close(event): try: await tibber_connection.update_info() - except (asyncio.TimeoutError, aiohttp.ClientError): + except asyncio.TimeoutError as err: + _LOGGER.error("Timeout connecting to Tibber: %s ", err) + return False + except aiohttp.ClientError as err: + _LOGGER.error("Error connecting to Tibber: %s ", err) return False except tibber.InvalidLogin as exp: _LOGGER.error("Failed to login. %s", exp) diff --git a/requirements_all.txt b/requirements_all.txt index 06b49b6d51483..ee2f5991e1564 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -827,7 +827,7 @@ pyRFXtrx==0.23 pySwitchmate==0.4.4 # homeassistant.components.tibber -pyTibber==0.8.3 +pyTibber==0.8.4 # homeassistant.components.switch.dlink pyW215==0.6.0 From 4486de743d724beef97b1a15d9ca0093bbac14e8 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Mon, 3 Dec 2018 15:45:12 -0500 Subject: [PATCH 241/325] Support for mulitple Blink sync modules (#18663) --- homeassistant/components/alarm_control_panel/blink.py | 11 +++++------ homeassistant/components/binary_sensor/blink.py | 4 ++-- homeassistant/components/blink/__init__.py | 6 +++--- homeassistant/components/camera/blink.py | 2 +- homeassistant/components/sensor/blink.py | 4 ++-- requirements_all.txt | 2 +- 6 files changed, 14 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/blink.py b/homeassistant/components/alarm_control_panel/blink.py index 728b5967db120..77267fd7516c8 100644 --- a/homeassistant/components/alarm_control_panel/blink.py +++ b/homeassistant/components/alarm_control_panel/blink.py @@ -25,21 +25,19 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return data = hass.data[BLINK_DATA] - # Current version of blinkpy API only supports one sync module. When - # support for additional models is added, the sync module name should - # come from the API. sync_modules = [] - sync_modules.append(BlinkSyncModule(data, 'sync')) + for sync_name, sync_module in data.sync.items(): + sync_modules.append(BlinkSyncModule(data, sync_name, sync_module)) add_entities(sync_modules, True) class BlinkSyncModule(AlarmControlPanel): """Representation of a Blink Alarm Control Panel.""" - def __init__(self, data, name): + def __init__(self, data, name, sync): """Initialize the alarm control panel.""" self.data = data - self.sync = data.sync + self.sync = sync self._name = name self._state = None @@ -68,6 +66,7 @@ def device_state_attributes(self): """Return the state attributes.""" attr = self.sync.attributes attr['network_info'] = self.data.networks + attr['associated_cameras'] = list(self.sync.cameras.keys()) attr[ATTR_ATTRIBUTION] = DEFAULT_ATTRIBUTION return attr diff --git a/homeassistant/components/binary_sensor/blink.py b/homeassistant/components/binary_sensor/blink.py index 46751ce5394e1..cd558f0368487 100644 --- a/homeassistant/components/binary_sensor/blink.py +++ b/homeassistant/components/binary_sensor/blink.py @@ -18,7 +18,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): data = hass.data[BLINK_DATA] devs = [] - for camera in data.sync.cameras: + for camera in data.cameras: for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]: devs.append(BlinkBinarySensor(data, camera, sensor_type)) add_entities(devs, True) @@ -34,7 +34,7 @@ def __init__(self, data, camera, sensor_type): name, icon = BINARY_SENSORS[sensor_type] self._name = "{} {} {}".format(BLINK_DATA, camera, name) self._icon = icon - self._camera = data.sync.cameras[camera] + self._camera = data.cameras[camera] self._state = None self._unique_id = "{}-{}".format(self._camera.serial, self._type) diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 62e73a52cc8bb..a56885a22a994 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -15,7 +15,7 @@ CONF_BINARY_SENSORS, CONF_SENSORS, CONF_FILENAME, CONF_MONITORED_CONDITIONS, TEMP_FAHRENHEIT) -REQUIREMENTS = ['blinkpy==0.10.3'] +REQUIREMENTS = ['blinkpy==0.11.0'] _LOGGER = logging.getLogger(__name__) @@ -111,7 +111,7 @@ def setup(hass, config): def trigger_camera(call): """Trigger a camera.""" - cameras = hass.data[BLINK_DATA].sync.cameras + cameras = hass.data[BLINK_DATA].cameras name = call.data[CONF_NAME] if name in cameras: cameras[name].snap_picture() @@ -148,7 +148,7 @@ async def async_handle_save_video_service(hass, call): def _write_video(camera_name, video_path): """Call video write.""" - all_cameras = hass.data[BLINK_DATA].sync.cameras + all_cameras = hass.data[BLINK_DATA].cameras if camera_name in all_cameras: all_cameras[camera_name].video_to_file(video_path) diff --git a/homeassistant/components/camera/blink.py b/homeassistant/components/camera/blink.py index 510c2ab256396..e904791445630 100644 --- a/homeassistant/components/camera/blink.py +++ b/homeassistant/components/camera/blink.py @@ -23,7 +23,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return data = hass.data[BLINK_DATA] devs = [] - for name, camera in data.sync.cameras.items(): + for name, camera in data.cameras.items(): devs.append(BlinkCamera(data, name, camera)) add_entities(devs) diff --git a/homeassistant/components/sensor/blink.py b/homeassistant/components/sensor/blink.py index 804f83de4fdd6..6d3ca87c4aea4 100644 --- a/homeassistant/components/sensor/blink.py +++ b/homeassistant/components/sensor/blink.py @@ -21,7 +21,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return data = hass.data[BLINK_DATA] devs = [] - for camera in data.sync.cameras: + for camera in data.cameras: for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]: devs.append(BlinkSensor(data, camera, sensor_type)) @@ -39,7 +39,7 @@ def __init__(self, data, camera, sensor_type): self._camera_name = name self._type = sensor_type self.data = data - self._camera = data.sync.cameras[camera] + self._camera = data.cameras[camera] self._state = None self._unit_of_measurement = units self._icon = icon diff --git a/requirements_all.txt b/requirements_all.txt index fadfe6491b66b..abd599c5f2c71 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -186,7 +186,7 @@ bellows==0.7.0 bimmer_connected==0.5.3 # homeassistant.components.blink -blinkpy==0.10.3 +blinkpy==0.11.0 # homeassistant.components.light.blinksticklight blinkstick==1.1.8 From b5e7e45f6cc8f968007f194f1bef8cbbb9cc6c1a Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 3 Dec 2018 21:54:34 +0100 Subject: [PATCH 242/325] no ordered dict (#18982) --- homeassistant/components/lovelace/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 49992bc6e393d..20792de82221c 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -271,7 +271,6 @@ def add_card(fname: str, view_id: str, card_config: str, card_config = yaml.yaml_to_object(card_config) if 'id' not in card_config: card_config['id'] = uuid.uuid4().hex - card_config.move_to_end('id', last=False) if position is None: cards.append(card_config) else: @@ -396,7 +395,6 @@ def add_view(fname: str, view_config: str, view_config = yaml.yaml_to_object(view_config) if 'id' not in view_config: view_config['id'] = uuid.uuid4().hex - view_config.move_to_end('id', last=False) if position is None: views.append(view_config) else: From ad0e3cea8ad33a6b89353e83bf25fcd023e1baca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 4 Dec 2018 03:49:15 +0100 Subject: [PATCH 243/325] Update CODEOWNERS (#18976) --- CODEOWNERS | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CODEOWNERS b/CODEOWNERS index 85f8d996fac0c..61ff6ce7079aa 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -51,6 +51,7 @@ homeassistant/components/alarm_control_panel/egardia.py @jeroenterheerdt homeassistant/components/alarm_control_panel/manual_mqtt.py @colinodell homeassistant/components/binary_sensor/hikvision.py @mezz64 homeassistant/components/binary_sensor/threshold.py @fabaff +homeassistant/components/binary_sensor/uptimerobot.py @ludeeus homeassistant/components/camera/yi.py @bachya homeassistant/components/climate/ephember.py @ttroy50 homeassistant/components/climate/eq3btsmart.py @rytilahti @@ -61,9 +62,11 @@ homeassistant/components/cover/group.py @cdce8p homeassistant/components/cover/template.py @PhracturedBlue homeassistant/components/device_tracker/asuswrt.py @kennedyshead homeassistant/components/device_tracker/automatic.py @armills +homeassistant/components/device_tracker/googlehome.py @ludeeus homeassistant/components/device_tracker/huawei_router.py @abmantis homeassistant/components/device_tracker/quantum_gateway.py @cisasteelersfan homeassistant/components/device_tracker/tile.py @bachya +homeassistant/components/device_tracker/traccar.py @ludeeus homeassistant/components/device_tracker/bt_smarthub.py @jxwolstenholme homeassistant/components/history_graph.py @andrey-git homeassistant/components/influx.py @fabaff @@ -109,6 +112,7 @@ homeassistant/components/sensor/glances.py @fabaff homeassistant/components/sensor/gpsd.py @fabaff homeassistant/components/sensor/irish_rail_transport.py @ttroy50 homeassistant/components/sensor/jewish_calendar.py @tsvi +homeassistant/components/sensor/launch_library.py @ludeeus homeassistant/components/sensor/linux_battery.py @fabaff homeassistant/components/sensor/miflora.py @danielhiversen @ChristianKuehnel homeassistant/components/sensor/min_max.py @fabaff @@ -119,6 +123,7 @@ homeassistant/components/sensor/pi_hole.py @fabaff homeassistant/components/sensor/pollen.py @bachya homeassistant/components/sensor/pvoutput.py @fabaff homeassistant/components/sensor/qnap.py @colinodell +homeassistant/components/sensor/ruter.py @ludeeus homeassistant/components/sensor/scrape.py @fabaff homeassistant/components/sensor/serial.py @fabaff homeassistant/components/sensor/seventeentrack.py @bachya @@ -128,6 +133,7 @@ homeassistant/components/sensor/sql.py @dgomes homeassistant/components/sensor/statistics.py @fabaff homeassistant/components/sensor/swiss*.py @fabaff homeassistant/components/sensor/sytadin.py @gautric +homeassistant/components/sensor/tautulli.py @ludeeus homeassistant/components/sensor/time_data.py @fabaff homeassistant/components/sensor/version.py @fabaff homeassistant/components/sensor/waqi.py @andrey-git @@ -157,6 +163,7 @@ homeassistant/components/*/bmw_connected_drive.py @ChristianKuehnel homeassistant/components/*/broadlink.py @danielhiversen # C +homeassistant/components/cloudflare.py @ludeeus homeassistant/components/counter/* @fabaff # D From b024c3a83345c8104d3db94f9d105df2da0aa9cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Tue, 4 Dec 2018 06:48:16 +0100 Subject: [PATCH 244/325] Add @danielhiversen as codeowner (#18979) --- CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CODEOWNERS b/CODEOWNERS index 61ff6ce7079aa..1bb62d154fdc1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -140,6 +140,8 @@ homeassistant/components/sensor/waqi.py @andrey-git homeassistant/components/sensor/worldclock.py @fabaff homeassistant/components/shiftr.py @fabaff homeassistant/components/spaceapi.py @fabaff +homeassistant/components/switch/switchbot.py @danielhiversen +homeassistant/components/switch/switchmate.py @danielhiversen homeassistant/components/switch/tplink.py @rytilahti homeassistant/components/vacuum/roomba.py @pschmitt homeassistant/components/weather/__init__.py @fabaff From 8e9c73eb180199b849b2b3ed9d32cab432228556 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Tue, 4 Dec 2018 06:48:27 +0100 Subject: [PATCH 245/325] Upgrade switchbot lib (#18980) --- homeassistant/components/switch/switchbot.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switch/switchbot.py b/homeassistant/components/switch/switchbot.py index 53f987c8b46c2..9682a4444aa8f 100644 --- a/homeassistant/components/switch/switchbot.py +++ b/homeassistant/components/switch/switchbot.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA from homeassistant.const import CONF_NAME, CONF_MAC -REQUIREMENTS = ['PySwitchbot==0.3'] +REQUIREMENTS = ['PySwitchbot==0.4'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index a60c75be9e2f4..abae7ab19d089 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -53,7 +53,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.1.3 # homeassistant.components.switch.switchbot -PySwitchbot==0.3 +PySwitchbot==0.4 # homeassistant.components.sensor.transport_nsw PyTransportNSW==0.1.1 From b900005d1efb11a435f6b92fee3f72b8ab2a84ca Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Tue, 4 Dec 2018 03:45:17 -0500 Subject: [PATCH 246/325] New Events and Context Fixes (#18765) * Add new events for automation trigger and script run, fix context for image processing, add tests to ensure same context * remove custom logbook entry for automation and add new automation event to logbook * code review updates --- .../components/automation/__init__.py | 9 ++- .../components/image_processing/__init__.py | 12 ++-- homeassistant/components/logbook.py | 25 ++++++- homeassistant/components/script.py | 11 ++- homeassistant/const.py | 2 + tests/components/automation/test_init.py | 67 ++++++++++++++++++- tests/components/test_logbook.py | 55 ++++++++++++++- tests/components/test_script.py | 52 +++++++++++++- 8 files changed, 214 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index f44d044ecfaf5..4a2df399e0a20 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -16,7 +16,8 @@ from homeassistant.loader import bind_hass from homeassistant.const import ( ATTR_ENTITY_ID, CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, - SERVICE_TOGGLE, SERVICE_RELOAD, EVENT_HOMEASSISTANT_START, CONF_ID) + SERVICE_TOGGLE, SERVICE_RELOAD, EVENT_HOMEASSISTANT_START, CONF_ID, + EVENT_AUTOMATION_TRIGGERED, ATTR_NAME) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import extract_domain_configs, script, condition from homeassistant.helpers.entity import ToggleEntity @@ -286,6 +287,10 @@ async def async_trigger(self, variables, skip_condition=False, """ if skip_condition or self._cond_func(variables): self.async_set_context(context) + self.hass.bus.async_fire(EVENT_AUTOMATION_TRIGGERED, { + ATTR_NAME: self._name, + ATTR_ENTITY_ID: self.entity_id, + }, context=context) await self._async_action(self.entity_id, variables, context) self._last_triggered = utcnow() await self.async_update_ha_state() @@ -370,8 +375,6 @@ def _async_get_action(hass, config, name): async def action(entity_id, variables, context): """Execute an action.""" _LOGGER.info('Executing %s', name) - hass.components.logbook.async_log_entry( - name, 'has been triggered', DOMAIN, entity_id) await script_obj.async_run(variables, context) return action diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 84d9236154102..72a4a8155e291 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -76,10 +76,14 @@ async def async_scan_service(service): """Service handler for scan.""" image_entities = component.async_extract_from_service(service) - update_task = [entity.async_update_ha_state(True) for - entity in image_entities] - if update_task: - await asyncio.wait(update_task, loop=hass.loop) + update_tasks = [] + for entity in image_entities: + entity.async_set_context(service.context) + update_tasks.append( + entity.async_update_ha_state(True)) + + if update_tasks: + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SCAN, async_scan_service, diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index b6f434a82ad7d..78da5733a065e 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -17,7 +17,8 @@ ATTR_DOMAIN, ATTR_ENTITY_ID, ATTR_HIDDEN, ATTR_NAME, ATTR_SERVICE, CONF_EXCLUDE, CONF_INCLUDE, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_LOGBOOK_ENTRY, EVENT_STATE_CHANGED, - HTTP_BAD_REQUEST, STATE_NOT_HOME, STATE_OFF, STATE_ON) + EVENT_AUTOMATION_TRIGGERED, EVENT_SCRIPT_STARTED, HTTP_BAD_REQUEST, + STATE_NOT_HOME, STATE_OFF, STATE_ON) from homeassistant.core import ( DOMAIN as HA_DOMAIN, State, callback, split_entity_id) from homeassistant.components.alexa.smart_home import EVENT_ALEXA_SMART_HOME @@ -316,6 +317,28 @@ def humanify(hass, events): 'context_user_id': event.context.user_id } + elif event.event_type == EVENT_AUTOMATION_TRIGGERED: + yield { + 'when': event.time_fired, + 'name': event.data.get(ATTR_NAME), + 'message': "has been triggered", + 'domain': 'automation', + 'entity_id': event.data.get(ATTR_ENTITY_ID), + 'context_id': event.context.id, + 'context_user_id': event.context.user_id + } + + elif event.event_type == EVENT_SCRIPT_STARTED: + yield { + 'when': event.time_fired, + 'name': event.data.get(ATTR_NAME), + 'message': 'started', + 'domain': 'script', + 'entity_id': event.data.get(ATTR_ENTITY_ID), + 'context_id': event.context.id, + 'context_user_id': event.context.user_id + } + def _get_related_entity_ids(session, entity_filter): from homeassistant.components.recorder.models import States diff --git a/homeassistant/components/script.py b/homeassistant/components/script.py index 16c9f65420c3c..54490af3cfac1 100644 --- a/homeassistant/components/script.py +++ b/homeassistant/components/script.py @@ -14,7 +14,8 @@ from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, - SERVICE_TOGGLE, SERVICE_RELOAD, STATE_ON, CONF_ALIAS) + SERVICE_TOGGLE, SERVICE_RELOAD, STATE_ON, CONF_ALIAS, + EVENT_SCRIPT_STARTED, ATTR_NAME) from homeassistant.loader import bind_hass from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent @@ -170,8 +171,14 @@ def is_on(self): async def async_turn_on(self, **kwargs): """Turn the script on.""" + context = kwargs.get('context') + self.async_set_context(context) + self.hass.bus.async_fire(EVENT_SCRIPT_STARTED, { + ATTR_NAME: self.script.name, + ATTR_ENTITY_ID: self.entity_id, + }, context=context) await self.script.async_run( - kwargs.get(ATTR_VARIABLES), kwargs.get('context')) + kwargs.get(ATTR_VARIABLES), context) async def async_turn_off(self, **kwargs): """Turn script off.""" diff --git a/homeassistant/const.py b/homeassistant/const.py index eb53140339ad8..b4a94d318f6cc 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -170,6 +170,8 @@ EVENT_LOGBOOK_ENTRY = 'logbook_entry' EVENT_THEMES_UPDATED = 'themes_updated' EVENT_TIMER_OUT_OF_SYNC = 'timer_out_of_sync' +EVENT_AUTOMATION_TRIGGERED = 'automation_triggered' +EVENT_SCRIPT_STARTED = 'script_started' # #### DEVICE CLASSES #### DEVICE_CLASS_BATTERY = 'battery' diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 28d4c0979c45d..a01b48b9190de 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1,15 +1,16 @@ """The tests for the automation component.""" import asyncio from datetime import timedelta -from unittest.mock import patch +from unittest.mock import patch, Mock import pytest -from homeassistant.core import State, CoreState +from homeassistant.core import State, CoreState, Context from homeassistant.setup import async_setup_component import homeassistant.components.automation as automation from homeassistant.const import ( - ATTR_ENTITY_ID, STATE_ON, STATE_OFF, EVENT_HOMEASSISTANT_START) + ATTR_NAME, ATTR_ENTITY_ID, STATE_ON, STATE_OFF, + EVENT_HOMEASSISTANT_START, EVENT_AUTOMATION_TRIGGERED) from homeassistant.exceptions import HomeAssistantError import homeassistant.util.dt as dt_util @@ -342,6 +343,66 @@ async def test_automation_calling_two_actions(hass, calls): assert calls[1].data['position'] == 1 +async def test_shared_context(hass, calls): + """Test that the shared context is passed down the chain.""" + assert await async_setup_component(hass, automation.DOMAIN, { + automation.DOMAIN: [ + { + 'alias': 'hello', + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'action': {'event': 'test_event2'} + }, + { + 'alias': 'bye', + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event2', + }, + 'action': { + 'service': 'test.automation', + } + } + ] + }) + + context = Context() + automation_mock = Mock() + event_mock = Mock() + + hass.bus.async_listen('test_event2', automation_mock) + hass.bus.async_listen(EVENT_AUTOMATION_TRIGGERED, event_mock) + hass.bus.async_fire('test_event', context=context) + await hass.async_block_till_done() + + # Ensure events was fired + assert automation_mock.call_count == 1 + assert event_mock.call_count == 2 + + # Ensure context carries through the event + args, kwargs = automation_mock.call_args + assert args[0].context == context + + for call in event_mock.call_args_list: + args, kwargs = call + assert args[0].context == context + # Ensure event data has all attributes set + assert args[0].data.get(ATTR_NAME) is not None + assert args[0].data.get(ATTR_ENTITY_ID) is not None + + # Ensure the automation state shares the same context + state = hass.states.get('automation.hello') + assert state is not None + assert state.context == context + + # Ensure the service call from the second automation + # shares the same context + assert len(calls) == 1 + assert calls[0].context == context + + async def test_services(hass, calls): """Test the automation services for turning entities on/off.""" entity_id = 'automation.hello' diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index b530c3dac3c57..321a16ae64e5a 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -10,9 +10,10 @@ from homeassistant.components import sun import homeassistant.core as ha from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_SERVICE, + ATTR_ENTITY_ID, ATTR_SERVICE, ATTR_NAME, EVENT_STATE_CHANGED, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, - ATTR_HIDDEN, STATE_NOT_HOME, STATE_ON, STATE_OFF) + EVENT_AUTOMATION_TRIGGERED, EVENT_SCRIPT_STARTED, ATTR_HIDDEN, + STATE_NOT_HOME, STATE_ON, STATE_OFF) import homeassistant.util.dt as dt_util from homeassistant.components import logbook, recorder from homeassistant.components.alexa.smart_home import EVENT_ALEXA_SMART_HOME @@ -751,7 +752,55 @@ async def test_humanify_homekit_changed_event(hass): assert event1['entity_id'] == 'lock.front_door' assert event2['name'] == 'HomeKit' - assert event1['domain'] == DOMAIN_HOMEKIT + assert event2['domain'] == DOMAIN_HOMEKIT assert event2['message'] == \ 'send command set_cover_position to 75 for Window' assert event2['entity_id'] == 'cover.window' + + +async def test_humanify_automation_triggered_event(hass): + """Test humanifying Automation Trigger event.""" + event1, event2 = list(logbook.humanify(hass, [ + ha.Event(EVENT_AUTOMATION_TRIGGERED, { + ATTR_ENTITY_ID: 'automation.hello', + ATTR_NAME: 'Hello Automation', + }), + ha.Event(EVENT_AUTOMATION_TRIGGERED, { + ATTR_ENTITY_ID: 'automation.bye', + ATTR_NAME: 'Bye Automation', + }), + ])) + + assert event1['name'] == 'Hello Automation' + assert event1['domain'] == 'automation' + assert event1['message'] == 'has been triggered' + assert event1['entity_id'] == 'automation.hello' + + assert event2['name'] == 'Bye Automation' + assert event2['domain'] == 'automation' + assert event2['message'] == 'has been triggered' + assert event2['entity_id'] == 'automation.bye' + + +async def test_humanify_script_started_event(hass): + """Test humanifying Script Run event.""" + event1, event2 = list(logbook.humanify(hass, [ + ha.Event(EVENT_SCRIPT_STARTED, { + ATTR_ENTITY_ID: 'script.hello', + ATTR_NAME: 'Hello Script' + }), + ha.Event(EVENT_SCRIPT_STARTED, { + ATTR_ENTITY_ID: 'script.bye', + ATTR_NAME: 'Bye Script' + }), + ])) + + assert event1['name'] == 'Hello Script' + assert event1['domain'] == 'script' + assert event1['message'] == 'started' + assert event1['entity_id'] == 'script.hello' + + assert event2['name'] == 'Bye Script' + assert event2['domain'] == 'script' + assert event2['message'] == 'started' + assert event2['entity_id'] == 'script.bye' diff --git a/tests/components/test_script.py b/tests/components/test_script.py index 5b7d0dfb70f1f..790d5c2e844e7 100644 --- a/tests/components/test_script.py +++ b/tests/components/test_script.py @@ -1,15 +1,16 @@ """The tests for the Script component.""" # pylint: disable=protected-access import unittest -from unittest.mock import patch +from unittest.mock import patch, Mock from homeassistant.components import script from homeassistant.components.script import DOMAIN from homeassistant.const import ( - ATTR_ENTITY_ID, SERVICE_RELOAD, SERVICE_TOGGLE, SERVICE_TURN_OFF) + ATTR_ENTITY_ID, ATTR_NAME, SERVICE_RELOAD, SERVICE_TOGGLE, + SERVICE_TURN_OFF, SERVICE_TURN_ON, EVENT_SCRIPT_STARTED) from homeassistant.core import Context, callback, split_entity_id from homeassistant.loader import bind_hass -from homeassistant.setup import setup_component +from homeassistant.setup import setup_component, async_setup_component from tests.common import get_test_home_assistant @@ -254,3 +255,48 @@ def test_reload_service(self): assert self.hass.states.get("script.test2") is not None assert self.hass.services.has_service(script.DOMAIN, 'test2') + + +async def test_shared_context(hass): + """Test that the shared context is passed down the chain.""" + event = 'test_event' + context = Context() + + event_mock = Mock() + run_mock = Mock() + + hass.bus.async_listen(event, event_mock) + hass.bus.async_listen(EVENT_SCRIPT_STARTED, run_mock) + + assert await async_setup_component(hass, 'script', { + 'script': { + 'test': { + 'sequence': [ + {'event': event} + ] + } + } + }) + + await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + context=context) + await hass.async_block_till_done() + + assert event_mock.call_count == 1 + assert run_mock.call_count == 1 + + args, kwargs = run_mock.call_args + assert args[0].context == context + # Ensure event data has all attributes set + assert args[0].data.get(ATTR_NAME) == 'test' + assert args[0].data.get(ATTR_ENTITY_ID) == 'script.test' + + # Ensure context carries through the event + args, kwargs = event_mock.call_args + assert args[0].context == context + + # Ensure the script state shares the same context + state = hass.states.get('script.test') + assert state is not None + assert state.context == context From f3d7cc66e526c269fca1cfb5534f21aa2db92848 Mon Sep 17 00:00:00 2001 From: "Craig J. Midwinter" Date: Tue, 4 Dec 2018 02:52:30 -0600 Subject: [PATCH 247/325] downgrade version of client (#18995) * downgrade version of client * update requirements --- homeassistant/components/goalfeed.py | 5 +++-- requirements_all.txt | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/goalfeed.py b/homeassistant/components/goalfeed.py index 1a571960bc7e5..c16390302d69a 100644 --- a/homeassistant/components/goalfeed.py +++ b/homeassistant/components/goalfeed.py @@ -12,8 +12,9 @@ import homeassistant.helpers.config_validation as cv from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -REQUIREMENTS = ['pysher==1.0.4'] - +# Version downgraded due to regression in library +# For details: https://github.com/nlsdfnbch/Pysher/issues/38 +REQUIREMENTS = ['pysher==1.0.1'] DOMAIN = 'goalfeed' CONFIG_SCHEMA = vol.Schema({ diff --git a/requirements_all.txt b/requirements_all.txt index abae7ab19d089..9f89234bec671 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1141,7 +1141,7 @@ pyserial==3.1.1 pysesame==0.1.0 # homeassistant.components.goalfeed -pysher==1.0.4 +pysher==1.0.1 # homeassistant.components.sensor.sma pysma==0.2.2 From d8a7e9ded8317c2b0a7cbef85b92a8940d35ca9c Mon Sep 17 00:00:00 2001 From: Dom Date: Tue, 4 Dec 2018 08:56:14 +0000 Subject: [PATCH 248/325] Updated Yale Smart Alarm platform to new Yale API (#18990) * Updated Yale Smart Alarm platform to use Yale's new API which replaces the deprecated version. Bumped yalesmartalarmclient to v0.1.5. * Update requirements --- .../components/alarm_control_panel/yale_smart_alarm.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/yale_smart_alarm.py b/homeassistant/components/alarm_control_panel/yale_smart_alarm.py index e512d15fcdd70..357a8c350bc2c 100755 --- a/homeassistant/components/alarm_control_panel/yale_smart_alarm.py +++ b/homeassistant/components/alarm_control_panel/yale_smart_alarm.py @@ -15,7 +15,7 @@ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['yalesmartalarmclient==0.1.4'] +REQUIREMENTS = ['yalesmartalarmclient==0.1.5'] CONF_AREA_ID = 'area_id' diff --git a/requirements_all.txt b/requirements_all.txt index 9f89234bec671..8b4f94481cd7f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1652,7 +1652,7 @@ xmltodict==0.11.0 yahooweather==0.10 # homeassistant.components.alarm_control_panel.yale_smart_alarm -yalesmartalarmclient==0.1.4 +yalesmartalarmclient==0.1.5 # homeassistant.components.light.yeelight yeelight==0.4.3 From 75b855ef930bb922768e64956629067cea69cd65 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 4 Dec 2018 09:56:30 +0100 Subject: [PATCH 249/325] Lovelace fix: badges are removed from view after update (#18983) * badges are removed from view after update * Only add badges and cards when not provided in new config --- homeassistant/components/lovelace/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 20792de82221c..36130e362cd14 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -380,7 +380,10 @@ def update_view(fname: str, view_id: str, view_config, data_format: "View with ID: {} was not found in {}.".format(view_id, fname)) if data_format == FORMAT_YAML: view_config = yaml.yaml_to_object(view_config) - view_config['cards'] = found.get('cards', []) + if not view_config.get('cards') and found.get('cards'): + view_config['cards'] = found.get('cards', []) + if not view_config.get('badges') and found.get('badges'): + view_config['badges'] = found.get('badges', []) found.clear() found.update(view_config) yaml.save_yaml(fname, config) From a6511fc0b9f02bd7e399d48e4e72aa6d7a1ba66e Mon Sep 17 00:00:00 2001 From: Pierre Gronlier Date: Tue, 4 Dec 2018 09:59:03 +0100 Subject: [PATCH 250/325] remove the need to have query feature support (#18942) * remove the need to have query feature support Some InfluxDB servers don't have /query support feature but are still valid servers for storing data. Usually those servers are proxies to others timeseries databases. The change proposes to still validate the configuration but with less requirements on the server side. * `.query` call is replaced by `.write_points` * no more query call in the influxdb component. remove test * reset mock after the setup and before the test * remove unused import * reset mock stats after component setup --- homeassistant/components/influxdb.py | 2 +- tests/components/test_influxdb.py | 43 ++++++++++++---------------- 2 files changed, 19 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/influxdb.py b/homeassistant/components/influxdb.py index c28527886b1ac..dfb41ddf617ba 100644 --- a/homeassistant/components/influxdb.py +++ b/homeassistant/components/influxdb.py @@ -136,7 +136,7 @@ def setup(hass, config): try: influx = InfluxDBClient(**kwargs) - influx.query("SHOW SERIES LIMIT 1;", database=conf[CONF_DB_NAME]) + influx.write_points([]) except (exceptions.InfluxDBClientError, requests.exceptions.ConnectionError) as exc: _LOGGER.error("Database host is not accessible due to '%s', please " diff --git a/tests/components/test_influxdb.py b/tests/components/test_influxdb.py index 5de6e16475099..d74ec41b7497a 100644 --- a/tests/components/test_influxdb.py +++ b/tests/components/test_influxdb.py @@ -3,8 +3,6 @@ import unittest from unittest import mock -import influxdb as influx_client - from homeassistant.setup import setup_component import homeassistant.components.influxdb as influxdb from homeassistant.const import EVENT_STATE_CHANGED, STATE_OFF, STATE_ON, \ @@ -48,7 +46,7 @@ def test_setup_config_full(self, mock_client): assert self.hass.bus.listen.called assert \ EVENT_STATE_CHANGED == self.hass.bus.listen.call_args_list[0][0][0] - assert mock_client.return_value.query.called + assert mock_client.return_value.write_points.call_count == 1 def test_setup_config_defaults(self, mock_client): """Test the setup with default configuration.""" @@ -82,20 +80,7 @@ def test_setup_missing_password(self, mock_client): assert not setup_component(self.hass, influxdb.DOMAIN, config) - def test_setup_query_fail(self, mock_client): - """Test the setup for query failures.""" - config = { - 'influxdb': { - 'host': 'host', - 'username': 'user', - 'password': 'pass', - } - } - mock_client.return_value.query.side_effect = \ - influx_client.exceptions.InfluxDBClientError('fake') - assert not setup_component(self.hass, influxdb.DOMAIN, config) - - def _setup(self, **kwargs): + def _setup(self, mock_client, **kwargs): """Set up the client.""" config = { 'influxdb': { @@ -111,10 +96,11 @@ def _setup(self, **kwargs): config['influxdb'].update(kwargs) assert setup_component(self.hass, influxdb.DOMAIN, config) self.handler_method = self.hass.bus.listen.call_args_list[0][0][1] + mock_client.return_value.write_points.reset_mock() def test_event_listener(self, mock_client): """Test the event listener.""" - self._setup() + self._setup(mock_client) # map of HA State to valid influxdb [state, value] fields valid = { @@ -176,7 +162,7 @@ def test_event_listener(self, mock_client): def test_event_listener_no_units(self, mock_client): """Test the event listener for missing units.""" - self._setup() + self._setup(mock_client) for unit in (None, ''): if unit: @@ -207,7 +193,7 @@ def test_event_listener_no_units(self, mock_client): def test_event_listener_inf(self, mock_client): """Test the event listener for missing units.""" - self._setup() + self._setup(mock_client) attrs = {'bignumstring': '9' * 999, 'nonumstring': 'nan'} state = mock.MagicMock( @@ -234,7 +220,7 @@ def test_event_listener_inf(self, mock_client): def test_event_listener_states(self, mock_client): """Test the event listener against ignored states.""" - self._setup() + self._setup(mock_client) for state_state in (1, 'unknown', '', 'unavailable'): state = mock.MagicMock( @@ -264,7 +250,7 @@ def test_event_listener_states(self, mock_client): def test_event_listener_blacklist(self, mock_client): """Test the event listener against a blacklist.""" - self._setup() + self._setup(mock_client) for entity_id in ('ok', 'blacklisted'): state = mock.MagicMock( @@ -294,7 +280,7 @@ def test_event_listener_blacklist(self, mock_client): def test_event_listener_blacklist_domain(self, mock_client): """Test the event listener against a blacklist.""" - self._setup() + self._setup(mock_client) for domain in ('ok', 'another_fake'): state = mock.MagicMock( @@ -337,6 +323,7 @@ def test_event_listener_whitelist(self, mock_client): } assert setup_component(self.hass, influxdb.DOMAIN, config) self.handler_method = self.hass.bus.listen.call_args_list[0][0][1] + mock_client.return_value.write_points.reset_mock() for entity_id in ('included', 'default'): state = mock.MagicMock( @@ -378,6 +365,7 @@ def test_event_listener_whitelist_domain(self, mock_client): } assert setup_component(self.hass, influxdb.DOMAIN, config) self.handler_method = self.hass.bus.listen.call_args_list[0][0][1] + mock_client.return_value.write_points.reset_mock() for domain in ('fake', 'another_fake'): state = mock.MagicMock( @@ -408,7 +396,7 @@ def test_event_listener_whitelist_domain(self, mock_client): def test_event_listener_invalid_type(self, mock_client): """Test the event listener when an attribute has an invalid type.""" - self._setup() + self._setup(mock_client) # map of HA State to valid influxdb [state, value] fields valid = { @@ -470,6 +458,7 @@ def test_event_listener_default_measurement(self, mock_client): } assert setup_component(self.hass, influxdb.DOMAIN, config) self.handler_method = self.hass.bus.listen.call_args_list[0][0][1] + mock_client.return_value.write_points.reset_mock() for entity_id in ('ok', 'blacklisted'): state = mock.MagicMock( @@ -509,6 +498,7 @@ def test_event_listener_unit_of_measurement_field(self, mock_client): } assert setup_component(self.hass, influxdb.DOMAIN, config) self.handler_method = self.hass.bus.listen.call_args_list[0][0][1] + mock_client.return_value.write_points.reset_mock() attrs = { 'unit_of_measurement': 'foobars', @@ -548,6 +538,7 @@ def test_event_listener_tags_attributes(self, mock_client): } assert setup_component(self.hass, influxdb.DOMAIN, config) self.handler_method = self.hass.bus.listen.call_args_list[0][0][1] + mock_client.return_value.write_points.reset_mock() attrs = { 'friendly_fake': 'tag_str', @@ -604,6 +595,7 @@ def test_event_listener_component_override_measurement(self, mock_client): } assert setup_component(self.hass, influxdb.DOMAIN, config) self.handler_method = self.hass.bus.listen.call_args_list[0][0][1] + mock_client.return_value.write_points.reset_mock() test_components = [ {'domain': 'sensor', 'id': 'fake_humidity', 'res': 'humidity'}, @@ -647,6 +639,7 @@ def test_scheduled_write(self, mock_client): } assert setup_component(self.hass, influxdb.DOMAIN, config) self.handler_method = self.hass.bus.listen.call_args_list[0][0][1] + mock_client.return_value.write_points.reset_mock() state = mock.MagicMock( state=1, domain='fake', entity_id='entity.id', object_id='entity', @@ -674,7 +667,7 @@ def test_scheduled_write(self, mock_client): def test_queue_backlog_full(self, mock_client): """Test the event listener to drop old events.""" - self._setup() + self._setup(mock_client) state = mock.MagicMock( state=1, domain='fake', entity_id='entity.id', object_id='entity', From d6a4e106a9bfc700643bad0cd2c252d4f4da7d0d Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Tue, 4 Dec 2018 10:08:40 +0100 Subject: [PATCH 251/325] Tellduslive refactoring (#18780) * move component to a package * move TelldusLiveEntry to separate file * refactor * move entities from a shared container * using the dispatch helper instead for communication between component and platforms * updated covereagerc and codeowners * suggestions from MartinHjelmare * don't make update async * "Strip is good!" --- .coveragerc | 3 +- CODEOWNERS | 4 +- .../components/binary_sensor/tellduslive.py | 6 +- homeassistant/components/cover/tellduslive.py | 9 +- homeassistant/components/light/tellduslive.py | 11 +- .../components/sensor/tellduslive.py | 17 ++- .../components/switch/tellduslive.py | 9 +- .../__init__.py} | 144 +++--------------- homeassistant/components/tellduslive/const.py | 5 + homeassistant/components/tellduslive/entry.py | 113 ++++++++++++++ 10 files changed, 168 insertions(+), 153 deletions(-) rename homeassistant/components/{tellduslive.py => tellduslive/__init__.py} (68%) create mode 100644 homeassistant/components/tellduslive/const.py create mode 100644 homeassistant/components/tellduslive/entry.py diff --git a/.coveragerc b/.coveragerc index ecfafa916e4f2..10e07dc2da5b5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -329,7 +329,8 @@ omit = homeassistant/components/tahoma.py homeassistant/components/*/tahoma.py - homeassistant/components/tellduslive.py + homeassistant/components/tellduslive/__init__.py + homeassistant/components/tellduslive/entry.py homeassistant/components/*/tellduslive.py homeassistant/components/tellstick.py diff --git a/CODEOWNERS b/CODEOWNERS index 1bb62d154fdc1..659f434d14b27 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -236,8 +236,8 @@ homeassistant/components/*/simplisafe.py @bachya # T homeassistant/components/tahoma.py @philklei homeassistant/components/*/tahoma.py @philklei -homeassistant/components/tellduslive.py @molobrakos @fredrike -homeassistant/components/*/tellduslive.py @molobrakos @fredrike +homeassistant/components/tellduslive/*.py @fredrike +homeassistant/components/*/tellduslive.py @fredrike homeassistant/components/tesla.py @zabuldon homeassistant/components/*/tesla.py @zabuldon homeassistant/components/thethingsnetwork.py @fabaff diff --git a/homeassistant/components/binary_sensor/tellduslive.py b/homeassistant/components/binary_sensor/tellduslive.py index 450a5e580bdc0..7f60e40c68bb4 100644 --- a/homeassistant/components/binary_sensor/tellduslive.py +++ b/homeassistant/components/binary_sensor/tellduslive.py @@ -9,8 +9,9 @@ """ import logging -from homeassistant.components.tellduslive import TelldusLiveEntity +from homeassistant.components import tellduslive from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.tellduslive.entry import TelldusLiveEntity _LOGGER = logging.getLogger(__name__) @@ -19,8 +20,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up Tellstick sensors.""" if discovery_info is None: return + client = hass.data[tellduslive.DOMAIN] add_entities( - TelldusLiveSensor(hass, binary_sensor) + TelldusLiveSensor(client, binary_sensor) for binary_sensor in discovery_info ) diff --git a/homeassistant/components/cover/tellduslive.py b/homeassistant/components/cover/tellduslive.py index 9d292d9e8b5bb..67affdae04e4b 100644 --- a/homeassistant/components/cover/tellduslive.py +++ b/homeassistant/components/cover/tellduslive.py @@ -8,8 +8,9 @@ """ import logging +from homeassistant.components import tellduslive from homeassistant.components.cover import CoverDevice -from homeassistant.components.tellduslive import TelldusLiveEntity +from homeassistant.components.tellduslive.entry import TelldusLiveEntity _LOGGER = logging.getLogger(__name__) @@ -19,7 +20,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if discovery_info is None: return - add_entities(TelldusLiveCover(hass, cover) for cover in discovery_info) + client = hass.data[tellduslive.DOMAIN] + add_entities(TelldusLiveCover(client, cover) for cover in discovery_info) class TelldusLiveCover(TelldusLiveEntity, CoverDevice): @@ -33,14 +35,11 @@ def is_closed(self): def close_cover(self, **kwargs): """Close the cover.""" self.device.down() - self.changed() def open_cover(self, **kwargs): """Open the cover.""" self.device.up() - self.changed() def stop_cover(self, **kwargs): """Stop the cover.""" self.device.stop() - self.changed() diff --git a/homeassistant/components/light/tellduslive.py b/homeassistant/components/light/tellduslive.py index 07b5458fa4506..8601fe3cf1f58 100644 --- a/homeassistant/components/light/tellduslive.py +++ b/homeassistant/components/light/tellduslive.py @@ -8,9 +8,10 @@ """ import logging +from homeassistant.components import tellduslive from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) -from homeassistant.components.tellduslive import TelldusLiveEntity +from homeassistant.components.tellduslive.entry import TelldusLiveEntity _LOGGER = logging.getLogger(__name__) @@ -19,21 +20,21 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Tellstick Net lights.""" if discovery_info is None: return - add_entities(TelldusLiveLight(hass, light) for light in discovery_info) + client = hass.data[tellduslive.DOMAIN] + add_entities(TelldusLiveLight(client, light) for light in discovery_info) class TelldusLiveLight(TelldusLiveEntity, Light): """Representation of a Tellstick Net light.""" - def __init__(self, hass, device_id): + def __init__(self, client, device_id): """Initialize the Tellstick Net light.""" - super().__init__(hass, device_id) + super().__init__(client, device_id) self._last_brightness = self.brightness def changed(self): """Define a property of the device that might have changed.""" self._last_brightness = self.brightness - super().changed() @property def brightness(self): diff --git a/homeassistant/components/sensor/tellduslive.py b/homeassistant/components/sensor/tellduslive.py index 9bd5a1d841319..4afff115b9df4 100644 --- a/homeassistant/components/sensor/tellduslive.py +++ b/homeassistant/components/sensor/tellduslive.py @@ -6,7 +6,8 @@ """ import logging -from homeassistant.components.tellduslive import TelldusLiveEntity +from homeassistant.components import tellduslive +from homeassistant.components.tellduslive.entry import TelldusLiveEntity from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS) @@ -27,8 +28,8 @@ SENSOR_TYPE_BAROMETRIC_PRESSURE = 'barpress' SENSOR_TYPES = { - SENSOR_TYPE_TEMPERATURE: ['Temperature', TEMP_CELSIUS, None, - DEVICE_CLASS_TEMPERATURE], + SENSOR_TYPE_TEMPERATURE: + ['Temperature', TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], SENSOR_TYPE_HUMIDITY: ['Humidity', '%', None, DEVICE_CLASS_HUMIDITY], SENSOR_TYPE_RAINRATE: ['Rain rate', 'mm/h', 'mdi:water', None], SENSOR_TYPE_RAINTOTAL: ['Rain total', 'mm', 'mdi:water', None], @@ -39,7 +40,7 @@ SENSOR_TYPE_WATT: ['Power', 'W', '', None], SENSOR_TYPE_LUMINANCE: ['Luminance', 'lx', None, DEVICE_CLASS_ILLUMINANCE], SENSOR_TYPE_DEW_POINT: - ['Dew Point', TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], + ['Dew Point', TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], SENSOR_TYPE_BAROMETRIC_PRESSURE: ['Barometric Pressure', 'kPa', '', None], } @@ -48,7 +49,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Tellstick sensors.""" if discovery_info is None: return - add_entities(TelldusLiveSensor(hass, sensor) for sensor in discovery_info) + client = hass.data[tellduslive.DOMAIN] + add_entities( + TelldusLiveSensor(client, sensor) for sensor in discovery_info) class TelldusLiveSensor(TelldusLiveEntity): @@ -87,9 +90,7 @@ def _value_as_humidity(self): @property def name(self): """Return the name of the sensor.""" - return '{} {}'.format( - super().name, - self.quantity_name or '') + return '{} {}'.format(super().name, self.quantity_name or '').strip() @property def state(self): diff --git a/homeassistant/components/switch/tellduslive.py b/homeassistant/components/switch/tellduslive.py index 0263dfd8198c9..ed4f825f5ac6b 100644 --- a/homeassistant/components/switch/tellduslive.py +++ b/homeassistant/components/switch/tellduslive.py @@ -9,7 +9,8 @@ """ import logging -from homeassistant.components.tellduslive import TelldusLiveEntity +from homeassistant.components import tellduslive +from homeassistant.components.tellduslive.entry import TelldusLiveEntity from homeassistant.helpers.entity import ToggleEntity _LOGGER = logging.getLogger(__name__) @@ -19,7 +20,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up Tellstick switches.""" if discovery_info is None: return - add_entities(TelldusLiveSwitch(hass, switch) for switch in discovery_info) + client = hass.data[tellduslive.DOMAIN] + add_entities( + TelldusLiveSwitch(client, switch) for switch in discovery_info) class TelldusLiveSwitch(TelldusLiveEntity, ToggleEntity): @@ -33,9 +36,7 @@ def is_on(self): def turn_on(self, **kwargs): """Turn the switch on.""" self.device.turn_on() - self.changed() def turn_off(self, **kwargs): """Turn the switch off.""" self.device.turn_off() - self.changed() diff --git a/homeassistant/components/tellduslive.py b/homeassistant/components/tellduslive/__init__.py similarity index 68% rename from homeassistant/components/tellduslive.py rename to homeassistant/components/tellduslive/__init__.py index a6ba248b99b4e..89e7446448999 100644 --- a/homeassistant/components/tellduslive.py +++ b/homeassistant/components/tellduslive/__init__.py @@ -4,25 +4,22 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/tellduslive/ """ -from datetime import datetime, timedelta +from datetime import timedelta import logging import voluptuous as vol from homeassistant.components.discovery import SERVICE_TELLDUSLIVE -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, CONF_HOST, CONF_TOKEN, DEVICE_DEFAULT_NAME, - EVENT_HOMEASSISTANT_START) +from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import track_point_in_utc_time -from homeassistant.util.dt import utcnow +from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.event import track_time_interval from homeassistant.util.json import load_json, save_json -APPLICATION_NAME = 'Home Assistant' +from .const import DOMAIN, SIGNAL_UPDATE_ENTITY -DOMAIN = 'tellduslive' +APPLICATION_NAME = 'Home Assistant' REQUIREMENTS = ['tellduslive==0.10.4'] @@ -48,9 +45,6 @@ }), }, extra=vol.ALLOW_EXTRA) - -ATTR_LAST_UPDATED = 'time_last_updated' - CONFIG_INSTRUCTIONS = """ To link your TelldusLive account: @@ -146,7 +140,7 @@ def tellstick_discovered(service, info): if not supports_local_api(device): _LOGGER.debug('Tellstick does not support local API') # Configure the cloud service - hass.async_add_job(request_configuration) + hass.add_job(request_configuration) return _LOGGER.debug('Tellstick does support local API') @@ -189,18 +183,17 @@ def tellstick_discovered(service, info): return True if not session.is_authorized: - _LOGGER.error( - 'Authentication Error') + _LOGGER.error('Authentication Error') return False client = TelldusLiveClient(hass, config, session) - hass.data[DOMAIN] = client + client.update() - if session: - client.update() - else: - hass.bus.listen(EVENT_HOMEASSISTANT_START, client.update) + interval = config.get(DOMAIN, {}).get(CONF_UPDATE_INTERVAL, + DEFAULT_UPDATE_INTERVAL) + _LOGGER.debug('Update interval %s', interval) + track_time_interval(hass, client.update, interval) return True @@ -210,27 +203,15 @@ class TelldusLiveClient: def __init__(self, hass, config, session): """Initialize the Tellus data object.""" - self.entities = [] + self._known_devices = set() self._hass = hass self._config = config - - self._interval = config.get(DOMAIN, {}).get( - CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL) - _LOGGER.debug('Update interval %s', self._interval) self._client = session def update(self, *args): - """Periodically poll the servers for current state.""" - _LOGGER.debug('Updating') - try: - self._sync() - finally: - track_point_in_utc_time( - self._hass, self.update, utcnow() + self._interval) - - def _sync(self): """Update local list of devices.""" + _LOGGER.debug('Updating') if not self._client.update(): _LOGGER.warning('Failed request') @@ -254,9 +235,8 @@ def discover(device_id, component): discovery.load_platform( self._hass, component, DOMAIN, [device_id], self._config) - known_ids = {entity.device_id for entity in self.entities} for device in self._client.devices: - if device.device_id in known_ids: + if device.device_id in self._known_devices: continue if device.is_sensor: for item in device.items: @@ -265,9 +245,9 @@ def discover(device_id, component): else: discover(device.device_id, identify_device(device)) + self._known_devices.add(device.device_id) - for entity in self.entities: - entity.changed() + dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY) def device(self, device_id): """Return device representation.""" @@ -276,91 +256,3 @@ def device(self, device_id): def is_available(self, device_id): """Return device availability.""" return device_id in self._client.device_ids - - -class TelldusLiveEntity(Entity): - """Base class for all Telldus Live entities.""" - - def __init__(self, hass, device_id): - """Initialize the entity.""" - self._id = device_id - self._client = hass.data[DOMAIN] - self._client.entities.append(self) - self._name = self.device.name - _LOGGER.debug('Created device %s', self) - - def changed(self): - """Return the property of the device might have changed.""" - if self.device.name: - self._name = self.device.name - self.schedule_update_ha_state() - - @property - def device_id(self): - """Return the id of the device.""" - return self._id - - @property - def device(self): - """Return the representation of the device.""" - return self._client.device(self.device_id) - - @property - def _state(self): - """Return the state of the device.""" - return self.device.state - - @property - def should_poll(self): - """Return the polling state.""" - return False - - @property - def assumed_state(self): - """Return true if unable to access real state of entity.""" - return True - - @property - def name(self): - """Return name of device.""" - return self._name or DEVICE_DEFAULT_NAME - - @property - def available(self): - """Return true if device is not offline.""" - return self._client.is_available(self.device_id) - - @property - def device_state_attributes(self): - """Return the state attributes.""" - attrs = {} - if self._battery_level: - attrs[ATTR_BATTERY_LEVEL] = self._battery_level - if self._last_updated: - attrs[ATTR_LAST_UPDATED] = self._last_updated - return attrs - - @property - def _battery_level(self): - """Return the battery level of a device.""" - from tellduslive import (BATTERY_LOW, - BATTERY_UNKNOWN, - BATTERY_OK) - if self.device.battery == BATTERY_LOW: - return 1 - if self.device.battery == BATTERY_UNKNOWN: - return None - if self.device.battery == BATTERY_OK: - return 100 - return self.device.battery # Percentage - - @property - def _last_updated(self): - """Return the last update of a device.""" - return str(datetime.fromtimestamp(self.device.lastUpdated)) \ - if self.device.lastUpdated else None - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._id diff --git a/homeassistant/components/tellduslive/const.py b/homeassistant/components/tellduslive/const.py new file mode 100644 index 0000000000000..a4ef33af5186d --- /dev/null +++ b/homeassistant/components/tellduslive/const.py @@ -0,0 +1,5 @@ +"""Consts used by TelldusLive.""" + +DOMAIN = 'tellduslive' + +SIGNAL_UPDATE_ENTITY = 'tellduslive_update' diff --git a/homeassistant/components/tellduslive/entry.py b/homeassistant/components/tellduslive/entry.py new file mode 100644 index 0000000000000..88b7d47ad9dc2 --- /dev/null +++ b/homeassistant/components/tellduslive/entry.py @@ -0,0 +1,113 @@ +"""Base Entity for all TelldusLiveEntities.""" +from datetime import datetime +import logging + +from homeassistant.const import ATTR_BATTERY_LEVEL, DEVICE_DEFAULT_NAME +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import SIGNAL_UPDATE_ENTITY + +_LOGGER = logging.getLogger(__name__) + +ATTR_LAST_UPDATED = 'time_last_updated' + + +class TelldusLiveEntity(Entity): + """Base class for all Telldus Live entities.""" + + def __init__(self, client, device_id): + """Initialize the entity.""" + self._id = device_id + self._client = client + self._name = self.device.name + self._async_unsub_dispatcher_connect = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + _LOGGER.debug('Created device %s', self) + self._async_unsub_dispatcher_connect = async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback) + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + if self._async_unsub_dispatcher_connect: + self._async_unsub_dispatcher_connect() + + @callback + def _update_callback(self): + """Return the property of the device might have changed.""" + if self.device.name: + self._name = self.device.name + self.async_schedule_update_ha_state() + + @property + def device_id(self): + """Return the id of the device.""" + return self._id + + @property + def device(self): + """Return the representation of the device.""" + return self._client.device(self.device_id) + + @property + def _state(self): + """Return the state of the device.""" + return self.device.state + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def assumed_state(self): + """Return true if unable to access real state of entity.""" + return True + + @property + def name(self): + """Return name of device.""" + return self._name or DEVICE_DEFAULT_NAME + + @property + def available(self): + """Return true if device is not offline.""" + return self._client.is_available(self.device_id) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {} + if self._battery_level: + attrs[ATTR_BATTERY_LEVEL] = self._battery_level + if self._last_updated: + attrs[ATTR_LAST_UPDATED] = self._last_updated + return attrs + + @property + def _battery_level(self): + """Return the battery level of a device.""" + from tellduslive import (BATTERY_LOW, + BATTERY_UNKNOWN, + BATTERY_OK) + if self.device.battery == BATTERY_LOW: + return 1 + if self.device.battery == BATTERY_UNKNOWN: + return None + if self.device.battery == BATTERY_OK: + return 100 + return self.device.battery # Percentage + + @property + def _last_updated(self): + """Return the last update of a device.""" + return str(datetime.fromtimestamp(self.device.lastUpdated)) \ + if self.device.lastUpdated else None + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._id From ab7c52a9c4b4748f614651f29c93c26827870752 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Tue, 4 Dec 2018 10:45:16 +0100 Subject: [PATCH 252/325] Add unnecessary-pass for pylint-update (#18985) --- pylintrc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pylintrc b/pylintrc index be06f83e6f256..a88aabe1936f1 100644 --- a/pylintrc +++ b/pylintrc @@ -12,6 +12,7 @@ # abstract-method - with intro of async there are always methods missing # inconsistent-return-statements - doesn't handle raise # not-an-iterable - https://github.com/PyCQA/pylint/issues/2311 +# unnecessary-pass - readability for functions which only contain pass disable= abstract-class-little-used, abstract-method, @@ -32,6 +33,7 @@ disable= too-many-public-methods, too-many-return-statements, too-many-statements, + unnecessary-pass, unused-argument [REPORTS] From b65bffd849832d08935babd15e012be4e468308c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 4 Dec 2018 10:45:41 +0100 Subject: [PATCH 253/325] Mock out device tracker configuration loading funcs in Geofency + OwnTracks (#18968) * Mock out device tracker configuration loading funcs * Update test_init.py * Update test_init.py --- tests/components/geofency/test_init.py | 6 ++++++ tests/components/owntracks/test_init.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py index c8044b1ad5e29..ae90af61ceda8 100644 --- a/tests/components/geofency/test_init.py +++ b/tests/components/geofency/test_init.py @@ -104,6 +104,12 @@ } +@pytest.fixture(autouse=True) +def mock_dev_track(mock_device_tracker_conf): + """Mock device tracker config loading.""" + pass + + @pytest.fixture def geofency_client(loop, hass, hass_client): """Geofency mock client.""" diff --git a/tests/components/owntracks/test_init.py b/tests/components/owntracks/test_init.py index ee79c8b9e10be..ba362da905af2 100644 --- a/tests/components/owntracks/test_init.py +++ b/tests/components/owntracks/test_init.py @@ -33,6 +33,12 @@ } +@pytest.fixture(autouse=True) +def mock_dev_track(mock_device_tracker_conf): + """Mock device tracker config loading.""" + pass + + @pytest.fixture def mock_client(hass, aiohttp_client): """Start the Hass HTTP component.""" From 2a0c2d52475b55f4a17dbadbcfc5e84bdd927fc4 Mon Sep 17 00:00:00 2001 From: pbalogh77 Date: Tue, 4 Dec 2018 11:38:21 +0100 Subject: [PATCH 254/325] Fibaro Light fixes (#18972) * minor fixes to color scaling * capped input to fibaro on setcolor --- homeassistant/components/fibaro.py | 11 +++++++---- homeassistant/components/light/fibaro.py | 18 +++++++++++------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/fibaro.py b/homeassistant/components/fibaro.py index 55f6f528622d9..dacf0c97edf1e 100644 --- a/homeassistant/components/fibaro.py +++ b/homeassistant/components/fibaro.py @@ -303,11 +303,14 @@ def call_turn_off(self): def call_set_color(self, red, green, blue, white): """Set the color of Fibaro device.""" - color_str = "{},{},{},{}".format(int(red), int(green), - int(blue), int(white)) + red = int(max(0, min(255, red))) + green = int(max(0, min(255, green))) + blue = int(max(0, min(255, blue))) + white = int(max(0, min(255, white))) + color_str = "{},{},{},{}".format(red, green, blue, white) self.fibaro_device.properties.color = color_str - self.action("setColor", str(int(red)), str(int(green)), - str(int(blue)), str(int(white))) + self.action("setColor", str(red), str(green), + str(blue), str(white)) def action(self, cmd, *args): """Perform an action on the Fibaro HC.""" diff --git a/homeassistant/components/light/fibaro.py b/homeassistant/components/light/fibaro.py index 7157dcfd31b34..636e4376ae28c 100644 --- a/homeassistant/components/light/fibaro.py +++ b/homeassistant/components/light/fibaro.py @@ -27,7 +27,7 @@ def scaleto255(value): # depending on device type (e.g. dimmer vs led) if value > 98: value = 100 - return max(0, min(255, ((value * 256.0) / 100.0))) + return max(0, min(255, ((value * 255.0) / 100.0))) def scaleto100(value): @@ -35,7 +35,7 @@ def scaleto100(value): # Make sure a low but non-zero value is not rounded down to zero if 0 < value < 3: return 1 - return max(0, min(100, ((value * 100.4) / 255.0))) + return max(0, min(100, ((value * 100.0) / 255.0))) async def async_setup_platform(hass, @@ -122,11 +122,11 @@ def _turn_on(self, **kwargs): self._color = kwargs.get(ATTR_HS_COLOR, self._color) rgb = color_util.color_hs_to_RGB(*self._color) self.call_set_color( - int(rgb[0] * self._brightness / 99.0 + 0.5), - int(rgb[1] * self._brightness / 99.0 + 0.5), - int(rgb[2] * self._brightness / 99.0 + 0.5), - int(self._white * self._brightness / 99.0 + - 0.5)) + round(rgb[0] * self._brightness / 100.0), + round(rgb[1] * self._brightness / 100.0), + round(rgb[2] * self._brightness / 100.0), + round(self._white * self._brightness / 100.0)) + if self.state == 'off': self.set_level(int(self._brightness)) return @@ -168,6 +168,10 @@ def _update(self): # Brightness handling if self._supported_flags & SUPPORT_BRIGHTNESS: self._brightness = float(self.fibaro_device.properties.value) + # Fibaro might report 0-99 or 0-100 for brightness, + # based on device type, so we round up here + if self._brightness > 99: + self._brightness = 100 # Color handling if self._supported_flags & SUPPORT_COLOR and \ 'color' in self.fibaro_device.properties and \ From 3e1ab1b23ad5b6c4d3a619af3be7b6bc4394e898 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Tue, 4 Dec 2018 05:38:57 -0500 Subject: [PATCH 255/325] Sort import order of zha component. (#18993) --- homeassistant/components/binary_sensor/zha.py | 7 +++---- homeassistant/components/fan/zha.py | 14 ++++++------- homeassistant/components/light/zha.py | 8 +++---- homeassistant/components/sensor/zha.py | 7 +++---- homeassistant/components/switch/zha.py | 7 +++---- homeassistant/components/zha/__init__.py | 21 +++++++++---------- homeassistant/components/zha/config_flow.py | 8 +++---- .../components/zha/entities/__init__.py | 2 +- .../components/zha/entities/device_entity.py | 1 + .../components/zha/entities/entity.py | 9 ++++---- homeassistant/components/zha/helpers.py | 5 +++-- 11 files changed, 43 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/binary_sensor/zha.py b/homeassistant/components/binary_sensor/zha.py index 087e7963c000f..62c57f0288b05 100644 --- a/homeassistant/components/binary_sensor/zha.py +++ b/homeassistant/components/binary_sensor/zha.py @@ -7,12 +7,11 @@ import logging from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice -from homeassistant.components.zha.entities import ZhaEntity from homeassistant.components.zha import helpers -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.components.zha.const import ( - ZHA_DISCOVERY_NEW, DATA_ZHA, DATA_ZHA_DISPATCHERS -) + DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW) +from homeassistant.components.zha.entities import ZhaEntity +from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fan/zha.py b/homeassistant/components/fan/zha.py index 4f8254672a850..d1731e89894d0 100644 --- a/homeassistant/components/fan/zha.py +++ b/homeassistant/components/fan/zha.py @@ -5,15 +5,15 @@ at https://home-assistant.io/components/fan.zha/ """ import logging -from homeassistant.components.zha.entities import ZhaEntity + +from homeassistant.components.fan import ( + DOMAIN, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF, SUPPORT_SET_SPEED, + FanEntity) from homeassistant.components.zha import helpers -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.components.zha.const import ( - ZHA_DISCOVERY_NEW, DATA_ZHA, DATA_ZHA_DISPATCHERS -) -from homeassistant.components.fan import ( - DOMAIN, FanEntity, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, - SUPPORT_SET_SPEED) + DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW) +from homeassistant.components.zha.entities import ZhaEntity +from homeassistant.helpers.dispatcher import async_dispatcher_connect DEPENDENCIES = ['zha'] diff --git a/homeassistant/components/light/zha.py b/homeassistant/components/light/zha.py index 67b65edb0a64c..83448b39d9eaa 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -5,13 +5,13 @@ at https://home-assistant.io/components/light.zha/ """ import logging + from homeassistant.components import light -from homeassistant.components.zha.entities import ZhaEntity from homeassistant.components.zha import helpers -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.components.zha.const import ( - ZHA_DISCOVERY_NEW, DATA_ZHA, DATA_ZHA_DISPATCHERS -) + DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW) +from homeassistant.components.zha.entities import ZhaEntity +from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/zha.py b/homeassistant/components/sensor/zha.py index 97432b2512f2b..80aad9ac93727 100644 --- a/homeassistant/components/sensor/zha.py +++ b/homeassistant/components/sensor/zha.py @@ -7,13 +7,12 @@ import logging from homeassistant.components.sensor import DOMAIN -from homeassistant.components.zha.entities import ZhaEntity from homeassistant.components.zha import helpers -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.components.zha.const import ( - ZHA_DISCOVERY_NEW, DATA_ZHA, DATA_ZHA_DISPATCHERS -) + DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW) +from homeassistant.components.zha.entities import ZhaEntity from homeassistant.const import TEMP_CELSIUS +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.temperature import convert as convert_temperature _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/zha.py b/homeassistant/components/switch/zha.py index d34ca5e71bafb..4dac3bfbb22fe 100644 --- a/homeassistant/components/switch/zha.py +++ b/homeassistant/components/switch/zha.py @@ -6,13 +6,12 @@ """ import logging -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.components.switch import DOMAIN, SwitchDevice -from homeassistant.components.zha.entities import ZhaEntity from homeassistant.components.zha import helpers from homeassistant.components.zha.const import ( - ZHA_DISCOVERY_NEW, DATA_ZHA, DATA_ZHA_DISPATCHERS -) + DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW) +from homeassistant.components.zha.entities import ZhaEntity +from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index d67fbd02b8f7e..fb909b6fedf28 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -10,23 +10,22 @@ import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.components.zha.entities import ZhaDeviceEntity from homeassistant import config_entries, const as ha_const -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.components.zha.entities import ZhaDeviceEntity +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE -from . import const as zha_const +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity_component import EntityComponent # Loading the config flow file will register the flow from . import config_flow # noqa # pylint: disable=unused-import +from . import const as zha_const from .const import ( - DOMAIN, COMPONENTS, CONF_BAUDRATE, CONF_DATABASE, CONF_RADIO_TYPE, - CONF_USB_PATH, CONF_DEVICE_CONFIG, ZHA_DISCOVERY_NEW, DATA_ZHA, - DATA_ZHA_CONFIG, DATA_ZHA_BRIDGE_ID, DATA_ZHA_RADIO, DATA_ZHA_DISPATCHERS, - DATA_ZHA_CORE_COMPONENT, DEFAULT_RADIO_TYPE, DEFAULT_DATABASE_NAME, - DEFAULT_BAUDRATE, RadioType -) + COMPONENTS, CONF_BAUDRATE, CONF_DATABASE, CONF_DEVICE_CONFIG, + CONF_RADIO_TYPE, CONF_USB_PATH, DATA_ZHA, DATA_ZHA_BRIDGE_ID, + DATA_ZHA_CONFIG, DATA_ZHA_CORE_COMPONENT, DATA_ZHA_DISPATCHERS, + DATA_ZHA_RADIO, DEFAULT_BAUDRATE, DEFAULT_DATABASE_NAME, + DEFAULT_RADIO_TYPE, DOMAIN, ZHA_DISCOVERY_NEW, RadioType) REQUIREMENTS = [ 'bellows==0.7.0', diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index fa45194ea3fe7..1c903ec30566f 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -1,14 +1,14 @@ """Config flow for ZHA.""" -import os from collections import OrderedDict +import os import voluptuous as vol from homeassistant import config_entries -from .helpers import check_zigpy_connection + from .const import ( - DOMAIN, CONF_RADIO_TYPE, CONF_USB_PATH, DEFAULT_DATABASE_NAME, RadioType -) + CONF_RADIO_TYPE, CONF_USB_PATH, DEFAULT_DATABASE_NAME, DOMAIN, RadioType) +from .helpers import check_zigpy_connection @config_entries.HANDLERS.register(DOMAIN) diff --git a/homeassistant/components/zha/entities/__init__.py b/homeassistant/components/zha/entities/__init__.py index d5e52e9277f4b..c3c3ea163ed66 100644 --- a/homeassistant/components/zha/entities/__init__.py +++ b/homeassistant/components/zha/entities/__init__.py @@ -6,5 +6,5 @@ """ # flake8: noqa -from .entity import ZhaEntity from .device_entity import ZhaDeviceEntity +from .entity import ZhaEntity diff --git a/homeassistant/components/zha/entities/device_entity.py b/homeassistant/components/zha/entities/device_entity.py index 1a10f2494897e..2d2a5d76b817d 100644 --- a/homeassistant/components/zha/entities/device_entity.py +++ b/homeassistant/components/zha/entities/device_entity.py @@ -6,6 +6,7 @@ """ import time + from homeassistant.helpers import entity from homeassistant.util import slugify diff --git a/homeassistant/components/zha/entities/entity.py b/homeassistant/components/zha/entities/entity.py index a4454244364e7..da8f615a6650f 100644 --- a/homeassistant/components/zha/entities/entity.py +++ b/homeassistant/components/zha/entities/entity.py @@ -4,13 +4,12 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/zha/ """ -from homeassistant.helpers import entity -from homeassistant.util import slugify +from homeassistant.components.zha.const import ( + DATA_ZHA, DATA_ZHA_BRIDGE_ID, DOMAIN) from homeassistant.core import callback +from homeassistant.helpers import entity from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE -from homeassistant.components.zha.const import ( - DOMAIN, DATA_ZHA, DATA_ZHA_BRIDGE_ID -) +from homeassistant.util import slugify class ZhaEntity(entity.Entity): diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index f3e1a27dca27a..7ae6fbf2d222f 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -4,9 +4,10 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/zha/ """ -import logging import asyncio -from .const import RadioType, DEFAULT_BAUDRATE +import logging + +from .const import DEFAULT_BAUDRATE, RadioType _LOGGER = logging.getLogger(__name__) From 1c999603577f7d1a7d80f025efa04a6c250c87bb Mon Sep 17 00:00:00 2001 From: Emil Stjerneman Date: Tue, 4 Dec 2018 11:39:42 +0100 Subject: [PATCH 256/325] Fix VOC configuration resource list (#18992) --- homeassistant/components/volvooncall.py | 26 ++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/volvooncall.py b/homeassistant/components/volvooncall.py index 46c22c65e8537..b47c7f7cdf7fd 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -69,19 +69,19 @@ 'engine_start', 'last_trip', 'is_engine_running', - 'doors.hood_open', - 'doors.front_left_door_open', - 'doors.front_right_door_open', - 'doors.rear_left_door_open', - 'doors.rear_right_door_open', - 'windows.front_left_window_open', - 'windows.front_right_window_open', - 'windows.rear_left_window_open', - 'windows.rear_right_window_open', - 'tyre_pressure.front_left_tyre_pressure', - 'tyre_pressure.front_right_tyre_pressure', - 'tyre_pressure.rear_left_tyre_pressure', - 'tyre_pressure.rear_right_tyre_pressure', + 'doors_hood_open', + 'doors_front_left_door_open', + 'doors_front_right_door_open', + 'doors_rear_left_door_open', + 'doors_rear_right_door_open', + 'windows_front_left_window_open', + 'windows_front_right_window_open', + 'windows_rear_left_window_open', + 'windows_rear_right_window_open', + 'tyre_pressure_front_left_tyre_pressure', + 'tyre_pressure_front_right_tyre_pressure', + 'tyre_pressure_rear_left_tyre_pressure', + 'tyre_pressure_rear_right_tyre_pressure', 'any_door_open', 'any_window_open' ] From 26dd490e8e278e00ef7a723d8f710c1ddf019359 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 4 Dec 2018 13:24:18 +0100 Subject: [PATCH 257/325] Fix toon operation mode (#18966) * Fix toon * Update toon.py --- homeassistant/components/climate/toon.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/climate/toon.py b/homeassistant/components/climate/toon.py index 5972ff52a8b2b..022a509ce06bd 100644 --- a/homeassistant/components/climate/toon.py +++ b/homeassistant/components/climate/toon.py @@ -15,6 +15,14 @@ SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE +HA_TOON = { + STATE_AUTO: 'Comfort', + STATE_HEAT: 'Home', + STATE_ECO: 'Away', + STATE_COOL: 'Sleep', +} +TOON_HA = {value: key for key, value in HA_TOON.items()} + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Toon climate device.""" @@ -58,8 +66,7 @@ def temperature_unit(self): @property def current_operation(self): """Return current operation i.e. comfort, home, away.""" - state = self.thermos.get_data('state') - return state + return TOON_HA.get(self.thermos.get_data('state')) @property def operation_list(self): @@ -83,14 +90,7 @@ def set_temperature(self, **kwargs): def set_operation_mode(self, operation_mode): """Set new operation mode.""" - toonlib_values = { - STATE_AUTO: 'Comfort', - STATE_HEAT: 'Home', - STATE_ECO: 'Away', - STATE_COOL: 'Sleep', - } - - self.thermos.set_state(toonlib_values[operation_mode]) + self.thermos.set_state(HA_TOON[operation_mode]) def update(self): """Update local state.""" From 38b09b1613d1f696d170e38d266b073c36f999cc Mon Sep 17 00:00:00 2001 From: Matt Hamilton Date: Tue, 4 Dec 2018 08:39:43 -0500 Subject: [PATCH 258/325] Remove stale user salts code (#19004) user['salt'] was originally used as a part of the pbkdf2 implementation. I failed to remove this as a part of the cleanup in #18736. --- homeassistant/auth/providers/homeassistant.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index 19aeea5b22e6f..2c5a76d2c9061 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -13,7 +13,6 @@ from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow from ..models import Credentials, UserMeta -from ..util import generate_secret STORAGE_VERSION = 1 @@ -59,7 +58,6 @@ async def async_load(self) -> None: if data is None: data = { - 'salt': generate_secret(), 'users': [] } From 47d48c5990cd4426bb55f9f4a33dcf8110648179 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 4 Dec 2018 16:39:49 +0100 Subject: [PATCH 259/325] Small refactoring of MQTT switch --- homeassistant/components/switch/mqtt.py | 67 ++++++++++++------------- 1 file changed, 31 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/switch/mqtt.py b/homeassistant/components/switch/mqtt.py index 75da1f4cf7425..19e72a9d021e9 100644 --- a/homeassistant/components/switch/mqtt.py +++ b/homeassistant/components/switch/mqtt.py @@ -86,18 +86,9 @@ def __init__(self, config, discovery_hash): self._state = False self._sub_state = None - self._name = None - self._icon = None - self._state_topic = None - self._command_topic = None - self._qos = None - self._retain = None - self._payload_on = None - self._payload_off = None self._state_on = None self._state_off = None self._optimistic = None - self._template = None self._unique_id = config.get(CONF_UNIQUE_ID) # Load config @@ -106,9 +97,10 @@ def __init__(self, config, discovery_hash): availability_topic = config.get(CONF_AVAILABILITY_TOPIC) payload_available = config.get(CONF_PAYLOAD_AVAILABLE) payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) + qos = config.get(CONF_QOS) device_config = config.get(CONF_DEVICE) - MqttAvailability.__init__(self, availability_topic, self._qos, + MqttAvailability.__init__(self, availability_topic, qos, payload_available, payload_not_available) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) @@ -129,32 +121,28 @@ async def discovery_update(self, discovery_payload): def _setup_from_config(self, config): """(Re)Setup the entity.""" - self._name = config.get(CONF_NAME) - self._icon = config.get(CONF_ICON) - self._state_topic = config.get(CONF_STATE_TOPIC) - self._command_topic = config.get(CONF_COMMAND_TOPIC) - self._qos = config.get(CONF_QOS) - self._retain = config.get(CONF_RETAIN) - self._payload_on = config.get(CONF_PAYLOAD_ON) - self._payload_off = config.get(CONF_PAYLOAD_OFF) + self._config = config + state_on = config.get(CONF_STATE_ON) - self._state_on = state_on if state_on else self._payload_on + self._state_on = state_on if state_on else config.get(CONF_PAYLOAD_ON) + state_off = config.get(CONF_STATE_OFF) - self._state_off = state_off if state_off else self._payload_off + self._state_off = state_off if state_off else \ + config.get(CONF_PAYLOAD_OFF) + self._optimistic = config.get(CONF_OPTIMISTIC) - config.get(CONF_UNIQUE_ID) - self._template = config.get(CONF_VALUE_TEMPLATE) async def _subscribe_topics(self): """(Re)Subscribe to topics.""" - if self._template is not None: - self._template.hass = self.hass + template = self._config.get(CONF_VALUE_TEMPLATE) + if template is not None: + template.hass = self.hass @callback def state_message_received(topic, payload, qos): """Handle new MQTT state messages.""" - if self._template is not None: - payload = self._template.async_render_with_possible_json_value( + if template is not None: + payload = template.async_render_with_possible_json_value( payload) if payload == self._state_on: self._state = True @@ -163,15 +151,16 @@ def state_message_received(topic, payload, qos): self.async_schedule_update_ha_state() - if self._state_topic is None: + if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. self._optimistic = True else: self._sub_state = await subscription.async_subscribe_topics( self.hass, self._sub_state, - {'state_topic': {'topic': self._state_topic, - 'msg_callback': state_message_received, - 'qos': self._qos}}) + {CONF_STATE_TOPIC: + {'topic': self._config.get(CONF_STATE_TOPIC), + 'msg_callback': state_message_received, + 'qos': self._config.get(CONF_QOS)}}) if self._optimistic: last_state = await self.async_get_last_state() @@ -191,7 +180,7 @@ def should_poll(self): @property def name(self): """Return the name of the switch.""" - return self._name + return self._config.get(CONF_NAME) @property def is_on(self): @@ -211,7 +200,7 @@ def unique_id(self): @property def icon(self): """Return the icon.""" - return self._icon + return self._config.get(CONF_ICON) async def async_turn_on(self, **kwargs): """Turn the device on. @@ -219,8 +208,11 @@ async def async_turn_on(self, **kwargs): This method is a coroutine. """ mqtt.async_publish( - self.hass, self._command_topic, self._payload_on, self._qos, - self._retain) + self.hass, + self._config.get(CONF_COMMAND_TOPIC), + self._config.get(CONF_PAYLOAD_ON), + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic: # Optimistically assume that switch has changed state. self._state = True @@ -232,8 +224,11 @@ async def async_turn_off(self, **kwargs): This method is a coroutine. """ mqtt.async_publish( - self.hass, self._command_topic, self._payload_off, self._qos, - self._retain) + self.hass, + self._config.get(CONF_COMMAND_TOPIC), + self._config.get(CONF_PAYLOAD_OFF), + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic: # Optimistically assume that switch has changed state. self._state = False From 21197fb9689ee63813560b0238453a2dbd14a9af Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 4 Dec 2018 10:55:30 +0100 Subject: [PATCH 260/325] Review comments --- homeassistant/components/mqtt/__init__.py | 7 ++++--- tests/components/binary_sensor/test_mqtt.py | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 11b837113c508..aa63adb72d8ed 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -840,6 +840,7 @@ async def async_added_to_hass(self) -> None: This method must be run in the event loop and returns a coroutine. """ + await super().async_added_to_hass() await self._attributes_subscribe_topics() async def attributes_discovery_update(self, config: dict): @@ -861,15 +862,15 @@ def attributes_message_received(topic: str, self._attributes = json_dict self.async_schedule_update_ha_state() else: - _LOGGER.debug("JSON result was not a dictionary") + _LOGGER.warning("JSON result was not a dictionary") self._attributes = None except ValueError: - _LOGGER.debug("Erroneous JSON: %s", payload) + _LOGGER.warning("Erroneous JSON: %s", payload) self._attributes = None self._attributes_sub_state = await async_subscribe_topics( self.hass, self._attributes_sub_state, - {'attributes_topic': { + {CONF_JSON_ATTRS_TOPIC: { 'topic': self._attributes_config.get(CONF_JSON_ATTRS_TOPIC), 'msg_callback': attributes_message_received, 'qos': self._attributes_config.get(CONF_QOS)}}) diff --git a/tests/components/binary_sensor/test_mqtt.py b/tests/components/binary_sensor/test_mqtt.py index a4357eefed830..74c7d32927ba6 100644 --- a/tests/components/binary_sensor/test_mqtt.py +++ b/tests/components/binary_sensor/test_mqtt.py @@ -293,7 +293,7 @@ def test_update_with_json_attrs_not_dict(self, mock_logger): state = self.hass.states.get('binary_sensor.test') assert state.attributes.get('val') is None - mock_logger.debug.assert_called_with( + mock_logger.warning.assert_called_with( 'JSON result was not a dictionary') @patch('homeassistant.components.mqtt._LOGGER') @@ -314,7 +314,7 @@ def test_update_with_json_attrs_bad_JSON(self, mock_logger): state = self.hass.states.get('binary_sensor.test') assert state.attributes.get('val') is None - mock_logger.debug.assert_called_with( + mock_logger.warning.assert_called_with( 'Erroneous JSON: %s', 'This is not JSON') From f54710c454f33ea7fe9683a2e352eb0811a1bd54 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 4 Dec 2018 21:25:18 +0100 Subject: [PATCH 261/325] Fix bug when reconfiguring MQTT availability --- homeassistant/components/mqtt/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 7ff32a7914270..b403f296bd872 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -852,7 +852,8 @@ def _availability_setup_from_config(self, config): """(Re)Setup.""" self._availability_topic = config.get(CONF_AVAILABILITY_TOPIC) self._availability_qos = config.get(CONF_QOS) - self._available = self._availability_topic is None # type: bool + if self._availability_topic is None: + self._available = True self._payload_available = config.get(CONF_PAYLOAD_AVAILABLE) self._payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) From a8b5cc833de731a10167f1f4d97a84e593d98371 Mon Sep 17 00:00:00 2001 From: majuss Date: Tue, 4 Dec 2018 21:04:39 +0000 Subject: [PATCH 262/325] Lupupy version push to 0.0.17 - will now transmitted state_alarm_triggered (#19008) * added state_alarm_triggered transmission; pushed lupupy version * added state_alarm_triggered transmission; pushed lupupy version * added state_alarm_triggered transmission; pushed lupupy version * added state_alarm_triggered transmission; pushed lupupy version --- homeassistant/components/alarm_control_panel/lupusec.py | 5 ++++- homeassistant/components/lupusec.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/lupusec.py b/homeassistant/components/alarm_control_panel/lupusec.py index 44d8a068ce22a..21eefc238a051 100644 --- a/homeassistant/components/alarm_control_panel/lupusec.py +++ b/homeassistant/components/alarm_control_panel/lupusec.py @@ -12,7 +12,8 @@ from homeassistant.components.lupusec import LupusecDevice from homeassistant.const import (STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, - STATE_ALARM_DISARMED) + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED) DEPENDENCIES = ['lupusec'] @@ -50,6 +51,8 @@ def state(self): state = STATE_ALARM_ARMED_AWAY elif self._device.is_home: state = STATE_ALARM_ARMED_HOME + elif self._device.is_alarm_triggered: + state = STATE_ALARM_TRIGGERED else: state = None return state diff --git a/homeassistant/components/lupusec.py b/homeassistant/components/lupusec.py index 17b04fce8675e..94cb3abc4a279 100644 --- a/homeassistant/components/lupusec.py +++ b/homeassistant/components/lupusec.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['lupupy==0.0.13'] +REQUIREMENTS = ['lupupy==0.0.17'] DOMAIN = 'lupusec' diff --git a/requirements_all.txt b/requirements_all.txt index 8b4f94481cd7f..99e0c2d5e39f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -612,7 +612,7 @@ logi_circle==0.1.7 luftdaten==0.3.4 # homeassistant.components.lupusec -lupupy==0.0.13 +lupupy==0.0.17 # homeassistant.components.light.lw12wifi lw12==0.9.2 From 2680bf8a6146cc26914485670dc8c0ad712dfb6a Mon Sep 17 00:00:00 2001 From: jxwolstenholme Date: Tue, 4 Dec 2018 22:26:20 +0000 Subject: [PATCH 263/325] Update requirement btsmarthub_devicelist==0.1.3 (#18961) * Added requirement 'btsmarthub_devicelist==0.1.2' * Update requirements_all.txt * Update bt_smarthub.py * Update requirements_all.txt * Update bt_smarthub.py --- homeassistant/components/device_tracker/bt_smarthub.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/bt_smarthub.py b/homeassistant/components/device_tracker/bt_smarthub.py index e7d60aaed6dd3..821182ec1036f 100644 --- a/homeassistant/components/device_tracker/bt_smarthub.py +++ b/homeassistant/components/device_tracker/bt_smarthub.py @@ -13,7 +13,7 @@ DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST -REQUIREMENTS = ['btsmarthub_devicelist==0.1.1'] +REQUIREMENTS = ['btsmarthub_devicelist==0.1.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 99e0c2d5e39f9..56f1dbcb70967 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -230,7 +230,7 @@ bt_proximity==0.1.2 bthomehub5-devicelist==0.1.1 # homeassistant.components.device_tracker.bt_smarthub -btsmarthub_devicelist==0.1.1 +btsmarthub_devicelist==0.1.3 # homeassistant.components.sensor.buienradar # homeassistant.components.weather.buienradar From 3928d034a347fe54123cc20bf6c4a2d6a6ba2be5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 5 Dec 2018 11:41:00 +0100 Subject: [PATCH 264/325] Allow checking entity permissions based on devices (#19007) * Allow checking entity permissions based on devices * Fix tests --- homeassistant/auth/auth_store.py | 13 +++- homeassistant/auth/models.py | 6 +- homeassistant/auth/permissions/__init__.py | 12 +++- homeassistant/auth/permissions/entities.py | 29 ++++++++- homeassistant/auth/permissions/models.py | 17 ++++++ homeassistant/helpers/entity_registry.py | 6 ++ tests/auth/permissions/test_entities.py | 60 +++++++++++++++---- .../auth/permissions/test_system_policies.py | 4 +- tests/auth/test_models.py | 13 +++- tests/common.py | 4 +- tests/helpers/test_service.py | 6 +- 11 files changed, 143 insertions(+), 27 deletions(-) create mode 100644 homeassistant/auth/permissions/models.py diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index bad1bdcf913e6..c6078e03f63a7 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -1,4 +1,5 @@ """Storage for auth models.""" +import asyncio from collections import OrderedDict from datetime import timedelta import hmac @@ -11,7 +12,7 @@ from . import models from .const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY -from .permissions import system_policies +from .permissions import PermissionLookup, system_policies from .permissions.types import PolicyType # noqa: F401 STORAGE_VERSION = 1 @@ -34,6 +35,7 @@ def __init__(self, hass: HomeAssistant) -> None: self.hass = hass self._users = None # type: Optional[Dict[str, models.User]] self._groups = None # type: Optional[Dict[str, models.Group]] + self._perm_lookup = None # type: Optional[PermissionLookup] self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY, private=True) @@ -94,6 +96,7 @@ async def async_create_user( # Until we get group management, we just put everyone in the # same group. 'groups': groups, + 'perm_lookup': self._perm_lookup, } # type: Dict[str, Any] if is_owner is not None: @@ -269,13 +272,18 @@ def async_log_refresh_token_usage( async def _async_load(self) -> None: """Load the users.""" - data = await self._store.async_load() + [ent_reg, data] = await asyncio.gather( + self.hass.helpers.entity_registry.async_get_registry(), + self._store.async_load(), + ) # Make sure that we're not overriding data if 2 loads happened at the # same time if self._users is not None: return + self._perm_lookup = perm_lookup = PermissionLookup(ent_reg) + if data is None: self._set_defaults() return @@ -374,6 +382,7 @@ async def _async_load(self) -> None: is_owner=user_dict['is_owner'], is_active=user_dict['is_active'], system_generated=user_dict['system_generated'], + perm_lookup=perm_lookup, ) for cred_dict in data['credentials']: diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index 4b192c35898e1..588d80047bedd 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -31,6 +31,9 @@ class User: """A user.""" name = attr.ib(type=str) # type: Optional[str] + perm_lookup = attr.ib( + type=perm_mdl.PermissionLookup, cmp=False, + ) # type: perm_mdl.PermissionLookup id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex) is_owner = attr.ib(type=bool, default=False) is_active = attr.ib(type=bool, default=False) @@ -66,7 +69,8 @@ def permissions(self) -> perm_mdl.AbstractPermissions: self._permissions = perm_mdl.PolicyPermissions( perm_mdl.merge_policies([ - group.policy for group in self.groups])) + group.policy for group in self.groups]), + self.perm_lookup) return self._permissions diff --git a/homeassistant/auth/permissions/__init__.py b/homeassistant/auth/permissions/__init__.py index 9113f2b03a956..63e76dd249690 100644 --- a/homeassistant/auth/permissions/__init__.py +++ b/homeassistant/auth/permissions/__init__.py @@ -1,15 +1,18 @@ """Permissions for Home Assistant.""" import logging from typing import ( # noqa: F401 - cast, Any, Callable, Dict, List, Mapping, Set, Tuple, Union) + cast, Any, Callable, Dict, List, Mapping, Set, Tuple, Union, + TYPE_CHECKING) import voluptuous as vol from .const import CAT_ENTITIES +from .models import PermissionLookup from .types import PolicyType from .entities import ENTITY_POLICY_SCHEMA, compile_entities from .merge import merge_policies # noqa + POLICY_SCHEMA = vol.Schema({ vol.Optional(CAT_ENTITIES): ENTITY_POLICY_SCHEMA }) @@ -39,13 +42,16 @@ def check_entity(self, entity_id: str, key: str) -> bool: class PolicyPermissions(AbstractPermissions): """Handle permissions.""" - def __init__(self, policy: PolicyType) -> None: + def __init__(self, policy: PolicyType, + perm_lookup: PermissionLookup) -> None: """Initialize the permission class.""" self._policy = policy + self._perm_lookup = perm_lookup def _entity_func(self) -> Callable[[str, str], bool]: """Return a function that can test entity access.""" - return compile_entities(self._policy.get(CAT_ENTITIES)) + return compile_entities(self._policy.get(CAT_ENTITIES), + self._perm_lookup) def __eq__(self, other: Any) -> bool: """Equals check.""" diff --git a/homeassistant/auth/permissions/entities.py b/homeassistant/auth/permissions/entities.py index 59bba468a5983..0073c9526488e 100644 --- a/homeassistant/auth/permissions/entities.py +++ b/homeassistant/auth/permissions/entities.py @@ -5,6 +5,7 @@ import voluptuous as vol from .const import SUBCAT_ALL, POLICY_READ, POLICY_CONTROL, POLICY_EDIT +from .models import PermissionLookup from .types import CategoryType, ValueType SINGLE_ENTITY_SCHEMA = vol.Any(True, vol.Schema({ @@ -14,6 +15,7 @@ })) ENTITY_DOMAINS = 'domains' +ENTITY_DEVICE_IDS = 'device_ids' ENTITY_ENTITY_IDS = 'entity_ids' ENTITY_VALUES_SCHEMA = vol.Any(True, vol.Schema({ @@ -22,6 +24,7 @@ ENTITY_POLICY_SCHEMA = vol.Any(True, vol.Schema({ vol.Optional(SUBCAT_ALL): SINGLE_ENTITY_SCHEMA, + vol.Optional(ENTITY_DEVICE_IDS): ENTITY_VALUES_SCHEMA, vol.Optional(ENTITY_DOMAINS): ENTITY_VALUES_SCHEMA, vol.Optional(ENTITY_ENTITY_IDS): ENTITY_VALUES_SCHEMA, })) @@ -36,7 +39,7 @@ def _entity_allowed(schema: ValueType, key: str) \ return schema.get(key) -def compile_entities(policy: CategoryType) \ +def compile_entities(policy: CategoryType, perm_lookup: PermissionLookup) \ -> Callable[[str, str], bool]: """Compile policy into a function that tests policy.""" # None, Empty Dict, False @@ -57,6 +60,7 @@ def apply_policy_allow_all(entity_id: str, key: str) -> bool: assert isinstance(policy, dict) domains = policy.get(ENTITY_DOMAINS) + device_ids = policy.get(ENTITY_DEVICE_IDS) entity_ids = policy.get(ENTITY_ENTITY_IDS) all_entities = policy.get(SUBCAT_ALL) @@ -84,6 +88,29 @@ def allowed_entity_id_dict(entity_id: str, key: str) \ funcs.append(allowed_entity_id_dict) + if isinstance(device_ids, bool): + def allowed_device_id_bool(entity_id: str, key: str) \ + -> Union[None, bool]: + """Test if allowed device_id.""" + return device_ids + + funcs.append(allowed_device_id_bool) + + elif device_ids is not None: + def allowed_device_id_dict(entity_id: str, key: str) \ + -> Union[None, bool]: + """Test if allowed device_id.""" + entity_entry = perm_lookup.entity_registry.async_get(entity_id) + + if entity_entry is None or entity_entry.device_id is None: + return None + + return _entity_allowed( + device_ids.get(entity_entry.device_id), key # type: ignore + ) + + funcs.append(allowed_device_id_dict) + if isinstance(domains, bool): def allowed_domain_bool(entity_id: str, key: str) \ -> Union[None, bool]: diff --git a/homeassistant/auth/permissions/models.py b/homeassistant/auth/permissions/models.py new file mode 100644 index 0000000000000..7ad7d5521c5f9 --- /dev/null +++ b/homeassistant/auth/permissions/models.py @@ -0,0 +1,17 @@ +"""Models for permissions.""" +from typing import TYPE_CHECKING + +import attr + +if TYPE_CHECKING: + # pylint: disable=unused-import + from homeassistant.helpers import ( # noqa + entity_registry as ent_reg, + ) + + +@attr.s(slots=True) +class PermissionLookup: + """Class to hold data for permission lookups.""" + + entity_registry = attr.ib(type='ent_reg.EntityRegistry') diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index c40d14652ad26..57c8bcf0af884 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -10,6 +10,7 @@ from collections import OrderedDict from itertools import chain import logging +from typing import Optional import weakref import attr @@ -85,6 +86,11 @@ def async_is_registered(self, entity_id): """Check if an entity_id is currently registered.""" return entity_id in self.entities + @callback + def async_get(self, entity_id: str) -> Optional[RegistryEntry]: + """Get EntityEntry for an entity_id.""" + return self.entities.get(entity_id) + @callback def async_get_entity_id(self, domain: str, platform: str, unique_id: str): """Check if an entity_id is currently registered.""" diff --git a/tests/auth/permissions/test_entities.py b/tests/auth/permissions/test_entities.py index 40de5ca73343b..1fd70668f8b67 100644 --- a/tests/auth/permissions/test_entities.py +++ b/tests/auth/permissions/test_entities.py @@ -4,12 +4,16 @@ from homeassistant.auth.permissions.entities import ( compile_entities, ENTITY_POLICY_SCHEMA) +from homeassistant.auth.permissions.models import PermissionLookup +from homeassistant.helpers.entity_registry import RegistryEntry + +from tests.common import mock_registry def test_entities_none(): """Test entity ID policy.""" policy = None - compiled = compile_entities(policy) + compiled = compile_entities(policy, None) assert compiled('light.kitchen', 'read') is False @@ -17,7 +21,7 @@ def test_entities_empty(): """Test entity ID policy.""" policy = {} ENTITY_POLICY_SCHEMA(policy) - compiled = compile_entities(policy) + compiled = compile_entities(policy, None) assert compiled('light.kitchen', 'read') is False @@ -32,7 +36,7 @@ def test_entities_true(): """Test entity ID policy.""" policy = True ENTITY_POLICY_SCHEMA(policy) - compiled = compile_entities(policy) + compiled = compile_entities(policy, None) assert compiled('light.kitchen', 'read') is True @@ -42,7 +46,7 @@ def test_entities_domains_true(): 'domains': True } ENTITY_POLICY_SCHEMA(policy) - compiled = compile_entities(policy) + compiled = compile_entities(policy, None) assert compiled('light.kitchen', 'read') is True @@ -54,7 +58,7 @@ def test_entities_domains_domain_true(): } } ENTITY_POLICY_SCHEMA(policy) - compiled = compile_entities(policy) + compiled = compile_entities(policy, None) assert compiled('light.kitchen', 'read') is True assert compiled('switch.kitchen', 'read') is False @@ -76,7 +80,7 @@ def test_entities_entity_ids_true(): 'entity_ids': True } ENTITY_POLICY_SCHEMA(policy) - compiled = compile_entities(policy) + compiled = compile_entities(policy, None) assert compiled('light.kitchen', 'read') is True @@ -97,7 +101,7 @@ def test_entities_entity_ids_entity_id_true(): } } ENTITY_POLICY_SCHEMA(policy) - compiled = compile_entities(policy) + compiled = compile_entities(policy, None) assert compiled('light.kitchen', 'read') is True assert compiled('switch.kitchen', 'read') is False @@ -123,7 +127,7 @@ def test_entities_control_only(): } } ENTITY_POLICY_SCHEMA(policy) - compiled = compile_entities(policy) + compiled = compile_entities(policy, None) assert compiled('light.kitchen', 'read') is True assert compiled('light.kitchen', 'control') is False assert compiled('light.kitchen', 'edit') is False @@ -140,7 +144,7 @@ def test_entities_read_control(): } } ENTITY_POLICY_SCHEMA(policy) - compiled = compile_entities(policy) + compiled = compile_entities(policy, None) assert compiled('light.kitchen', 'read') is True assert compiled('light.kitchen', 'control') is True assert compiled('light.kitchen', 'edit') is False @@ -152,7 +156,7 @@ def test_entities_all_allow(): 'all': True } ENTITY_POLICY_SCHEMA(policy) - compiled = compile_entities(policy) + compiled = compile_entities(policy, None) assert compiled('light.kitchen', 'read') is True assert compiled('light.kitchen', 'control') is True assert compiled('switch.kitchen', 'read') is True @@ -166,7 +170,7 @@ def test_entities_all_read(): } } ENTITY_POLICY_SCHEMA(policy) - compiled = compile_entities(policy) + compiled = compile_entities(policy, None) assert compiled('light.kitchen', 'read') is True assert compiled('light.kitchen', 'control') is False assert compiled('switch.kitchen', 'read') is True @@ -180,8 +184,40 @@ def test_entities_all_control(): } } ENTITY_POLICY_SCHEMA(policy) - compiled = compile_entities(policy) + compiled = compile_entities(policy, None) assert compiled('light.kitchen', 'read') is False assert compiled('light.kitchen', 'control') is True assert compiled('switch.kitchen', 'read') is False assert compiled('switch.kitchen', 'control') is True + + +def test_entities_device_id_boolean(hass): + """Test entity ID policy applying control on device id.""" + registry = mock_registry(hass, { + 'test_domain.allowed': RegistryEntry( + entity_id='test_domain.allowed', + unique_id='1234', + platform='test_platform', + device_id='mock-allowed-dev-id' + ), + 'test_domain.not_allowed': RegistryEntry( + entity_id='test_domain.not_allowed', + unique_id='5678', + platform='test_platform', + device_id='mock-not-allowed-dev-id' + ), + }) + + policy = { + 'device_ids': { + 'mock-allowed-dev-id': { + 'read': True, + } + } + } + ENTITY_POLICY_SCHEMA(policy) + compiled = compile_entities(policy, PermissionLookup(registry)) + assert compiled('test_domain.allowed', 'read') is True + assert compiled('test_domain.allowed', 'control') is False + assert compiled('test_domain.not_allowed', 'read') is False + assert compiled('test_domain.not_allowed', 'control') is False diff --git a/tests/auth/permissions/test_system_policies.py b/tests/auth/permissions/test_system_policies.py index ba6fe21414632..f6a68f0865a0c 100644 --- a/tests/auth/permissions/test_system_policies.py +++ b/tests/auth/permissions/test_system_policies.py @@ -8,7 +8,7 @@ def test_admin_policy(): # Make sure it's valid POLICY_SCHEMA(system_policies.ADMIN_POLICY) - perms = PolicyPermissions(system_policies.ADMIN_POLICY) + perms = PolicyPermissions(system_policies.ADMIN_POLICY, None) assert perms.check_entity('light.kitchen', 'read') assert perms.check_entity('light.kitchen', 'control') assert perms.check_entity('light.kitchen', 'edit') @@ -19,7 +19,7 @@ def test_read_only_policy(): # Make sure it's valid POLICY_SCHEMA(system_policies.READ_ONLY_POLICY) - perms = PolicyPermissions(system_policies.READ_ONLY_POLICY) + perms = PolicyPermissions(system_policies.READ_ONLY_POLICY, None) assert perms.check_entity('light.kitchen', 'read') assert not perms.check_entity('light.kitchen', 'control') assert not perms.check_entity('light.kitchen', 'edit') diff --git a/tests/auth/test_models.py b/tests/auth/test_models.py index b02111e8d02aa..329124bc979cc 100644 --- a/tests/auth/test_models.py +++ b/tests/auth/test_models.py @@ -5,7 +5,12 @@ def test_owner_fetching_owner_permissions(): """Test we fetch the owner permissions for an owner user.""" group = models.Group(name="Test Group", policy={}) - owner = models.User(name="Test User", groups=[group], is_owner=True) + owner = models.User( + name="Test User", + perm_lookup=None, + groups=[group], + is_owner=True + ) assert owner.permissions is permissions.OwnerPermissions @@ -25,7 +30,11 @@ def test_permissions_merged(): } } }) - user = models.User(name="Test User", groups=[group, group2]) + user = models.User( + name="Test User", + perm_lookup=None, + groups=[group, group2] + ) # Make sure we cache instance assert user.permissions is user.permissions diff --git a/tests/common.py b/tests/common.py index db7ce6e3a1722..d7b28b3039a27 100644 --- a/tests/common.py +++ b/tests/common.py @@ -384,6 +384,7 @@ def __init__(self, id=None, is_owner=False, is_active=True, 'name': name, 'system_generated': system_generated, 'groups': groups or [], + 'perm_lookup': None, } if id is not None: kwargs['id'] = id @@ -401,7 +402,8 @@ def add_to_auth_manager(self, auth_mgr): def mock_policy(self, policy): """Mock a policy for a user.""" - self._permissions = auth_permissions.PolicyPermissions(policy) + self._permissions = auth_permissions.PolicyPermissions( + policy, self.perm_lookup) async def register_auth_provider(hass, config): diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index a4e9a5719434f..8fca7df69c134 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -232,7 +232,7 @@ async def test_call_context_target_all(hass, mock_service_platform_call, 'light.kitchen': True } } - })))): + }, None)))): await service.entity_service_call(hass, [ Mock(entities=mock_entities) ], Mock(), ha.ServiceCall('test_domain', 'test_service', @@ -253,7 +253,7 @@ async def test_call_context_target_specific(hass, mock_service_platform_call, 'light.kitchen': True } } - })))): + }, None)))): await service.entity_service_call(hass, [ Mock(entities=mock_entities) ], Mock(), ha.ServiceCall('test_domain', 'test_service', { @@ -271,7 +271,7 @@ async def test_call_context_target_specific_no_auth( with pytest.raises(exceptions.Unauthorized) as err: with patch('homeassistant.auth.AuthManager.async_get_user', return_value=mock_coro(Mock( - permissions=PolicyPermissions({})))): + permissions=PolicyPermissions({}, None)))): await service.entity_service_call(hass, [ Mock(entities=mock_entities) ], Mock(), ha.ServiceCall('test_domain', 'test_service', { From a785a1ab5d16ae86e79afd70ad64f967d4fb9714 Mon Sep 17 00:00:00 2001 From: Sean Dague Date: Wed, 5 Dec 2018 05:42:27 -0500 Subject: [PATCH 265/325] update mychevy to 1.0.1 After six months the chevy website finally has been reimplemented to something that seems to work and is stable. The backend library has been updated thanks to upstream help, and now is working again. --- homeassistant/components/mychevy.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mychevy.py b/homeassistant/components/mychevy.py index baac86f4bf197..685bbb90cbb98 100644 --- a/homeassistant/components/mychevy.py +++ b/homeassistant/components/mychevy.py @@ -16,7 +16,7 @@ from homeassistant.helpers import discovery from homeassistant.util import Throttle -REQUIREMENTS = ["mychevy==0.4.0"] +REQUIREMENTS = ["mychevy==1.0.1"] DOMAIN = 'mychevy' UPDATE_TOPIC = DOMAIN diff --git a/requirements_all.txt b/requirements_all.txt index 56f1dbcb70967..24cba0199661b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -655,7 +655,7 @@ motorparts==1.0.2 mutagen==1.41.1 # homeassistant.components.mychevy -mychevy==0.4.0 +mychevy==1.0.1 # homeassistant.components.mycroft mycroftapi==2.0 From 8c0b50b5dfd11000edcf0f90fcc70d5641fc65d3 Mon Sep 17 00:00:00 2001 From: Sean Dague Date: Wed, 5 Dec 2018 07:03:27 -0500 Subject: [PATCH 266/325] Bump waterfurnace to 1.0 This bumps to the new version of the waterfurnace API. In the new version the unit id is no longer manually set by the user, instead it is retrieved from the service after login. This is less error prone as it turns out discovering the correct unit id is hard from an end user perspective. Breaking change on the config, as the unit parameter is removed from config. However I believe the number of users is very low (possibly only 2), so adaptation should be easy. --- homeassistant/components/waterfurnace.py | 11 ++++------- requirements_all.txt | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/waterfurnace.py b/homeassistant/components/waterfurnace.py index e9024131af84d..0947afea1414e 100644 --- a/homeassistant/components/waterfurnace.py +++ b/homeassistant/components/waterfurnace.py @@ -19,13 +19,12 @@ from homeassistant.helpers import discovery -REQUIREMENTS = ["waterfurnace==0.7.0"] +REQUIREMENTS = ["waterfurnace==1.0.0"] _LOGGER = logging.getLogger(__name__) DOMAIN = "waterfurnace" UPDATE_TOPIC = DOMAIN + "_update" -CONF_UNIT = "unit" SCAN_INTERVAL = timedelta(seconds=10) ERROR_INTERVAL = timedelta(seconds=300) MAX_FAILS = 10 @@ -36,8 +35,7 @@ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_UNIT): cv.string, + vol.Required(CONF_USERNAME): cv.string }), }, extra=vol.ALLOW_EXTRA) @@ -49,9 +47,8 @@ def setup(hass, base_config): username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) - unit = config.get(CONF_UNIT) - wfconn = wf.WaterFurnace(username, password, unit) + wfconn = wf.WaterFurnace(username, password) # NOTE(sdague): login will throw an exception if this doesn't # work, which will abort the setup. try: @@ -83,7 +80,7 @@ def __init__(self, hass, client): super().__init__() self.hass = hass self.client = client - self.unit = client.unit + self.unit = self.client.gwid self.data = None self._shutdown = False self._fails = 0 diff --git a/requirements_all.txt b/requirements_all.txt index 56f1dbcb70967..ad5f2d9245d1d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1617,7 +1617,7 @@ warrant==0.6.1 watchdog==0.8.3 # homeassistant.components.waterfurnace -waterfurnace==0.7.0 +waterfurnace==1.0.0 # homeassistant.components.media_player.gpmdp websocket-client==0.37.0 From 850caef5c107a9dc2b5ef92f352c9c35e59c3692 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 5 Dec 2018 14:27:35 +0100 Subject: [PATCH 267/325] Add states to panels (#19026) * Add states to panels * Line too long * remove extra urls for states * Update __init__.py --- homeassistant/components/frontend/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index f8f7cb3b1edc8..408f19436cece 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -250,7 +250,8 @@ def async_finalize_panel(panel): await asyncio.wait( [async_register_built_in_panel(hass, panel) for panel in ( 'dev-event', 'dev-info', 'dev-service', 'dev-state', - 'dev-template', 'dev-mqtt', 'kiosk', 'lovelace', 'profile')], + 'dev-template', 'dev-mqtt', 'kiosk', 'lovelace', + 'states', 'profile')], loop=hass.loop) hass.data[DATA_FINALIZE_PANEL] = async_finalize_panel @@ -362,7 +363,6 @@ class IndexView(HomeAssistantView): url = '/' name = 'frontend:index' requires_auth = False - extra_urls = ['/states', '/states/{extra}'] def __init__(self, repo_path, js_option): """Initialize the frontend view.""" From 0e9e253b7b921d4213c6ea3d1f9d3bce10fb47bd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 5 Dec 2018 14:43:29 +0100 Subject: [PATCH 268/325] Fix CI by pinning IDNA (#19038) * Fix CI * Actual fix by @sdague --- homeassistant/package_constraints.txt | 1 + requirements_all.txt | 1 + setup.py | 2 ++ 3 files changed, 4 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 481cd9da3ea58..7236380d42a7a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,6 +4,7 @@ async_timeout==3.0.1 attrs==18.2.0 bcrypt==3.1.4 certifi>=2018.04.16 +idna==2.7 jinja2>=2.10 PyJWT==1.6.4 cryptography==2.3.1 diff --git a/requirements_all.txt b/requirements_all.txt index 56f1dbcb70967..d252f75caaf2b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -5,6 +5,7 @@ async_timeout==3.0.1 attrs==18.2.0 bcrypt==3.1.4 certifi>=2018.04.16 +idna==2.7 jinja2>=2.10 PyJWT==1.6.4 cryptography==2.3.1 diff --git a/setup.py b/setup.py index 68c830190abb4..f4da5411ed58b 100755 --- a/setup.py +++ b/setup.py @@ -38,6 +38,8 @@ 'attrs==18.2.0', 'bcrypt==3.1.4', 'certifi>=2018.04.16', + # Dec 5, 2018: Idna released 2.8, requests caps idna at <2.8, CI fails + 'idna==2.7', 'jinja2>=2.10', 'PyJWT==1.6.4', # PyJWT has loose dependency. We want the latest one. From 12f222b5e33c70ec8129f538370741c2346fda1d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 5 Dec 2018 14:45:30 +0100 Subject: [PATCH 269/325] Don't wait for answer for webhook register (#19025) --- homeassistant/components/cloud/cloudhooks.py | 2 +- homeassistant/components/cloud/iot.py | 18 +++++++- tests/components/cloud/test_iot.py | 48 ++++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cloud/cloudhooks.py b/homeassistant/components/cloud/cloudhooks.py index fdf7bb2a12e80..3c638d2916637 100644 --- a/homeassistant/components/cloud/cloudhooks.py +++ b/homeassistant/components/cloud/cloudhooks.py @@ -18,7 +18,7 @@ async def async_publish_cloudhooks(self): await self.cloud.iot.async_send_message('webhook-register', { 'cloudhook_ids': [info['cloudhook_id'] for info in cloudhooks.values()] - }) + }, expect_answer=False) async def async_create(self, webhook_id): """Create a cloud webhook.""" diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index 3c7275afa7a47..7d633a4b2ac7a 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -28,6 +28,10 @@ class UnknownHandler(Exception): """Exception raised when trying to handle unknown handler.""" +class NotConnected(Exception): + """Exception raised when trying to handle unknown handler.""" + + class ErrorMessage(Exception): """Exception raised when there was error handling message in the cloud.""" @@ -116,10 +120,17 @@ def _handle_hass_stop(event): if remove_hass_stop_listener is not None: remove_hass_stop_listener() - async def async_send_message(self, handler, payload): + async def async_send_message(self, handler, payload, + expect_answer=True): """Send a message.""" + if self.state != STATE_CONNECTED: + raise NotConnected + msgid = uuid.uuid4().hex - self._response_handler[msgid] = asyncio.Future() + + if expect_answer: + fut = self._response_handler[msgid] = asyncio.Future() + message = { 'msgid': msgid, 'handler': handler, @@ -130,6 +141,9 @@ async def async_send_message(self, handler, payload): pprint.pformat(message)) await self.client.send_json(message) + if expect_answer: + return await fut + @asyncio.coroutine def _handle_connection(self): """Connect to the IoT broker.""" diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index 10488779dd83e..b11de7da4e409 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -451,3 +451,51 @@ async def handler(hass, webhook_id, request): assert await received[0].json() == { 'hello': 'world' } + + +async def test_send_message_not_connected(mock_cloud): + """Test sending a message that expects no answer.""" + cloud_iot = iot.CloudIoT(mock_cloud) + + with pytest.raises(iot.NotConnected): + await cloud_iot.async_send_message('webhook', {'msg': 'yo'}) + + +async def test_send_message_no_answer(mock_cloud): + """Test sending a message that expects no answer.""" + cloud_iot = iot.CloudIoT(mock_cloud) + cloud_iot.state = iot.STATE_CONNECTED + cloud_iot.client = MagicMock(send_json=MagicMock(return_value=mock_coro())) + + await cloud_iot.async_send_message('webhook', {'msg': 'yo'}, + expect_answer=False) + assert not cloud_iot._response_handler + assert len(cloud_iot.client.send_json.mock_calls) == 1 + msg = cloud_iot.client.send_json.mock_calls[0][1][0] + assert msg['handler'] == 'webhook' + assert msg['payload'] == {'msg': 'yo'} + + +async def test_send_message_answer(loop, mock_cloud): + """Test sending a message that expects no answer.""" + cloud_iot = iot.CloudIoT(mock_cloud) + cloud_iot.state = iot.STATE_CONNECTED + cloud_iot.client = MagicMock(send_json=MagicMock(return_value=mock_coro())) + + uuid = 5 + + with patch('homeassistant.components.cloud.iot.uuid.uuid4', + return_value=MagicMock(hex=uuid)): + send_task = loop.create_task(cloud_iot.async_send_message( + 'webhook', {'msg': 'yo'})) + await asyncio.sleep(0) + + assert len(cloud_iot.client.send_json.mock_calls) == 1 + assert len(cloud_iot._response_handler) == 1 + msg = cloud_iot.client.send_json.mock_calls[0][1][0] + assert msg['handler'] == 'webhook' + assert msg['payload'] == {'msg': 'yo'} + + cloud_iot._response_handler[uuid].set_result({'response': True}) + response = await send_task + assert response == {'response': True} From 69fd3aa856cb0b283d22d6f54baee721cffe2aa7 Mon Sep 17 00:00:00 2001 From: emontnemery Date: Wed, 5 Dec 2018 14:46:37 +0100 Subject: [PATCH 270/325] Small refactoring of MQTT light (#19009) --- .../components/light/mqtt/schema_basic.py | 95 ++++++++++--------- .../components/light/mqtt/schema_json.py | 63 +++++------- .../components/light/mqtt/schema_template.py | 27 +++--- 3 files changed, 84 insertions(+), 101 deletions(-) diff --git a/homeassistant/components/light/mqtt/schema_basic.py b/homeassistant/components/light/mqtt/schema_basic.py index 4c648b5ddaead..74f3dbdec91af 100644 --- a/homeassistant/components/light/mqtt/schema_basic.py +++ b/homeassistant/components/light/mqtt/schema_basic.py @@ -131,11 +131,7 @@ def __init__(self, config, discovery_hash): self._white_value = None self._supported_features = 0 - self._name = None - self._effect_list = None self._topic = None - self._qos = None - self._retain = None self._payload = None self._templates = None self._optimistic = False @@ -146,9 +142,6 @@ def __init__(self, config, discovery_hash): self._optimistic_hs = False self._optimistic_white_value = False self._optimistic_xy = False - self._brightness_scale = None - self._white_value_scale = None - self._on_command_type = None self._unique_id = config.get(CONF_UNIQUE_ID) # Load config @@ -157,8 +150,9 @@ def __init__(self, config, discovery_hash): availability_topic = config.get(CONF_AVAILABILITY_TOPIC) payload_available = config.get(CONF_PAYLOAD_AVAILABLE) payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) + qos = config.get(CONF_QOS) - MqttAvailability.__init__(self, availability_topic, self._qos, + MqttAvailability.__init__(self, availability_topic, qos, payload_available, payload_not_available) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) @@ -178,8 +172,8 @@ async def discovery_update(self, discovery_payload): def _setup_from_config(self, config): """(Re)Setup the entity.""" - self._name = config.get(CONF_NAME) - self._effect_list = config.get(CONF_EFFECT_LIST) + self._config = config + topic = { key: config.get(key) for key in ( CONF_BRIGHTNESS_COMMAND_TOPIC, @@ -201,8 +195,6 @@ def _setup_from_config(self, config): ) } self._topic = topic - self._qos = config.get(CONF_QOS) - self._retain = config.get(CONF_RETAIN) self._payload = { 'on': config.get(CONF_PAYLOAD_ON), 'off': config.get(CONF_PAYLOAD_OFF), @@ -240,10 +232,6 @@ def _setup_from_config(self, config): self._optimistic_xy = \ optimistic or topic[CONF_XY_STATE_TOPIC] is None - self._brightness_scale = config.get(CONF_BRIGHTNESS_SCALE) - self._white_value_scale = config.get(CONF_WHITE_VALUE_SCALE) - self._on_command_type = config.get(CONF_ON_COMMAND_TYPE) - self._supported_features = 0 self._supported_features |= ( topic[CONF_RGB_COMMAND_TOPIC] is not None and @@ -296,7 +284,7 @@ def state_received(topic, payload, qos): topics[CONF_STATE_TOPIC] = { 'topic': self._topic[CONF_STATE_TOPIC], 'msg_callback': state_received, - 'qos': self._qos} + 'qos': self._config.get(CONF_QOS)} elif self._optimistic and last_state: self._state = last_state.state == STATE_ON @@ -310,7 +298,8 @@ def brightness_received(topic, payload, qos): return device_value = float(payload) - percent_bright = device_value / self._brightness_scale + percent_bright = \ + device_value / self._config.get(CONF_BRIGHTNESS_SCALE) self._brightness = int(percent_bright * 255) self.async_schedule_update_ha_state() @@ -318,7 +307,7 @@ def brightness_received(topic, payload, qos): topics[CONF_BRIGHTNESS_STATE_TOPIC] = { 'topic': self._topic[CONF_BRIGHTNESS_STATE_TOPIC], 'msg_callback': brightness_received, - 'qos': self._qos} + 'qos': self._config.get(CONF_QOS)} self._brightness = 255 elif self._optimistic_brightness and last_state\ and last_state.attributes.get(ATTR_BRIGHTNESS): @@ -348,7 +337,7 @@ def rgb_received(topic, payload, qos): topics[CONF_RGB_STATE_TOPIC] = { 'topic': self._topic[CONF_RGB_STATE_TOPIC], 'msg_callback': rgb_received, - 'qos': self._qos} + 'qos': self._config.get(CONF_QOS)} self._hs = (0, 0) if self._optimistic_rgb and last_state\ and last_state.attributes.get(ATTR_HS_COLOR): @@ -372,7 +361,7 @@ def color_temp_received(topic, payload, qos): topics[CONF_COLOR_TEMP_STATE_TOPIC] = { 'topic': self._topic[CONF_COLOR_TEMP_STATE_TOPIC], 'msg_callback': color_temp_received, - 'qos': self._qos} + 'qos': self._config.get(CONF_QOS)} self._color_temp = 150 if self._optimistic_color_temp and last_state\ and last_state.attributes.get(ATTR_COLOR_TEMP): @@ -397,7 +386,7 @@ def effect_received(topic, payload, qos): topics[CONF_EFFECT_STATE_TOPIC] = { 'topic': self._topic[CONF_EFFECT_STATE_TOPIC], 'msg_callback': effect_received, - 'qos': self._qos} + 'qos': self._config.get(CONF_QOS)} self._effect = 'none' if self._optimistic_effect and last_state\ and last_state.attributes.get(ATTR_EFFECT): @@ -427,7 +416,7 @@ def hs_received(topic, payload, qos): topics[CONF_HS_STATE_TOPIC] = { 'topic': self._topic[CONF_HS_STATE_TOPIC], 'msg_callback': hs_received, - 'qos': self._qos} + 'qos': self._config.get(CONF_QOS)} self._hs = (0, 0) if self._optimistic_hs and last_state\ and last_state.attributes.get(ATTR_HS_COLOR): @@ -445,7 +434,8 @@ def white_value_received(topic, payload, qos): return device_value = float(payload) - percent_white = device_value / self._white_value_scale + percent_white = \ + device_value / self._config.get(CONF_WHITE_VALUE_SCALE) self._white_value = int(percent_white * 255) self.async_schedule_update_ha_state() @@ -453,7 +443,7 @@ def white_value_received(topic, payload, qos): topics[CONF_WHITE_VALUE_STATE_TOPIC] = { 'topic': self._topic[CONF_WHITE_VALUE_STATE_TOPIC], 'msg_callback': white_value_received, - 'qos': self._qos} + 'qos': self._config.get(CONF_QOS)} self._white_value = 255 elif self._optimistic_white_value and last_state\ and last_state.attributes.get(ATTR_WHITE_VALUE): @@ -480,7 +470,7 @@ def xy_received(topic, payload, qos): topics[CONF_XY_STATE_TOPIC] = { 'topic': self._topic[CONF_XY_STATE_TOPIC], 'msg_callback': xy_received, - 'qos': self._qos} + 'qos': self._config.get(CONF_QOS)} self._hs = (0, 0) if self._optimistic_xy and last_state\ and last_state.attributes.get(ATTR_HS_COLOR): @@ -525,7 +515,7 @@ def should_poll(self): @property def name(self): """Return the name of the device if any.""" - return self._name + return self._config.get(CONF_NAME) @property def unique_id(self): @@ -545,7 +535,7 @@ def assumed_state(self): @property def effect_list(self): """Return the list of supported effects.""" - return self._effect_list + return self._config.get(CONF_EFFECT_LIST) @property def effect(self): @@ -563,17 +553,19 @@ async def async_turn_on(self, **kwargs): This method is a coroutine. """ should_update = False + on_command_type = self._config.get(CONF_ON_COMMAND_TYPE) - if self._on_command_type == 'first': + if on_command_type == 'first': mqtt.async_publish( self.hass, self._topic[CONF_COMMAND_TOPIC], - self._payload['on'], self._qos, self._retain) + self._payload['on'], self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) should_update = True # If brightness is being used instead of an on command, make sure # there is a brightness input. Either set the brightness to our # saved value or the maximum value if this is the first call - elif self._on_command_type == 'brightness': + elif on_command_type == 'brightness': if ATTR_BRIGHTNESS not in kwargs: kwargs[ATTR_BRIGHTNESS] = self._brightness if \ self._brightness else 255 @@ -605,7 +597,8 @@ async def async_turn_on(self, **kwargs): mqtt.async_publish( self.hass, self._topic[CONF_RGB_COMMAND_TOPIC], - rgb_color_str, self._qos, self._retain) + rgb_color_str, self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic_rgb: self._hs = kwargs[ATTR_HS_COLOR] @@ -617,8 +610,8 @@ async def async_turn_on(self, **kwargs): hs_color = kwargs[ATTR_HS_COLOR] mqtt.async_publish( self.hass, self._topic[CONF_HS_COMMAND_TOPIC], - '{},{}'.format(*hs_color), self._qos, - self._retain) + '{},{}'.format(*hs_color), self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic_hs: self._hs = kwargs[ATTR_HS_COLOR] @@ -630,8 +623,8 @@ async def async_turn_on(self, **kwargs): xy_color = color_util.color_hs_to_xy(*kwargs[ATTR_HS_COLOR]) mqtt.async_publish( self.hass, self._topic[CONF_XY_COMMAND_TOPIC], - '{},{}'.format(*xy_color), self._qos, - self._retain) + '{},{}'.format(*xy_color), self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic_xy: self._hs = kwargs[ATTR_HS_COLOR] @@ -640,10 +633,12 @@ async def async_turn_on(self, **kwargs): if ATTR_BRIGHTNESS in kwargs and \ self._topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is not None: percent_bright = float(kwargs[ATTR_BRIGHTNESS]) / 255 - device_brightness = int(percent_bright * self._brightness_scale) + device_brightness = \ + int(percent_bright * self._config.get(CONF_BRIGHTNESS_SCALE)) mqtt.async_publish( self.hass, self._topic[CONF_BRIGHTNESS_COMMAND_TOPIC], - device_brightness, self._qos, self._retain) + device_brightness, self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic_brightness: self._brightness = kwargs[ATTR_BRIGHTNESS] @@ -664,7 +659,8 @@ async def async_turn_on(self, **kwargs): mqtt.async_publish( self.hass, self._topic[CONF_RGB_COMMAND_TOPIC], - rgb_color_str, self._qos, self._retain) + rgb_color_str, self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic_brightness: self._brightness = kwargs[ATTR_BRIGHTNESS] @@ -675,7 +671,8 @@ async def async_turn_on(self, **kwargs): color_temp = int(kwargs[ATTR_COLOR_TEMP]) mqtt.async_publish( self.hass, self._topic[CONF_COLOR_TEMP_COMMAND_TOPIC], - color_temp, self._qos, self._retain) + color_temp, self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic_color_temp: self._color_temp = kwargs[ATTR_COLOR_TEMP] @@ -684,10 +681,11 @@ async def async_turn_on(self, **kwargs): if ATTR_EFFECT in kwargs and \ self._topic[CONF_EFFECT_COMMAND_TOPIC] is not None: effect = kwargs[ATTR_EFFECT] - if effect in self._effect_list: + if effect in self._config.get(CONF_EFFECT_LIST): mqtt.async_publish( self.hass, self._topic[CONF_EFFECT_COMMAND_TOPIC], - effect, self._qos, self._retain) + effect, self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic_effect: self._effect = kwargs[ATTR_EFFECT] @@ -696,18 +694,21 @@ async def async_turn_on(self, **kwargs): if ATTR_WHITE_VALUE in kwargs and \ self._topic[CONF_WHITE_VALUE_COMMAND_TOPIC] is not None: percent_white = float(kwargs[ATTR_WHITE_VALUE]) / 255 - device_white_value = int(percent_white * self._white_value_scale) + device_white_value = \ + int(percent_white * self._config.get(CONF_WHITE_VALUE_SCALE)) mqtt.async_publish( self.hass, self._topic[CONF_WHITE_VALUE_COMMAND_TOPIC], - device_white_value, self._qos, self._retain) + device_white_value, self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic_white_value: self._white_value = kwargs[ATTR_WHITE_VALUE] should_update = True - if self._on_command_type == 'last': + if on_command_type == 'last': mqtt.async_publish(self.hass, self._topic[CONF_COMMAND_TOPIC], - self._payload['on'], self._qos, self._retain) + self._payload['on'], self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) should_update = True if self._optimistic: @@ -725,7 +726,7 @@ async def async_turn_off(self, **kwargs): """ mqtt.async_publish( self.hass, self._topic[CONF_COMMAND_TOPIC], self._payload['off'], - self._qos, self._retain) + self._config.get(CONF_QOS), self._config.get(CONF_RETAIN)) if self._optimistic: # Optimistically assume that the light has changed state. diff --git a/homeassistant/components/light/mqtt/schema_json.py b/homeassistant/components/light/mqtt/schema_json.py index dd3c896532f27..8a72f7b1f8986 100644 --- a/homeassistant/components/light/mqtt/schema_json.py +++ b/homeassistant/components/light/mqtt/schema_json.py @@ -99,22 +99,14 @@ def __init__(self, config, discovery_hash): self._sub_state = None self._supported_features = 0 - self._name = None - self._effect_list = None self._topic = None - self._qos = None - self._retain = None self._optimistic = False - self._rgb = False - self._xy = False - self._hs_support = False self._brightness = None self._color_temp = None self._effect = None self._hs = None self._white_value = None self._flash_times = None - self._brightness_scale = None self._unique_id = config.get(CONF_UNIQUE_ID) # Load config @@ -123,8 +115,9 @@ def __init__(self, config, discovery_hash): availability_topic = config.get(CONF_AVAILABILITY_TOPIC) payload_available = config.get(CONF_PAYLOAD_AVAILABLE) payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) + qos = config.get(CONF_QOS) - MqttAvailability.__init__(self, availability_topic, self._qos, + MqttAvailability.__init__(self, availability_topic, qos, payload_available, payload_not_available) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) @@ -144,16 +137,14 @@ async def discovery_update(self, discovery_payload): def _setup_from_config(self, config): """(Re)Setup the entity.""" - self._name = config.get(CONF_NAME) - self._effect_list = config.get(CONF_EFFECT_LIST) + self._config = config + self._topic = { key: config.get(key) for key in ( CONF_STATE_TOPIC, CONF_COMMAND_TOPIC ) } - self._qos = config.get(CONF_QOS) - self._retain = config.get(CONF_RETAIN) optimistic = config.get(CONF_OPTIMISTIC) self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None @@ -181,11 +172,7 @@ def _setup_from_config(self, config): else: self._white_value = None - self._rgb = config.get(CONF_RGB) - self._xy = config.get(CONF_XY) - self._hs_support = config.get(CONF_HS) - - if self._hs_support or self._rgb or self._xy: + if config.get(CONF_HS) or config.get(CONF_RGB) or config.get(CONF_XY): self._hs = [0, 0] else: self._hs = None @@ -196,16 +183,15 @@ def _setup_from_config(self, config): CONF_FLASH_TIME_LONG ) } - self._brightness_scale = config.get(CONF_BRIGHTNESS_SCALE) self._supported_features = (SUPPORT_TRANSITION | SUPPORT_FLASH) - self._supported_features |= (self._rgb and SUPPORT_COLOR) + self._supported_features |= (config.get(CONF_RGB) and SUPPORT_COLOR) self._supported_features |= (brightness and SUPPORT_BRIGHTNESS) self._supported_features |= (color_temp and SUPPORT_COLOR_TEMP) self._supported_features |= (effect and SUPPORT_EFFECT) self._supported_features |= (white_value and SUPPORT_WHITE_VALUE) - self._supported_features |= (self._xy and SUPPORT_COLOR) - self._supported_features |= (self._hs_support and SUPPORT_COLOR) + self._supported_features |= (config.get(CONF_XY) and SUPPORT_COLOR) + self._supported_features |= (config.get(CONF_HS) and SUPPORT_COLOR) async def _subscribe_topics(self): """(Re)Subscribe to topics.""" @@ -255,9 +241,9 @@ def state_received(topic, payload, qos): if self._brightness is not None: try: - self._brightness = int(values['brightness'] / - float(self._brightness_scale) * - 255) + self._brightness = int( + values['brightness'] / + float(self._config.get(CONF_BRIGHTNESS_SCALE)) * 255) except KeyError: pass except ValueError: @@ -294,7 +280,7 @@ def state_received(topic, payload, qos): self.hass, self._sub_state, {'state_topic': {'topic': self._topic[CONF_STATE_TOPIC], 'msg_callback': state_received, - 'qos': self._qos}}) + 'qos': self._config.get(CONF_QOS)}}) if self._optimistic and last_state: self._state = last_state.state == STATE_ON @@ -332,7 +318,7 @@ def effect(self): @property def effect_list(self): """Return the list of supported effects.""" - return self._effect_list + return self._config.get(CONF_EFFECT_LIST) @property def hs_color(self): @@ -352,7 +338,7 @@ def should_poll(self): @property def name(self): """Return the name of the device if any.""" - return self._name + return self._config.get(CONF_NAME) @property def unique_id(self): @@ -383,11 +369,12 @@ async def async_turn_on(self, **kwargs): message = {'state': 'ON'} - if ATTR_HS_COLOR in kwargs and (self._hs_support - or self._rgb or self._xy): + if ATTR_HS_COLOR in kwargs and ( + self._config.get(CONF_HS) or self._config.get(CONF_RGB) + or self._config.get(CONF_XY)): hs_color = kwargs[ATTR_HS_COLOR] message['color'] = {} - if self._rgb: + if self._config.get(CONF_RGB): # If there's a brightness topic set, we don't want to scale the # RGB values given using the brightness. if self._brightness is not None: @@ -401,11 +388,11 @@ async def async_turn_on(self, **kwargs): message['color']['r'] = rgb[0] message['color']['g'] = rgb[1] message['color']['b'] = rgb[2] - if self._xy: + if self._config.get(CONF_XY): xy_color = color_util.color_hs_to_xy(*kwargs[ATTR_HS_COLOR]) message['color']['x'] = xy_color[0] message['color']['y'] = xy_color[1] - if self._hs_support: + if self._config.get(CONF_HS): message['color']['h'] = hs_color[0] message['color']['s'] = hs_color[1] @@ -425,9 +412,9 @@ async def async_turn_on(self, **kwargs): message['transition'] = int(kwargs[ATTR_TRANSITION]) if ATTR_BRIGHTNESS in kwargs: - message['brightness'] = int(kwargs[ATTR_BRIGHTNESS] / - float(DEFAULT_BRIGHTNESS_SCALE) * - self._brightness_scale) + message['brightness'] = int( + kwargs[ATTR_BRIGHTNESS] / float(DEFAULT_BRIGHTNESS_SCALE) * + self._config.get(CONF_BRIGHTNESS_SCALE)) if self._optimistic: self._brightness = kwargs[ATTR_BRIGHTNESS] @@ -456,7 +443,7 @@ async def async_turn_on(self, **kwargs): mqtt.async_publish( self.hass, self._topic[CONF_COMMAND_TOPIC], json.dumps(message), - self._qos, self._retain) + self._config.get(CONF_QOS), self._config.get(CONF_RETAIN)) if self._optimistic: # Optimistically assume that the light has changed state. @@ -478,7 +465,7 @@ async def async_turn_off(self, **kwargs): mqtt.async_publish( self.hass, self._topic[CONF_COMMAND_TOPIC], json.dumps(message), - self._qos, self._retain) + self._config.get(CONF_QOS), self._config.get(CONF_RETAIN)) if self._optimistic: # Optimistically assume that the light has changed state. diff --git a/homeassistant/components/light/mqtt/schema_template.py b/homeassistant/components/light/mqtt/schema_template.py index e14e8e32be7b7..419472d19276c 100644 --- a/homeassistant/components/light/mqtt/schema_template.py +++ b/homeassistant/components/light/mqtt/schema_template.py @@ -81,13 +81,9 @@ def __init__(self, config, discovery_hash): self._state = False self._sub_state = None - self._name = None - self._effect_list = None self._topics = None self._templates = None self._optimistic = False - self._qos = None - self._retain = None # features self._brightness = None @@ -102,8 +98,9 @@ def __init__(self, config, discovery_hash): availability_topic = config.get(CONF_AVAILABILITY_TOPIC) payload_available = config.get(CONF_PAYLOAD_AVAILABLE) payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) + qos = config.get(CONF_QOS) - MqttAvailability.__init__(self, availability_topic, self._qos, + MqttAvailability.__init__(self, availability_topic, qos, payload_available, payload_not_available) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) @@ -123,8 +120,8 @@ async def discovery_update(self, discovery_payload): def _setup_from_config(self, config): """(Re)Setup the entity.""" - self._name = config.get(CONF_NAME) - self._effect_list = config.get(CONF_EFFECT_LIST) + self._config = config + self._topics = { key: config.get(key) for key in ( CONF_STATE_TOPIC, @@ -149,8 +146,6 @@ def _setup_from_config(self, config): self._optimistic = optimistic \ or self._topics[CONF_STATE_TOPIC] is None \ or self._templates[CONF_STATE_TEMPLATE] is None - self._qos = config.get(CONF_QOS) - self._retain = config.get(CONF_RETAIN) # features if self._templates[CONF_BRIGHTNESS_TEMPLATE] is not None: @@ -242,7 +237,7 @@ def state_received(topic, payload, qos): effect = self._templates[CONF_EFFECT_TEMPLATE].\ async_render_with_possible_json_value(payload) - if effect in self._effect_list: + if effect in self._config.get(CONF_EFFECT_LIST): self._effect = effect else: _LOGGER.warning("Unsupported effect value received") @@ -254,7 +249,7 @@ def state_received(topic, payload, qos): self.hass, self._sub_state, {'state_topic': {'topic': self._topics[CONF_STATE_TOPIC], 'msg_callback': state_received, - 'qos': self._qos}}) + 'qos': self._config.get(CONF_QOS)}}) if self._optimistic and last_state: self._state = last_state.state == STATE_ON @@ -305,7 +300,7 @@ def should_poll(self): @property def name(self): """Return the name of the entity.""" - return self._name + return self._config.get(CONF_NAME) @property def is_on(self): @@ -320,7 +315,7 @@ def assumed_state(self): @property def effect_list(self): """Return the list of supported effects.""" - return self._effect_list + return self._config.get(CONF_EFFECT_LIST) @property def effect(self): @@ -386,7 +381,7 @@ async def async_turn_on(self, **kwargs): mqtt.async_publish( self.hass, self._topics[CONF_COMMAND_TOPIC], self._templates[CONF_COMMAND_ON_TEMPLATE].async_render(**values), - self._qos, self._retain + self._config.get(CONF_QOS), self._config.get(CONF_RETAIN) ) if self._optimistic: @@ -407,7 +402,7 @@ async def async_turn_off(self, **kwargs): mqtt.async_publish( self.hass, self._topics[CONF_COMMAND_TOPIC], self._templates[CONF_COMMAND_OFF_TEMPLATE].async_render(**values), - self._qos, self._retain + self._config.get(CONF_QOS), self._config.get(CONF_RETAIN) ) if self._optimistic: @@ -421,7 +416,7 @@ def supported_features(self): features = features | SUPPORT_BRIGHTNESS if self._hs is not None: features = features | SUPPORT_COLOR - if self._effect_list is not None: + if self._config.get(CONF_EFFECT_LIST) is not None: features = features | SUPPORT_EFFECT if self._color_temp is not None: features = features | SUPPORT_COLOR_TEMP From b31c52419d1458329292b78557c94c808aff672d Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Wed, 5 Dec 2018 09:31:07 -0500 Subject: [PATCH 271/325] Bump version of elkm1_lib (#19030) --- homeassistant/components/elkm1/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 9424800060130..8ac3cec641131 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -20,7 +20,7 @@ DOMAIN = "elkm1" -REQUIREMENTS = ['elkm1-lib==0.7.12'] +REQUIREMENTS = ['elkm1-lib==0.7.13'] CONF_AREA = 'area' CONF_COUNTER = 'counter' diff --git a/requirements_all.txt b/requirements_all.txt index 236825cefe034..f330ff75fc863 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -334,7 +334,7 @@ einder==0.3.1 eliqonline==1.0.14 # homeassistant.components.elkm1 -elkm1-lib==0.7.12 +elkm1-lib==0.7.13 # homeassistant.components.enocean enocean==0.40 From 3627de3e8ab78c485a2a0cf250384f322745b2ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20RAMAGE?= Date: Wed, 5 Dec 2018 15:58:46 +0100 Subject: [PATCH 272/325] Change error to warning (#19035) --- homeassistant/components/media_player/plex.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index 0b4069ed66490..b70c1ffbf2858 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -150,7 +150,7 @@ def update_devices(): _LOGGER.exception("Error listing plex devices") return except requests.exceptions.RequestException as ex: - _LOGGER.error( + _LOGGER.warning( "Could not connect to plex server at http://%s (%s)", host, ex) return @@ -218,7 +218,7 @@ def update_sessions(): _LOGGER.exception("Error listing plex sessions") return except requests.exceptions.RequestException as ex: - _LOGGER.error( + _LOGGER.warning( "Could not connect to plex server at http://%s (%s)", host, ex) return From 16e25f203943cc2ad71402e737ad5628e9368041 Mon Sep 17 00:00:00 2001 From: Jeff Irion Date: Wed, 5 Dec 2018 08:04:08 -0800 Subject: [PATCH 273/325] Catch 'BrokenPipeError' exceptions for ADB commands (#19011) --- homeassistant/components/media_player/firetv.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/media_player/firetv.py b/homeassistant/components/media_player/firetv.py index 0c1984b3bce66..80be58c04e104 100644 --- a/homeassistant/components/media_player/firetv.py +++ b/homeassistant/components/media_player/firetv.py @@ -125,7 +125,7 @@ class FireTVDevice(MediaPlayerDevice): def __init__(self, ftv, name, get_source, get_sources): """Initialize the FireTV device.""" from adb.adb_protocol import ( - InvalidCommandError, InvalidResponseError, InvalidChecksumError) + InvalidChecksumError, InvalidCommandError, InvalidResponseError) self.firetv = ftv @@ -137,9 +137,9 @@ def __init__(self, ftv, name, get_source, get_sources): self.adb_lock = threading.Lock() # ADB exceptions to catch - self.exceptions = (TypeError, ValueError, AttributeError, - InvalidCommandError, InvalidResponseError, - InvalidChecksumError) + self.exceptions = (AttributeError, BrokenPipeError, TypeError, + ValueError, InvalidChecksumError, + InvalidCommandError, InvalidResponseError) self._state = None self._available = self.firetv.available From da0542e961009c2c14e41dcedb2509fbd9ee920c Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 5 Dec 2018 18:19:30 +0100 Subject: [PATCH 274/325] Bump python-miio to 0.4.4 (#19042) --- homeassistant/components/device_tracker/xiaomi_miio.py | 2 +- homeassistant/components/fan/xiaomi_miio.py | 2 +- homeassistant/components/light/xiaomi_miio.py | 2 +- homeassistant/components/remote/xiaomi_miio.py | 2 +- homeassistant/components/sensor/xiaomi_miio.py | 2 +- homeassistant/components/switch/xiaomi_miio.py | 2 +- homeassistant/components/vacuum/xiaomi_miio.py | 2 +- requirements_all.txt | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/device_tracker/xiaomi_miio.py b/homeassistant/components/device_tracker/xiaomi_miio.py index 1abd86ffd8a47..c5c6ebcbc35ec 100644 --- a/homeassistant/components/device_tracker/xiaomi_miio.py +++ b/homeassistant/components/device_tracker/xiaomi_miio.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_HOST, CONF_TOKEN import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-miio==0.4.3', 'construct==2.9.45'] +REQUIREMENTS = ['python-miio==0.4.4', 'construct==2.9.45'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index 3462b0bc1eb72..ca35f75b0972c 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -18,7 +18,7 @@ from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-miio==0.4.3', 'construct==2.9.45'] +REQUIREMENTS = ['python-miio==0.4.4', 'construct==2.9.45'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/light/xiaomi_miio.py b/homeassistant/components/light/xiaomi_miio.py index f2e8e120d5348..9e650562fe8d1 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -21,7 +21,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util import dt -REQUIREMENTS = ['python-miio==0.4.3', 'construct==2.9.45'] +REQUIREMENTS = ['python-miio==0.4.4', 'construct==2.9.45'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/remote/xiaomi_miio.py b/homeassistant/components/remote/xiaomi_miio.py index 915f38745a455..a247cb3e914fe 100644 --- a/homeassistant/components/remote/xiaomi_miio.py +++ b/homeassistant/components/remote/xiaomi_miio.py @@ -22,7 +22,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow -REQUIREMENTS = ['python-miio==0.4.3', 'construct==2.9.45'] +REQUIREMENTS = ['python-miio==0.4.4', 'construct==2.9.45'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/xiaomi_miio.py b/homeassistant/components/sensor/xiaomi_miio.py index dddf7b23922e2..ef5ed1d5c3805 100644 --- a/homeassistant/components/sensor/xiaomi_miio.py +++ b/homeassistant/components/sensor/xiaomi_miio.py @@ -14,7 +14,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['python-miio==0.4.3', 'construct==2.9.45'] +REQUIREMENTS = ['python-miio==0.4.4', 'construct==2.9.45'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/xiaomi_miio.py b/homeassistant/components/switch/xiaomi_miio.py index 7e11f986b9261..125f89f504027 100644 --- a/homeassistant/components/switch/xiaomi_miio.py +++ b/homeassistant/components/switch/xiaomi_miio.py @@ -17,7 +17,7 @@ from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-miio==0.4.3', 'construct==2.9.45'] +REQUIREMENTS = ['python-miio==0.4.4', 'construct==2.9.45'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index a491b69ca2f67..a6fe49430711f 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -21,7 +21,7 @@ ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-miio==0.4.3', 'construct==2.9.45'] +REQUIREMENTS = ['python-miio==0.4.4', 'construct==2.9.45'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index f330ff75fc863..415259903c8ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1225,7 +1225,7 @@ python-juicenet==0.0.5 # homeassistant.components.sensor.xiaomi_miio # homeassistant.components.switch.xiaomi_miio # homeassistant.components.vacuum.xiaomi_miio -python-miio==0.4.3 +python-miio==0.4.4 # homeassistant.components.media_player.mpd python-mpd2==1.0.0 From bc69309b4663754d4ad78c60547f9701ab0d552e Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 5 Dec 2018 18:20:26 +0100 Subject: [PATCH 275/325] Add last clean times to xiaomi vacuum (#19043) --- homeassistant/components/vacuum/xiaomi_miio.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index a6fe49430711f..943b487857fbb 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -45,6 +45,8 @@ 'Turbo': 77, 'Max': 90} +ATTR_CLEAN_START = 'clean_start' +ATTR_CLEAN_STOP = 'clean_stop' ATTR_CLEANING_TIME = 'cleaning_time' ATTR_DO_NOT_DISTURB = 'do_not_disturb' ATTR_DO_NOT_DISTURB_START = 'do_not_disturb_start' @@ -169,6 +171,7 @@ def __init__(self, name, vacuum): self.consumable_state = None self.clean_history = None self.dnd_state = None + self.last_clean = None @property def name(self): @@ -248,6 +251,10 @@ def device_state_attributes(self): ATTR_STATUS: str(self.vacuum_state.state) }) + if self.last_clean: + attrs[ATTR_CLEAN_START] = self.last_clean.start + attrs[ATTR_CLEAN_STOP] = self.last_clean.end + if self.vacuum_state.got_error: attrs[ATTR_ERROR] = self.vacuum_state.error return attrs @@ -368,6 +375,7 @@ def update(self): self.consumable_state = self._vacuum.consumable_status() self.clean_history = self._vacuum.clean_history() + self.last_clean = self._vacuum.last_clean_details() self.dnd_state = self._vacuum.dnd_status() self._available = True From 08702548f3b74b07923fc49d37533303e7196524 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 5 Dec 2018 10:31:32 -0700 Subject: [PATCH 276/325] Add support for multiple RainMachine controllers (#18989) * Add support for multiple RainMachine controllers * Member comments * Member comments * Member comments * Cleanup * More config flow cleanup * Member comments --- .../components/rainmachine/__init__.py | 68 ++++++++++--------- .../components/rainmachine/config_flow.py | 10 ++- homeassistant/components/rainmachine/const.py | 1 + 3 files changed, 45 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 928c2ab202703..6e1b8b68437a8 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -21,7 +21,8 @@ from homeassistant.helpers.event import async_track_time_interval from .config_flow import configured_instances -from .const import DATA_CLIENT, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import ( + DATA_CLIENT, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DEFAULT_SSL, DOMAIN) REQUIREMENTS = ['regenmaschine==1.0.7'] @@ -33,13 +34,13 @@ SENSOR_UPDATE_TOPIC = '{0}_data_update'.format(DOMAIN) ZONE_UPDATE_TOPIC = '{0}_zone_update'.format(DOMAIN) +CONF_CONTROLLERS = 'controllers' CONF_PROGRAM_ID = 'program_id' CONF_ZONE_ID = 'zone_id' CONF_ZONE_RUN_TIME = 'zone_run_time' DEFAULT_ATTRIBUTION = 'Data provided by Green Electronics LLC' DEFAULT_ICON = 'mdi:water' -DEFAULT_SSL = True DEFAULT_ZONE_RUN = 60 * 10 TYPE_FREEZE = 'freeze' @@ -97,23 +98,26 @@ SWITCH_SCHEMA = vol.Schema({vol.Optional(CONF_ZONE_RUN_TIME): cv.positive_int}) -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: - vol.Schema({ - vol.Required(CONF_IP_ADDRESS): cv.string, - 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_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): - cv.time_period, - vol.Optional(CONF_BINARY_SENSORS, default={}): - BINARY_SENSOR_SCHEMA, - vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA, - vol.Optional(CONF_SWITCHES, default={}): SWITCH_SCHEMA, - }) - }, - extra=vol.ALLOW_EXTRA) + +CONTROLLER_SCHEMA = vol.Schema({ + vol.Required(CONF_IP_ADDRESS): cv.string, + 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_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): + cv.time_period, + vol.Optional(CONF_BINARY_SENSORS, default={}): BINARY_SENSOR_SCHEMA, + vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA, + vol.Optional(CONF_SWITCHES, default={}): SWITCH_SCHEMA, +}) + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_CONTROLLERS): + vol.All(cv.ensure_list, [CONTROLLER_SCHEMA]), + }), +}, extra=vol.ALLOW_EXTRA) async def async_setup(hass, config): @@ -127,14 +131,15 @@ async def async_setup(hass, config): conf = config[DOMAIN] - if conf[CONF_IP_ADDRESS] in configured_instances(hass): - return True + for controller in conf[CONF_CONTROLLERS]: + if controller[CONF_IP_ADDRESS] in configured_instances(hass): + continue - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={'source': SOURCE_IMPORT}, - data=conf)) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={'source': SOURCE_IMPORT}, + data=controller)) return True @@ -144,16 +149,15 @@ async def async_setup_entry(hass, config_entry): from regenmaschine import login from regenmaschine.errors import RainMachineError - ip_address = config_entry.data[CONF_IP_ADDRESS] - password = config_entry.data[CONF_PASSWORD] - port = config_entry.data[CONF_PORT] - ssl = config_entry.data.get(CONF_SSL, DEFAULT_SSL) - websession = aiohttp_client.async_get_clientsession(hass) try: client = await login( - ip_address, password, websession, port=port, ssl=ssl) + config_entry.data[CONF_IP_ADDRESS], + config_entry.data[CONF_PASSWORD], + websession, + port=config_entry.data[CONF_PORT], + ssl=config_entry.data[CONF_SSL]) rainmachine = RainMachine( client, config_entry.data.get(CONF_BINARY_SENSORS, {}).get( diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py index ecf497333cbd3..59b27fe0099f3 100644 --- a/homeassistant/components/rainmachine/config_flow.py +++ b/homeassistant/components/rainmachine/config_flow.py @@ -7,10 +7,10 @@ from homeassistant import config_entries from homeassistant.core import callback from homeassistant.const import ( - CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SCAN_INTERVAL) + CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SCAN_INTERVAL, CONF_SSL) from homeassistant.helpers import aiohttp_client -from .const import DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DEFAULT_SSL, DOMAIN @callback @@ -74,6 +74,12 @@ async def async_step_user(self, user_input=None): CONF_PASSWORD: 'invalid_credentials' }) + # Since the config entry doesn't allow for configuration of SSL, make + # sure it's set: + if user_input.get(CONF_SSL) is None: + user_input[CONF_SSL] = DEFAULT_SSL + + # Timedeltas are easily serializable, so store the seconds instead: scan_interval = user_input.get( CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) user_input[CONF_SCAN_INTERVAL] = scan_interval.seconds diff --git a/homeassistant/components/rainmachine/const.py b/homeassistant/components/rainmachine/const.py index ec1f0436ccb18..e0e79e8c160ed 100644 --- a/homeassistant/components/rainmachine/const.py +++ b/homeassistant/components/rainmachine/const.py @@ -10,5 +10,6 @@ DEFAULT_PORT = 8080 DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) +DEFAULT_SSL = True TOPIC_UPDATE = 'update_{0}' From df346feb65809626df41971151d1daad2451818f Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 5 Dec 2018 19:48:44 +0100 Subject: [PATCH 277/325] Review comments --- homeassistant/components/mqtt/__init__.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index b403f296bd872..6093be7d0915f 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -828,10 +828,10 @@ def __init__(self, availability_topic: Optional[str], qos: Optional[int], payload_not_available: Optional[str]) -> None: """Initialize the availability mixin.""" self._availability_sub_state = None + self._available = False # type: bool self._availability_topic = availability_topic self._availability_qos = qos - self._available = self._availability_topic is None # type: bool self._payload_available = payload_available self._payload_not_available = payload_not_available @@ -852,8 +852,6 @@ def _availability_setup_from_config(self, config): """(Re)Setup.""" self._availability_topic = config.get(CONF_AVAILABILITY_TOPIC) self._availability_qos = config.get(CONF_QOS) - if self._availability_topic is None: - self._available = True self._payload_available = config.get(CONF_PAYLOAD_AVAILABLE) self._payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) @@ -888,7 +886,7 @@ async def async_will_remove_from_hass(self): @property def available(self) -> bool: """Return if the device is available.""" - return self._available + return self._availability_topic is None or self._available class MqttDiscoveryUpdate(Entity): From af96694430a8b687093cd50b79ab7e0088a1f104 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Wed, 5 Dec 2018 20:56:43 +0100 Subject: [PATCH 278/325] Remove unsupported strong mode of the Xiaomi Air Humidifier CA1 (#18926) * Remove unsupported strong mode of the Xiaomi Air Humidifier CA1 * Clean up filter of unsupported modes --- homeassistant/components/fan/xiaomi_miio.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index ca35f75b0972c..e6349782cd150 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -755,12 +755,13 @@ def __init__(self, name, device, model, unique_id): if self._model == MODEL_AIRHUMIDIFIER_CA: self._device_features = FEATURE_FLAGS_AIRHUMIDIFIER_CA self._available_attributes = AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_CA - self._speed_list = [mode.name for mode in OperationMode] + self._speed_list = [mode.name for mode in OperationMode if + mode is not OperationMode.Strong] else: self._device_features = FEATURE_FLAGS_AIRHUMIDIFIER self._available_attributes = AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER self._speed_list = [mode.name for mode in OperationMode if - mode.name != 'Auto'] + mode is not OperationMode.Auto] self._state_attrs.update( {attribute: None for attribute in self._available_attributes}) From b2b4712bb7346437afbd3256be674df0631b0fff Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Wed, 5 Dec 2018 21:17:02 +0100 Subject: [PATCH 279/325] Remove Instapush notify platform --- .coveragerc | 1 - homeassistant/components/notify/instapush.py | 96 -------------------- 2 files changed, 97 deletions(-) delete mode 100644 homeassistant/components/notify/instapush.py diff --git a/.coveragerc b/.coveragerc index 10e07dc2da5b5..8d98a0c23e0f4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -646,7 +646,6 @@ omit = homeassistant/components/notify/group.py homeassistant/components/notify/hipchat.py homeassistant/components/notify/homematic.py - homeassistant/components/notify/instapush.py homeassistant/components/notify/kodi.py homeassistant/components/notify/lannouncer.py homeassistant/components/notify/llamalab_automate.py diff --git a/homeassistant/components/notify/instapush.py b/homeassistant/components/notify/instapush.py deleted file mode 100644 index e792045ec8010..0000000000000 --- a/homeassistant/components/notify/instapush.py +++ /dev/null @@ -1,96 +0,0 @@ -""" -Instapush notification service. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/notify.instapush/ -""" -import json -import logging - -from aiohttp.hdrs import CONTENT_TYPE -import requests -import voluptuous as vol - -from homeassistant.components.notify import ( - ATTR_TITLE, PLATFORM_SCHEMA, ATTR_TITLE_DEFAULT, BaseNotificationService) -from homeassistant.const import CONF_API_KEY, CONTENT_TYPE_JSON -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) -_RESOURCE = 'https://api.instapush.im/v1/' - -CONF_APP_SECRET = 'app_secret' -CONF_EVENT = 'event' -CONF_TRACKER = 'tracker' - -DEFAULT_TIMEOUT = 10 - -HTTP_HEADER_APPID = 'x-instapush-appid' -HTTP_HEADER_APPSECRET = 'x-instapush-appsecret' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_APP_SECRET): cv.string, - vol.Required(CONF_EVENT): cv.string, - vol.Required(CONF_TRACKER): cv.string, -}) - - -def get_service(hass, config, discovery_info=None): - """Get the Instapush notification service.""" - headers = { - HTTP_HEADER_APPID: config[CONF_API_KEY], - HTTP_HEADER_APPSECRET: config[CONF_APP_SECRET], - } - - try: - response = requests.get( - '{}{}'.format(_RESOURCE, 'events/list'), headers=headers, - timeout=DEFAULT_TIMEOUT).json() - except ValueError: - _LOGGER.error("Unexpected answer from Instapush API") - return None - - if 'error' in response: - _LOGGER.error(response['msg']) - return None - - if not [app for app in response if app['title'] == config[CONF_EVENT]]: - _LOGGER.error("No app match your given value") - return None - - return InstapushNotificationService( - config.get(CONF_API_KEY), config.get(CONF_APP_SECRET), - config.get(CONF_EVENT), config.get(CONF_TRACKER)) - - -class InstapushNotificationService(BaseNotificationService): - """Implementation of the notification service for Instapush.""" - - def __init__(self, api_key, app_secret, event, tracker): - """Initialize the service.""" - self._api_key = api_key - self._app_secret = app_secret - self._event = event - self._tracker = tracker - self._headers = { - HTTP_HEADER_APPID: self._api_key, - HTTP_HEADER_APPSECRET: self._app_secret, - CONTENT_TYPE: CONTENT_TYPE_JSON, - } - - def send_message(self, message="", **kwargs): - """Send a message to a user.""" - title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) - data = { - 'event': self._event, - 'trackers': {self._tracker: '{} : {}'.format(title, message)} - } - - response = requests.post( - '{}{}'.format(_RESOURCE, 'post'), data=json.dumps(data), - headers=self._headers, timeout=DEFAULT_TIMEOUT) - - if response.json()['status'] == 401: - _LOGGER.error(response.json()['msg'], - "Please check your Instapush settings") From 0aee355b14fadeee7a9b43a8568b968f042bd936 Mon Sep 17 00:00:00 2001 From: photinus Date: Wed, 5 Dec 2018 13:00:49 -0800 Subject: [PATCH 280/325] Bump pyvizio version (#19048) * Update vizio.py Bump pyvizio version to reoslve get volume call and component setup failures * Update of requirement_all --- homeassistant/components/media_player/vizio.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/vizio.py b/homeassistant/components/media_player/vizio.py index 9564a8d3df0b2..e3f426cc5c6e1 100644 --- a/homeassistant/components/media_player/vizio.py +++ b/homeassistant/components/media_player/vizio.py @@ -20,7 +20,7 @@ STATE_UNKNOWN) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['pyvizio==0.0.3'] +REQUIREMENTS = ['pyvizio==0.0.4'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 415259903c8ce..e3d958e8a7ba4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1325,7 +1325,7 @@ pyvera==0.2.45 pyvesync==0.1.1 # homeassistant.components.media_player.vizio -pyvizio==0.0.3 +pyvizio==0.0.4 # homeassistant.components.velux pyvlx==0.1.3 From 83311df933b5717ee3a6dce00d983074aa283e10 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 6 Dec 2018 00:30:33 +0100 Subject: [PATCH 281/325] Add translations --- .../components/auth/.translations/ca.json | 2 +- .../components/auth/.translations/sl.json | 6 ++-- .../components/deconz/.translations/ca.json | 2 +- .../components/deconz/.translations/lb.json | 2 +- .../components/deconz/.translations/ru.json | 2 +- .../dialogflow/.translations/ca.json | 2 +- .../dialogflow/.translations/hu.json | 10 ++++++ .../dialogflow/.translations/ko.json | 2 +- .../dialogflow/.translations/ru.json | 2 +- .../homematicip_cloud/.translations/ca.json | 2 +- .../homematicip_cloud/.translations/ru.json | 2 +- .../components/hue/.translations/ru.json | 4 +-- .../components/hue/.translations/sl.json | 2 +- .../components/ifttt/.translations/ca.json | 2 +- .../components/ifttt/.translations/ko.json | 2 +- .../components/ifttt/.translations/ru.json | 2 +- .../components/lifx/.translations/hu.json | 5 +++ .../luftdaten/.translations/hu.json | 10 ++++++ .../luftdaten/.translations/pt.json | 16 +++++++++ .../luftdaten/.translations/ru.json | 2 +- .../components/mailgun/.translations/ca.json | 2 +- .../components/mailgun/.translations/ko.json | 2 +- .../components/mailgun/.translations/ru.json | 2 +- .../components/mailgun/.translations/sl.json | 2 +- .../components/mqtt/.translations/ru.json | 2 +- .../components/openuv/.translations/ru.json | 2 +- .../owntracks/.translations/ca.json | 17 +++++++++ .../owntracks/.translations/hu.json | 11 ++++++ .../owntracks/.translations/ko.json | 17 +++++++++ .../owntracks/.translations/lb.json | 17 +++++++++ .../owntracks/.translations/no.json | 17 +++++++++ .../owntracks/.translations/pl.json | 17 +++++++++ .../owntracks/.translations/pt.json | 17 +++++++++ .../owntracks/.translations/ru.json | 17 +++++++++ .../owntracks/.translations/sl.json | 17 +++++++++ .../owntracks/.translations/zh-Hans.json | 17 +++++++++ .../owntracks/.translations/zh-Hant.json | 17 +++++++++ .../components/point/.translations/ko.json | 2 +- .../components/point/.translations/nl.json | 12 +++++++ .../components/point/.translations/pt.json | 12 +++++++ .../components/point/.translations/sl.json | 32 +++++++++++++++++ .../point/.translations/zh-Hans.json | 6 ++++ .../rainmachine/.translations/hu.json | 15 ++++++++ .../rainmachine/.translations/pt.json | 13 +++++++ .../rainmachine/.translations/ru.json | 2 +- .../rainmachine/.translations/sl.json | 19 ++++++++++ .../simplisafe/.translations/ru.json | 2 +- .../components/smhi/.translations/hu.json | 3 ++ .../components/sonos/.translations/hu.json | 3 +- .../components/tradfri/.translations/ru.json | 2 +- .../components/twilio/.translations/ca.json | 2 +- .../components/twilio/.translations/ko.json | 4 +-- .../components/twilio/.translations/ru.json | 2 +- .../components/unifi/.translations/ru.json | 2 +- .../components/upnp/.translations/hu.json | 1 + .../components/upnp/.translations/ru.json | 2 +- .../components/zha/.translations/ca.json | 20 +++++++++++ .../components/zha/.translations/en.json | 35 +++++++++---------- .../components/zha/.translations/ko.json | 21 +++++++++++ .../components/zha/.translations/lb.json | 21 +++++++++++ .../components/zha/.translations/nl.json | 15 ++++++++ .../components/zha/.translations/no.json | 20 +++++++++++ .../components/zha/.translations/pl.json | 21 +++++++++++ .../components/zha/.translations/pt.json | 19 ++++++++++ .../components/zha/.translations/ru.json | 20 +++++++++++ .../components/zha/.translations/sl.json | 21 +++++++++++ .../components/zha/.translations/zh-Hans.json | 11 ++++++ .../components/zha/.translations/zh-Hant.json | 21 +++++++++++ .../components/zone/.translations/ru.json | 2 +- .../components/zwave/.translations/ca.json | 2 +- .../components/zwave/.translations/ru.json | 12 +++---- 71 files changed, 607 insertions(+), 62 deletions(-) create mode 100644 homeassistant/components/dialogflow/.translations/hu.json create mode 100644 homeassistant/components/luftdaten/.translations/hu.json create mode 100644 homeassistant/components/luftdaten/.translations/pt.json create mode 100644 homeassistant/components/owntracks/.translations/ca.json create mode 100644 homeassistant/components/owntracks/.translations/hu.json create mode 100644 homeassistant/components/owntracks/.translations/ko.json create mode 100644 homeassistant/components/owntracks/.translations/lb.json create mode 100644 homeassistant/components/owntracks/.translations/no.json create mode 100644 homeassistant/components/owntracks/.translations/pl.json create mode 100644 homeassistant/components/owntracks/.translations/pt.json create mode 100644 homeassistant/components/owntracks/.translations/ru.json create mode 100644 homeassistant/components/owntracks/.translations/sl.json create mode 100644 homeassistant/components/owntracks/.translations/zh-Hans.json create mode 100644 homeassistant/components/owntracks/.translations/zh-Hant.json create mode 100644 homeassistant/components/point/.translations/nl.json create mode 100644 homeassistant/components/point/.translations/pt.json create mode 100644 homeassistant/components/point/.translations/sl.json create mode 100644 homeassistant/components/rainmachine/.translations/hu.json create mode 100644 homeassistant/components/rainmachine/.translations/pt.json create mode 100644 homeassistant/components/rainmachine/.translations/sl.json create mode 100644 homeassistant/components/zha/.translations/ca.json create mode 100644 homeassistant/components/zha/.translations/ko.json create mode 100644 homeassistant/components/zha/.translations/lb.json create mode 100644 homeassistant/components/zha/.translations/nl.json create mode 100644 homeassistant/components/zha/.translations/no.json create mode 100644 homeassistant/components/zha/.translations/pl.json create mode 100644 homeassistant/components/zha/.translations/pt.json create mode 100644 homeassistant/components/zha/.translations/ru.json create mode 100644 homeassistant/components/zha/.translations/sl.json create mode 100644 homeassistant/components/zha/.translations/zh-Hans.json create mode 100644 homeassistant/components/zha/.translations/zh-Hant.json diff --git a/homeassistant/components/auth/.translations/ca.json b/homeassistant/components/auth/.translations/ca.json index f4318a0eb21cd..236352a90183c 100644 --- a/homeassistant/components/auth/.translations/ca.json +++ b/homeassistant/components/auth/.translations/ca.json @@ -13,7 +13,7 @@ "title": "Configureu una contrasenya d'un sol \u00fas a trav\u00e9s del component de notificacions" }, "setup": { - "description": "**notify.{notify_service}** ha enviat una contrasenya d'un sol \u00fas. Introdu\u00efu-la a continuaci\u00f3:", + "description": "S'ha enviat una contrasenya d'un sol \u00fas mitjan\u00e7ant **notify.{notify_service}**. Introdu\u00efu-la a continuaci\u00f3:", "title": "Verifiqueu la configuraci\u00f3" } }, diff --git a/homeassistant/components/auth/.translations/sl.json b/homeassistant/components/auth/.translations/sl.json index 2efc23f78f671..223dc91a4800a 100644 --- a/homeassistant/components/auth/.translations/sl.json +++ b/homeassistant/components/auth/.translations/sl.json @@ -2,18 +2,18 @@ "mfa_setup": { "notify": { "abort": { - "no_available_service": "Ni na voljo storitev obve\u0161\u010danja." + "no_available_service": "Storitve obve\u0161\u010danja niso na voljo." }, "error": { "invalid_code": "Neveljavna koda, poskusite znova." }, "step": { "init": { - "description": "Prosimo, izberite eno od storitev obve\u0161\u010danja:", + "description": "Izberite eno od storitev obve\u0161\u010danja:", "title": "Nastavite enkratno geslo, ki ga dostavite z obvestilno komponento" }, "setup": { - "description": "Enkratno geslo je poslal **notify.{notify_service} **. Vnesite ga spodaj:", + "description": "Enkratno geslo je poslal **notify.{notify_service} **. Prosimo, vnesite ga spodaj:", "title": "Preverite nastavitev" } }, diff --git a/homeassistant/components/deconz/.translations/ca.json b/homeassistant/components/deconz/.translations/ca.json index 10eb9f5bc7316..a3aa5491e23e9 100644 --- a/homeassistant/components/deconz/.translations/ca.json +++ b/homeassistant/components/deconz/.translations/ca.json @@ -12,7 +12,7 @@ "init": { "data": { "host": "Amfitri\u00f3", - "port": "Port (predeterminat: '80')" + "port": "Port" }, "title": "Definiu la passarel\u00b7la deCONZ" }, diff --git a/homeassistant/components/deconz/.translations/lb.json b/homeassistant/components/deconz/.translations/lb.json index 3de7de9ddb3e6..51cb5419e90dc 100644 --- a/homeassistant/components/deconz/.translations/lb.json +++ b/homeassistant/components/deconz/.translations/lb.json @@ -17,7 +17,7 @@ "title": "deCONZ gateway d\u00e9fin\u00e9ieren" }, "link": { - "description": "Entsperrt \u00e4r deCONZ gateway fir se mat Home Assistant ze registr\u00e9ieren.\n\n1. Gidd op\u00a0deCONZ System Astellungen\n2. Dr\u00e9ckt \"Unlock\" Gateway Kn\u00e4ppchen", + "description": "Entsperrt \u00e4r deCONZ gateway fir se mat Home Assistant ze registr\u00e9ieren.\n\n1. Gidd op deCONZ System Astellungen\n2. Dr\u00e9ckt \"Unlock\" Gateway Kn\u00e4ppchen", "title": "Link mat deCONZ" }, "options": { diff --git a/homeassistant/components/deconz/.translations/ru.json b/homeassistant/components/deconz/.translations/ru.json index a9b66314f3152..3ff60254a6a50 100644 --- a/homeassistant/components/deconz/.translations/ru.json +++ b/homeassistant/components/deconz/.translations/ru.json @@ -12,7 +12,7 @@ "init": { "data": { "host": "\u0425\u043e\u0441\u0442", - "port": "\u041f\u043e\u0440\u0442 (\u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e: '80')" + "port": "\u041f\u043e\u0440\u0442" }, "title": "\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0448\u043b\u044e\u0437 deCONZ" }, diff --git a/homeassistant/components/dialogflow/.translations/ca.json b/homeassistant/components/dialogflow/.translations/ca.json index aa81c06d750e9..ffc10269776bf 100644 --- a/homeassistant/components/dialogflow/.translations/ca.json +++ b/homeassistant/components/dialogflow/.translations/ca.json @@ -5,7 +5,7 @@ "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia." }, "create_entry": { - "default": "Per enviar esdeveniments a Home Assistant, haureu de configurar [integraci\u00f3 webhook de Dialogflow]({dialogflow_url}). \n\n Ompliu la informaci\u00f3 seg\u00fcent: \n\n - URL: `{webhook_url}` \n - M\u00e8tode: POST \n - Tipus de contingut: application/json\n\nConsulteu [la documentaci\u00f3]({docs_url}) per a m\u00e9s detalls." + "default": "Per enviar esdeveniments a Home Assistant, haureu de configurar [integraci\u00f3 webhook de Dialogflow]({dialogflow_url}). \n\n Ompliu la informaci\u00f3 seg\u00fcent: \n\n - URL: `{webhook_url}` \n - M\u00e8tode: POST \n - Tipus de contingut: application/json\n\nVegeu [la documentaci\u00f3]({docs_url}) per a m\u00e9s detalls." }, "step": { "user": { diff --git a/homeassistant/components/dialogflow/.translations/hu.json b/homeassistant/components/dialogflow/.translations/hu.json new file mode 100644 index 0000000000000..89e8205bb09ef --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/hu.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "user": { + "title": "Dialogflow Webhook be\u00e1ll\u00edt\u00e1sa" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/ko.json b/homeassistant/components/dialogflow/.translations/ko.json index f9a71747bd649..cf53f81bdb8e9 100644 --- a/homeassistant/components/dialogflow/.translations/ko.json +++ b/homeassistant/components/dialogflow/.translations/ko.json @@ -5,7 +5,7 @@ "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4." }, "create_entry": { - "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 [Dialogflow Webhook]({dialogflow_url}) \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694. \n\n - URL: `{webhook_url}`\n - Method: POST\n - Content Type: application/json\n \n \uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." + "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 [Dialogflow Webhook]({dialogflow_url}) \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694. \n\n - URL: `{webhook_url}`\n - Method: POST\n - Content Type: application/json\n \n\uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." }, "step": { "user": { diff --git a/homeassistant/components/dialogflow/.translations/ru.json b/homeassistant/components/dialogflow/.translations/ru.json index 7bc785f2613fa..8625780e65c05 100644 --- a/homeassistant/components/dialogflow/.translations/ru.json +++ b/homeassistant/components/dialogflow/.translations/ru.json @@ -10,7 +10,7 @@ "step": { "user": { "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Dialogflow?", - "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Dialogflow Webhook" + "title": "Dialogflow Webhook" } }, "title": "Dialogflow" diff --git a/homeassistant/components/homematicip_cloud/.translations/ca.json b/homeassistant/components/homematicip_cloud/.translations/ca.json index 7cc5943b83078..9ad495c720aaf 100644 --- a/homeassistant/components/homematicip_cloud/.translations/ca.json +++ b/homeassistant/components/homematicip_cloud/.translations/ca.json @@ -21,7 +21,7 @@ "title": "Trieu el punt d'acc\u00e9s HomematicIP" }, "link": { - "description": "Premeu el bot\u00f3 blau del punt d'acc\u00e9s i el bot\u00f3 de enviar per registrar HomematicIP amb Home Assistent. \n\n ![Ubicaci\u00f3 del bot\u00f3 al pont](/static/images/config_flows/config_homematicip_cloud.png)", + "description": "Premeu el bot\u00f3 blau del punt d'acc\u00e9s i el bot\u00f3 de enviar per registrar HomematicIP amb Home Assistent. \n\n![Ubicaci\u00f3 del bot\u00f3 al pont](/static/images/config_flows/config_homematicip_cloud.png)", "title": "Enlla\u00e7ar punt d'acc\u00e9s" } }, diff --git a/homeassistant/components/homematicip_cloud/.translations/ru.json b/homeassistant/components/homematicip_cloud/.translations/ru.json index ae67c616f3fd7..e1aec6162f4c2 100644 --- a/homeassistant/components/homematicip_cloud/.translations/ru.json +++ b/homeassistant/components/homematicip_cloud/.translations/ru.json @@ -18,7 +18,7 @@ "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f \u043a\u0430\u043a \u043f\u0440\u0435\u0444\u0438\u043a\u0441 \u0434\u043b\u044f \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u044f \u0432\u0441\u0435\u0445 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432)", "pin": "PIN-\u043a\u043e\u0434 (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)" }, - "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0442\u043e\u0447\u043a\u0443 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 HomematicIP" + "title": "HomematicIP Cloud" }, "link": { "description": "\u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u0441\u0438\u043d\u044e\u044e \u043a\u043d\u043e\u043f\u043a\u0443 \u043d\u0430 \u0442\u043e\u0447\u043a\u0435 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0438 \u043a\u043d\u043e\u043f\u043a\u0443 \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438, \u0447\u0442\u043e\u0431\u044b \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c HomematicIP \u0432 Home Assistant. \n\n ![\u0420\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043a\u043d\u043e\u043f\u043a\u0438](/static/images/config_flows/config_homematicip_cloud.png)", diff --git a/homeassistant/components/hue/.translations/ru.json b/homeassistant/components/hue/.translations/ru.json index 4b2581dde658c..b6e2ccce8ed39 100644 --- a/homeassistant/components/hue/.translations/ru.json +++ b/homeassistant/components/hue/.translations/ru.json @@ -3,8 +3,8 @@ "abort": { "all_configured": "\u0412\u0441\u0435 Philips Hue \u0448\u043b\u044e\u0437\u044b \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u044b", "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", - "cannot_connect": "\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0448\u043b\u044e\u0437\u0443", - "discover_timeout": "\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0442\u044c \u0448\u043b\u044e\u0437\u044b Philips Hue", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0448\u043b\u044e\u0437\u0443", + "discover_timeout": "\u0428\u043b\u044e\u0437 Philips Hue \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d", "no_bridges": "\u0428\u043b\u044e\u0437\u044b Philips Hue \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b", "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430" }, diff --git a/homeassistant/components/hue/.translations/sl.json b/homeassistant/components/hue/.translations/sl.json index 4245ce02c66e1..05d52d5c37e80 100644 --- a/homeassistant/components/hue/.translations/sl.json +++ b/homeassistant/components/hue/.translations/sl.json @@ -24,6 +24,6 @@ "title": "Link Hub" } }, - "title": "Philips Hue Bridge" + "title": "Philips Hue" } } \ No newline at end of file diff --git a/homeassistant/components/ifttt/.translations/ca.json b/homeassistant/components/ifttt/.translations/ca.json index f93fbe19078ad..aadd66902b633 100644 --- a/homeassistant/components/ifttt/.translations/ca.json +++ b/homeassistant/components/ifttt/.translations/ca.json @@ -5,7 +5,7 @@ "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia." }, "create_entry": { - "default": "Per enviar esdeveniments a Home Assistant, necessitareu utilitzar l'acci\u00f3 \"Make a web resquest\" de [IFTTT Webhook applet]({applet_url}). \n\n Ompliu la informaci\u00f3 seg\u00fcent: \n\n - URL: `{webhook_url}` \n - Method: POST \n - Content Type: application/json \n\nConsulteu [la documentaci\u00f3]({docs_url}) sobre com configurar els automatismes per gestionar les dades entrants." + "default": "Per enviar esdeveniments a Home Assistant, necessitareu utilitzar l'acci\u00f3 \"Make a web resquest\" de [IFTTT Webhook applet]({applet_url}). \n\n Ompliu la informaci\u00f3 seg\u00fcent: \n\n - URL: `{webhook_url}` \n - Method: POST \n - Content Type: application/json \n\nVegeu [la documentaci\u00f3]({docs_url}) sobre com configurar els automatismes per gestionar les dades entrants." }, "step": { "user": { diff --git a/homeassistant/components/ifttt/.translations/ko.json b/homeassistant/components/ifttt/.translations/ko.json index 2f033e4f4eeb8..bb54f7ef6cba1 100644 --- a/homeassistant/components/ifttt/.translations/ko.json +++ b/homeassistant/components/ifttt/.translations/ko.json @@ -5,7 +5,7 @@ "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4." }, "create_entry": { - "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\uae30 \uc704\ud574\uc11c\ub294 [IFTTT Webhook \uc560\ud50c\ub9bf]({applet_url}) \uc5d0\uc11c \"Make a web request\" \ub97c \uc0ac\uc6a9\ud574\uc57c \ud569\ub2c8\ub2e4. \n\n \ub2e4\uc74c\uc758 \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694.\n\n - URL: `{webhook_url}` \n - Method: POST \n - Content Type: application/json \n\n Home Assistant \ub85c \ub4e4\uc5b4\uc624\ub294 \ub370\uc774\ud130\ub97c \ucc98\ub9ac\ud558\uae30 \uc704\ud55c \uc790\ub3d9\ud654\ub97c \uad6c\uc131\ud558\ub294 \ubc29\ubc95\uc740 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." + "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\uae30 \uc704\ud574\uc11c\ub294 [IFTTT Webhook \uc560\ud50c\ub9bf]({applet_url}) \uc5d0\uc11c \"Make a web request\" \ub97c \uc0ac\uc6a9\ud574\uc57c \ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c\uc758 \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694.\n\n - URL: `{webhook_url}` \n - Method: POST \n - Content Type: application/json \n\nHome Assistant \ub85c \ub4e4\uc5b4\uc624\ub294 \ub370\uc774\ud130\ub97c \ucc98\ub9ac\ud558\uae30 \uc704\ud55c \uc790\ub3d9\ud654\ub97c \uad6c\uc131\ud558\ub294 \ubc29\ubc95\uc740 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." }, "step": { "user": { diff --git a/homeassistant/components/ifttt/.translations/ru.json b/homeassistant/components/ifttt/.translations/ru.json index 3c1d7b580e4d0..dc846993e2ec0 100644 --- a/homeassistant/components/ifttt/.translations/ru.json +++ b/homeassistant/components/ifttt/.translations/ru.json @@ -10,7 +10,7 @@ "step": { "user": { "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c IFTTT?", - "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 IFTTT Webhook Applet" + "title": "IFTTT Webhook" } }, "title": "IFTTT" diff --git a/homeassistant/components/lifx/.translations/hu.json b/homeassistant/components/lifx/.translations/hu.json index c78905b09c8f9..255b2efc91a07 100644 --- a/homeassistant/components/lifx/.translations/hu.json +++ b/homeassistant/components/lifx/.translations/hu.json @@ -1,7 +1,12 @@ { "config": { + "abort": { + "no_devices_found": "Nem tal\u00e1lhat\u00f3k LIFX eszk\u00f6z\u00f6k a h\u00e1l\u00f3zaton.", + "single_instance_allowed": "Csak egyetlen LIFX konfigur\u00e1ci\u00f3 lehets\u00e9ges." + }, "step": { "confirm": { + "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a LIFX-t?", "title": "LIFX" } }, diff --git a/homeassistant/components/luftdaten/.translations/hu.json b/homeassistant/components/luftdaten/.translations/hu.json new file mode 100644 index 0000000000000..48914a944654c --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/hu.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "user": { + "title": "Luftdaten be\u00e1ll\u00edt\u00e1sa" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/pt.json b/homeassistant/components/luftdaten/.translations/pt.json new file mode 100644 index 0000000000000..6a242c441af4e --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/pt.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "communication_error": "N\u00e3o \u00e9 poss\u00edvel comunicar com a API da Luftdaten", + "invalid_sensor": "Sensor n\u00e3o dispon\u00edvel ou inv\u00e1lido", + "sensor_exists": "Sensor j\u00e1 registado" + }, + "step": { + "user": { + "data": { + "show_on_map": "Mostrar no mapa" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/ru.json b/homeassistant/components/luftdaten/.translations/ru.json index 506a5c0548560..d37aa3567d197 100644 --- a/homeassistant/components/luftdaten/.translations/ru.json +++ b/homeassistant/components/luftdaten/.translations/ru.json @@ -11,7 +11,7 @@ "show_on_map": "\u041f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043d\u0430 \u043a\u0430\u0440\u0442\u0435", "station_id": "\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u0434\u0430\u0442\u0447\u0438\u043a\u0430 Luftdaten" }, - "title": "\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c Luftdaten" + "title": "Luftdaten" } }, "title": "Luftdaten" diff --git a/homeassistant/components/mailgun/.translations/ca.json b/homeassistant/components/mailgun/.translations/ca.json index f31c4838a4dd4..fcb087e68852f 100644 --- a/homeassistant/components/mailgun/.translations/ca.json +++ b/homeassistant/components/mailgun/.translations/ca.json @@ -5,7 +5,7 @@ "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia." }, "create_entry": { - "default": "Per enviar esdeveniments a Home Assistant, haureu de configurar [Webhooks amb Mailgun]({mailgun_url}). \n\n Ompliu la informaci\u00f3 seg\u00fcent: \n\n - URL: `{webhook_url}` \n - M\u00e8tode: POST \n - Tipus de contingut: application/x-www-form-urlencoded\n\nConsulteu [la documentaci\u00f3]({docs_url}) sobre com configurar els automatismes per gestionar les dades entrants." + "default": "Per enviar esdeveniments a Home Assistant, haureu de configurar [Webhooks amb Mailgun]({mailgun_url}). \n\n Ompliu la informaci\u00f3 seg\u00fcent: \n\n - URL: `{webhook_url}` \n - M\u00e8tode: POST \n - Tipus de contingut: application/x-www-form-urlencoded\n\nVegeu [la documentaci\u00f3]({docs_url}) sobre com configurar els automatismes per gestionar les dades entrants." }, "step": { "user": { diff --git a/homeassistant/components/mailgun/.translations/ko.json b/homeassistant/components/mailgun/.translations/ko.json index 0dd8cbdb47d26..95897a25f1535 100644 --- a/homeassistant/components/mailgun/.translations/ko.json +++ b/homeassistant/components/mailgun/.translations/ko.json @@ -5,7 +5,7 @@ "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4." }, "create_entry": { - "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 [Mailgun Webhook]({mailgun_url}) \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694. \n\n - URL: `{webhook_url}`\n - Method: POST\n - Content Type: application/x-www-form-urlencoded\n \n Home Assistant \ub85c \ub4e4\uc5b4\uc624\ub294 \ub370\uc774\ud130\ub97c \ucc98\ub9ac\ud558\uae30 \uc704\ud55c \uc790\ub3d9\ud654\ub97c \uad6c\uc131\ud558\ub294 \ubc29\ubc95\uc740 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." + "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 [Mailgun Webhook]({mailgun_url}) \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694. \n\n - URL: `{webhook_url}`\n - Method: POST\n - Content Type: application/x-www-form-urlencoded\n \nHome Assistant \ub85c \ub4e4\uc5b4\uc624\ub294 \ub370\uc774\ud130\ub97c \ucc98\ub9ac\ud558\uae30 \uc704\ud55c \uc790\ub3d9\ud654\ub97c \uad6c\uc131\ud558\ub294 \ubc29\ubc95\uc740 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." }, "step": { "user": { diff --git a/homeassistant/components/mailgun/.translations/ru.json b/homeassistant/components/mailgun/.translations/ru.json index 62007a9580926..b1828ee28ef39 100644 --- a/homeassistant/components/mailgun/.translations/ru.json +++ b/homeassistant/components/mailgun/.translations/ru.json @@ -10,7 +10,7 @@ "step": { "user": { "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Mailgun?", - "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Mailgun Webhook" + "title": "Mailgun Webhook" } }, "title": "Mailgun" diff --git a/homeassistant/components/mailgun/.translations/sl.json b/homeassistant/components/mailgun/.translations/sl.json index 12dad4d8c7ec3..4eb12d7343ce9 100644 --- a/homeassistant/components/mailgun/.translations/sl.json +++ b/homeassistant/components/mailgun/.translations/sl.json @@ -5,7 +5,7 @@ "one_instance_allowed": "Potrebna je samo ena instanca." }, "create_entry": { - "default": "Za po\u0161iljanje dogodkov Home Assistentu boste morali nastaviti [Webhooks z Mailgun]({mailgun_url}).\n\nIzpolnite naslednje informacije:\n\n- URL: `{webhook_url}`\n- Metoda: POST\n- Vrsta vsebine: application/x-www-form-urlencoded\n\nGlej [dokumentacijo]({docs_url}) o tem, kako nastavite automations za obravnavo dohodnih podatkov." + "default": "Za po\u0161iljanje dogodkov Home Assistentu boste morali nastaviti [Webhooks z Mailgun]({mailgun_url}).\n\nIzpolnite naslednje informacije:\n\n- URL: `{webhook_url}`\n- Metoda: POST\n- Vrsta vsebine: application/json\n\nGlej [dokumentacijo]({docs_url}) o tem, kako nastavite automations za obravnavo dohodnih podatkov." }, "step": { "user": { diff --git a/homeassistant/components/mqtt/.translations/ru.json b/homeassistant/components/mqtt/.translations/ru.json index 7e35c219c45c7..9757716b1bffa 100644 --- a/homeassistant/components/mqtt/.translations/ru.json +++ b/homeassistant/components/mqtt/.translations/ru.json @@ -4,7 +4,7 @@ "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." }, "error": { - "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0431\u0440\u043e\u043a\u0435\u0440\u0443." + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0431\u0440\u043e\u043a\u0435\u0440\u0443" }, "step": { "broker": { diff --git a/homeassistant/components/openuv/.translations/ru.json b/homeassistant/components/openuv/.translations/ru.json index bd7fc3f81917e..38e261ab6bd86 100644 --- a/homeassistant/components/openuv/.translations/ru.json +++ b/homeassistant/components/openuv/.translations/ru.json @@ -12,7 +12,7 @@ "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430" }, - "title": "\u0417\u0430\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u0441\u0432\u043e\u044e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e" + "title": "OpenUV" } }, "title": "OpenUV" diff --git a/homeassistant/components/owntracks/.translations/ca.json b/homeassistant/components/owntracks/.translations/ca.json new file mode 100644 index 0000000000000..438148f414c55 --- /dev/null +++ b/homeassistant/components/owntracks/.translations/ca.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia." + }, + "create_entry": { + "default": "\n\nPer Android: obre [l'app OwnTracks]({android_url}), ves a prefer\u00e8ncies -> connexi\u00f3, i posa els par\u00e0metres seguents:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\nPer iOS: obre [l'app OwnTracks]({ios_url}), clica l'icona (i) a dalt a l'esquerra -> configuraci\u00f3 (settings), i posa els par\u00e0metres settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\nVegeu [the documentation]({docs_url}) per a m\u00e9s informaci\u00f3." + }, + "step": { + "user": { + "description": "Esteu segur que voleu configurar OwnTracks?", + "title": "Configureu OwnTracks" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/.translations/hu.json b/homeassistant/components/owntracks/.translations/hu.json new file mode 100644 index 0000000000000..9c4e46a28bfe0 --- /dev/null +++ b/homeassistant/components/owntracks/.translations/hu.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani az Owntracks-t?", + "title": "Owntracks be\u00e1ll\u00edt\u00e1sa" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/.translations/ko.json b/homeassistant/components/owntracks/.translations/ko.json new file mode 100644 index 0000000000000..ba264ad4b473f --- /dev/null +++ b/homeassistant/components/owntracks/.translations/ko.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4." + }, + "create_entry": { + "default": "\n\nAndroid \uc778 \uacbd\uc6b0, [OwnTracks \uc571]({android_url}) \uc744 \uc5f4\uace0 preferences -> connection \uc73c\ub85c \uc774\ub3d9\ud558\uc5ec \ub2e4\uc74c\uacfc \uac19\uc774 \uc124\uc815\ud574\uc8fc\uc138\uc694:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\niOS \uc778 \uacbd\uc6b0, [OwnTracks \uc571]({ios_url}) \uc744 \uc5f4\uace0 \uc67c\ucabd \uc0c1\ub2e8\uc758 (i) \uc544\uc774\ucf58\uc744 \ud0ed\ud558\uc5ec \uc124\uc815\uc73c\ub85c \uc774\ub3d9\ud558\uc5ec \ub2e4\uc74c\uacfc \uac19\uc774 \uc124\uc815\ud574\uc8fc\uc138\uc694:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret} \n \n\uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." + }, + "step": { + "user": { + "description": "OwnTracks \ub97c \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "OwnTracks \uc124\uc815" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/.translations/lb.json b/homeassistant/components/owntracks/.translations/lb.json new file mode 100644 index 0000000000000..146fda64b1ef4 --- /dev/null +++ b/homeassistant/components/owntracks/.translations/lb.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "N\u00ebmmen eng eenzeg Instanz ass n\u00e9ideg." + }, + "create_entry": { + "default": "\n\nOp Android, an [der OwnTracks App]({android_url}), g\u00e9i an Preferences -> Connection. \u00c4nnert folgend Astellungen:\n- Mode: Private HTTP\n- Host {webhool_url}\n- Identification:\n - Username: ``\n - Device ID: ``\n\nOp IOS, an [der OwnTracks App]({ios_url}), klick op (i) Ikon uewen l\u00e9nks -> Settings. \u00c4nnert folgend Astellungen:\n- Mode: HTTP\n- URL: {webhool_url}\n- Turn on authentication:\n- UserID: ``\n\n{secret}\n\nKuckt w.e.g. [Dokumentatioun]({docs_url}) fir m\u00e9i Informatiounen." + }, + "step": { + "user": { + "description": "S\u00e9cher fir OwnTracks anzeriichten?", + "title": "OwnTracks ariichten" + } + }, + "title": "Owntracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/.translations/no.json b/homeassistant/components/owntracks/.translations/no.json new file mode 100644 index 0000000000000..9f86cd12cc4f5 --- /dev/null +++ b/homeassistant/components/owntracks/.translations/no.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "Kun \u00e9n enkelt forekomst er n\u00f8dvendig." + }, + "create_entry": { + "default": "\n\nP\u00e5 Android, \u00e5pne [OwnTracks appen]({android_url}), g\u00e5 til Instillinger -> tilkobling. Endre f\u00f8lgende innstillinger: \n - Modus: Privat HTTP\n - Vert: {webhook_url} \n - Identifikasjon: \n - Brukernavn: ` ` \n - Enhets-ID: ` ` \n\nP\u00e5 iOS, \u00e5pne [OwnTracks appen]({ios_url}), trykk p\u00e5 (i) ikonet \u00f8verst til venstre - > innstillinger. Endre f\u00f8lgende innstillinger: \n - Modus: HTTP \n - URL: {webhook_url} \n - Sl\u00e5 p\u00e5 autentisering \n - BrukerID: ` ` \n\n {secret} \n \n Se [dokumentasjonen]({docs_url}) for mer informasjon." + }, + "step": { + "user": { + "description": "Er du sikker p\u00e5 at du vil sette opp OwnTracks?", + "title": "Sett opp OwnTracks" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/.translations/pl.json b/homeassistant/components/owntracks/.translations/pl.json new file mode 100644 index 0000000000000..134c49ecbbb5c --- /dev/null +++ b/homeassistant/components/owntracks/.translations/pl.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "Wymagana jest tylko jedna instancja." + }, + "create_entry": { + "default": "Na Androida, otw\u00f3rz [the OwnTracks app]({android_url}), id\u017a do preferencje -> po\u0142aczenia. Zmie\u0144 nast\u0119puj\u0105ce ustawienia:\n - Tryb: Private HTTP\n - Host: {webhook_url}\n - Identyfikacja:\n - Nazwa u\u017cytkownika: ``\n - ID urz\u0105dzenia: ``\n\nNa iOS, otw\u00f3rz [the OwnTracks app]({ios_url}), stuknij ikon\u0119 (i) w lewym g\u00f3rnym rogu -> ustawienia. Zmie\u0144 nast\u0119puj\u0105ce ustawienia:\n - Tryb: HTTP\n - URL: {webhook_url}\n - W\u0142\u0105cz uwierzytelnianie\n - ID u\u017cytkownika: ``\n\n{secret}" + }, + "step": { + "user": { + "description": "Czy na pewno chcesz skonfigurowa\u0107 OwnTracks?", + "title": "Skonfiguruj OwnTracks" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/.translations/pt.json b/homeassistant/components/owntracks/.translations/pt.json new file mode 100644 index 0000000000000..91df7f5a8ea80 --- /dev/null +++ b/homeassistant/components/owntracks/.translations/pt.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "Apenas uma \u00fanica inst\u00e2ncia \u00e9 necess\u00e1ria." + }, + "create_entry": { + "default": "\n\n No Android, abra [o aplicativo OwnTracks] ( {android_url} ), v\u00e1 para prefer\u00eancias - > conex\u00e3o. Altere as seguintes configura\u00e7\u00f5es: \n - Modo: HTTP privado \n - Anfitri\u00e3o: {webhook_url} \n - Identifica\u00e7\u00e3o: \n - Nome de usu\u00e1rio: ` \n - ID do dispositivo: ` ` \n\n No iOS, abra [o aplicativo OwnTracks] ( {ios_url} ), toque no \u00edcone (i) no canto superior esquerdo - > configura\u00e7\u00f5es. Altere as seguintes configura\u00e7\u00f5es: \n - Modo: HTTP \n - URL: {webhook_url} \n - Ativar autentica\u00e7\u00e3o \n - UserID: ` ` \n\n {secret} \n \n Veja [a documenta\u00e7\u00e3o] ( {docs_url} ) para mais informa\u00e7\u00f5es." + }, + "step": { + "user": { + "description": "Tem certeza de que deseja configurar o OwnTracks?", + "title": "Configurar OwnTracks" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/.translations/ru.json b/homeassistant/components/owntracks/.translations/ru.json new file mode 100644 index 0000000000000..bb9c7f39c5b14 --- /dev/null +++ b/homeassistant/components/owntracks/.translations/ru.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "create_entry": { + "default": "\u0415\u0441\u043b\u0438 \u0432\u0430\u0448\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u043d\u0430 \u043e\u043f\u0435\u0440\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u0435 Android, \u043e\u0442\u043a\u0440\u043e\u0439\u0442\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 [OwnTracks]({android_url}), \u0437\u0430\u0442\u0435\u043c preferences -> connection. \u0418\u0437\u043c\u0435\u043d\u0438\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0442\u0430\u043a, \u043a\u0430\u043a \u0443\u043a\u0430\u0437\u0430\u043d\u043e \u043d\u0438\u0436\u0435:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\n\u0415\u0441\u043b\u0438 \u0432\u0430\u0448\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u043d\u0430 iOS, \u043e\u0442\u043a\u0440\u043e\u0439\u0442\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 [OwnTracks]({ios_url}), \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u043d\u0430 \u0437\u043d\u0430\u0447\u0435\u043a (i) \u0432 \u043b\u0435\u0432\u043e\u043c \u0432\u0435\u0440\u0445\u043d\u0435\u043c \u0443\u0433\u043b\u0443 -> settings. \u0418\u0437\u043c\u0435\u043d\u0438\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0442\u0430\u043a, \u043a\u0430\u043a \u0443\u043a\u0430\u0437\u0430\u043d\u043e \u043d\u0438\u0436\u0435:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\n\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439]({docs_url}) \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0439 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438." + }, + "step": { + "user": { + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c OwnTracks?", + "title": "OwnTracks" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/.translations/sl.json b/homeassistant/components/owntracks/.translations/sl.json new file mode 100644 index 0000000000000..e7ae559363753 --- /dev/null +++ b/homeassistant/components/owntracks/.translations/sl.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "Potrebna je samo ena instanca." + }, + "create_entry": { + "default": "\n\n V Androidu odprite aplikacijo OwnTracks ( {android_url} ) in pojdite na {android_url} nastavitve - > povezave. Spremenite naslednje nastavitve: \n - Na\u010din: zasebni HTTP \n - gostitelj: {webhook_url} \n - Identifikacija: \n - Uporabni\u0161ko ime: ` ` \n - ID naprave: ` ` \n\n V iOS-ju odprite aplikacijo OwnTracks ( {ios_url} ), tapnite ikono (i) v zgornjem levem kotu - > nastavitve. Spremenite naslednje nastavitve: \n - na\u010din: HTTP \n - URL: {webhook_url} \n - Vklopite preverjanje pristnosti \n - UserID: ` ` \n\n {secret} \n \n Za ve\u010d informacij si oglejte [dokumentacijo] ( {docs_url} )." + }, + "step": { + "user": { + "description": "Ali ste prepri\u010dani, da \u017eelite nastaviti Owntracks?", + "title": "Nastavite OwnTracks" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/.translations/zh-Hans.json b/homeassistant/components/owntracks/.translations/zh-Hans.json new file mode 100644 index 0000000000000..64a6935a9b243 --- /dev/null +++ b/homeassistant/components/owntracks/.translations/zh-Hans.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "\u53ea\u6709\u4e00\u4e2a\u5b9e\u4f8b\u662f\u5fc5\u9700\u7684\u3002" + }, + "create_entry": { + "default": "\n\n\u5728 Android \u8bbe\u5907\u4e0a\uff0c\u6253\u5f00 [OwnTracks APP]({android_url})\uff0c\u524d\u5f80 Preferences -> Connection\u3002\u4fee\u6539\u4ee5\u4e0b\u8bbe\u5b9a\uff1a\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\n\u5728 iOS \u8bbe\u5907\u4e0a\uff0c\u6253\u5f00 [OwnTracks APP]({ios_url})\uff0c\u70b9\u51fb\u5de6\u4e0a\u89d2\u7684 (i) \u56fe\u6807-> Settings\u3002\u4fee\u6539\u4ee5\u4e0b\u8bbe\u5b9a\uff1a\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\n\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u591a\u4fe1\u606f\u3002" + }, + "step": { + "user": { + "description": "\u60a8\u786e\u5b9a\u8981\u8bbe\u7f6e OwnTracks \u5417\uff1f", + "title": "\u8bbe\u7f6e OwnTracks" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/.translations/zh-Hant.json b/homeassistant/components/owntracks/.translations/zh-Hant.json new file mode 100644 index 0000000000000..d8c195cb27738 --- /dev/null +++ b/homeassistant/components/owntracks/.translations/zh-Hant.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u7d44\u7269\u4ef6\u5373\u53ef\u3002" + }, + "create_entry": { + "default": "\n\n\u65bc Android \u88dd\u7f6e\uff0c\u6253\u958b [OwnTracks app]({android_url})\u3001\u9ede\u9078\u8a2d\u5b9a\uff08preferences\uff09 -> \u9023\u7dda\uff08connection\uff09\u3002\u8b8a\u66f4\u4ee5\u4e0b\u8a2d\u5b9a\uff1a\n - \u6a21\u5f0f\uff08Mode\uff09\uff1aPrivate HTTP\n - \u4e3b\u6a5f\u7aef\uff08Host\uff09\uff1a{webhook_url}\n - Identification\uff1a\n - Username\uff1a ``\n - Device ID\uff1a``\n\n\u65bc iOS \u88dd\u7f6e\uff0c\u6253\u958b [OwnTracks app]({ios_url})\u3001\u9ede\u9078\u5de6\u4e0a\u65b9\u7684 (i) \u5716\u793a -> \u8a2d\u5b9a\uff08settings\uff09\u3002\u8b8a\u66f4\u4ee5\u4e0b\u8a2d\u5b9a\uff1a\n - \u6a21\u5f0f\uff08Mode\uff09\uff1aHTTP\n - URL: {webhook_url}\n - \u958b\u555f authentication\n - UserID: ``\n\n{secret}\n\n\u8acb\u53c3\u95b1 [\u6587\u4ef6]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u8a73\u7d30\u8cc7\u6599\u3002" + }, + "step": { + "user": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a OwnTracks\uff1f", + "title": "\u8a2d\u5b9a OwnTracks" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/ko.json b/homeassistant/components/point/.translations/ko.json index fcc9a92bd5eed..0480b6d7195ee 100644 --- a/homeassistant/components/point/.translations/ko.json +++ b/homeassistant/components/point/.translations/ko.json @@ -16,7 +16,7 @@ }, "step": { "auth": { - "description": "\uc544\ub798 \ub9c1\ud06c\ub97c \ud074\ub9ad\ud558\uc5ec Minut \uacc4\uc815\uc5d0 \ub300\ud55c \ub3d9\uc758 \ud55c \ub2e4\uc74c, \ub2e4\uc2dc \ub3cc\uc544\uc640\uc11c \ud558\ub2e8\uc758 Submit \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694. \n\n [\ub9c1\ud06c] ( {authorization_url} )", + "description": "\uc544\ub798 \ub9c1\ud06c\ub97c \ud074\ub9ad\ud558\uc5ec Minut \uacc4\uc815\uc5d0 \ub300\ud574 \ub3d9\uc758 \ud55c \ub2e4\uc74c, \ub2e4\uc2dc \ub3cc\uc544\uc640\uc11c \ud558\ub2e8\uc758 Submit \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694. \n\n[\ub9c1\ud06c] ({authorization_url})", "title": "Point \uc778\uc99d" }, "user": { diff --git a/homeassistant/components/point/.translations/nl.json b/homeassistant/components/point/.translations/nl.json new file mode 100644 index 0000000000000..ff7f2cdcd5676 --- /dev/null +++ b/homeassistant/components/point/.translations/nl.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "flow_impl": "Leverancier" + }, + "title": "Authenticatieleverancier" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/pt.json b/homeassistant/components/point/.translations/pt.json new file mode 100644 index 0000000000000..8831696fcff1e --- /dev/null +++ b/homeassistant/components/point/.translations/pt.json @@ -0,0 +1,12 @@ +{ + "config": { + "error": { + "follow_link": "Por favor, siga o link e autentique antes de pressionar Enviar" + }, + "step": { + "user": { + "title": "Fornecedor de Autentica\u00e7\u00e3o" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/sl.json b/homeassistant/components/point/.translations/sl.json new file mode 100644 index 0000000000000..bd0ac2f1218a9 --- /dev/null +++ b/homeassistant/components/point/.translations/sl.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_setup": "Nastavite lahko samo en ra\u010dun Point.", + "authorize_url_fail": "Neznana napaka pri generiranju potrditvenega URL-ja.", + "authorize_url_timeout": "\u010casovna omejitev za generiranje potrditvenega URL-ja je potekla.", + "external_setup": "To\u010dka uspe\u0161no konfigurirana iz drugega toka.", + "no_flows": "Preden lahko preverite pristnost, morate konfigurirati Point. [Preberite navodila](https://www.home-assistant.io/components/point/)." + }, + "create_entry": { + "default": "Uspe\u0161no overjen z Minut-om za va\u0161e Point naprave" + }, + "error": { + "follow_link": "Prosimo, sledite povezavi in \u200b\u200bpreverite pristnost, preden pritisnete Po\u0161lji", + "no_token": "Ni potrjeno z Minutom" + }, + "step": { + "auth": { + "description": "Prosimo, sledite spodnji povezavi in Sprejmite dostop do va\u0161ega Minut ra\u010duna, nato se vrnite in pritisnite Po\u0161lji spodaj. \n\n [Povezava] ( {authorization_url} )", + "title": "To\u010dka za overovitev" + }, + "user": { + "data": { + "flow_impl": "Ponudnik" + }, + "description": "Izberite prek katerega ponudnika overjanja, ki ga \u017eelite overiti z Point-om.", + "title": "Ponudnik za preverjanje pristnosti" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/zh-Hans.json b/homeassistant/components/point/.translations/zh-Hans.json index 7d88bfeec42ae..6b5cb91cfeb25 100644 --- a/homeassistant/components/point/.translations/zh-Hans.json +++ b/homeassistant/components/point/.translations/zh-Hans.json @@ -1,7 +1,13 @@ { "config": { "step": { + "auth": { + "description": "\u8bf7\u8bbf\u95ee\u4e0b\u65b9\u7684\u94fe\u63a5\u5e76\u5141\u8bb8\u8bbf\u95ee\u60a8\u7684 Minut \u8d26\u6237\uff0c\u7136\u540e\u56de\u6765\u70b9\u51fb\u4e0b\u9762\u7684\u63d0\u4ea4\u3002\n\n[\u94fe\u63a5]({authorization_url})" + }, "user": { + "data": { + "flow_impl": "\u63d0\u4f9b\u8005" + }, "description": "\u9009\u62e9\u60a8\u60f3\u901a\u8fc7\u54ea\u4e2a\u6388\u6743\u63d0\u4f9b\u8005\u4e0e Point \u8fdb\u884c\u6388\u6743\u3002", "title": "\u6388\u6743\u63d0\u4f9b\u8005" } diff --git a/homeassistant/components/rainmachine/.translations/hu.json b/homeassistant/components/rainmachine/.translations/hu.json new file mode 100644 index 0000000000000..2fbb55b2833e2 --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/hu.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "invalid_credentials": "\u00c9rv\u00e9nytelen hiteles\u00edt\u0151 adatok" + }, + "step": { + "user": { + "data": { + "password": "Jelsz\u00f3" + } + } + }, + "title": "Rainmachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/pt.json b/homeassistant/components/rainmachine/.translations/pt.json new file mode 100644 index 0000000000000..20f963d9dfb65 --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/pt.json @@ -0,0 +1,13 @@ +{ + "config": { + "error": { + "identifier_exists": "Conta j\u00e1 registada", + "invalid_credentials": "Credenciais inv\u00e1lidas" + }, + "step": { + "user": { + "title": "Preencha as suas informa\u00e7\u00f5es" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/ru.json b/homeassistant/components/rainmachine/.translations/ru.json index 4a714f1899950..6eec3ef0ebac0 100644 --- a/homeassistant/components/rainmachine/.translations/ru.json +++ b/homeassistant/components/rainmachine/.translations/ru.json @@ -11,7 +11,7 @@ "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "port": "\u041f\u043e\u0440\u0442" }, - "title": "\u0417\u0430\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u0441\u0432\u043e\u044e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e" + "title": "RainMachine" } }, "title": "RainMachine" diff --git a/homeassistant/components/rainmachine/.translations/sl.json b/homeassistant/components/rainmachine/.translations/sl.json new file mode 100644 index 0000000000000..10d05fadf9385 --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/sl.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Ra\u010dun \u017ee registriran", + "invalid_credentials": "Neveljavne poverilnice" + }, + "step": { + "user": { + "data": { + "ip_address": "Ime gostitelja ali naslov IP", + "password": "Geslo", + "port": "port" + }, + "title": "Izpolnite svoje podatke" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/.translations/ru.json b/homeassistant/components/simplisafe/.translations/ru.json index 4ddf405e1eda8..f685297890eae 100644 --- a/homeassistant/components/simplisafe/.translations/ru.json +++ b/homeassistant/components/simplisafe/.translations/ru.json @@ -11,7 +11,7 @@ "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "username": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b" }, - "title": "\u0417\u0430\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u0441\u0432\u043e\u044e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e" + "title": "SimpliSafe" } }, "title": "SimpliSafe" diff --git a/homeassistant/components/smhi/.translations/hu.json b/homeassistant/components/smhi/.translations/hu.json index 740fc1a8179c9..86fed8933ef49 100644 --- a/homeassistant/components/smhi/.translations/hu.json +++ b/homeassistant/components/smhi/.translations/hu.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "name_exists": "A n\u00e9v m\u00e1r l\u00e9tezik" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/sonos/.translations/hu.json b/homeassistant/components/sonos/.translations/hu.json index 4726d57ad249a..7811a31ebdb04 100644 --- a/homeassistant/components/sonos/.translations/hu.json +++ b/homeassistant/components/sonos/.translations/hu.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "no_devices_found": "Nem tal\u00e1lhat\u00f3k Sonos eszk\u00f6z\u00f6k a h\u00e1l\u00f3zaton." + "no_devices_found": "Nem tal\u00e1lhat\u00f3k Sonos eszk\u00f6z\u00f6k a h\u00e1l\u00f3zaton.", + "single_instance_allowed": "Csak egyetlen Sonos konfigur\u00e1ci\u00f3 sz\u00fcks\u00e9ges." }, "step": { "confirm": { diff --git a/homeassistant/components/tradfri/.translations/ru.json b/homeassistant/components/tradfri/.translations/ru.json index c7fcfd50b56ca..c42ca6b7b2b3f 100644 --- a/homeassistant/components/tradfri/.translations/ru.json +++ b/homeassistant/components/tradfri/.translations/ru.json @@ -4,7 +4,7 @@ "already_configured": "\u0428\u043b\u044e\u0437 \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d" }, "error": { - "cannot_connect": "\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0448\u043b\u044e\u0437\u0443", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0448\u043b\u044e\u0437\u0443", "invalid_key": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0441\u044f \u0441 \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u043c \u043a\u043b\u044e\u0447\u043e\u043c. \u0415\u0441\u043b\u0438 \u044d\u0442\u043e \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u043f\u0435\u0440\u0435\u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c \u0448\u043b\u044e\u0437.", "timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u043a\u043e\u0434\u0430." }, diff --git a/homeassistant/components/twilio/.translations/ca.json b/homeassistant/components/twilio/.translations/ca.json index 6f63614fdb745..6f9e22bfd4030 100644 --- a/homeassistant/components/twilio/.translations/ca.json +++ b/homeassistant/components/twilio/.translations/ca.json @@ -5,7 +5,7 @@ "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia." }, "create_entry": { - "default": "Per enviar esdeveniments a Home Assistant, haureu de configurar [Webhooks amb Twilio]({twilio_url}).\n\n Ompliu la informaci\u00f3 seg\u00fcent: \n\n - URL: `{webhook_url}` \n - M\u00e8tode: POST \n - Tipus de contingut: application/x-www-form-urlencoded\n\nConsulteu [la documentaci\u00f3]({docs_url}) sobre com configurar els automatismes per gestionar les dades entrants." + "default": "Per enviar esdeveniments a Home Assistant, haureu de configurar [Webhooks amb Twilio]({twilio_url}).\n\n Ompliu la informaci\u00f3 seg\u00fcent: \n\n - URL: `{webhook_url}` \n - M\u00e8tode: POST \n - Tipus de contingut: application/x-www-form-urlencoded\n\nVegeu [la documentaci\u00f3]({docs_url}) sobre com configurar els automatismes per gestionar les dades entrants." }, "step": { "user": { diff --git a/homeassistant/components/twilio/.translations/ko.json b/homeassistant/components/twilio/.translations/ko.json index 028919bff9056..8790c70800883 100644 --- a/homeassistant/components/twilio/.translations/ko.json +++ b/homeassistant/components/twilio/.translations/ko.json @@ -5,11 +5,11 @@ "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4." }, "create_entry": { - "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 [Twilio Webhook]({twilio_url}) \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694. \n\n - URL: `{webhook_url}`\n - Method: POST\n - Content Type: application/x-www-form-urlencoded\n \n Home Assistant \ub85c \ub4e4\uc5b4\uc624\ub294 \ub370\uc774\ud130\ub97c \ucc98\ub9ac\ud558\uae30 \uc704\ud55c \uc790\ub3d9\ud654\ub97c \uad6c\uc131\ud558\ub294 \ubc29\ubc95\uc740 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." + "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 [Twilio Webhook]({twilio_url}) \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694. \n\n - URL: `{webhook_url}`\n - Method: POST\n - Content Type: application/x-www-form-urlencoded\n \nHome Assistant \ub85c \ub4e4\uc5b4\uc624\ub294 \ub370\uc774\ud130\ub97c \ucc98\ub9ac\ud558\uae30 \uc704\ud55c \uc790\ub3d9\ud654\ub97c \uad6c\uc131\ud558\ub294 \ubc29\ubc95\uc740 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." }, "step": { "user": { - "description": "Twilio \uc744 \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "description": "Twilio \ub97c \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", "title": "Twilio Webhook \uc124\uc815" } }, diff --git a/homeassistant/components/twilio/.translations/ru.json b/homeassistant/components/twilio/.translations/ru.json index e758a47064e15..c195392be2233 100644 --- a/homeassistant/components/twilio/.translations/ru.json +++ b/homeassistant/components/twilio/.translations/ru.json @@ -10,7 +10,7 @@ "step": { "user": { "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Twilio?", - "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Twilio Webhook" + "title": "Twilio Webhook" } }, "title": "Twilio" diff --git a/homeassistant/components/unifi/.translations/ru.json b/homeassistant/components/unifi/.translations/ru.json index 908c1c5d0c579..ca1a802a580c6 100644 --- a/homeassistant/components/unifi/.translations/ru.json +++ b/homeassistant/components/unifi/.translations/ru.json @@ -17,7 +17,7 @@ "site": "ID \u0441\u0430\u0439\u0442\u0430", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" }, - "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 UniFi Controller" + "title": "UniFi Controller" } }, "title": "UniFi Controller" diff --git a/homeassistant/components/upnp/.translations/hu.json b/homeassistant/components/upnp/.translations/hu.json index a2bf78a7f3e04..fc0225cc534fa 100644 --- a/homeassistant/components/upnp/.translations/hu.json +++ b/homeassistant/components/upnp/.translations/hu.json @@ -6,6 +6,7 @@ }, "user": { "data": { + "enable_sensors": "Forgalom \u00e9rz\u00e9kel\u0151k hozz\u00e1ad\u00e1sa", "igd": "UPnP/IGD" }, "title": "Az UPnP/IGD be\u00e1ll\u00edt\u00e1si lehet\u0151s\u00e9gei" diff --git a/homeassistant/components/upnp/.translations/ru.json b/homeassistant/components/upnp/.translations/ru.json index 5cb9a3f4a2768..8e86c41366bf1 100644 --- a/homeassistant/components/upnp/.translations/ru.json +++ b/homeassistant/components/upnp/.translations/ru.json @@ -16,7 +16,7 @@ "enable_sensors": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0434\u0430\u0442\u0447\u0438\u043a\u0438 \u0441\u0435\u0442\u0435\u0432\u043e\u0433\u043e \u0442\u0440\u0430\u0444\u0438\u043a\u0430", "igd": "UPnP / IGD" }, - "title": "\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0434\u043b\u044f UPnP / IGD" + "title": "UPnP / IGD" } }, "title": "UPnP / IGD" diff --git a/homeassistant/components/zha/.translations/ca.json b/homeassistant/components/zha/.translations/ca.json new file mode 100644 index 0000000000000..1feac454c454f --- /dev/null +++ b/homeassistant/components/zha/.translations/ca.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Nom\u00e9s es permet una \u00fanica configuraci\u00f3 de ZHA." + }, + "error": { + "cannot_connect": "No es pot connectar amb el dispositiu ZHA." + }, + "step": { + "user": { + "data": { + "radio_type": "Tipus de r\u00e0dio", + "usb_path": "Ruta del port USB amb el dispositiu" + }, + "title": "ZHA" + } + }, + "title": "ZHA" + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/en.json b/homeassistant/components/zha/.translations/en.json index b6d7948c0b3a1..f0da251f5eb64 100644 --- a/homeassistant/components/zha/.translations/en.json +++ b/homeassistant/components/zha/.translations/en.json @@ -1,21 +1,20 @@ { - "config": { - "title": "ZHA", - "step": { - "user": { - "title": "ZHA", - "description": "", - "data": { - "usb_path": "USB Device Path", - "radio_type": "Radio Type" - } - } - }, - "abort": { - "single_instance_allowed": "Only a single configuration of ZHA is allowed." - }, - "error": { - "cannot_connect": "Unable to connect to ZHA device." + "config": { + "abort": { + "single_instance_allowed": "Only a single configuration of ZHA is allowed." + }, + "error": { + "cannot_connect": "Unable to connect to ZHA device." + }, + "step": { + "user": { + "data": { + "radio_type": "Radio Type", + "usb_path": "USB Device Path" + }, + "title": "ZHA" + } + }, + "title": "ZHA" } - } } \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/ko.json b/homeassistant/components/zha/.translations/ko.json new file mode 100644 index 0000000000000..ffeaf4588e680 --- /dev/null +++ b/homeassistant/components/zha/.translations/ko.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\ud558\ub098\uc758 ZHA \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + }, + "error": { + "cannot_connect": "ZHA \uc7a5\uce58\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4." + }, + "step": { + "user": { + "data": { + "radio_type": "\ubb34\uc120 \uc720\ud615", + "usb_path": "USB \uc7a5\uce58 \uacbd\ub85c" + }, + "description": "\uc8c4\uc1a1\ud569\ub2c8\ub2e4. \uad00\ub828 \ub0b4\uc6a9\uc774 \uc544\uc9c1 \uc5c5\ub370\uc774\ud2b8 \ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \ucd94\ud6c4\uc5d0 \ubc18\uc601\ub420 \uc608\uc815\uc774\ub2c8 \uc870\uae08\ub9cc \uae30\ub2e4\ub824\uc8fc\uc138\uc694.", + "title": "ZHA" + } + }, + "title": "ZHA" + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/lb.json b/homeassistant/components/zha/.translations/lb.json new file mode 100644 index 0000000000000..37304c8c8fda8 --- /dev/null +++ b/homeassistant/components/zha/.translations/lb.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun ZHA ass erlaabt." + }, + "error": { + "cannot_connect": "Keng Verbindung mam ZHA Apparat m\u00e9iglech." + }, + "step": { + "user": { + "data": { + "radio_type": "Typ vun Radio", + "usb_path": "Pad zum USB Apparat" + }, + "description": "Eidel", + "title": "ZHA" + } + }, + "title": "ZHA" + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/nl.json b/homeassistant/components/zha/.translations/nl.json new file mode 100644 index 0000000000000..e7a3c901c21a4 --- /dev/null +++ b/homeassistant/components/zha/.translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "step": { + "user": { + "data": { + "radio_type": "Radio Type", + "usb_path": "USB-apparaatpad" + }, + "description": "Leeg", + "title": "ZHA" + } + }, + "title": "ZHA" + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/no.json b/homeassistant/components/zha/.translations/no.json new file mode 100644 index 0000000000000..9db55494ba4ac --- /dev/null +++ b/homeassistant/components/zha/.translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Kun \u00e9n enkelt konfigurasjon av ZHA er tillatt." + }, + "error": { + "cannot_connect": "Kan ikke koble til ZHA-enhet." + }, + "step": { + "user": { + "data": { + "radio_type": "Radio type", + "usb_path": "USB enhetsbane" + }, + "title": "ZHA" + } + }, + "title": "ZHA" + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/pl.json b/homeassistant/components/zha/.translations/pl.json new file mode 100644 index 0000000000000..88d4b83ca0dfe --- /dev/null +++ b/homeassistant/components/zha/.translations/pl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Dozwolona jest tylko jedna konfiguracja ZHA." + }, + "error": { + "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z urz\u0105dzeniem ZHA." + }, + "step": { + "user": { + "data": { + "radio_type": "Typ radia", + "usb_path": "\u015acie\u017cka urz\u0105dzenia USB" + }, + "description": "Puste", + "title": "ZHA" + } + }, + "title": "ZHA" + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/pt.json b/homeassistant/components/zha/.translations/pt.json new file mode 100644 index 0000000000000..8db9f20dc7bf7 --- /dev/null +++ b/homeassistant/components/zha/.translations/pt.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Apenas uma configura\u00e7\u00e3o do ZHA \u00e9 permitida." + }, + "error": { + "cannot_connect": "N\u00e3o \u00e9 poss\u00edvel conectar-se ao dispositivo ZHA." + }, + "step": { + "user": { + "data": { + "radio_type": "Tipo de r\u00e1dio", + "usb_path": "Caminho do Dispositivo USB" + }, + "description": "Vazio" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/ru.json b/homeassistant/components/zha/.translations/ru.json new file mode 100644 index 0000000000000..cd61807259242 --- /dev/null +++ b/homeassistant/components/zha/.translations/ru.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + }, + "step": { + "user": { + "data": { + "radio_type": "\u0422\u0438\u043f \u0420\u0430\u0434\u0438\u043e", + "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + }, + "title": "Zigbee Home Automation (ZHA)" + } + }, + "title": "Zigbee Home Automation" + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/sl.json b/homeassistant/components/zha/.translations/sl.json new file mode 100644 index 0000000000000..888b9be2bc7c3 --- /dev/null +++ b/homeassistant/components/zha/.translations/sl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Dovoljena je samo ena konfiguracija ZHA." + }, + "error": { + "cannot_connect": "Ne morem se povezati napravo ZHA." + }, + "step": { + "user": { + "data": { + "radio_type": "Vrsta radia", + "usb_path": "USB Pot" + }, + "description": "Prazno", + "title": "ZHA" + } + }, + "title": "ZHA" + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/zh-Hans.json b/homeassistant/components/zha/.translations/zh-Hans.json new file mode 100644 index 0000000000000..8befb2ee114d7 --- /dev/null +++ b/homeassistant/components/zha/.translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "usb_path": "USB \u8bbe\u5907\u8def\u5f84" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/zh-Hant.json b/homeassistant/components/zha/.translations/zh-Hant.json new file mode 100644 index 0000000000000..24809a59e0bad --- /dev/null +++ b/homeassistant/components/zha/.translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u50c5\u5141\u8a31\u8a2d\u5b9a\u4e00\u7d44 ZHA\u3002" + }, + "error": { + "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3 ZHA \u88dd\u7f6e\u3002" + }, + "step": { + "user": { + "data": { + "radio_type": "\u7121\u7dda\u96fb\u985e\u578b", + "usb_path": "USB \u88dd\u7f6e\u8def\u5f91" + }, + "description": "\u7a7a\u767d", + "title": "ZHA" + } + }, + "title": "ZHA" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/ru.json b/homeassistant/components/zone/.translations/ru.json index f0619f2163c36..dc408035d0f65 100644 --- a/homeassistant/components/zone/.translations/ru.json +++ b/homeassistant/components/zone/.translations/ru.json @@ -13,7 +13,7 @@ "passive": "\u041f\u0430\u0441\u0441\u0438\u0432\u043d\u0430\u044f", "radius": "\u0420\u0430\u0434\u0438\u0443\u0441" }, - "title": "\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0437\u043e\u043d\u044b" + "title": "\u0417\u043e\u043d\u0430" } }, "title": "\u0417\u043e\u043d\u0430" diff --git a/homeassistant/components/zwave/.translations/ca.json b/homeassistant/components/zwave/.translations/ca.json index b617a902374dc..7849f34bbf979 100644 --- a/homeassistant/components/zwave/.translations/ca.json +++ b/homeassistant/components/zwave/.translations/ca.json @@ -5,7 +5,7 @@ "one_instance_only": "El component nom\u00e9s admet una inst\u00e0ncia de Z-Wave" }, "error": { - "option_error": "Ha fallat la validaci\u00f3 de Z-Wave. \u00c9s correcta la ruta al port on hi ha la mem\u00f2ria USB?" + "option_error": "Ha fallat la validaci\u00f3 de Z-Wave. \u00c9s correcta la ruta al port USB on hi ha la mem\u00f2ria?" }, "step": { "user": { diff --git a/homeassistant/components/zwave/.translations/ru.json b/homeassistant/components/zwave/.translations/ru.json index 457bfd3baa8e1..b6856e4590ace 100644 --- a/homeassistant/components/zwave/.translations/ru.json +++ b/homeassistant/components/zwave/.translations/ru.json @@ -2,19 +2,19 @@ "config": { "abort": { "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", - "one_instance_only": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u0438\u043d \u044d\u043a\u0437\u0435\u043c\u043f\u043b\u044f\u0440 Z-Wave" + "one_instance_only": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u043c\u043e\u0436\u0435\u0442 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c\u0441\u044f \u0442\u043e\u043b\u044c\u043a\u043e \u0441 \u043e\u0434\u043d\u0438\u043c \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440\u043e\u043c Z-Wave" }, "error": { - "option_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 Z-Wave. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043f\u0443\u0442\u044c \u043a USB-\u043d\u0430\u043a\u043e\u043f\u0438\u0442\u0435\u043b\u044e." + "option_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 Z-Wave. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443." }, "step": { "user": { "data": { - "network_key": "\u041a\u043b\u044e\u0447 \u0441\u0435\u0442\u0438 (\u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u043e\u043b\u0435 \u043f\u0443\u0441\u0442\u044b\u043c \u0434\u043b\u044f \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0439 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438)", - "usb_path": "\u041f\u0443\u0442\u044c \u043a USB" + "network_key": "\u041a\u043b\u044e\u0447 \u0441\u0435\u0442\u0438 (\u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u0443\u0441\u0442\u044b\u043c \u0434\u043b\u044f \u0430\u0432\u0442\u043e\u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438)", + "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" }, - "description": "\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439](https://www.home-assistant.io/docs/z-wave/installation/) \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438 \u043e \u043f\u0435\u0440\u0435\u043c\u0435\u043d\u043d\u044b\u0445 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438", - "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Z-Wave" + "description": "\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439](https://www.home-assistant.io/docs/z-wave/installation/) \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0439 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438 \u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0435 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430", + "title": "Z-Wave" } }, "title": "Z-Wave" From 26a38f1faeeb8c034ae6aa388281011b3662b655 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 6 Dec 2018 00:30:45 +0100 Subject: [PATCH 282/325] Updated frontend to 20181205.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 408f19436cece..43a4839bf4392 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20181126.0'] +REQUIREMENTS = ['home-assistant-frontend==20181205.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index e3d958e8a7ba4..b4b6825f8338b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -493,7 +493,7 @@ hole==0.3.0 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181126.0 +home-assistant-frontend==20181205.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f62bb98fa887e..f9baf85a06220 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -101,7 +101,7 @@ hdate==0.7.5 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181126.0 +home-assistant-frontend==20181205.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From 962358bf87468213fd1e4187c2b93a3390bd0279 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 6 Dec 2018 09:20:53 +0100 Subject: [PATCH 283/325] Fix cloud const (#19052) * Fix cloud const * Fix tests --- homeassistant/components/cloud/cloud_api.py | 17 +++++++++++++++++ homeassistant/components/cloud/const.py | 2 +- tests/test_util/aiohttp.py | 5 +++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/cloud_api.py b/homeassistant/components/cloud/cloud_api.py index 13575068a3e10..c62768cc5145a 100644 --- a/homeassistant/components/cloud/cloud_api.py +++ b/homeassistant/components/cloud/cloud_api.py @@ -1,8 +1,11 @@ """Cloud APIs.""" from functools import wraps +import logging from . import auth_api +_LOGGER = logging.getLogger(__name__) + def _check_token(func): """Decorate a function to verify valid token.""" @@ -15,7 +18,21 @@ async def check_token(cloud, *args): return check_token +def _log_response(func): + """Decorate a function to log bad responses.""" + @wraps(func) + async def log_response(*args): + """Log response if it's bad.""" + resp = await func(*args) + meth = _LOGGER.debug if resp.status < 400 else _LOGGER.warning + meth('Fetched %s (%s)', resp.url, resp.status) + return resp + + return log_response + + @_check_token +@_log_response async def async_create_cloudhook(cloud): """Create a cloudhook.""" websession = cloud.hass.helpers.aiohttp_client.async_get_clientsession() diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 01d92c6f50fe4..a5019efaa8eec 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -18,7 +18,7 @@ 'amazonaws.com/prod/smart_home_sync'), 'subscription_info_url': ('https://stripe-api.nabucasa.com/payments/' 'subscription_info'), - 'cloudhook_create_url': 'https://webhook-api.nabucasa.com/generate' + 'cloudhook_create_url': 'https://webhooks-api.nabucasa.com/generate' } } diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index d662f3b195521..f5bf0b8a4f8ff 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -177,6 +177,11 @@ def cookies(self): """Return dict of cookies.""" return self._cookies + @property + def url(self): + """Return yarl of URL.""" + return self._url + @property def content(self): """Return content.""" From b71d65015aad6fd12728d7bf731e2b1d8e869b4e Mon Sep 17 00:00:00 2001 From: Erik Eriksson <8228319+molobrakos@users.noreply.github.com> Date: Thu, 6 Dec 2018 09:22:49 +0100 Subject: [PATCH 284/325] VOC: Update external dependency to fix engine start issue (#19062) --- homeassistant/components/volvooncall.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/volvooncall.py b/homeassistant/components/volvooncall.py index b47c7f7cdf7fd..75339171cbc4c 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -24,7 +24,7 @@ DATA_KEY = DOMAIN -REQUIREMENTS = ['volvooncall==0.7.9'] +REQUIREMENTS = ['volvooncall==0.7.11'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index b4b6825f8338b..eebd9d006d587 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1591,7 +1591,7 @@ venstarcolortouch==0.6 volkszaehler==0.1.2 # homeassistant.components.volvooncall -volvooncall==0.7.9 +volvooncall==0.7.11 # homeassistant.components.verisure vsure==1.5.2 From b9ed4b7a763b91e49d35ed078b20ef161c75416b Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 6 Dec 2018 09:24:49 +0100 Subject: [PATCH 285/325] Fix saving YAML as JSON with empty array (#19057) * Fix saving YAML as JSON with empty array * Lint --- homeassistant/components/lovelace/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 36130e362cd14..0d9b6a6d9fe89 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -267,6 +267,8 @@ def add_card(fname: str, view_id: str, card_config: str, if str(view.get('id', '')) != view_id: continue cards = view.get('cards', []) + if not cards and 'cards' in view: + del view['cards'] if data_format == FORMAT_YAML: card_config = yaml.yaml_to_object(card_config) if 'id' not in card_config: @@ -275,6 +277,8 @@ def add_card(fname: str, view_id: str, card_config: str, cards.append(card_config) else: cards.insert(position, card_config) + if 'cards' not in view: + view['cards'] = cards yaml.save_yaml(fname, config) return @@ -402,6 +406,8 @@ def add_view(fname: str, view_config: str, views.append(view_config) else: views.insert(position, view_config) + if 'views' not in config: + config['views'] = views yaml.save_yaml(fname, config) From 47320adcc6d7c48e3d495712f40e78384ddb5f69 Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Thu, 6 Dec 2018 09:25:39 +0100 Subject: [PATCH 286/325] Update pyhomematic to 0.1.53 (#19056) --- homeassistant/components/homematic/__init__.py | 18 ++++++++++++++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index d53362172217f..ee99e236fa93a 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -13,12 +13,13 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_NAME, CONF_HOST, CONF_HOSTS, CONF_PASSWORD, - CONF_PLATFORM, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN) + CONF_PLATFORM, CONF_USERNAME, CONF_SSL, CONF_VERIFY_SSL, + EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN) from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyhomematic==0.1.52'] +REQUIREMENTS = ['pyhomematic==0.1.53'] _LOGGER = logging.getLogger(__name__) @@ -77,7 +78,8 @@ 'IPSmoke', 'RFSiren', 'PresenceIP', 'IPAreaThermostat', 'IPWeatherSensor', 'RotaryHandleSensorIP', 'IPPassageSensor', 'IPKeySwitchPowermeter', 'IPThermostatWall230V', 'IPWeatherSensorPlus', - 'IPWeatherSensorBasic', 'IPBrightnessSensor', 'IPGarage'], + 'IPWeatherSensorBasic', 'IPBrightnessSensor', 'IPGarage', + 'UniversalSensor'], DISCOVER_CLIMATE: [ 'Thermostat', 'ThermostatWall', 'MAXThermostat', 'ThermostatWall2', 'MAXWallThermostat', 'IPThermostat', 'IPThermostatWall', @@ -173,6 +175,9 @@ DEFAULT_PATH = '' DEFAULT_USERNAME = 'Admin' DEFAULT_PASSWORD = '' +DEFAULT_SSL = False +DEFAULT_VERIFY_SSL = False +DEFAULT_CHANNEL = 1 DEVICE_SCHEMA = vol.Schema({ @@ -180,7 +185,7 @@ vol.Required(ATTR_NAME): cv.string, vol.Required(ATTR_ADDRESS): cv.string, vol.Required(ATTR_INTERFACE): cv.string, - vol.Optional(ATTR_CHANNEL, default=1): vol.Coerce(int), + vol.Optional(ATTR_CHANNEL, default=DEFAULT_CHANNEL): vol.Coerce(int), vol.Optional(ATTR_PARAM): cv.string, vol.Optional(ATTR_UNIQUE_ID): cv.string, }) @@ -198,6 +203,9 @@ vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, vol.Optional(CONF_CALLBACK_IP): cv.string, vol.Optional(CONF_CALLBACK_PORT): cv.port, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional( + CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, }}, vol.Optional(CONF_HOSTS, default={}): {cv.match_all: { vol.Required(CONF_HOST): cv.string, @@ -268,6 +276,8 @@ def setup(hass, config): 'password': rconfig.get(CONF_PASSWORD), 'callbackip': rconfig.get(CONF_CALLBACK_IP), 'callbackport': rconfig.get(CONF_CALLBACK_PORT), + 'ssl': rconfig.get(CONF_SSL), + 'verify_ssl': rconfig.get(CONF_VERIFY_SSL), 'connect': True, } diff --git a/requirements_all.txt b/requirements_all.txt index eebd9d006d587..5105eb8b532bd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -978,7 +978,7 @@ pyhik==0.1.8 pyhiveapi==0.2.14 # homeassistant.components.homematic -pyhomematic==0.1.52 +pyhomematic==0.1.53 # homeassistant.components.sensor.hydroquebec pyhydroquebec==2.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f9baf85a06220..c63fb9a120059 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -169,7 +169,7 @@ pydeconz==47 pydispatcher==2.0.5 # homeassistant.components.homematic -pyhomematic==0.1.52 +pyhomematic==0.1.53 # homeassistant.components.litejet pylitejet==0.1 From f0d534cebce3a5fb0958e1752352e4bfc690449a Mon Sep 17 00:00:00 2001 From: pbalogh77 Date: Thu, 6 Dec 2018 09:28:06 +0100 Subject: [PATCH 287/325] Implemented unique ID support for Fibaro hub integration (#19055) * Unique ID support New unique ID support, based on hub's serial number and device's permanent ID * Fixes, showing attributes Minor fixes Showing room, hub, fibaro_id for easier mapping and finding of devices * Update fibaro.py --- homeassistant/components/fibaro.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/fibaro.py b/homeassistant/components/fibaro.py index dacf0c97edf1e..5813b1948909f 100644 --- a/homeassistant/components/fibaro.py +++ b/homeassistant/components/fibaro.py @@ -7,6 +7,7 @@ import logging from collections import defaultdict +from typing import Optional import voluptuous as vol from homeassistant.const import (ATTR_ARMED, ATTR_BATTERY_LEVEL, @@ -65,8 +66,8 @@ class FibaroController(): _device_map = None # Dict for mapping deviceId to device object fibaro_devices = None # List of devices by type _callbacks = {} # Dict of update value callbacks by deviceId - _client = None # Fiblary's Client object for communication - _state_handler = None # Fiblary's StateHandler object + _client = None # Fiblary's Client object for communication + _state_handler = None # Fiblary's StateHandler object _import_plugins = None # Whether to import devices from plugins def __init__(self, username, password, url, import_plugins): @@ -74,11 +75,14 @@ def __init__(self, username, password, url, import_plugins): from fiblary3.client.v4.client import Client as FibaroClient self._client = FibaroClient(url, username, password) self._scene_map = None + self.hub_serial = None # Unique serial number of the hub def connect(self): """Start the communication with the Fibaro controller.""" try: login = self._client.login.get() + info = self._client.info.get() + self.hub_serial = slugify(info.serialNumber) except AssertionError: _LOGGER.error("Can't connect to Fibaro HC. " "Please check URL.") @@ -180,9 +184,12 @@ def _read_scenes(self): room_name = 'Unknown' else: room_name = self._room_map[device.roomID].name + device.room_name = room_name device.friendly_name = '{} {}'.format(room_name, device.name) device.ha_id = '{}_{}_{}'.format( slugify(room_name), slugify(device.name), device.id) + device.unique_id_str = "{}.{}".format( + self.hub_serial, device.id) self._scene_map[device.id] = device self.fibaro_devices['scene'].append(device) @@ -197,6 +204,7 @@ def _read_devices(self): room_name = 'Unknown' else: room_name = self._room_map[device.roomID].name + device.room_name = room_name device.friendly_name = room_name + ' ' + device.name device.ha_id = '{}_{}_{}'.format( slugify(room_name), slugify(device.name), device.id) @@ -207,6 +215,8 @@ def _read_devices(self): else: device.mapped_type = None if device.mapped_type: + device.unique_id_str = "{}.{}".format( + self.hub_serial, device.id) self._device_map[device.id] = device self.fibaro_devices[device.mapped_type].append(device) else: @@ -347,7 +357,12 @@ def current_binary_state(self): return False @property - def name(self): + def unique_id(self) -> str: + """Return a unique ID.""" + return self.fibaro_device.unique_id_str + + @property + def name(self) -> Optional[str]: """Return the name of the device.""" return self._name @@ -380,5 +395,5 @@ def device_state_attributes(self): except (ValueError, KeyError): pass - attr['id'] = self.ha_id + attr['fibaro_id'] = self.fibaro_device.id return attr From 72379c166ed62e9a6568bf959f3f6e63ced8cd55 Mon Sep 17 00:00:00 2001 From: Martin Gross Date: Thu, 6 Dec 2018 09:29:30 +0100 Subject: [PATCH 288/325] Update locationsharinglib to 3.0.9 (#19045) --- homeassistant/components/device_tracker/google_maps.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/google_maps.py b/homeassistant/components/device_tracker/google_maps.py index 1995179ff5abe..1f95414541cc2 100644 --- a/homeassistant/components/device_tracker/google_maps.py +++ b/homeassistant/components/device_tracker/google_maps.py @@ -19,7 +19,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify, dt as dt_util -REQUIREMENTS = ['locationsharinglib==3.0.8'] +REQUIREMENTS = ['locationsharinglib==3.0.9'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 5105eb8b532bd..2899ab0e9887b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -604,7 +604,7 @@ liveboxplaytv==2.0.2 lmnotify==0.0.4 # homeassistant.components.device_tracker.google_maps -locationsharinglib==3.0.8 +locationsharinglib==3.0.9 # homeassistant.components.logi_circle logi_circle==0.1.7 From d4c80245220f6835db4a2ca644d0dce95372bd65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Thu, 6 Dec 2018 09:30:11 +0100 Subject: [PATCH 289/325] Add support for more Tibber Pulse data (#19033) --- homeassistant/components/sensor/tibber.py | 6 +++++- homeassistant/components/tibber/__init__.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index 2c921e95863f4..0ba470ca778e7 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -191,7 +191,11 @@ async def _async_callback(self, payload): if live_measurement is None: return self._state = live_measurement.pop('power', None) - self._device_state_attributes = live_measurement + for key, value in live_measurement.items(): + if value is None: + continue + self._device_state_attributes[key] = value + self.async_schedule_update_ha_state() @property diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 27595dc09c766..fce4312ad68bd 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -16,7 +16,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['pyTibber==0.8.4'] +REQUIREMENTS = ['pyTibber==0.8.5'] DOMAIN = 'tibber' diff --git a/requirements_all.txt b/requirements_all.txt index 2899ab0e9887b..c56bf8888e5fe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -834,7 +834,7 @@ pyRFXtrx==0.23 pySwitchmate==0.4.4 # homeassistant.components.tibber -pyTibber==0.8.4 +pyTibber==0.8.5 # homeassistant.components.switch.dlink pyW215==0.6.0 From 30c77b9e64662ebfe219083ed30e8a4506372f23 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 6 Dec 2018 09:36:44 +0100 Subject: [PATCH 290/325] Bumped version to 0.85.0.dev0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index b4a94d318f6cc..3f24da30a0a41 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 = 84 +MINOR_VERSION = 85 PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) From 04c7d5c128c61bc26caf6950ccb231cb27faacac Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 6 Dec 2018 10:38:26 +0100 Subject: [PATCH 291/325] Revert #17745 (#19064) --- homeassistant/components/google_assistant/http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index d688491fe8925..f0294c3bcb23e 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -48,7 +48,7 @@ def is_exposed(entity) -> bool: entity_config.get(entity.entity_id, {}).get(CONF_EXPOSE) domain_exposed_by_default = \ - expose_by_default or entity.domain in exposed_domains + expose_by_default and entity.domain in exposed_domains # Expose an entity if the entity's domain is exposed by default and # the configuration doesn't explicitly exclude it from being From 1be440a72b79c96d633ed796b16f802e6aa0a1f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Thu, 6 Dec 2018 12:54:44 +0200 Subject: [PATCH 292/325] Upgrade pylint to 2.2.2 (#18750) * Upgrade to 2.2.0 * simplifiable-if-expression fixes * duplicate-string-formatting-argument fixes * unused-import fixes * Upgrade to 2.2.1 * Remove no longer needed disable * Upgrade to 2.2.2 --- .../components/binary_sensor/alarmdecoder.py | 16 ++++++++-------- .../components/binary_sensor/mystrom.py | 3 +-- homeassistant/components/frontend/__init__.py | 4 ++-- homeassistant/components/homekit/__init__.py | 2 +- homeassistant/components/homekit/type_fans.py | 2 +- .../components/media_player/bluesound.py | 2 +- .../components/media_player/ue_smart_radio.py | 2 +- .../components/owntracks/config_flow.py | 3 +-- homeassistant/components/sensor/bme680.py | 6 ++---- homeassistant/components/sensor/miflora.py | 2 +- homeassistant/components/sensor/mitemp_bt.py | 2 +- homeassistant/components/sensor/mvglive.py | 8 ++++---- homeassistant/components/sensor/statistics.py | 3 +-- homeassistant/components/spaceapi.py | 2 +- homeassistant/components/switch/raspihats.py | 4 ++-- homeassistant/config.py | 7 ++++--- homeassistant/helpers/intent.py | 3 +-- requirements_test.txt | 2 +- requirements_test_all.txt | 2 +- 19 files changed, 35 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/binary_sensor/alarmdecoder.py b/homeassistant/components/binary_sensor/alarmdecoder.py index 1b50d6c6c7259..f7a42e9b831e0 100644 --- a/homeassistant/components/binary_sensor/alarmdecoder.py +++ b/homeassistant/components/binary_sensor/alarmdecoder.py @@ -92,14 +92,14 @@ def device_state_attributes(self): """Return the state attributes.""" attr = {} if self._rfid and self._rfstate is not None: - attr[ATTR_RF_BIT0] = True if self._rfstate & 0x01 else False - attr[ATTR_RF_LOW_BAT] = True if self._rfstate & 0x02 else False - attr[ATTR_RF_SUPERVISED] = True if self._rfstate & 0x04 else False - attr[ATTR_RF_BIT3] = True if self._rfstate & 0x08 else False - attr[ATTR_RF_LOOP3] = True if self._rfstate & 0x10 else False - attr[ATTR_RF_LOOP2] = True if self._rfstate & 0x20 else False - attr[ATTR_RF_LOOP4] = True if self._rfstate & 0x40 else False - attr[ATTR_RF_LOOP1] = True if self._rfstate & 0x80 else False + attr[ATTR_RF_BIT0] = bool(self._rfstate & 0x01) + attr[ATTR_RF_LOW_BAT] = bool(self._rfstate & 0x02) + attr[ATTR_RF_SUPERVISED] = bool(self._rfstate & 0x04) + attr[ATTR_RF_BIT3] = bool(self._rfstate & 0x08) + attr[ATTR_RF_LOOP3] = bool(self._rfstate & 0x10) + attr[ATTR_RF_LOOP2] = bool(self._rfstate & 0x20) + attr[ATTR_RF_LOOP4] = bool(self._rfstate & 0x40) + attr[ATTR_RF_LOOP1] = bool(self._rfstate & 0x80) return attr @property diff --git a/homeassistant/components/binary_sensor/mystrom.py b/homeassistant/components/binary_sensor/mystrom.py index 5785ed464fd44..4927be27eb303 100644 --- a/homeassistant/components/binary_sensor/mystrom.py +++ b/homeassistant/components/binary_sensor/mystrom.py @@ -61,8 +61,7 @@ async def _handle(self, hass, data): '{}_{}'.format(button_id, button_action)) self.add_entities([self.buttons[entity_id]]) else: - new_state = True if self.buttons[entity_id].state == 'off' \ - else False + new_state = self.buttons[entity_id].state == 'off' self.buttons[entity_id].async_on_update(new_state) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 43a4839bf4392..77c98ab6aa2df 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -55,8 +55,8 @@ for size in (192, 384, 512, 1024): MANIFEST_JSON['icons'].append({ - 'src': '/static/icons/favicon-{}x{}.png'.format(size, size), - 'sizes': '{}x{}'.format(size, size), + 'src': '/static/icons/favicon-{size}x{size}.png'.format(size=size), + 'sizes': '{size}x{size}'.format(size=size), 'type': 'image/png' }) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index da8daf50f2a65..c8aea5f8fb37e 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -245,7 +245,7 @@ def start(self, *args): return self.status = STATUS_WAIT - # pylint: disable=unused-variable + # pylint: disable=unused-import from . import ( # noqa F401 type_covers, type_fans, type_lights, type_locks, type_media_players, type_security_systems, type_sensors, diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index 5a860ed21c8e4..2b4e55c4c8dc5 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -78,7 +78,7 @@ def set_oscillating(self, value): """Set state if call came from HomeKit.""" _LOGGER.debug('%s: Set oscillating to %d', self.entity_id, value) self._flag[CHAR_SWING_MODE] = True - oscillating = True if value == 1 else False + oscillating = value == 1 params = {ATTR_ENTITY_ID: self.entity_id, ATTR_OSCILLATING: oscillating} self.call_service(DOMAIN, SERVICE_OSCILLATE, params, oscillating) diff --git a/homeassistant/components/media_player/bluesound.py b/homeassistant/components/media_player/bluesound.py index f4ed62b15cdaf..998f559bc8aa5 100644 --- a/homeassistant/components/media_player/bluesound.py +++ b/homeassistant/components/media_player/bluesound.py @@ -779,7 +779,7 @@ def is_grouped(self): @property def shuffle(self): """Return true if shuffle is active.""" - return True if self._status.get('shuffle', '0') == '1' else False + return self._status.get('shuffle', '0') == '1' async def async_join(self, master): """Join the player to a group.""" diff --git a/homeassistant/components/media_player/ue_smart_radio.py b/homeassistant/components/media_player/ue_smart_radio.py index 066972aaa25b7..75f6d92a98c28 100644 --- a/homeassistant/components/media_player/ue_smart_radio.py +++ b/homeassistant/components/media_player/ue_smart_radio.py @@ -136,7 +136,7 @@ def icon(self): @property def is_volume_muted(self): """Boolean if volume is currently muted.""" - return True if self._volume <= 0 else False + return self._volume <= 0 @property def volume_level(self): diff --git a/homeassistant/components/owntracks/config_flow.py b/homeassistant/components/owntracks/config_flow.py index 8cf19e84bcdd2..6818efbbf7575 100644 --- a/homeassistant/components/owntracks/config_flow.py +++ b/homeassistant/components/owntracks/config_flow.py @@ -9,8 +9,7 @@ def supports_encryption(): """Test if we support encryption.""" try: - # pylint: disable=unused-variable - import libnacl # noqa + import libnacl # noqa pylint: disable=unused-import return True except OSError: return False diff --git a/homeassistant/components/sensor/bme680.py b/homeassistant/components/sensor/bme680.py index cbcb7f1080e89..64d28c25bf0b7 100644 --- a/homeassistant/components/sensor/bme680.py +++ b/homeassistant/components/sensor/bme680.py @@ -179,10 +179,8 @@ def _setup_bme680(config): sensor_handler = BME680Handler( sensor, - True if ( - SENSOR_GAS in config[CONF_MONITORED_CONDITIONS] or - SENSOR_AQ in config[CONF_MONITORED_CONDITIONS] - ) else False, + (SENSOR_GAS in config[CONF_MONITORED_CONDITIONS] or + SENSOR_AQ in config[CONF_MONITORED_CONDITIONS]), config[CONF_AQ_BURN_IN_TIME], config[CONF_AQ_HUM_BASELINE], config[CONF_AQ_HUM_WEIGHTING] diff --git a/homeassistant/components/sensor/miflora.py b/homeassistant/components/sensor/miflora.py index 74bb8261609da..412b339caf3a1 100644 --- a/homeassistant/components/sensor/miflora.py +++ b/homeassistant/components/sensor/miflora.py @@ -55,7 +55,7 @@ async def async_setup_platform(hass, config, async_add_entities, """Set up the MiFlora sensor.""" from miflora import miflora_poller try: - import bluepy.btle # noqa: F401 pylint: disable=unused-variable + import bluepy.btle # noqa: F401 pylint: disable=unused-import from btlewrap import BluepyBackend backend = BluepyBackend except ImportError: diff --git a/homeassistant/components/sensor/mitemp_bt.py b/homeassistant/components/sensor/mitemp_bt.py index 2ae5c29b04398..15e225fd2c0ed 100644 --- a/homeassistant/components/sensor/mitemp_bt.py +++ b/homeassistant/components/sensor/mitemp_bt.py @@ -60,7 +60,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the MiTempBt sensor.""" from mitemp_bt import mitemp_bt_poller try: - import bluepy.btle # noqa: F401 pylint: disable=unused-variable + import bluepy.btle # noqa: F401 pylint: disable=unused-import from btlewrap import BluepyBackend backend = BluepyBackend except ImportError: diff --git a/homeassistant/components/sensor/mvglive.py b/homeassistant/components/sensor/mvglive.py index 8634e4f457065..5ec66aafe2f32 100644 --- a/homeassistant/components/sensor/mvglive.py +++ b/homeassistant/components/sensor/mvglive.py @@ -147,10 +147,10 @@ def __init__(self, station, destinations, directions, self._products = products self._timeoffset = timeoffset self._number = number - self._include_ubahn = True if 'U-Bahn' in self._products else False - self._include_tram = True if 'Tram' in self._products else False - self._include_bus = True if 'Bus' in self._products else False - self._include_sbahn = True if 'S-Bahn' in self._products else False + self._include_ubahn = 'U-Bahn' in self._products + self._include_tram = 'Tram' in self._products + self._include_bus = 'Bus' in self._products + self._include_sbahn = 'S-Bahn' in self._products self.mvg = MVGLive.MVGLive() self.departures = [] diff --git a/homeassistant/components/sensor/statistics.py b/homeassistant/components/sensor/statistics.py index e011121f4a2ce..01c783dc1db25 100644 --- a/homeassistant/components/sensor/statistics.py +++ b/homeassistant/components/sensor/statistics.py @@ -80,8 +80,7 @@ def __init__(self, entity_id, name, sampling_size, max_age, precision): """Initialize the Statistics sensor.""" self._entity_id = entity_id - self.is_binary = True if self._entity_id.split('.')[0] == \ - 'binary_sensor' else False + self.is_binary = self._entity_id.split('.')[0] == 'binary_sensor' if not self.is_binary: self._name = '{} {}'.format(name, ATTR_MEAN) else: diff --git a/homeassistant/components/spaceapi.py b/homeassistant/components/spaceapi.py index eaf1508071ad0..fa2e5e8e1eafb 100644 --- a/homeassistant/components/spaceapi.py +++ b/homeassistant/components/spaceapi.py @@ -129,7 +129,7 @@ def get(self, request): if space_state is not None: state = { - ATTR_OPEN: False if space_state.state == 'off' else True, + ATTR_OPEN: space_state.state != 'off', ATTR_LASTCHANGE: dt_util.as_timestamp(space_state.last_updated), } diff --git a/homeassistant/components/switch/raspihats.py b/homeassistant/components/switch/raspihats.py index c697d7042a6c1..b422efea2ffe4 100644 --- a/homeassistant/components/switch/raspihats.py +++ b/homeassistant/components/switch/raspihats.py @@ -123,7 +123,7 @@ def is_on(self): def turn_on(self, **kwargs): """Turn the device on.""" try: - state = True if self._invert_logic is False else False + state = self._invert_logic is False self.I2C_HATS_MANAGER.write_dq(self._address, self._channel, state) self.schedule_update_ha_state() except I2CHatsException as ex: @@ -132,7 +132,7 @@ def turn_on(self, **kwargs): def turn_off(self, **kwargs): """Turn the device off.""" try: - state = False if self._invert_logic is False else True + state = self._invert_logic is not False self.I2C_HATS_MANAGER.write_dq(self._address, self._channel, state) self.schedule_update_ha_state() except I2CHatsException as ex: diff --git a/homeassistant/config.py b/homeassistant/config.py index 4fc77bd81cdce..10d3ce21a0009 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -437,9 +437,10 @@ def _format_config_error(ex: vol.Invalid, domain: str, config: Dict) -> str: """ message = "Invalid config for [{}]: ".format(domain) if 'extra keys not allowed' in ex.error_message: - message += '[{}] is an invalid option for [{}]. Check: {}->{}.'\ - .format(ex.path[-1], domain, domain, - '->'.join(str(m) for m in ex.path)) + message += '[{option}] is an invalid option for [{domain}]. ' \ + 'Check: {domain}->{path}.'.format( + option=ex.path[-1], domain=domain, + path='->'.join(str(m) for m in ex.path)) else: message += '{}.'.format(humanize_error(config, ex)) diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index d942aabccced6..f4d57ce86cdb5 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -68,8 +68,7 @@ async def async_handle(hass: HomeAssistantType, platform: str, intent_type, err) raise InvalidSlotInfo( 'Received invalid slot info for {}'.format(intent_type)) from err - # https://github.com/PyCQA/pylint/issues/2284 - except IntentHandleError: # pylint: disable=try-except-raise + except IntentHandleError: raise except Exception as err: raise IntentUnexpectedError( diff --git a/requirements_test.txt b/requirements_test.txt index 8d761c1e614ba..d9c52bbd053b3 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,7 +8,7 @@ flake8==3.6.0 mock-open==1.3.1 mypy==0.641 pydocstyle==2.1.1 -pylint==2.1.1 +pylint==2.2.2 pytest-aiohttp==0.3.0 pytest-cov==2.6.0 pytest-sugar==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c63fb9a120059..5105350739a3e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -9,7 +9,7 @@ flake8==3.6.0 mock-open==1.3.1 mypy==0.641 pydocstyle==2.1.1 -pylint==2.1.1 +pylint==2.2.2 pytest-aiohttp==0.3.0 pytest-cov==2.6.0 pytest-sugar==0.9.2 From c9316192691e096397964ac30b85b1919b9443f1 Mon Sep 17 00:00:00 2001 From: Sean Wilson Date: Thu, 6 Dec 2018 10:25:59 -0500 Subject: [PATCH 293/325] Add CM17A support (#19041) * Add CM17A support. * Update log entry --- homeassistant/components/light/x10.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/light/x10.py b/homeassistant/components/light/x10.py index ef2211a4469fc..9618a13a1a975 100644 --- a/homeassistant/components/light/x10.py +++ b/homeassistant/components/light/x10.py @@ -41,24 +41,26 @@ def get_unit_status(code): def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the x10 Light platform.""" + is_cm11a = True try: x10_command('info') except CalledProcessError as err: - _LOGGER.error(err.output) - return False + _LOGGER.info("Assuming that the device is CM17A: %s", err.output) + is_cm11a = False - add_entities(X10Light(light) for light in config[CONF_DEVICES]) + add_entities(X10Light(light, is_cm11a) for light in config[CONF_DEVICES]) class X10Light(Light): """Representation of an X10 Light.""" - def __init__(self, light): + def __init__(self, light, is_cm11a): """Initialize an X10 Light.""" self._name = light['name'] self._id = light['id'] self._brightness = 0 self._state = False + self._is_cm11a = is_cm11a @property def name(self): @@ -82,15 +84,25 @@ def supported_features(self): def turn_on(self, **kwargs): """Instruct the light to turn on.""" - x10_command('on ' + self._id) + if self._is_cm11a: + x10_command('on ' + self._id) + else: + x10_command('fon ' + self._id) self._brightness = kwargs.get(ATTR_BRIGHTNESS, 255) self._state = True def turn_off(self, **kwargs): """Instruct the light to turn off.""" - x10_command('off ' + self._id) + if self._is_cm11a: + x10_command('off ' + self._id) + else: + x10_command('foff ' + self._id) self._state = False def update(self): """Fetch update state.""" - self._state = bool(get_unit_status(self._id)) + if self._is_cm11a: + self._state = bool(get_unit_status(self._id)) + else: + # Not supported on CM17A + pass From dd92318762e685e016a7f27ba1f05dd79302df7e Mon Sep 17 00:00:00 2001 From: Mike Miller Date: Thu, 6 Dec 2018 18:05:15 +0200 Subject: [PATCH 294/325] Fix missing colorTemperatureInKelvin from Alexa responses (#19069) * Fix missing colorTemperatureInKelvin from Alexa responses * Update smart_home.py * Add test --- homeassistant/components/alexa/smart_home.py | 14 ++++++++++++++ tests/components/alexa/test_smart_home.py | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 2a61533a2b9f4..f06b853087f14 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -504,6 +504,20 @@ class _AlexaColorTemperatureController(_AlexaInterface): def name(self): return 'Alexa.ColorTemperatureController' + def properties_supported(self): + return [{'name': 'colorTemperatureInKelvin'}] + + def properties_retrievable(self): + return True + + def get_property(self, name): + if name != 'colorTemperatureInKelvin': + raise _UnsupportedProperty(name) + if 'color_temp' in self.entity.attributes: + return color_util.color_temperature_mired_to_kelvin( + self.entity.attributes['color_temp']) + return 0 + class _AlexaPercentageController(_AlexaInterface): """Implements Alexa.PercentageController. diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 3cfb8068177f7..ddf66d1c6177b 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1354,6 +1354,25 @@ async def test_report_colored_light_state(hass): }) +async def test_report_colored_temp_light_state(hass): + """Test ColorTemperatureController reports color temp correctly.""" + hass.states.async_set( + 'light.test_on', 'on', {'friendly_name': "Test light On", + 'color_temp': 240, + 'supported_features': 2}) + hass.states.async_set( + 'light.test_off', 'off', {'friendly_name': "Test light Off", + 'supported_features': 2}) + + properties = await reported_properties(hass, 'light.test_on') + properties.assert_equal('Alexa.ColorTemperatureController', + 'colorTemperatureInKelvin', 4166) + + properties = await reported_properties(hass, 'light.test_off') + properties.assert_equal('Alexa.ColorTemperatureController', + 'colorTemperatureInKelvin', 0) + + async def reported_properties(hass, endpoint): """Use ReportState to get properties and return them. From 0a3af545fe4cefb6b5695e9109de8a345d3e680a Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Fri, 7 Dec 2018 07:06:35 +0100 Subject: [PATCH 295/325] Upgrade aiolifx to 0.6.7 (#19077) --- homeassistant/components/lifx/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 52df3d47ca102..f2713197ed12b 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -8,7 +8,7 @@ DOMAIN = 'lifx' -REQUIREMENTS = ['aiolifx==0.6.6'] +REQUIREMENTS = ['aiolifx==0.6.7'] CONF_SERVER = 'server' CONF_BROADCAST = 'broadcast' diff --git a/requirements_all.txt b/requirements_all.txt index c56bf8888e5fe..1d0d0fb918f2c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -112,7 +112,7 @@ aiohue==1.5.0 aioimaplib==0.7.13 # homeassistant.components.lifx -aiolifx==0.6.6 +aiolifx==0.6.7 # homeassistant.components.light.lifx aiolifx_effects==0.2.1 From 455508deac02233a5de34f79f6369f4edbe36e4e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 7 Dec 2018 07:09:05 +0100 Subject: [PATCH 296/325] Force refresh Lovelace (#19073) * Force refresh Lovelace * Check config on load * Update __init__.py * Update __init__.py --- homeassistant/components/lovelace/__init__.py | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 0d9b6a6d9fe89..f6a8a3fd68802 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -48,6 +48,7 @@ SCHEMA_GET_LOVELACE_UI = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): vol.Any(WS_TYPE_GET_LOVELACE_UI, OLD_WS_TYPE_GET_LOVELACE_UI), + vol.Optional('force', default=False): bool, }) SCHEMA_MIGRATE_CONFIG = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ @@ -144,12 +145,12 @@ class DuplicateIdError(HomeAssistantError): """Duplicate ID's.""" -def load_config(hass) -> JSON_TYPE: +def load_config(hass, force: bool) -> JSON_TYPE: """Load a YAML file.""" fname = hass.config.path(LOVELACE_CONFIG_FILE) # Check for a cached version of the config - if LOVELACE_DATA in hass.data: + if not force and LOVELACE_DATA in hass.data: config, last_update = hass.data[LOVELACE_DATA] modtime = os.path.getmtime(fname) if config and last_update > modtime: @@ -158,23 +159,29 @@ def load_config(hass) -> JSON_TYPE: config = yaml.load_yaml(fname, False) seen_card_ids = set() seen_view_ids = set() + if 'views' in config and not isinstance(config['views'], list): + raise HomeAssistantError("Views should be a list.") for view in config.get('views', []): - view_id = view.get('id') - if view_id: - view_id = str(view_id) - if view_id in seen_view_ids: - raise DuplicateIdError( - 'ID `{}` has multiple occurances in views'.format(view_id)) - seen_view_ids.add(view_id) + if 'id' in view and not isinstance(view['id'], (str, int)): + raise HomeAssistantError( + "Your config contains view(s) with invalid ID(s).") + view_id = str(view.get('id', '')) + if view_id in seen_view_ids: + raise DuplicateIdError( + 'ID `{}` has multiple occurances in views'.format(view_id)) + seen_view_ids.add(view_id) + if 'cards' in view and not isinstance(view['cards'], list): + raise HomeAssistantError("Cards should be a list.") for card in view.get('cards', []): - card_id = card.get('id') - if card_id: - card_id = str(card_id) - if card_id in seen_card_ids: - raise DuplicateIdError( - 'ID `{}` has multiple occurances in cards' - .format(card_id)) - seen_card_ids.add(card_id) + if 'id' in card and not isinstance(card['id'], (str, int)): + raise HomeAssistantError( + "Your config contains card(s) with invalid ID(s).") + card_id = str(card.get('id', '')) + if card_id in seen_card_ids: + raise DuplicateIdError( + 'ID `{}` has multiple occurances in cards' + .format(card_id)) + seen_card_ids.add(card_id) hass.data[LOVELACE_DATA] = (config, time.time()) return config @@ -539,7 +546,8 @@ async def send_with_error_handling(hass, connection, msg): @handle_yaml_errors async def websocket_lovelace_config(hass, connection, msg): """Send Lovelace UI config over WebSocket configuration.""" - return await hass.async_add_executor_job(load_config, hass) + return await hass.async_add_executor_job(load_config, hass, + msg.get('force', False)) @websocket_api.async_response From ce736e7ba1e58cf0d3427c88c39e922376a05063 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 7 Dec 2018 07:12:59 +0100 Subject: [PATCH 297/325] Updated frontend to 20181207.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 77c98ab6aa2df..36fbe14aefde1 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20181205.0'] +REQUIREMENTS = ['home-assistant-frontend==20181207.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 1d0d0fb918f2c..8bcbd4496354e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -493,7 +493,7 @@ hole==0.3.0 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181205.0 +home-assistant-frontend==20181207.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5105350739a3e..2512d74e04449 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -101,7 +101,7 @@ hdate==0.7.5 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181205.0 +home-assistant-frontend==20181207.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From 8a62bc92376996d6e9a1c4d66d2e4448c8245b01 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Thu, 6 Dec 2018 23:26:49 -0700 Subject: [PATCH 298/325] Set directv unavailable state when errors returned for longer then a minute (#19014) * Fix unavailable Change setting to unavailable when getting request exceptions only after 1 minute has past since 1st occurrence. * Put common code in _check_state_available Put common code to determine if available should be set to False in method _check_state_available --- .../components/media_player/directv.py | 40 ++++++++++++++++--- tests/components/media_player/test_directv.py | 24 ++++++++++- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/media_player/directv.py b/homeassistant/components/media_player/directv.py index d8c67e372b2fb..707014328c63b 100644 --- a/homeassistant/components/media_player/directv.py +++ b/homeassistant/components/media_player/directv.py @@ -133,6 +133,7 @@ def __init__(self, name, host, port, device): self._is_client = device != '0' self._assumed_state = None self._available = False + self._first_error_timestamp = None if self._is_client: _LOGGER.debug("Created DirecTV client %s for device %s", @@ -142,7 +143,7 @@ def __init__(self, name, host, port, device): def update(self): """Retrieve latest state.""" - _LOGGER.debug("Updating status for %s", self._name) + _LOGGER.debug("%s: Updating status", self.entity_id) try: self._available = True self._is_standby = self.dtv.get_standby() @@ -156,6 +157,7 @@ def update(self): else: self._current = self.dtv.get_tuned() if self._current['status']['code'] == 200: + self._first_error_timestamp = None self._is_recorded = self._current.get('uniqueId')\ is not None self._paused = self._last_position == \ @@ -165,15 +167,41 @@ def update(self): self._last_update = dt_util.utcnow() if not self._paused \ or self._last_update is None else self._last_update else: - self._available = False + # If an error is received then only set to unavailable if + # this started at least 1 minute ago. + log_message = "{}: Invalid status {} received".format( + self.entity_id, + self._current['status']['code'] + ) + if self._check_state_available(): + _LOGGER.debug(log_message) + else: + _LOGGER.error(log_message) + except requests.RequestException as ex: - _LOGGER.error("Request error trying to update current status for" - " %s. %s", self._name, ex) - self._available = False - except Exception: + _LOGGER.error("%s: Request error trying to update current status: " + "%s", self.entity_id, ex) + self._check_state_available() + + except Exception as ex: + _LOGGER.error("%s: Exception trying to update current status: %s", + self.entity_id, ex) self._available = False + if not self._first_error_timestamp: + self._first_error_timestamp = dt_util.utcnow() raise + def _check_state_available(self): + """Set to unavailable if issue been occurring over 1 minute.""" + if not self._first_error_timestamp: + self._first_error_timestamp = dt_util.utcnow() + else: + tdelta = dt_util.utcnow() - self._first_error_timestamp + if tdelta.total_seconds() >= 60: + self._available = False + + return self._available + @property def device_state_attributes(self): """Return device specific state attributes.""" diff --git a/tests/components/media_player/test_directv.py b/tests/components/media_player/test_directv.py index 951f1319cc027..d8e561d8d2a7e 100644 --- a/tests/components/media_player/test_directv.py +++ b/tests/components/media_player/test_directv.py @@ -515,7 +515,7 @@ async def test_available(hass, platforms, main_dtv, mock_now): state = hass.states.get(MAIN_ENTITY_ID) assert state.state != STATE_UNAVAILABLE - # Make update fail (i.e. DVR offline) + # Make update fail 1st time next_update = next_update + timedelta(minutes=5) with patch.object( main_dtv, 'get_standby', side_effect=requests.RequestException), \ @@ -523,6 +523,28 @@ async def test_available(hass, platforms, main_dtv, mock_now): async_fire_time_changed(hass, next_update) await hass.async_block_till_done() + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state != STATE_UNAVAILABLE + + # Make update fail 2nd time within 1 minute + next_update = next_update + timedelta(seconds=30) + with patch.object( + main_dtv, 'get_standby', side_effect=requests.RequestException), \ + patch('homeassistant.util.dt.utcnow', return_value=next_update): + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state != STATE_UNAVAILABLE + + # Make update fail 3rd time more then a minute after 1st failure + next_update = next_update + timedelta(minutes=1) + with patch.object( + main_dtv, 'get_standby', side_effect=requests.RequestException), \ + patch('homeassistant.util.dt.utcnow', return_value=next_update): + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + state = hass.states.get(MAIN_ENTITY_ID) assert state.state == STATE_UNAVAILABLE From def4e89372de5232b571b5137a555acc69c7400e Mon Sep 17 00:00:00 2001 From: Matthew Garrett Date: Thu, 6 Dec 2018 22:32:21 -0800 Subject: [PATCH 299/325] Bump lakeside requirement to support more Eufy devices (#19080) The T1203 works fine with the existing protocol. --- homeassistant/components/eufy.py | 3 ++- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/eufy.py b/homeassistant/components/eufy.py index 31a4dddd424c4..c1166f8cf7b97 100644 --- a/homeassistant/components/eufy.py +++ b/homeassistant/components/eufy.py @@ -15,7 +15,7 @@ import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['lakeside==0.10'] +REQUIREMENTS = ['lakeside==0.11'] _LOGGER = logging.getLogger(__name__) @@ -43,6 +43,7 @@ 'T1013': 'light', 'T1201': 'switch', 'T1202': 'switch', + 'T1203': 'switch', 'T1211': 'switch' } diff --git a/requirements_all.txt b/requirements_all.txt index 8bcbd4496354e..fd45ca3da4043 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -564,7 +564,7 @@ kiwiki-client==0.1.1 konnected==0.1.4 # homeassistant.components.eufy -lakeside==0.10 +lakeside==0.11 # homeassistant.components.owntracks libnacl==1.6.1 From 5bf6951311029f99d770aff6479f7284ad5980db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20St=C3=A5hl?= Date: Fri, 7 Dec 2018 11:06:38 +0100 Subject: [PATCH 300/325] Upgrade pyatv to 0.3.12 (#19085) --- homeassistant/components/apple_tv.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index ff17b6d5e39ea..73cabdfbae615 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -16,7 +16,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyatv==0.3.11'] +REQUIREMENTS = ['pyatv==0.3.12'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index fd45ca3da4043..08355236f1118 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -861,7 +861,7 @@ pyarlo==0.2.2 pyatmo==1.4 # homeassistant.components.apple_tv -pyatv==0.3.11 +pyatv==0.3.12 # homeassistant.components.device_tracker.bbox # homeassistant.components.sensor.bbox From 7edd241059c3047082cc635a23b073db0bee32f8 Mon Sep 17 00:00:00 2001 From: speedmann Date: Fri, 7 Dec 2018 11:08:41 +0100 Subject: [PATCH 301/325] Automatically detect if ipv4/ipv6 is used for cert_expiry (#18916) * Automatically detect if ipv4/ipv6 is used for cert_expiry Fixes #18818 Python sockets use ipv4 per default. If the domain which should be checked only has an ipv6 record, socket creation errors out with `[Errno -2] Name or service not known` This fix tries to guess the protocol family and creates the socket with the correct family type * Fix line length violation --- homeassistant/components/sensor/cert_expiry.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/cert_expiry.py b/homeassistant/components/sensor/cert_expiry.py index df48ebbf41cd9..a04a631f2e9cc 100644 --- a/homeassistant/components/sensor/cert_expiry.py +++ b/homeassistant/components/sensor/cert_expiry.py @@ -85,8 +85,10 @@ def update(self): """Fetch the certificate information.""" try: ctx = ssl.create_default_context() + host_info = socket.getaddrinfo(self.server_name, self.server_port) + family = host_info[0][0] sock = ctx.wrap_socket( - socket.socket(), server_hostname=self.server_name) + socket.socket(family=family), server_hostname=self.server_name) sock.settimeout(TIMEOUT) sock.connect((self.server_name, self.server_port)) except socket.gaierror: From e567e3d4e71e732f19d4d83eb7eeebb9a7c0ffa2 Mon Sep 17 00:00:00 2001 From: Nick Horvath Date: Fri, 7 Dec 2018 13:20:05 -0500 Subject: [PATCH 302/325] Bump skybellpy version to fix api issue (#19100) --- homeassistant/components/skybell.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/skybell.py b/homeassistant/components/skybell.py index 3f27c91e7c5b0..b3c3b63bd84a7 100644 --- a/homeassistant/components/skybell.py +++ b/homeassistant/components/skybell.py @@ -14,7 +14,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['skybellpy==0.1.2'] +REQUIREMENTS = ['skybellpy==0.2.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 08355236f1118..332623a01790d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1431,7 +1431,7 @@ simplisafe-python==3.1.14 sisyphus-control==2.1 # homeassistant.components.skybell -skybellpy==0.1.2 +skybellpy==0.2.0 # homeassistant.components.notify.slack slacker==0.11.0 From a58b3aad59e4aa00e05f8e166f8118b2271d6b6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Fri, 7 Dec 2018 19:33:06 +0100 Subject: [PATCH 303/325] Upgrade Tibber lib (#19098) --- homeassistant/components/tibber/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index fce4312ad68bd..8462b646a22d5 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -16,7 +16,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['pyTibber==0.8.5'] +REQUIREMENTS = ['pyTibber==0.8.6'] DOMAIN = 'tibber' diff --git a/requirements_all.txt b/requirements_all.txt index 332623a01790d..df6ba3f8133e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -834,7 +834,7 @@ pyRFXtrx==0.23 pySwitchmate==0.4.4 # homeassistant.components.tibber -pyTibber==0.8.5 +pyTibber==0.8.6 # homeassistant.components.switch.dlink pyW215==0.6.0 From 05586de51f0148216bcf2f4e91ebaadd1bef53d9 Mon Sep 17 00:00:00 2001 From: Andrew Hayworth Date: Fri, 7 Dec 2018 14:17:34 -0600 Subject: [PATCH 304/325] Set lock status correctly for Schlage BE469 Z-Wave locks (#18737) * Set lock status correctly for Schlage BE469 Z-Wave locks PR #17386 attempted to improve the state of z-wave lock tracking for some problematic models. However, it operated under a flawed assumptions. Namely, that we can always trust `self.values` to have fresh data, and that the Schlage BE469 sends alarm reports after every lock event. We can't trust `self.values`, and the Schlage is very broken. :) When we receive a notification from the driver about a state change, we call `update_properties` - but we can (and do!) have _stale_ properties left over from previous updates. #17386 really works best if you start from a clean slate each time. However, `update_properties` is called on every value update, and we don't get a reason why. Moreover, values that weren't just refreshed are not removed. So blindly looking at something like `self.values.access_control` when deciding to apply a workaround is not going to always be correct - it may or may not be, depending on what happened in the past. For the sad case of the BE469, here are the Z-Wave events that happen under various circumstances: RF Lock / Unlock: - Send: door lock command set - Receive: door lock report - Send: door lock command get - Receive: door lock report Manual lock / Unlock: - Receive: alarm - Send: door lock command get - Receive: door lock report Keypad lock / Unlock: - Receive: alarm - Send: door lock command get - Receive: door lock report Thus, this PR introduces yet another work around - we track the current and last z-wave command that the driver saw, and make assumptions based on the sequence of events. This seems to be the most reliable way to go - simply asking the driver to refresh various states doesn't clear out alarms the way you would expect; this model doesn't support the access control logging commands; and trying to manually clear out alarm state when calling RF lock/unlock was tricky. The lock state, when the z-wave network restarts, may look out of sync for a few minutes. However, after the full network restart is complete, everything looks good in my testing. * Fix linter --- homeassistant/components/lock/zwave.py | 58 ++++++++++++++++++++------ tests/components/lock/test_zwave.py | 46 +++++++++++++++++++- 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/lock/zwave.py b/homeassistant/components/lock/zwave.py index 796c62377f186..b4bb233c9cc81 100644 --- a/homeassistant/components/lock/zwave.py +++ b/homeassistant/components/lock/zwave.py @@ -29,8 +29,9 @@ POLYCONTROL = 0x10E DANALOCK_V2_BTZE = 0x2 POLYCONTROL_DANALOCK_V2_BTZE_LOCK = (POLYCONTROL, DANALOCK_V2_BTZE) -WORKAROUND_V2BTZE = 'v2btze' -WORKAROUND_DEVICE_STATE = 'state' +WORKAROUND_V2BTZE = 1 +WORKAROUND_DEVICE_STATE = 2 +WORKAROUND_TRACK_MESSAGE = 4 DEVICE_MAPPINGS = { POLYCONTROL_DANALOCK_V2_BTZE_LOCK: WORKAROUND_V2BTZE, @@ -43,7 +44,7 @@ # Yale YRD220 (as reported by adrum in PR #17386) (0x0109, 0x0000): WORKAROUND_DEVICE_STATE, # Schlage BE469 - (0x003B, 0x5044): WORKAROUND_DEVICE_STATE, + (0x003B, 0x5044): WORKAROUND_DEVICE_STATE | WORKAROUND_TRACK_MESSAGE, # Schlage FE599NX (0x003B, 0x504C): WORKAROUND_DEVICE_STATE, } @@ -51,13 +52,15 @@ LOCK_NOTIFICATION = { '1': 'Manual Lock', '2': 'Manual Unlock', - '3': 'RF Lock', - '4': 'RF Unlock', '5': 'Keypad Lock', '6': 'Keypad Unlock', '11': 'Lock Jammed', '254': 'Unknown Event' } +NOTIFICATION_RF_LOCK = '3' +NOTIFICATION_RF_UNLOCK = '4' +LOCK_NOTIFICATION[NOTIFICATION_RF_LOCK] = 'RF Lock' +LOCK_NOTIFICATION[NOTIFICATION_RF_UNLOCK] = 'RF Unlock' LOCK_ALARM_TYPE = { '9': 'Deadbolt Jammed', @@ -66,8 +69,6 @@ '19': 'Unlocked with Keypad by user ', '21': 'Manually Locked ', '22': 'Manually Unlocked ', - '24': 'Locked by RF', - '25': 'Unlocked by RF', '27': 'Auto re-lock', '33': 'User deleted: ', '112': 'Master code changed or User added: ', @@ -79,6 +80,10 @@ '168': 'Critical Battery Level', '169': 'Battery too low to operate' } +ALARM_RF_LOCK = '24' +ALARM_RF_UNLOCK = '25' +LOCK_ALARM_TYPE[ALARM_RF_LOCK] = 'Locked by RF' +LOCK_ALARM_TYPE[ALARM_RF_UNLOCK] = 'Unlocked by RF' MANUAL_LOCK_ALARM_LEVEL = { '1': 'by Key Cylinder or Inside thumb turn', @@ -229,6 +234,8 @@ def __init__(self, values): self._lock_status = None self._v2btze = None self._state_workaround = False + self._track_message_workaround = False + self._previous_message = None # Enable appropriate workaround flags for our device # Make sure that we have values for the key before converting to int @@ -237,26 +244,30 @@ def __init__(self, values): specific_sensor_key = (int(self.node.manufacturer_id, 16), int(self.node.product_id, 16)) if specific_sensor_key in DEVICE_MAPPINGS: - if DEVICE_MAPPINGS[specific_sensor_key] == WORKAROUND_V2BTZE: + workaround = DEVICE_MAPPINGS[specific_sensor_key] + if workaround & WORKAROUND_V2BTZE: self._v2btze = 1 _LOGGER.debug("Polycontrol Danalock v2 BTZE " "workaround enabled") - if DEVICE_MAPPINGS[specific_sensor_key] == \ - WORKAROUND_DEVICE_STATE: + if workaround & WORKAROUND_DEVICE_STATE: self._state_workaround = True _LOGGER.debug( "Notification device state workaround enabled") + if workaround & WORKAROUND_TRACK_MESSAGE: + self._track_message_workaround = True + _LOGGER.debug("Message tracking workaround enabled") self.update_properties() def update_properties(self): """Handle data changes for node values.""" self._state = self.values.primary.data - _LOGGER.debug("Lock state set from Bool value and is %s", self._state) + _LOGGER.debug("lock state set to %s", self._state) if self.values.access_control: notification_data = self.values.access_control.data self._notification = LOCK_NOTIFICATION.get(str(notification_data)) if self._state_workaround: self._state = LOCK_STATUS.get(str(notification_data)) + _LOGGER.debug("workaround: lock state set to %s", self._state) if self._v2btze: if self.values.v2btze_advanced and \ self.values.v2btze_advanced.data == CONFIG_ADVANCED: @@ -265,16 +276,37 @@ def update_properties(self): "Lock state set from Access Control value and is %s, " "get=%s", str(notification_data), self.state) + if self._track_message_workaround: + this_message = self.node.stats['lastReceivedMessage'][5] + + if this_message == zwave.const.COMMAND_CLASS_DOOR_LOCK: + self._state = self.values.primary.data + _LOGGER.debug("set state to %s based on message tracking", + self._state) + if self._previous_message == \ + zwave.const.COMMAND_CLASS_DOOR_LOCK: + if self._state: + self._notification = \ + LOCK_NOTIFICATION[NOTIFICATION_RF_LOCK] + self._lock_status = \ + LOCK_ALARM_TYPE[ALARM_RF_LOCK] + else: + self._notification = \ + LOCK_NOTIFICATION[NOTIFICATION_RF_UNLOCK] + self._lock_status = \ + LOCK_ALARM_TYPE[ALARM_RF_UNLOCK] + return + + self._previous_message = this_message + if not self.values.alarm_type: return alarm_type = self.values.alarm_type.data - _LOGGER.debug("Lock alarm_type is %s", str(alarm_type)) if self.values.alarm_level: alarm_level = self.values.alarm_level.data else: alarm_level = None - _LOGGER.debug("Lock alarm_level is %s", str(alarm_level)) if not alarm_type: return diff --git a/tests/components/lock/test_zwave.py b/tests/components/lock/test_zwave.py index 3955538273b5e..484e47967595f 100644 --- a/tests/components/lock/test_zwave.py +++ b/tests/components/lock/test_zwave.py @@ -62,7 +62,7 @@ def test_lock_value_changed(mock_openzwave): assert device.is_locked -def test_lock_value_changed_workaround(mock_openzwave): +def test_lock_state_workaround(mock_openzwave): """Test value changed for Z-Wave lock using notification state.""" node = MockNode(manufacturer_id='0090', product_id='0440') values = MockEntityValues( @@ -78,6 +78,50 @@ def test_lock_value_changed_workaround(mock_openzwave): assert not device.is_locked +def test_track_message_workaround(mock_openzwave): + """Test value changed for Z-Wave lock by alarm-clearing workaround.""" + node = MockNode(manufacturer_id='003B', product_id='5044', + stats={'lastReceivedMessage': [0] * 6}) + values = MockEntityValues( + primary=MockValue(data=True, node=node), + access_control=None, + alarm_type=None, + alarm_level=None, + ) + + # Here we simulate an RF lock. The first zwave.get_device will call + # update properties, simulating the first DoorLock report. We then trigger + # a change, simulating the openzwave automatic refreshing behavior (which + # is enabled for at least the lock that needs this workaround) + node.stats['lastReceivedMessage'][5] = const.COMMAND_CLASS_DOOR_LOCK + device = zwave.get_device(node=node, values=values) + value_changed(values.primary) + assert device.is_locked + assert device.device_state_attributes[zwave.ATTR_NOTIFICATION] == 'RF Lock' + + # Simulate a keypad unlock. We trigger a value_changed() which simulates + # the Alarm notification received from the lock. Then, we trigger + # value_changed() to simulate the automatic refreshing behavior. + values.access_control = MockValue(data=6, node=node) + values.alarm_type = MockValue(data=19, node=node) + values.alarm_level = MockValue(data=3, node=node) + node.stats['lastReceivedMessage'][5] = const.COMMAND_CLASS_ALARM + value_changed(values.access_control) + node.stats['lastReceivedMessage'][5] = const.COMMAND_CLASS_DOOR_LOCK + values.primary.data = False + value_changed(values.primary) + assert not device.is_locked + assert device.device_state_attributes[zwave.ATTR_LOCK_STATUS] == \ + 'Unlocked with Keypad by user 3' + + # Again, simulate an RF lock. + device.lock() + node.stats['lastReceivedMessage'][5] = const.COMMAND_CLASS_DOOR_LOCK + value_changed(values.primary) + assert device.is_locked + assert device.device_state_attributes[zwave.ATTR_NOTIFICATION] == 'RF Lock' + + def test_v2btze_value_changed(mock_openzwave): """Test value changed for v2btze Z-Wave lock.""" node = MockNode(manufacturer_id='010e', product_id='0002') From 65c2a257369572ef348879258aa2996de7e109df Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Fri, 7 Dec 2018 23:40:48 +0100 Subject: [PATCH 305/325] Support next generation of the Xiaomi Mi Smart Plug (chuangmi.plug.hmi205) (#19071) * Add next generation of the Xiaomi Mi Smart Plug (chuangmi.plug.hmi205) * Fix linting --- homeassistant/components/switch/xiaomi_miio.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/switch/xiaomi_miio.py b/homeassistant/components/switch/xiaomi_miio.py index 125f89f504027..9db13446752d1 100644 --- a/homeassistant/components/switch/xiaomi_miio.py +++ b/homeassistant/components/switch/xiaomi_miio.py @@ -39,6 +39,7 @@ 'chuangmi.plug.m1', 'chuangmi.plug.v2', 'chuangmi.plug.v3', + 'chuangmi.plug.hmi205', ]), }) @@ -146,7 +147,8 @@ async def async_setup_platform(hass, config, async_add_entities, device = XiaomiPowerStripSwitch(name, plug, model, unique_id) devices.append(device) hass.data[DATA_KEY][host] = device - elif model in ['chuangmi.plug.m1', 'chuangmi.plug.v2']: + elif model in ['chuangmi.plug.m1', 'chuangmi.plug.v2', + 'chuangmi.plug.hmi205']: from miio import ChuangmiPlug plug = ChuangmiPlug(host, token, model=model) device = XiaomiPlugGenericSwitch(name, plug, model, unique_id) From ece7b498ed22583cd298b84bf6331af6cda6aa18 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Sat, 8 Dec 2018 08:28:39 +0100 Subject: [PATCH 306/325] Fix the Xiaomi Aqara Cube rotate event of the LAN protocol 2.0 (Closes: #18199) (#19104) --- homeassistant/components/binary_sensor/xiaomi_aqara.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/binary_sensor/xiaomi_aqara.py b/homeassistant/components/binary_sensor/xiaomi_aqara.py index 550bdaac17228..584d56c2e6845 100644 --- a/homeassistant/components/binary_sensor/xiaomi_aqara.py +++ b/homeassistant/components/binary_sensor/xiaomi_aqara.py @@ -467,4 +467,12 @@ def parse_data(self, data, raw_data): }) self._last_action = 'rotate' + if 'rotate_degree' in data: + self._hass.bus.fire('xiaomi_aqara.cube_action', { + 'entity_id': self.entity_id, + 'action_type': 'rotate', + 'action_value': float(data['rotate_degree'].replace(",", ".")) + }) + self._last_action = 'rotate' + return True From f2f649680f02f9b2edee9a3c60a456d05255313f Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Sat, 8 Dec 2018 08:45:03 +0100 Subject: [PATCH 307/325] Don't avoid async_schedule_update_ha_state by returning false (#19102) --- homeassistant/components/binary_sensor/xiaomi_aqara.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/binary_sensor/xiaomi_aqara.py b/homeassistant/components/binary_sensor/xiaomi_aqara.py index 584d56c2e6845..614b7253f2e15 100644 --- a/homeassistant/components/binary_sensor/xiaomi_aqara.py +++ b/homeassistant/components/binary_sensor/xiaomi_aqara.py @@ -423,9 +423,7 @@ def parse_data(self, data, raw_data): }) self._last_action = click_type - if value in ['long_click_press', 'long_click_release']: - return True - return False + return True class XiaomiCube(XiaomiBinarySensor): From ffe83d9ab1fc7058b7aefef9a5a281e7e12e1217 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Sat, 8 Dec 2018 11:38:42 +0100 Subject: [PATCH 308/325] Upgrade Mill library (#19117) --- homeassistant/components/climate/mill.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/climate/mill.py b/homeassistant/components/climate/mill.py index 5ea48614f6b09..48b15400a4376 100644 --- a/homeassistant/components/climate/mill.py +++ b/homeassistant/components/climate/mill.py @@ -19,7 +19,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['millheater==0.2.9'] +REQUIREMENTS = ['millheater==0.3.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index df6ba3f8133e3..aa2e52e13ba65 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -644,7 +644,7 @@ mficlient==0.3.0 miflora==0.4.0 # homeassistant.components.climate.mill -millheater==0.2.9 +millheater==0.3.0 # homeassistant.components.sensor.mitemp_bt mitemp_bt==0.0.1 From 2134331e2ba597c5a0de098130105e0df82cf0f1 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Sat, 8 Dec 2018 14:49:14 +0100 Subject: [PATCH 309/325] Add Philips Moonlight Bedside Lamp support (#18496) * Add Philips Moonlight Bedside Lamp support * Update comment * Make hound happy * Wrap the call that could raise the exception only * Remote blank line * Use updated python-miio API --- homeassistant/components/light/xiaomi_miio.py | 268 ++++++++++++------ 1 file changed, 178 insertions(+), 90 deletions(-) diff --git a/homeassistant/components/light/xiaomi_miio.py b/homeassistant/components/light/xiaomi_miio.py index 9e650562fe8d1..62433ca9f9738 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -1,5 +1,7 @@ """ -Support for Xiaomi Philips Lights (LED Ball & Ceiling Lamp, Eyecare Lamp 2). +Support for Xiaomi Philips Lights. + +LED Ball, Candle, Downlight, Ceiling, Eyecare 2, Bedside & Desklamp Lamp. For more details about this platform, please refer to the documentation https://home-assistant.io/components/light.xiaomi_miio/ @@ -19,7 +21,7 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.util import dt +from homeassistant.util import color, dt REQUIREMENTS = ['python-miio==0.4.4', 'construct==2.9.45'] @@ -38,6 +40,7 @@ ['philips.light.sread1', 'philips.light.ceiling', 'philips.light.zyceiling', + 'philips.light.moonlight', 'philips.light.bulb', 'philips.light.candle', 'philips.light.candle2', @@ -63,6 +66,13 @@ ATTR_REMINDER = 'reminder' ATTR_EYECARE_MODE = 'eyecare_mode' +# Moonlight +ATTR_SLEEP_ASSISTANT = 'sleep_assistant' +ATTR_SLEEP_OFF_TIME = 'sleep_off_time' +ATTR_TOTAL_ASSISTANT_SLEEP_TIME = 'total_assistant_sleep_time' +ATTR_BRAND_SLEEP = 'brand_sleep' +ATTR_BRAND = 'brand' + SERVICE_SET_SCENE = 'xiaomi_miio_set_scene' SERVICE_SET_DELAYED_TURN_OFF = 'xiaomi_miio_set_delayed_turn_off' SERVICE_REMINDER_ON = 'xiaomi_miio_reminder_on' @@ -151,6 +161,12 @@ async def async_setup_platform(hass, config, async_add_entities, device = XiaomiPhilipsCeilingLamp(name, light, model, unique_id) devices.append(device) hass.data[DATA_KEY][host] = device + elif model == 'philips.light.moonlight': + from miio import PhilipsMoonlight + light = PhilipsMoonlight(host, token) + device = XiaomiPhilipsMoonlightLamp(name, light, model, unique_id) + devices.append(device) + hass.data[DATA_KEY][host] = device elif model in ['philips.light.bulb', 'philips.light.candle', 'philips.light.candle2', @@ -307,15 +323,15 @@ async def async_update(self): from miio import DeviceException try: state = await self.hass.async_add_executor_job(self._light.status) - _LOGGER.debug("Got new state: %s", state) - - self._available = True - self._state = state.is_on - self._brightness = ceil((255 / 100.0) * state.brightness) - except DeviceException as ex: self._available = False _LOGGER.error("Got exception while fetching the state: %s", ex) + return + + _LOGGER.debug("Got new state: %s", state) + self._available = True + self._state = state.is_on + self._brightness = ceil((255 / 100.0) * state.brightness) class XiaomiPhilipsGenericLight(XiaomiPhilipsAbstractLight): @@ -335,25 +351,25 @@ async def async_update(self): from miio import DeviceException try: state = await self.hass.async_add_executor_job(self._light.status) - _LOGGER.debug("Got new state: %s", state) - - self._available = True - self._state = state.is_on - self._brightness = ceil((255 / 100.0) * state.brightness) - - delayed_turn_off = self.delayed_turn_off_timestamp( - state.delay_off_countdown, - dt.utcnow(), - self._state_attrs[ATTR_DELAYED_TURN_OFF]) - - self._state_attrs.update({ - ATTR_SCENE: state.scene, - ATTR_DELAYED_TURN_OFF: delayed_turn_off, - }) - except DeviceException as ex: self._available = False _LOGGER.error("Got exception while fetching the state: %s", ex) + return + + _LOGGER.debug("Got new state: %s", state) + self._available = True + self._state = state.is_on + self._brightness = ceil((255 / 100.0) * state.brightness) + + delayed_turn_off = self.delayed_turn_off_timestamp( + state.delay_off_countdown, + dt.utcnow(), + self._state_attrs[ATTR_DELAYED_TURN_OFF]) + + self._state_attrs.update({ + ATTR_SCENE: state.scene, + ATTR_DELAYED_TURN_OFF: delayed_turn_off, + }) async def async_set_scene(self, scene: int = 1): """Set the fixed scene.""" @@ -485,29 +501,29 @@ async def async_update(self): from miio import DeviceException try: state = await self.hass.async_add_executor_job(self._light.status) - _LOGGER.debug("Got new state: %s", state) - - self._available = True - self._state = state.is_on - self._brightness = ceil((255 / 100.0) * state.brightness) - self._color_temp = self.translate( - state.color_temperature, - CCT_MIN, CCT_MAX, - self.max_mireds, self.min_mireds) - - delayed_turn_off = self.delayed_turn_off_timestamp( - state.delay_off_countdown, - dt.utcnow(), - self._state_attrs[ATTR_DELAYED_TURN_OFF]) - - self._state_attrs.update({ - ATTR_SCENE: state.scene, - ATTR_DELAYED_TURN_OFF: delayed_turn_off, - }) - except DeviceException as ex: self._available = False _LOGGER.error("Got exception while fetching the state: %s", ex) + return + + _LOGGER.debug("Got new state: %s", state) + self._available = True + self._state = state.is_on + self._brightness = ceil((255 / 100.0) * state.brightness) + self._color_temp = self.translate( + state.color_temperature, + CCT_MIN, CCT_MAX, + self.max_mireds, self.min_mireds) + + delayed_turn_off = self.delayed_turn_off_timestamp( + state.delay_off_countdown, + dt.utcnow(), + self._state_attrs[ATTR_DELAYED_TURN_OFF]) + + self._state_attrs.update({ + ATTR_SCENE: state.scene, + ATTR_DELAYED_TURN_OFF: delayed_turn_off, + }) @staticmethod def translate(value, left_min, left_max, right_min, right_max): @@ -545,32 +561,32 @@ async def async_update(self): from miio import DeviceException try: state = await self.hass.async_add_executor_job(self._light.status) - _LOGGER.debug("Got new state: %s", state) - - self._available = True - self._state = state.is_on - self._brightness = ceil((255 / 100.0) * state.brightness) - self._color_temp = self.translate( - state.color_temperature, - CCT_MIN, CCT_MAX, - self.max_mireds, self.min_mireds) - - delayed_turn_off = self.delayed_turn_off_timestamp( - state.delay_off_countdown, - dt.utcnow(), - self._state_attrs[ATTR_DELAYED_TURN_OFF]) - - self._state_attrs.update({ - ATTR_SCENE: state.scene, - ATTR_DELAYED_TURN_OFF: delayed_turn_off, - ATTR_NIGHT_LIGHT_MODE: state.smart_night_light, - ATTR_AUTOMATIC_COLOR_TEMPERATURE: - state.automatic_color_temperature, - }) - except DeviceException as ex: self._available = False _LOGGER.error("Got exception while fetching the state: %s", ex) + return + + _LOGGER.debug("Got new state: %s", state) + self._available = True + self._state = state.is_on + self._brightness = ceil((255 / 100.0) * state.brightness) + self._color_temp = self.translate( + state.color_temperature, + CCT_MIN, CCT_MAX, + self.max_mireds, self.min_mireds) + + delayed_turn_off = self.delayed_turn_off_timestamp( + state.delay_off_countdown, + dt.utcnow(), + self._state_attrs[ATTR_DELAYED_TURN_OFF]) + + self._state_attrs.update({ + ATTR_SCENE: state.scene, + ATTR_DELAYED_TURN_OFF: delayed_turn_off, + ATTR_NIGHT_LIGHT_MODE: state.smart_night_light, + ATTR_AUTOMATIC_COLOR_TEMPERATURE: + state.automatic_color_temperature, + }) class XiaomiPhilipsEyecareLamp(XiaomiPhilipsGenericLight): @@ -591,28 +607,28 @@ async def async_update(self): from miio import DeviceException try: state = await self.hass.async_add_executor_job(self._light.status) - _LOGGER.debug("Got new state: %s", state) - - self._available = True - self._state = state.is_on - self._brightness = ceil((255 / 100.0) * state.brightness) - - delayed_turn_off = self.delayed_turn_off_timestamp( - state.delay_off_countdown, - dt.utcnow(), - self._state_attrs[ATTR_DELAYED_TURN_OFF]) - - self._state_attrs.update({ - ATTR_SCENE: state.scene, - ATTR_DELAYED_TURN_OFF: delayed_turn_off, - ATTR_REMINDER: state.reminder, - ATTR_NIGHT_LIGHT_MODE: state.smart_night_light, - ATTR_EYECARE_MODE: state.eyecare, - }) - except DeviceException as ex: self._available = False _LOGGER.error("Got exception while fetching the state: %s", ex) + return + + _LOGGER.debug("Got new state: %s", state) + self._available = True + self._state = state.is_on + self._brightness = ceil((255 / 100.0) * state.brightness) + + delayed_turn_off = self.delayed_turn_off_timestamp( + state.delay_off_countdown, + dt.utcnow(), + self._state_attrs[ATTR_DELAYED_TURN_OFF]) + + self._state_attrs.update({ + ATTR_SCENE: state.scene, + ATTR_DELAYED_TURN_OFF: delayed_turn_off, + ATTR_REMINDER: state.reminder, + ATTR_NIGHT_LIGHT_MODE: state.smart_night_light, + ATTR_EYECARE_MODE: state.eyecare, + }) async def async_set_delayed_turn_off(self, time_period: timedelta): """Set delayed turn off.""" @@ -719,12 +735,84 @@ async def async_update(self): from miio import DeviceException try: state = await self.hass.async_add_executor_job(self._light.status) - _LOGGER.debug("Got new state: %s", state) + except DeviceException as ex: + self._available = False + _LOGGER.error("Got exception while fetching the state: %s", ex) + return + + _LOGGER.debug("Got new state: %s", state) + self._available = True + self._state = state.ambient + self._brightness = ceil((255 / 100.0) * state.ambient_brightness) + + +class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): + """Representation of a Xiaomi Philips Zhirui Bedside Lamp.""" + + def __init__(self, name, light, model, unique_id): + """Initialize the light device.""" + super().__init__(name, light, model, unique_id) + + self._hs_color = None + self._state_attrs.pop(ATTR_DELAYED_TURN_OFF) + self._state_attrs.update({ + ATTR_SLEEP_ASSISTANT: None, + ATTR_SLEEP_OFF_TIME: None, + ATTR_TOTAL_ASSISTANT_SLEEP_TIME: None, + ATTR_BRAND_SLEEP: None, + ATTR_BRAND: None, + }) - self._available = True - self._state = state.ambient - self._brightness = ceil((255 / 100.0) * state.ambient_brightness) + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + return 153 + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + return 588 + @property + def hs_color(self) -> tuple: + """Return the hs color value.""" + return self._hs_color + + @property + def supported_features(self): + """Return the supported features.""" + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP + + async def async_update(self): + """Fetch state from the device.""" + from miio import DeviceException + try: + state = await self.hass.async_add_executor_job(self._light.status) except DeviceException as ex: self._available = False _LOGGER.error("Got exception while fetching the state: %s", ex) + return + + _LOGGER.debug("Got new state: %s", state) + self._available = True + self._state = state.is_on + self._brightness = ceil((255 / 100.0) * state.brightness) + self._color_temp = self.translate( + state.color_temperature, + CCT_MIN, CCT_MAX, + self.max_mireds, self.min_mireds) + self._hs_color = color.color_RGB_to_hs(*state.rgb) + + self._state_attrs.update({ + ATTR_SCENE: state.scene, + ATTR_SLEEP_ASSISTANT: state.sleep_assistant, + ATTR_SLEEP_OFF_TIME: state.sleep_off_time, + ATTR_TOTAL_ASSISTANT_SLEEP_TIME: + state.total_assistant_sleep_time, + ATTR_BRAND_SLEEP: state.brand_sleep, + ATTR_BRAND: state.brand, + }) + + async def async_set_delayed_turn_off(self, time_period: timedelta): + """Set delayed turn off. Unsupported.""" + return From 6e55c2a3453e02523a684987019f2bff0eb79895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Sat, 8 Dec 2018 14:16:16 -0600 Subject: [PATCH 310/325] update edp_redy version (#19078) * update edp_redy version * update requirements_all.txt --- homeassistant/components/edp_redy.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/edp_redy.py b/homeassistant/components/edp_redy.py index 210d7eb6afc97..1078010361336 100644 --- a/homeassistant/components/edp_redy.py +++ b/homeassistant/components/edp_redy.py @@ -26,7 +26,7 @@ DATA_UPDATE_TOPIC = '{0}_data_update'.format(DOMAIN) UPDATE_INTERVAL = 60 -REQUIREMENTS = ['edp_redy==0.0.2'] +REQUIREMENTS = ['edp_redy==0.0.3'] CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ diff --git a/requirements_all.txt b/requirements_all.txt index aa2e52e13ba65..7907b7c0f8df1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -325,7 +325,7 @@ dsmr_parser==0.12 dweepy==0.3.0 # homeassistant.components.edp_redy -edp_redy==0.0.2 +edp_redy==0.0.3 # homeassistant.components.media_player.horizon einder==0.3.1 From fd5b92b2fb777d3ff9e0b6ef32d338538be330ae Mon Sep 17 00:00:00 2001 From: edif30 Date: Sat, 8 Dec 2018 20:39:51 -0500 Subject: [PATCH 311/325] Update Google Assistant services description and request sync timeout (#19113) * Fix google assistant request sync service call * More descriptive services.yaml * Update services.yaml * Update __init__.py * Update request sync service call timeout Change from 5s to 15s to allow Google to respond. 5s was too short. The service would sync but the service call would time out and throw the error. --- homeassistant/components/google_assistant/__init__.py | 2 +- homeassistant/components/google_assistant/services.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index bf0c72ec1c8e0..c0dff15d888c2 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -67,7 +67,7 @@ async def request_sync_service_handler(call: ServiceCall): """Handle request sync service calls.""" websession = async_get_clientsession(hass) try: - with async_timeout.timeout(5, loop=hass.loop): + with async_timeout.timeout(15, loop=hass.loop): agent_user_id = call.data.get('agent_user_id') or \ call.context.user_id res = await websession.post( diff --git a/homeassistant/components/google_assistant/services.yaml b/homeassistant/components/google_assistant/services.yaml index 7d3af71ac2bba..33a52c8ef6050 100644 --- a/homeassistant/components/google_assistant/services.yaml +++ b/homeassistant/components/google_assistant/services.yaml @@ -2,4 +2,4 @@ request_sync: description: Send a request_sync command to Google. fields: agent_user_id: - description: Optional. Only needed for automations. Specific Home Assistant user id to sync with Google Assistant. Do not need when you call this service through Home Assistant front end or API. Used in automation script or other place where context.user_id is missing. + description: "Optional. Only needed for automations. Specific Home Assistant user id (not username, ID in configuration > users > under username) to sync with Google Assistant. Do not need when you call this service through Home Assistant front end or API. Used in automation script or other place where context.user_id is missing." From 30064655c26f1e335e09f6e5a6f81d13a9974e49 Mon Sep 17 00:00:00 2001 From: arigilder <43716164+arigilder@users.noreply.github.com> Date: Sat, 8 Dec 2018 23:27:01 -0500 Subject: [PATCH 312/325] Remove marking device tracker stale if state is stale (#19133) --- homeassistant/components/device_tracker/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 16d9022c98fa1..202883713c740 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -383,7 +383,6 @@ def async_update_stale(self, now: dt_util.dt.datetime): for device in self.devices.values(): if (device.track and device.last_update_home) and \ device.stale(now): - device.mark_stale() self.hass.async_create_task(device.async_update_ha_state(True)) async def async_setup_tracked_device(self): From 4b4f51fb6f65c6ad7602266ba29deb43b06f6622 Mon Sep 17 00:00:00 2001 From: Bas Schipper Date: Sun, 9 Dec 2018 10:33:39 +0100 Subject: [PATCH 313/325] Fixed doorbird config without events (empty list) (#19121) --- homeassistant/components/doorbird.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/doorbird.py b/homeassistant/components/doorbird.py index 578855011cc24..42d14205e75ca 100644 --- a/homeassistant/components/doorbird.py +++ b/homeassistant/components/doorbird.py @@ -104,7 +104,7 @@ def setup(hass, config): return False # Subscribe to doorbell or motion events - if events is not None: + if events: doorstation.update_schedule(hass) hass.data[DOMAIN] = doorstations From 4d4967d0dd115199981067512d08b4baa2b14c59 Mon Sep 17 00:00:00 2001 From: Ludovico de Nittis Date: Sun, 9 Dec 2018 11:08:39 +0100 Subject: [PATCH 314/325] Add code support for iAlarm (#19124) --- .../components/alarm_control_panel/ialarm.py | 38 +++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/ialarm.py b/homeassistant/components/alarm_control_panel/ialarm.py index efc7436e21b27..6115edf406ebb 100644 --- a/homeassistant/components/alarm_control_panel/ialarm.py +++ b/homeassistant/components/alarm_control_panel/ialarm.py @@ -5,14 +5,16 @@ https://home-assistant.io/components/alarm_control_panel.ialarm/ """ import logging +import re import voluptuous as vol import homeassistant.components.alarm_control_panel as alarm from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED) + CONF_CODE, CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED) import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['pyialarm==0.3'] @@ -36,6 +38,7 @@ def no_application_protocol(value): vol.Required(CONF_HOST): vol.All(cv.string, no_application_protocol), vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_CODE): cv.positive_int, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) @@ -43,23 +46,25 @@ def no_application_protocol(value): def setup_platform(hass, config, add_entities, discovery_info=None): """Set up an iAlarm control panel.""" name = config.get(CONF_NAME) + code = config.get(CONF_CODE) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) host = config.get(CONF_HOST) url = 'http://{}'.format(host) - ialarm = IAlarmPanel(name, username, password, url) + ialarm = IAlarmPanel(name, code, username, password, url) add_entities([ialarm], True) class IAlarmPanel(alarm.AlarmControlPanel): """Representation of an iAlarm status.""" - def __init__(self, name, username, password, url): + def __init__(self, name, code, username, password, url): """Initialize the iAlarm status.""" from pyialarm import IAlarm self._name = name + self._code = str(code) if code else None self._username = username self._password = password self._url = url @@ -71,6 +76,15 @@ def name(self): """Return the name of the device.""" return self._name + @property + def code_format(self): + """Return one or more digits/characters.""" + if self._code is None: + return None + if isinstance(self._code, str) and re.search('^\\d+$', self._code): + return 'Number' + return 'Any' + @property def state(self): """Return the state of the device.""" @@ -98,12 +112,22 @@ def update(self): def alarm_disarm(self, code=None): """Send disarm command.""" - self._client.disarm() + if self._validate_code(code): + self._client.disarm() def alarm_arm_away(self, code=None): """Send arm away command.""" - self._client.arm_away() + if self._validate_code(code): + self._client.arm_away() def alarm_arm_home(self, code=None): """Send arm home command.""" - self._client.arm_stay() + if self._validate_code(code): + self._client.arm_stay() + + def _validate_code(self, code): + """Validate given code.""" + check = self._code is None or code == self._code + if not check: + _LOGGER.warning("Wrong code entered") + return check From 863edfd66017e6e541221f07399fa4ec5c31c9f5 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 9 Dec 2018 11:34:53 +0100 Subject: [PATCH 315/325] Upgrade slacker to 0.12.0 --- homeassistant/components/notify/slack.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/slack.py b/homeassistant/components/notify/slack.py index 599633ff5ff2f..8e23c9f4fa0fd 100644 --- a/homeassistant/components/notify/slack.py +++ b/homeassistant/components/notify/slack.py @@ -17,7 +17,7 @@ BaseNotificationService) from homeassistant.const import (CONF_API_KEY, CONF_USERNAME, CONF_ICON) -REQUIREMENTS = ['slacker==0.11.0'] +REQUIREMENTS = ['slacker==0.12.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 7907b7c0f8df1..b4fdd09723bd3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1434,7 +1434,7 @@ sisyphus-control==2.1 skybellpy==0.2.0 # homeassistant.components.notify.slack -slacker==0.11.0 +slacker==0.12.0 # homeassistant.components.sleepiq sleepyq==0.6 From dbbbfaa86975f67b167231db36088d5b51335412 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 9 Dec 2018 12:38:42 +0100 Subject: [PATCH 316/325] Upgrade youtube_dl to 2018.12.03 (#19139) --- 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 296c6c8d75dd1..0aac40d9f334e 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -14,7 +14,7 @@ SERVICE_PLAY_MEDIA) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2018.11.23'] +REQUIREMENTS = ['youtube_dl==2018.12.03'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 7907b7c0f8df1..cc7b0ee0aca31 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1662,7 +1662,7 @@ yeelight==0.4.3 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2018.11.23 +youtube_dl==2018.12.03 # homeassistant.components.light.zengge zengge==0.2 From ce998cdc87ff1e804d465b26f93b643b8ba042fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sun, 9 Dec 2018 19:19:13 +0200 Subject: [PATCH 317/325] Upgrade upcloud-api to 0.4.3 --- homeassistant/components/upcloud.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/upcloud.py b/homeassistant/components/upcloud.py index a0b61f86e56c7..ca0f554bd3923 100644 --- a/homeassistant/components/upcloud.py +++ b/homeassistant/components/upcloud.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_time_interval -REQUIREMENTS = ['upcloud-api==0.4.2'] +REQUIREMENTS = ['upcloud-api==0.4.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index cc7b0ee0aca31..d867867594322 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1573,7 +1573,7 @@ twilio==6.19.1 uber_rides==0.6.0 # homeassistant.components.upcloud -upcloud-api==0.4.2 +upcloud-api==0.4.3 # homeassistant.components.sensor.ups upsmychoice==1.0.6 From 7d3a962f7342221f782a045199bdf992650e1aee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sun, 9 Dec 2018 21:22:08 +0200 Subject: [PATCH 318/325] Upgrade mypy to 0.650 (#19150) * Upgrade to 0.650 * Remove no longer needed type: ignore --- homeassistant/util/async_.py | 2 +- requirements_test.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index 0185128abac65..a4ad0e98a2e70 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -28,7 +28,7 @@ def asyncio_run(main: Awaitable[_T], *, debug: bool = False) -> _T: try: return loop.run_until_complete(main) finally: - asyncio.set_event_loop(None) # type: ignore # not a bug + asyncio.set_event_loop(None) loop.close() diff --git a/requirements_test.txt b/requirements_test.txt index d9c52bbd053b3..1cadd996c9c49 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -6,7 +6,7 @@ coveralls==1.2.0 flake8-docstrings==1.3.0 flake8==3.6.0 mock-open==1.3.1 -mypy==0.641 +mypy==0.650 pydocstyle==2.1.1 pylint==2.2.2 pytest-aiohttp==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2512d74e04449..4f77f457289ea 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ coveralls==1.2.0 flake8-docstrings==1.3.0 flake8==3.6.0 mock-open==1.3.1 -mypy==0.641 +mypy==0.650 pydocstyle==2.1.1 pylint==2.2.2 pytest-aiohttp==0.3.0 From 5e65e27bda0d89b84112d308ee9d2fb32aa1f474 Mon Sep 17 00:00:00 2001 From: Lukas Barth Date: Sun, 9 Dec 2018 23:22:33 +0100 Subject: [PATCH 319/325] Update geizhals dependency (#19152) --- homeassistant/components/sensor/geizhals.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/geizhals.py b/homeassistant/components/sensor/geizhals.py index 7d215fb6bafb9..654ad0ccafb97 100644 --- a/homeassistant/components/sensor/geizhals.py +++ b/homeassistant/components/sensor/geizhals.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.const import CONF_NAME -REQUIREMENTS = ['geizhals==0.0.7'] +REQUIREMENTS = ['geizhals==0.0.9'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index cc7b0ee0aca31..220614d385b26 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -416,7 +416,7 @@ gTTS-token==1.1.3 gearbest_parser==1.0.7 # homeassistant.components.sensor.geizhals -geizhals==0.0.7 +geizhals==0.0.9 # homeassistant.components.geo_location.geo_json_events # homeassistant.components.geo_location.nsw_rural_fire_service_feed From 4c04fe652c80765397456523583c1f91405642b2 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 9 Dec 2018 23:22:56 +0100 Subject: [PATCH 320/325] Upgrade sphinx-autodoc-typehints to 1.5.2 (#19140) --- requirements_docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_docs.txt b/requirements_docs.txt index 7fd779d423134..5a11383a17cc1 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,3 +1,3 @@ Sphinx==1.8.2 -sphinx-autodoc-typehints==1.5.1 +sphinx-autodoc-typehints==1.5.2 sphinx-autodoc-annotation==1.0.post1 From 866c2ca994a656cbad1b4f5e66ccf568b2b238de Mon Sep 17 00:00:00 2001 From: clayton craft Date: Sun, 9 Dec 2018 16:27:31 -0600 Subject: [PATCH 321/325] Update radiotherm to 2.0.0 and handle change in tstat error detection (#19107) * radiotherm: bump version to 2.0.0 * radiotherm: change handling of transient errors from tstat Radiotherm 2.0.0 now throws an exception when a transient error is detected, instead of returning -1 for the field where the error was detected. This change supports handling the exception. --- homeassistant/components/climate/radiotherm.py | 14 ++++++++------ requirements_all.txt | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/climate/radiotherm.py b/homeassistant/components/climate/radiotherm.py index f914b9b4762f8..f0423d32c967c 100644 --- a/homeassistant/components/climate/radiotherm.py +++ b/homeassistant/components/climate/radiotherm.py @@ -17,7 +17,7 @@ CONF_HOST, TEMP_FAHRENHEIT, ATTR_TEMPERATURE, PRECISION_HALVES) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['radiotherm==1.4.1'] +REQUIREMENTS = ['radiotherm==2.0.0'] _LOGGER = logging.getLogger(__name__) @@ -235,13 +235,15 @@ def update(self): self._name = self.device.name['raw'] # Request the current state from the thermostat. - data = self.device.tstat['raw'] + import radiotherm + try: + data = self.device.tstat['raw'] + except radiotherm.validate.RadiothermTstatError: + _LOGGER.error('%s (%s) was busy (invalid value returned)', + self._name, self.device.host) + return current_temp = data['temp'] - if current_temp == -1: - _LOGGER.error('%s (%s) was busy (temp == -1)', self._name, - self.device.host) - return # Map thermostat values into various STATE_ flags. self._current_temperature = current_temp diff --git a/requirements_all.txt b/requirements_all.txt index 27863a99aecc4..ec22bccfa9af1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1352,7 +1352,7 @@ quantum-gateway==0.0.3 rachiopy==0.1.3 # homeassistant.components.climate.radiotherm -radiotherm==1.4.1 +radiotherm==2.0.0 # homeassistant.components.raincloud raincloudy==0.0.5 From a744dc270b4bff3e498540c60f7716571e66dbc4 Mon Sep 17 00:00:00 2001 From: Yaron de Leeuw Date: Sun, 9 Dec 2018 17:32:48 -0500 Subject: [PATCH 322/325] Update pygtfs to upstream's 0.1.5 (#19151) This works by adding `?check_same_thread=False` to the sqlite connection string, as suggested by robbiet480@. It upgrades pygtfs from homeassitant's forked 0.1.3 version to 0.1.5. Fixes #15725 --- homeassistant/components/sensor/gtfs.py | 6 +++--- requirements_all.txt | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/gtfs.py b/homeassistant/components/sensor/gtfs.py index 633a50f15c153..3ccc60457b6ab 100644 --- a/homeassistant/components/sensor/gtfs.py +++ b/homeassistant/components/sensor/gtfs.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pygtfs-homeassistant==0.1.3.dev0'] +REQUIREMENTS = ['pygtfs==0.1.5'] _LOGGER = logging.getLogger(__name__) @@ -169,9 +169,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): import pygtfs - split_file_name = os.path.splitext(data) + (gtfs_root, _) = os.path.splitext(data) - sqlite_file = "{}.sqlite".format(split_file_name[0]) + sqlite_file = "{}.sqlite?check_same_thread=False".format(gtfs_root) joined_path = os.path.join(gtfs_dir, sqlite_file) gtfs = pygtfs.Schedule(joined_path) diff --git a/requirements_all.txt b/requirements_all.txt index ec22bccfa9af1..0606252b18d5a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -963,7 +963,7 @@ pygatt==3.2.0 pygogogate2==0.1.1 # homeassistant.components.sensor.gtfs -pygtfs-homeassistant==0.1.3.dev0 +pygtfs==0.1.5 # homeassistant.components.remote.harmony pyharmony==1.0.20 From a521b885bf14e14a75791a2d7411aafac2d6416d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 10 Dec 2018 08:57:17 +0100 Subject: [PATCH 323/325] Lovelace using storage (#19101) * Add MVP * Remove unused code * Fix * Add force back * Fix tests * Storage keyed * Error out when storage doesnt find config * Use old load_yaml * Set config for panel correct * Use instance cache var * Make config option --- homeassistant/components/frontend/__init__.py | 3 +- homeassistant/components/lovelace/__init__.py | 655 ++------------ tests/components/lovelace/test_init.py | 838 ++---------------- 3 files changed, 180 insertions(+), 1316 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 36fbe14aefde1..f14a3b0b3249e 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -250,8 +250,7 @@ def async_finalize_panel(panel): await asyncio.wait( [async_register_built_in_panel(hass, panel) for panel in ( 'dev-event', 'dev-info', 'dev-service', 'dev-state', - 'dev-template', 'dev-mqtt', 'kiosk', 'lovelace', - 'states', 'profile')], + 'dev-template', 'dev-mqtt', 'kiosk', 'states', 'profile')], loop=hass.loop) hass.data[DATA_FINALIZE_PANEL] = async_finalize_panel diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index f6a8a3fd68802..68c322b39565e 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -7,505 +7,137 @@ from functools import wraps import logging import os -from typing import Dict, List, Union import time -import uuid import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.ruamel_yaml as yaml +from homeassistant.util.yaml import load_yaml _LOGGER = logging.getLogger(__name__) DOMAIN = 'lovelace' -LOVELACE_DATA = 'lovelace' +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 +CONF_MODE = 'mode' +MODE_YAML = 'yaml' +MODE_STORAGE = 'storage' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_MODE, default=MODE_STORAGE): + vol.All(vol.Lower, vol.In([MODE_YAML, MODE_STORAGE])), + }), +}, extra=vol.ALLOW_EXTRA) -LOVELACE_CONFIG_FILE = 'ui-lovelace.yaml' -JSON_TYPE = Union[List, Dict, str] # pylint: disable=invalid-name -FORMAT_YAML = 'yaml' -FORMAT_JSON = 'json' +LOVELACE_CONFIG_FILE = 'ui-lovelace.yaml' -OLD_WS_TYPE_GET_LOVELACE_UI = 'frontend/lovelace_config' WS_TYPE_GET_LOVELACE_UI = 'lovelace/config' -WS_TYPE_MIGRATE_CONFIG = 'lovelace/config/migrate' WS_TYPE_SAVE_CONFIG = 'lovelace/config/save' -WS_TYPE_GET_CARD = 'lovelace/config/card/get' -WS_TYPE_UPDATE_CARD = 'lovelace/config/card/update' -WS_TYPE_ADD_CARD = 'lovelace/config/card/add' -WS_TYPE_MOVE_CARD = 'lovelace/config/card/move' -WS_TYPE_DELETE_CARD = 'lovelace/config/card/delete' - -WS_TYPE_GET_VIEW = 'lovelace/config/view/get' -WS_TYPE_UPDATE_VIEW = 'lovelace/config/view/update' -WS_TYPE_ADD_VIEW = 'lovelace/config/view/add' -WS_TYPE_MOVE_VIEW = 'lovelace/config/view/move' -WS_TYPE_DELETE_VIEW = 'lovelace/config/view/delete' - SCHEMA_GET_LOVELACE_UI = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): - vol.Any(WS_TYPE_GET_LOVELACE_UI, OLD_WS_TYPE_GET_LOVELACE_UI), + vol.Required('type'): WS_TYPE_GET_LOVELACE_UI, vol.Optional('force', default=False): bool, }) -SCHEMA_MIGRATE_CONFIG = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_MIGRATE_CONFIG, -}) - SCHEMA_SAVE_CONFIG = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_SAVE_CONFIG, vol.Required('config'): vol.Any(str, dict), - vol.Optional('format', default=FORMAT_JSON): - vol.Any(FORMAT_JSON, FORMAT_YAML), }) -SCHEMA_GET_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_GET_CARD, - vol.Required('card_id'): str, - vol.Optional('format', default=FORMAT_YAML): - vol.Any(FORMAT_JSON, FORMAT_YAML), -}) - -SCHEMA_UPDATE_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_UPDATE_CARD, - vol.Required('card_id'): str, - vol.Required('card_config'): vol.Any(str, dict), - vol.Optional('format', default=FORMAT_YAML): - vol.Any(FORMAT_JSON, FORMAT_YAML), -}) -SCHEMA_ADD_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_ADD_CARD, - vol.Required('view_id'): str, - vol.Required('card_config'): vol.Any(str, dict), - vol.Optional('position'): int, - vol.Optional('format', default=FORMAT_YAML): - vol.Any(FORMAT_JSON, FORMAT_YAML), -}) +class ConfigNotFound(HomeAssistantError): + """When no config available.""" -SCHEMA_MOVE_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_MOVE_CARD, - vol.Required('card_id'): str, - vol.Optional('new_position'): int, - vol.Optional('new_view_id'): str, -}) -SCHEMA_DELETE_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_DELETE_CARD, - vol.Required('card_id'): str, -}) - -SCHEMA_GET_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_GET_VIEW, - vol.Required('view_id'): str, - vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, - FORMAT_YAML), -}) - -SCHEMA_UPDATE_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_UPDATE_VIEW, - vol.Required('view_id'): str, - vol.Required('view_config'): vol.Any(str, dict), - vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, - FORMAT_YAML), -}) - -SCHEMA_ADD_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_ADD_VIEW, - vol.Required('view_config'): vol.Any(str, dict), - vol.Optional('position'): int, - vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, - FORMAT_YAML), -}) - -SCHEMA_MOVE_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_MOVE_VIEW, - vol.Required('view_id'): str, - vol.Required('new_position'): int, -}) - -SCHEMA_DELETE_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_DELETE_VIEW, - vol.Required('view_id'): str, -}) +async def async_setup(hass, config): + """Set up the Lovelace commands.""" + # Pass in default to `get` because defaults not set if loaded as dep + mode = config.get(DOMAIN, {}).get(CONF_MODE, MODE_STORAGE) + await hass.components.frontend.async_register_built_in_panel( + DOMAIN, config={ + 'mode': mode + }) -class CardNotFoundError(HomeAssistantError): - """Card not found in data.""" - - -class ViewNotFoundError(HomeAssistantError): - """View not found in data.""" - - -class DuplicateIdError(HomeAssistantError): - """Duplicate ID's.""" - - -def load_config(hass, force: bool) -> JSON_TYPE: - """Load a YAML file.""" - fname = hass.config.path(LOVELACE_CONFIG_FILE) - - # Check for a cached version of the config - if not force and LOVELACE_DATA in hass.data: - config, last_update = hass.data[LOVELACE_DATA] - modtime = os.path.getmtime(fname) - if config and last_update > modtime: - return config - - config = yaml.load_yaml(fname, False) - seen_card_ids = set() - seen_view_ids = set() - if 'views' in config and not isinstance(config['views'], list): - raise HomeAssistantError("Views should be a list.") - for view in config.get('views', []): - if 'id' in view and not isinstance(view['id'], (str, int)): - raise HomeAssistantError( - "Your config contains view(s) with invalid ID(s).") - view_id = str(view.get('id', '')) - if view_id in seen_view_ids: - raise DuplicateIdError( - 'ID `{}` has multiple occurances in views'.format(view_id)) - seen_view_ids.add(view_id) - if 'cards' in view and not isinstance(view['cards'], list): - raise HomeAssistantError("Cards should be a list.") - for card in view.get('cards', []): - if 'id' in card and not isinstance(card['id'], (str, int)): - raise HomeAssistantError( - "Your config contains card(s) with invalid ID(s).") - card_id = str(card.get('id', '')) - if card_id in seen_card_ids: - raise DuplicateIdError( - 'ID `{}` has multiple occurances in cards' - .format(card_id)) - seen_card_ids.add(card_id) - hass.data[LOVELACE_DATA] = (config, time.time()) - return config - - -def migrate_config(fname: str) -> None: - """Add id to views and cards if not present and check duplicates.""" - config = yaml.load_yaml(fname, True) - updated = False - seen_card_ids = set() - seen_view_ids = set() - index = 0 - for view in config.get('views', []): - view_id = str(view.get('id', '')) - if not view_id: - updated = True - view.insert(0, 'id', index, comment="Automatically created id") - else: - if view_id in seen_view_ids: - raise DuplicateIdError( - 'ID `{}` has multiple occurrences in views'.format( - view_id)) - seen_view_ids.add(view_id) - for card in view.get('cards', []): - card_id = str(card.get('id', '')) - if not card_id: - updated = True - card.insert(0, 'id', uuid.uuid4().hex, - comment="Automatically created id") - else: - if card_id in seen_card_ids: - raise DuplicateIdError( - 'ID `{}` has multiple occurrences in cards' - .format(card_id)) - seen_card_ids.add(card_id) - index += 1 - if updated: - yaml.save_yaml(fname, config) - - -def save_config(fname: str, config, data_format: str = FORMAT_JSON) -> None: - """Save config to file.""" - if data_format == FORMAT_YAML: - config = yaml.yaml_to_object(config) - yaml.save_yaml(fname, config) - - -def get_card(fname: str, card_id: str, data_format: str = FORMAT_YAML)\ - -> JSON_TYPE: - """Load a specific card config for id.""" - round_trip = data_format == FORMAT_YAML - - config = yaml.load_yaml(fname, round_trip) - - for view in config.get('views', []): - for card in view.get('cards', []): - if str(card.get('id', '')) != card_id: - continue - if data_format == FORMAT_YAML: - return yaml.object_to_yaml(card) - return card - - raise CardNotFoundError( - "Card with ID: {} was not found in {}.".format(card_id, fname)) - - -def update_card(fname: str, card_id: str, card_config: str, - data_format: str = FORMAT_YAML) -> None: - """Save a specific card config for id.""" - config = yaml.load_yaml(fname, True) - for view in config.get('views', []): - for card in view.get('cards', []): - if str(card.get('id', '')) != card_id: - continue - if data_format == FORMAT_YAML: - card_config = yaml.yaml_to_object(card_config) - card.clear() - card.update(card_config) - yaml.save_yaml(fname, config) - return - - raise CardNotFoundError( - "Card with ID: {} was not found in {}.".format(card_id, fname)) - - -def add_card(fname: str, view_id: str, card_config: str, - position: int = None, data_format: str = FORMAT_YAML) -> None: - """Add a card to a view.""" - config = yaml.load_yaml(fname, True) - for view in config.get('views', []): - if str(view.get('id', '')) != view_id: - continue - cards = view.get('cards', []) - if not cards and 'cards' in view: - del view['cards'] - if data_format == FORMAT_YAML: - card_config = yaml.yaml_to_object(card_config) - if 'id' not in card_config: - card_config['id'] = uuid.uuid4().hex - if position is None: - cards.append(card_config) - else: - cards.insert(position, card_config) - if 'cards' not in view: - view['cards'] = cards - yaml.save_yaml(fname, config) - return - - raise ViewNotFoundError( - "View with ID: {} was not found in {}.".format(view_id, fname)) - - -def move_card(fname: str, card_id: str, position: int = None) -> None: - """Move a card to a different position.""" - if position is None: - raise HomeAssistantError( - 'Position is required if view is not specified.') - config = yaml.load_yaml(fname, True) - for view in config.get('views', []): - for card in view.get('cards', []): - if str(card.get('id', '')) != card_id: - continue - cards = view.get('cards') - cards.insert(position, cards.pop(cards.index(card))) - yaml.save_yaml(fname, config) - return - - raise CardNotFoundError( - "Card with ID: {} was not found in {}.".format(card_id, fname)) - - -def move_card_view(fname: str, card_id: str, view_id: str, - position: int = None) -> None: - """Move a card to a different view.""" - config = yaml.load_yaml(fname, True) - for view in config.get('views', []): - if str(view.get('id', '')) == view_id: - destination = view.get('cards') - for card in view.get('cards'): - if str(card.get('id', '')) != card_id: - continue - origin = view.get('cards') - card_to_move = card - - if 'destination' not in locals(): - raise ViewNotFoundError( - "View with ID: {} was not found in {}.".format(view_id, fname)) - if 'card_to_move' not in locals(): - raise CardNotFoundError( - "Card with ID: {} was not found in {}.".format(card_id, fname)) - - origin.pop(origin.index(card_to_move)) - - if position is None: - destination.append(card_to_move) + if mode == MODE_YAML: + hass.data[DOMAIN] = LovelaceYAML(hass) else: - destination.insert(position, card_to_move) - - yaml.save_yaml(fname, config) - - -def delete_card(fname: str, card_id: str) -> None: - """Delete a card from view.""" - config = yaml.load_yaml(fname, True) - for view in config.get('views', []): - for card in view.get('cards', []): - if str(card.get('id', '')) != card_id: - continue - cards = view.get('cards') - cards.pop(cards.index(card)) - yaml.save_yaml(fname, config) - return - - raise CardNotFoundError( - "Card with ID: {} was not found in {}.".format(card_id, fname)) - - -def get_view(fname: str, view_id: str, data_format: str = FORMAT_YAML) -> None: - """Get view without it's cards.""" - round_trip = data_format == FORMAT_YAML - config = yaml.load_yaml(fname, round_trip) - found = None - for view in config.get('views', []): - if str(view.get('id', '')) == view_id: - found = view - break - if found is None: - raise ViewNotFoundError( - "View with ID: {} was not found in {}.".format(view_id, fname)) - - del found['cards'] - if data_format == FORMAT_YAML: - return yaml.object_to_yaml(found) - return found - - -def update_view(fname: str, view_id: str, view_config, data_format: - str = FORMAT_YAML) -> None: - """Update view.""" - config = yaml.load_yaml(fname, True) - found = None - for view in config.get('views', []): - if str(view.get('id', '')) == view_id: - found = view - break - if found is None: - raise ViewNotFoundError( - "View with ID: {} was not found in {}.".format(view_id, fname)) - if data_format == FORMAT_YAML: - view_config = yaml.yaml_to_object(view_config) - if not view_config.get('cards') and found.get('cards'): - view_config['cards'] = found.get('cards', []) - if not view_config.get('badges') and found.get('badges'): - view_config['badges'] = found.get('badges', []) - found.clear() - found.update(view_config) - yaml.save_yaml(fname, config) - - -def add_view(fname: str, view_config: str, - position: int = None, data_format: str = FORMAT_YAML) -> None: - """Add a view.""" - config = yaml.load_yaml(fname, True) - views = config.get('views', []) - if data_format == FORMAT_YAML: - view_config = yaml.yaml_to_object(view_config) - if 'id' not in view_config: - view_config['id'] = uuid.uuid4().hex - if position is None: - views.append(view_config) - else: - views.insert(position, view_config) - if 'views' not in config: - config['views'] = views - yaml.save_yaml(fname, config) - - -def move_view(fname: str, view_id: str, position: int) -> None: - """Move a view to a different position.""" - config = yaml.load_yaml(fname, True) - views = config.get('views', []) - found = None - for view in views: - if str(view.get('id', '')) == view_id: - found = view - break - if found is None: - raise ViewNotFoundError( - "View with ID: {} was not found in {}.".format(view_id, fname)) - - views.insert(position, views.pop(views.index(found))) - yaml.save_yaml(fname, config) - - -def delete_view(fname: str, view_id: str) -> None: - """Delete a view.""" - config = yaml.load_yaml(fname, True) - views = config.get('views', []) - found = None - for view in views: - if str(view.get('id', '')) == view_id: - found = view - break - if found is None: - raise ViewNotFoundError( - "View with ID: {} was not found in {}.".format(view_id, fname)) - - views.pop(views.index(found)) - yaml.save_yaml(fname, config) - - -async def async_setup(hass, config): - """Set up the Lovelace commands.""" - # Backwards compat. Added in 0.80. Remove after 0.85 - hass.components.websocket_api.async_register_command( - OLD_WS_TYPE_GET_LOVELACE_UI, websocket_lovelace_config, - SCHEMA_GET_LOVELACE_UI) + hass.data[DOMAIN] = LovelaceStorage(hass) hass.components.websocket_api.async_register_command( WS_TYPE_GET_LOVELACE_UI, websocket_lovelace_config, SCHEMA_GET_LOVELACE_UI) - hass.components.websocket_api.async_register_command( - WS_TYPE_MIGRATE_CONFIG, websocket_lovelace_migrate_config, - SCHEMA_MIGRATE_CONFIG) - hass.components.websocket_api.async_register_command( WS_TYPE_SAVE_CONFIG, websocket_lovelace_save_config, SCHEMA_SAVE_CONFIG) - hass.components.websocket_api.async_register_command( - WS_TYPE_GET_CARD, websocket_lovelace_get_card, SCHEMA_GET_CARD) + return True - hass.components.websocket_api.async_register_command( - WS_TYPE_UPDATE_CARD, websocket_lovelace_update_card, - SCHEMA_UPDATE_CARD) - hass.components.websocket_api.async_register_command( - WS_TYPE_ADD_CARD, websocket_lovelace_add_card, SCHEMA_ADD_CARD) +class LovelaceStorage: + """Class to handle Storage based Lovelace config.""" - hass.components.websocket_api.async_register_command( - WS_TYPE_MOVE_CARD, websocket_lovelace_move_card, SCHEMA_MOVE_CARD) + def __init__(self, hass): + """Initialize Lovelace config based on storage helper.""" + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + self._data = None - hass.components.websocket_api.async_register_command( - WS_TYPE_DELETE_CARD, websocket_lovelace_delete_card, - SCHEMA_DELETE_CARD) + async def async_load(self, force): + """Load config.""" + if self._data is None: + data = await self._store.async_load() + self._data = data if data else {'config': None} - hass.components.websocket_api.async_register_command( - WS_TYPE_GET_VIEW, websocket_lovelace_get_view, SCHEMA_GET_VIEW) + config = self._data['config'] - hass.components.websocket_api.async_register_command( - WS_TYPE_UPDATE_VIEW, websocket_lovelace_update_view, - SCHEMA_UPDATE_VIEW) + if config is None: + raise ConfigNotFound - hass.components.websocket_api.async_register_command( - WS_TYPE_ADD_VIEW, websocket_lovelace_add_view, SCHEMA_ADD_VIEW) + return config - hass.components.websocket_api.async_register_command( - WS_TYPE_MOVE_VIEW, websocket_lovelace_move_view, SCHEMA_MOVE_VIEW) + async def async_save(self, config): + """Save config.""" + self._data = {'config': config} + await self._store.async_save(config) - hass.components.websocket_api.async_register_command( - WS_TYPE_DELETE_VIEW, websocket_lovelace_delete_view, - SCHEMA_DELETE_VIEW) - return True +class LovelaceYAML: + """Class to handle YAML-based Lovelace config.""" + + def __init__(self, hass): + """Initialize the YAML config.""" + self.hass = hass + self._cache = None + + async def async_load(self, force): + """Load config.""" + return await self.hass.async_add_executor_job(self._load_config, force) + + def _load_config(self, force): + """Load the actual config.""" + fname = self.hass.config.path(LOVELACE_CONFIG_FILE) + # Check for a cached version of the config + if not force and self._cache is not None: + config, last_update = self._cache + modtime = os.path.getmtime(fname) + if config and last_update > modtime: + return config + + try: + config = load_yaml(fname) + except FileNotFoundError: + raise ConfigNotFound from None + + self._cache = (config, time.time()) + return config + + async def async_save(self, config): + """Save config.""" + raise HomeAssistantError('Not supported') def handle_yaml_errors(func): @@ -518,19 +150,8 @@ async def send_with_error_handling(hass, connection, msg): message = websocket_api.result_message( msg['id'], result ) - except FileNotFoundError: - error = ('file_not_found', - 'Could not find ui-lovelace.yaml in your config dir.') - except yaml.UnsupportedYamlError as err: - error = 'unsupported_error', str(err) - except yaml.WriteError as err: - error = 'write_error', str(err) - except DuplicateIdError as err: - error = 'duplicate_id', str(err) - except CardNotFoundError as err: - error = 'card_not_found', str(err) - except ViewNotFoundError as err: - error = 'view_not_found', str(err) + except ConfigNotFound: + error = 'config_not_found', 'No config found.' except HomeAssistantError as err: error = 'error', str(err) @@ -546,117 +167,11 @@ async def send_with_error_handling(hass, connection, msg): @handle_yaml_errors async def websocket_lovelace_config(hass, connection, msg): """Send Lovelace UI config over WebSocket configuration.""" - return await hass.async_add_executor_job(load_config, hass, - msg.get('force', False)) - - -@websocket_api.async_response -@handle_yaml_errors -async def websocket_lovelace_migrate_config(hass, connection, msg): - """Migrate Lovelace UI configuration.""" - return await hass.async_add_executor_job( - migrate_config, hass.config.path(LOVELACE_CONFIG_FILE)) + return await hass.data[DOMAIN].async_load(msg['force']) @websocket_api.async_response @handle_yaml_errors async def websocket_lovelace_save_config(hass, connection, msg): """Save Lovelace UI configuration.""" - return await hass.async_add_executor_job( - save_config, hass.config.path(LOVELACE_CONFIG_FILE), msg['config'], - msg.get('format', FORMAT_JSON)) - - -@websocket_api.async_response -@handle_yaml_errors -async def websocket_lovelace_get_card(hass, connection, msg): - """Send Lovelace card config over WebSocket configuration.""" - return await hass.async_add_executor_job( - get_card, hass.config.path(LOVELACE_CONFIG_FILE), msg['card_id'], - msg.get('format', FORMAT_YAML)) - - -@websocket_api.async_response -@handle_yaml_errors -async def websocket_lovelace_update_card(hass, connection, msg): - """Receive Lovelace card configuration over WebSocket and save.""" - return await hass.async_add_executor_job( - update_card, hass.config.path(LOVELACE_CONFIG_FILE), - msg['card_id'], msg['card_config'], msg.get('format', FORMAT_YAML)) - - -@websocket_api.async_response -@handle_yaml_errors -async def websocket_lovelace_add_card(hass, connection, msg): - """Add new card to view over WebSocket and save.""" - return await hass.async_add_executor_job( - add_card, hass.config.path(LOVELACE_CONFIG_FILE), - msg['view_id'], msg['card_config'], msg.get('position'), - msg.get('format', FORMAT_YAML)) - - -@websocket_api.async_response -@handle_yaml_errors -async def websocket_lovelace_move_card(hass, connection, msg): - """Move card to different position over WebSocket and save.""" - if 'new_view_id' in msg: - return await hass.async_add_executor_job( - move_card_view, hass.config.path(LOVELACE_CONFIG_FILE), - msg['card_id'], msg['new_view_id'], msg.get('new_position')) - - return await hass.async_add_executor_job( - move_card, hass.config.path(LOVELACE_CONFIG_FILE), - msg['card_id'], msg.get('new_position')) - - -@websocket_api.async_response -@handle_yaml_errors -async def websocket_lovelace_delete_card(hass, connection, msg): - """Delete card from Lovelace over WebSocket and save.""" - return await hass.async_add_executor_job( - delete_card, hass.config.path(LOVELACE_CONFIG_FILE), msg['card_id']) - - -@websocket_api.async_response -@handle_yaml_errors -async def websocket_lovelace_get_view(hass, connection, msg): - """Send Lovelace view config over WebSocket config.""" - return await hass.async_add_executor_job( - get_view, hass.config.path(LOVELACE_CONFIG_FILE), msg['view_id'], - msg.get('format', FORMAT_YAML)) - - -@websocket_api.async_response -@handle_yaml_errors -async def websocket_lovelace_update_view(hass, connection, msg): - """Receive Lovelace card config over WebSocket and save.""" - return await hass.async_add_executor_job( - update_view, hass.config.path(LOVELACE_CONFIG_FILE), - msg['view_id'], msg['view_config'], msg.get('format', FORMAT_YAML)) - - -@websocket_api.async_response -@handle_yaml_errors -async def websocket_lovelace_add_view(hass, connection, msg): - """Add new view over WebSocket and save.""" - return await hass.async_add_executor_job( - add_view, hass.config.path(LOVELACE_CONFIG_FILE), - msg['view_config'], msg.get('position'), - msg.get('format', FORMAT_YAML)) - - -@websocket_api.async_response -@handle_yaml_errors -async def websocket_lovelace_move_view(hass, connection, msg): - """Move view to different position over WebSocket and save.""" - return await hass.async_add_executor_job( - move_view, hass.config.path(LOVELACE_CONFIG_FILE), - msg['view_id'], msg['new_position']) - - -@websocket_api.async_response -@handle_yaml_errors -async def websocket_lovelace_delete_view(hass, connection, msg): - """Delete card from Lovelace over WebSocket and save.""" - return await hass.async_add_executor_job( - delete_view, hass.config.path(LOVELACE_CONFIG_FILE), msg['view_id']) + await hass.data[DOMAIN].async_save(msg['config']) diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index e296d14c6f888..ea856b464c318 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -1,748 +1,98 @@ """Test the Lovelace initialization.""" from unittest.mock import patch -from ruamel.yaml import YAML -from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -from homeassistant.components.websocket_api.const import TYPE_RESULT -from homeassistant.components.lovelace import migrate_config -from homeassistant.util.ruamel_yaml import UnsupportedYamlError - -TEST_YAML_A = """\ -title: My Awesome Home -# Include external resources -resources: - - url: /local/my-custom-card.js - type: js - - url: /local/my-webfont.css - type: css - -# Exclude entities from "Unused entities" view -excluded_entities: - - weblink.router -views: - # View tab title. - - title: Example - # Optional unique id for direct access /lovelace/${id} - id: example - # Optional background (overwrites the global background). - background: radial-gradient(crimson, skyblue) - # Each view can have a different theme applied. - theme: dark-mode - # The cards to show on this view. - cards: - # The filter card will filter entities for their state - - type: entity-filter - entities: - - device_tracker.paulus - - device_tracker.anne_there - state_filter: - - 'home' - card: - type: glance - title: People that are home - - # The picture entity card will represent an entity with a picture - - type: picture-entity - image: https://www.home-assistant.io/images/default-social.png - entity: light.bed_light - - # Specify a tab icon if you want the view tab to be an icon. - - icon: mdi:home-assistant - # Title of the view. Will be used as the tooltip for tab icon - title: Second view - cards: - - id: test - type: entities - title: Test card - # Entities card will take a list of entities and show their state. - - type: entities - # Title of the entities card - title: Example - # The entities here will be shown in the same order as specified. - # Each entry is an entity ID or a map with extra options. - entities: - - light.kitchen - - switch.ac - - entity: light.living_room - # Override the name to use - name: LR Lights - - # The markdown card will render markdown text. - - type: markdown - title: Lovelace - content: > - Welcome to your **Lovelace UI**. -""" - -TEST_YAML_B = """\ -title: Home -views: - - title: Dashboard - id: dashboard - icon: mdi:home - cards: - - id: testid - type: vertical-stack - cards: - - type: picture-entity - entity: group.sample - name: Sample - image: /local/images/sample.jpg - tap_action: toggle -""" - -# Test data that can not be loaded as YAML -TEST_BAD_YAML = """\ -title: Home -views: - - title: Dashboard - icon: mdi:home - cards: - - id: testid - type: vertical-stack -""" - -# Test unsupported YAML -TEST_UNSUP_YAML = """\ -title: Home -views: - - title: Dashboard - icon: mdi:home - cards: !include cards.yaml -""" - - -def test_add_id(): - """Test if id is added.""" - yaml = YAML(typ='rt') - - fname = "dummy.yaml" - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - migrate_config(fname) - - result = save_yaml_mock.call_args_list[0][0][1] - assert 'id' in result['views'][0]['cards'][0] - assert 'id' in result['views'][1] - - -def test_id_not_changed(): - """Test if id is not changed if already exists.""" - yaml = YAML(typ='rt') - - fname = "dummy.yaml" - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_B)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - migrate_config(fname) - assert save_yaml_mock.call_count == 0 - - -async def test_deprecated_lovelace_ui(hass, hass_ws_client): - """Test lovelace_ui command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - - with patch('homeassistant.components.lovelace.load_config', - return_value={'hello': 'world'}): - await client.send_json({ - 'id': 5, - 'type': 'frontend/lovelace_config', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - assert msg['result'] == {'hello': 'world'} - - -async def test_deprecated_lovelace_ui_not_found(hass, hass_ws_client): - """Test lovelace_ui command cannot find file.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - - with patch('homeassistant.components.lovelace.load_config', - side_effect=FileNotFoundError): - await client.send_json({ - 'id': 5, - 'type': 'frontend/lovelace_config', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] is False - assert msg['error']['code'] == 'file_not_found' - - -async def test_deprecated_lovelace_ui_load_err(hass, hass_ws_client): - """Test lovelace_ui command cannot find file.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - - with patch('homeassistant.components.lovelace.load_config', - side_effect=HomeAssistantError): - await client.send_json({ - 'id': 5, - 'type': 'frontend/lovelace_config', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] is False - assert msg['error']['code'] == 'error' - - -async def test_lovelace_ui(hass, hass_ws_client): - """Test lovelace_ui command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - - with patch('homeassistant.components.lovelace.load_config', - return_value={'hello': 'world'}): - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - assert msg['result'] == {'hello': 'world'} - - -async def test_lovelace_ui_not_found(hass, hass_ws_client): - """Test lovelace_ui command cannot find file.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - - with patch('homeassistant.components.lovelace.load_config', - side_effect=FileNotFoundError): - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] is False - assert msg['error']['code'] == 'file_not_found' - - -async def test_lovelace_ui_load_err(hass, hass_ws_client): - """Test lovelace_ui command load error.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - - with patch('homeassistant.components.lovelace.load_config', - side_effect=HomeAssistantError): - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] is False - assert msg['error']['code'] == 'error' - - -async def test_lovelace_ui_load_json_err(hass, hass_ws_client): - """Test lovelace_ui command load error.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - - with patch('homeassistant.components.lovelace.load_config', - side_effect=UnsupportedYamlError): - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] is False - assert msg['error']['code'] == 'unsupported_error' - - -async def test_lovelace_get_card(hass, hass_ws_client): - """Test get_card command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)): - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/card/get', - 'card_id': 'test', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - assert msg['result'] == 'id: test\ntype: entities\ntitle: Test card\n' - - -async def test_lovelace_get_card_not_found(hass, hass_ws_client): - """Test get_card command cannot find card.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)): - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/card/get', - 'card_id': 'not_found', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] is False - assert msg['error']['code'] == 'card_not_found' - - -async def test_lovelace_get_card_bad_yaml(hass, hass_ws_client): - """Test get_card command bad yaml.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - side_effect=HomeAssistantError): - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/card/get', - 'card_id': 'testid', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] is False - assert msg['error']['code'] == 'error' - - -async def test_lovelace_update_card(hass, hass_ws_client): - """Test update_card command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/card/update', - 'card_id': 'test', - 'card_config': 'id: test\ntype: glance\n', - }) - msg = await client.receive_json() - - result = save_yaml_mock.call_args_list[0][0][1] - assert result.mlget(['views', 1, 'cards', 0, 'type'], - list_ok=True) == 'glance' - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - - -async def test_lovelace_update_card_not_found(hass, hass_ws_client): - """Test update_card command cannot find card.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)): - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/card/update', - 'card_id': 'not_found', - 'card_config': 'id: test\ntype: glance\n', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] is False - assert msg['error']['code'] == 'card_not_found' - - -async def test_lovelace_update_card_bad_yaml(hass, hass_ws_client): - """Test update_card command bad yaml.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.yaml_to_object', - side_effect=HomeAssistantError): - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/card/update', - 'card_id': 'test', - 'card_config': 'id: test\ntype: glance\n', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] is False - assert msg['error']['code'] == 'error' - - -async def test_lovelace_add_card(hass, hass_ws_client): - """Test add_card command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/card/add', - 'view_id': 'example', - 'card_config': 'id: test\ntype: added\n', - }) - msg = await client.receive_json() - - result = save_yaml_mock.call_args_list[0][0][1] - assert result.mlget(['views', 0, 'cards', 2, 'type'], - list_ok=True) == 'added' - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - - -async def test_lovelace_add_card_position(hass, hass_ws_client): - """Test add_card command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/card/add', - 'view_id': 'example', - 'position': 0, - 'card_config': 'id: test\ntype: added\n', - }) - msg = await client.receive_json() - - result = save_yaml_mock.call_args_list[0][0][1] - assert result.mlget(['views', 0, 'cards', 0, 'type'], - list_ok=True) == 'added' - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - - -async def test_lovelace_move_card_position(hass, hass_ws_client): - """Test move_card command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/card/move', - 'card_id': 'test', - 'new_position': 2, - }) - msg = await client.receive_json() - - result = save_yaml_mock.call_args_list[0][0][1] - assert result.mlget(['views', 1, 'cards', 2, 'title'], - list_ok=True) == 'Test card' - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - - -async def test_lovelace_move_card_view(hass, hass_ws_client): - """Test move_card to view command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/card/move', - 'card_id': 'test', - 'new_view_id': 'example', - }) - msg = await client.receive_json() - - result = save_yaml_mock.call_args_list[0][0][1] - assert result.mlget(['views', 0, 'cards', 2, 'title'], - list_ok=True) == 'Test card' - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - - -async def test_lovelace_move_card_view_position(hass, hass_ws_client): - """Test move_card to view with position command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/card/move', - 'card_id': 'test', - 'new_view_id': 'example', - 'new_position': 1, - }) - msg = await client.receive_json() - - result = save_yaml_mock.call_args_list[0][0][1] - assert result.mlget(['views', 0, 'cards', 1, 'title'], - list_ok=True) == 'Test card' - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - - -async def test_lovelace_delete_card(hass, hass_ws_client): - """Test delete_card command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/card/delete', - 'card_id': 'test', - }) - msg = await client.receive_json() - - result = save_yaml_mock.call_args_list[0][0][1] - cards = result.mlget(['views', 1, 'cards'], list_ok=True) - assert len(cards) == 2 - assert cards[0]['title'] == 'Example' - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - - -async def test_lovelace_get_view(hass, hass_ws_client): - """Test get_view command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)): - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/view/get', - 'view_id': 'example', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - assert "".join(msg['result'].split()) == "".join('title: Example\n # \ - Optional unique id for direct\ - access /lovelace/${id}\nid: example\n # Optional\ - background (overwrites the global background).\n\ - background: radial-gradient(crimson, skyblue)\n\ - # Each view can have a different theme applied.\n\ - theme: dark-mode\n'.split()) - - -async def test_lovelace_get_view_not_found(hass, hass_ws_client): - """Test get_card command cannot find card.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)): - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/view/get', - 'view_id': 'not_found', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] is False - assert msg['error']['code'] == 'view_not_found' - - -async def test_lovelace_update_view(hass, hass_ws_client): - """Test update_view command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - origyaml = yaml.load(TEST_YAML_A) - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=origyaml), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/view/update', - 'view_id': 'example', - 'view_config': 'id: example2\ntitle: New title\n', - }) - msg = await client.receive_json() - - result = save_yaml_mock.call_args_list[0][0][1] - orig_view = origyaml.mlget(['views', 0], list_ok=True) - new_view = result.mlget(['views', 0], list_ok=True) - assert new_view['title'] == 'New title' - assert new_view['cards'] == orig_view['cards'] - assert 'theme' not in new_view - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - - -async def test_lovelace_add_view(hass, hass_ws_client): - """Test add_view command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/view/add', - 'view_config': 'id: test\ntitle: added\n', - }) - msg = await client.receive_json() - - result = save_yaml_mock.call_args_list[0][0][1] - assert result.mlget(['views', 2, 'title'], - list_ok=True) == 'added' - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - - -async def test_lovelace_add_view_position(hass, hass_ws_client): - """Test add_view command with position.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/view/add', - 'position': 0, - 'view_config': 'id: test\ntitle: added\n', - }) - msg = await client.receive_json() - - result = save_yaml_mock.call_args_list[0][0][1] - assert result.mlget(['views', 0, 'title'], - list_ok=True) == 'added' - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - - -async def test_lovelace_move_view_position(hass, hass_ws_client): - """Test move_view command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/view/move', - 'view_id': 'example', - 'new_position': 1, - }) - msg = await client.receive_json() - - result = save_yaml_mock.call_args_list[0][0][1] - assert result.mlget(['views', 1, 'title'], - list_ok=True) == 'Example' - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - - -async def test_lovelace_delete_view(hass, hass_ws_client): - """Test delete_card command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/view/delete', - 'view_id': 'example', - }) - msg = await client.receive_json() - - result = save_yaml_mock.call_args_list[0][0][1] - views = result.get('views', []) - assert len(views) == 1 - assert views[0]['title'] == 'Second view' - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] +from homeassistant.components import frontend, lovelace + + +async def test_lovelace_from_storage(hass, hass_ws_client, hass_storage): + """Test we load lovelace config from storage.""" + assert await async_setup_component(hass, 'lovelace', {}) + assert hass.data[frontend.DATA_PANELS]['lovelace'].config == { + 'mode': 'storage' + } + + client = await hass_ws_client(hass) + + # Fetch data + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config' + }) + response = await client.receive_json() + assert not response['success'] + assert response['error']['code'] == 'config_not_found' + + # Store new config + await client.send_json({ + 'id': 6, + 'type': 'lovelace/config/save', + 'config': { + 'yo': 'hello' + } + }) + response = await client.receive_json() + assert response['success'] + assert hass_storage[lovelace.STORAGE_KEY]['data'] == { + 'yo': 'hello' + } + + # Load new config + await client.send_json({ + 'id': 7, + 'type': 'lovelace/config' + }) + response = await client.receive_json() + assert response['success'] + + assert response['result'] == { + 'yo': 'hello' + } + + +async def test_lovelace_from_yaml(hass, hass_ws_client): + """Test we load lovelace config from yaml.""" + assert await async_setup_component(hass, 'lovelace', { + 'lovelace': { + 'mode': 'YAML' + } + }) + assert hass.data[frontend.DATA_PANELS]['lovelace'].config == { + 'mode': 'yaml' + } + + client = await hass_ws_client(hass) + + # Fetch data + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config' + }) + response = await client.receive_json() + assert not response['success'] + + assert response['error']['code'] == 'config_not_found' + + # Store new config not allowed + await client.send_json({ + 'id': 6, + 'type': 'lovelace/config/save', + 'config': { + 'yo': 'hello' + } + }) + response = await client.receive_json() + assert not response['success'] + + # Patch data + with patch('homeassistant.components.lovelace.load_yaml', return_value={ + 'hello': 'yo' + }): + await client.send_json({ + 'id': 7, + 'type': 'lovelace/config' + }) + response = await client.receive_json() + + assert response['success'] + assert response['result'] == {'hello': 'yo'} From faab0aa9df99aec25b879e4bfe99eb36082ea22d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 10 Dec 2018 09:53:53 +0100 Subject: [PATCH 324/325] Updated frontend to 20181210.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 f14a3b0b3249e..cd592b2599303 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20181207.0'] +REQUIREMENTS = ['home-assistant-frontend==20181210.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 0606252b18d5a..5168bc2974524 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -493,7 +493,7 @@ hole==0.3.0 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181207.0 +home-assistant-frontend==20181210.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f77f457289ea..df4ea066991bf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -101,7 +101,7 @@ hdate==0.7.5 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181207.0 +home-assistant-frontend==20181210.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From fe2d24c240c4f90f7c6965caaf5785573537687a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 10 Dec 2018 09:54:12 +0100 Subject: [PATCH 325/325] Update translations --- .../components/auth/.translations/ca.json | 16 +++++++-------- .../components/cast/.translations/ca.json | 2 +- .../components/cast/.translations/hu.json | 3 ++- .../components/deconz/.translations/ca.json | 4 ++-- .../components/deconz/.translations/hu.json | 6 ++++-- .../dialogflow/.translations/ca.json | 8 ++++---- .../dialogflow/.translations/hu.json | 5 +++++ .../homematicip_cloud/.translations/ca.json | 12 +++++------ .../components/hue/.translations/ca.json | 8 ++++---- .../components/ifttt/.translations/ca.json | 8 ++++---- .../components/ifttt/.translations/hu.json | 3 ++- .../components/ios/.translations/ca.json | 2 +- .../components/lifx/.translations/ca.json | 2 +- .../luftdaten/.translations/ca.json | 4 ++-- .../components/mailgun/.translations/ca.json | 8 ++++---- .../components/mailgun/.translations/hu.json | 15 ++++++++++++++ .../components/mailgun/.translations/no.json | 2 +- .../components/mqtt/.translations/ca.json | 10 +++++----- .../components/nest/.translations/ca.json | 14 ++++++------- .../components/nest/.translations/hu.json | 3 +++ .../components/openuv/.translations/ca.json | 2 +- .../owntracks/.translations/ca.json | 6 +++--- .../owntracks/.translations/hu.json | 6 ++++++ .../components/point/.translations/ca.json | 12 +++++------ .../components/point/.translations/hu.json | 17 ++++++++++++++++ .../rainmachine/.translations/ca.json | 2 +- .../simplisafe/.translations/ca.json | 2 +- .../simplisafe/.translations/hu.json | 4 +++- .../components/smhi/.translations/ca.json | 2 +- .../components/smhi/.translations/hu.json | 9 ++++++--- .../components/sonos/.translations/ca.json | 2 +- .../components/tradfri/.translations/ca.json | 8 ++++---- .../components/tradfri/.translations/hu.json | 1 + .../components/twilio/.translations/ca.json | 8 ++++---- .../components/twilio/.translations/hu.json | 7 +++++++ .../components/unifi/.translations/ca.json | 2 +- .../components/unifi/.translations/hu.json | 2 ++ .../components/upnp/.translations/ca.json | 2 +- .../components/upnp/.translations/hu.json | 4 ++++ .../components/zha/.translations/ca.json | 4 ++-- .../components/zha/.translations/hu.json | 20 +++++++++++++++++++ .../components/zone/.translations/ca.json | 2 +- .../components/zwave/.translations/ca.json | 8 ++++---- .../components/zwave/.translations/hu.json | 4 +++- 44 files changed, 181 insertions(+), 90 deletions(-) create mode 100644 homeassistant/components/mailgun/.translations/hu.json create mode 100644 homeassistant/components/point/.translations/hu.json create mode 100644 homeassistant/components/twilio/.translations/hu.json create mode 100644 homeassistant/components/zha/.translations/hu.json diff --git a/homeassistant/components/auth/.translations/ca.json b/homeassistant/components/auth/.translations/ca.json index 236352a90183c..e5ece421a0b88 100644 --- a/homeassistant/components/auth/.translations/ca.json +++ b/homeassistant/components/auth/.translations/ca.json @@ -5,28 +5,28 @@ "no_available_service": "No hi ha serveis de notificaci\u00f3 disponibles." }, "error": { - "invalid_code": "Codi inv\u00e0lid, si us plau torni a provar-ho." + "invalid_code": "Codi inv\u00e0lid, si us plau torna a provar-ho." }, "step": { "init": { - "description": "Seleccioneu un dels serveis de notificaci\u00f3:", - "title": "Configureu una contrasenya d'un sol \u00fas a trav\u00e9s del component de notificacions" + "description": "Selecciona un dels serveis de notificaci\u00f3:", + "title": "Configuraci\u00f3 d'una contrasenya d'un sol \u00fas a trav\u00e9s del component de notificacions" }, "setup": { - "description": "S'ha enviat una contrasenya d'un sol \u00fas mitjan\u00e7ant **notify.{notify_service}**. Introdu\u00efu-la a continuaci\u00f3:", - "title": "Verifiqueu la configuraci\u00f3" + "description": "S'ha enviat una contrasenya d'un sol \u00fas mitjan\u00e7ant **notify.{notify_service}**. Introdueix-la a continuaci\u00f3:", + "title": "Verificaci\u00f3 de la configuraci\u00f3" } }, "title": "Contrasenya d'un sol \u00fas del servei de notificacions" }, "totp": { "error": { - "invalid_code": "Codi inv\u00e0lid, si us plau torni a provar-ho. Si obteniu aquest error repetidament, assegureu-vos que la data i hora de Home Assistant sigui correcta i precisa." + "invalid_code": "Codi inv\u00e0lid, si us plau torna a provar-ho. Si obtens aquest error repetidament, assegura't que la data i hora de Home Assistant siguin correctes i acurades." }, "step": { "init": { - "description": "Per activar la verificaci\u00f3 en dos passos mitjan\u00e7ant contrasenyes d'un sol \u00fas basades en temps, escanegeu el codi QR amb la vostre aplicaci\u00f3 de verificaci\u00f3. Si no en teniu cap, us recomanem [Google Authenticator](https://support.google.com/accounts/answer/1066447) o b\u00e9 [Authy](https://authy.com/). \n\n {qr_code} \n \nDespr\u00e9s d'escanejar el codi QR, introdu\u00efu el codi de sis d\u00edgits proporcionat per l'aplicaci\u00f3. Si teniu problemes per escanejar el codi QR, feu una configuraci\u00f3 manual amb el codi **`{code}`**.", - "title": "Configureu la verificaci\u00f3 en dos passos utilitzant TOTP" + "description": "Per activar la verificaci\u00f3 en dos passos mitjan\u00e7ant contrasenyes d'un sol \u00fas basades en temps, escaneja el codi QR amb la teva aplicaci\u00f3 de verificaci\u00f3. Si no en tens cap, et recomanem [Google Authenticator](https://support.google.com/accounts/answer/1066447) o b\u00e9 [Authy](https://authy.com/). \n\n {qr_code} \n \nDespr\u00e9s d'escanejar el codi QR, introdueix el codi de sis d\u00edgits proporcionat per l'aplicaci\u00f3. Si tens problemes per escanejar el codi QR, fes una configuraci\u00f3 manual amb el codi **`{code}`**.", + "title": "Configura la verificaci\u00f3 en dos passos utilitzant TOTP" } }, "title": "TOTP" diff --git a/homeassistant/components/cast/.translations/ca.json b/homeassistant/components/cast/.translations/ca.json index 570cc7fdc0073..26236397dec77 100644 --- a/homeassistant/components/cast/.translations/ca.json +++ b/homeassistant/components/cast/.translations/ca.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "Voleu configurar Google Cast?", + "description": "Vols configurar Google Cast?", "title": "Google Cast" } }, diff --git a/homeassistant/components/cast/.translations/hu.json b/homeassistant/components/cast/.translations/hu.json index f59a1b43ef1b1..66dc4ea8dd843 100644 --- a/homeassistant/components/cast/.translations/hu.json +++ b/homeassistant/components/cast/.translations/hu.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "no_devices_found": "Nem tal\u00e1lhat\u00f3k Google Cast eszk\u00f6z\u00f6k a h\u00e1l\u00f3zaton." + "no_devices_found": "Nem tal\u00e1lhat\u00f3k Google Cast eszk\u00f6z\u00f6k a h\u00e1l\u00f3zaton.", + "single_instance_allowed": "Csak egyetlen Google Cast konfigur\u00e1ci\u00f3 sz\u00fcks\u00e9ges." }, "step": { "confirm": { diff --git a/homeassistant/components/deconz/.translations/ca.json b/homeassistant/components/deconz/.translations/ca.json index a3aa5491e23e9..87189a938064a 100644 --- a/homeassistant/components/deconz/.translations/ca.json +++ b/homeassistant/components/deconz/.translations/ca.json @@ -14,7 +14,7 @@ "host": "Amfitri\u00f3", "port": "Port" }, - "title": "Definiu la passarel\u00b7la deCONZ" + "title": "Definici\u00f3 de la passarel\u00b7la deCONZ" }, "link": { "description": "Desbloqueja la teva passarel\u00b7la d'enlla\u00e7 deCONZ per a registrar-te amb Home Assistant.\n\n1. V\u00e9s a la configuraci\u00f3 del sistema deCONZ\n2. Prem el bot\u00f3 \"Desbloquejar passarel\u00b7la\"", @@ -23,7 +23,7 @@ "options": { "data": { "allow_clip_sensor": "Permet la importaci\u00f3 de sensors virtuals", - "allow_deconz_groups": "Permet la importaci\u00f3 de grups deCONZ" + "allow_deconz_groups": "Permetre la importaci\u00f3 de grups deCONZ" }, "title": "Opcions de configuraci\u00f3 addicionals per deCONZ" } diff --git a/homeassistant/components/deconz/.translations/hu.json b/homeassistant/components/deconz/.translations/hu.json index ca2466e992121..fbb5c26ba04aa 100644 --- a/homeassistant/components/deconz/.translations/hu.json +++ b/homeassistant/components/deconz/.translations/hu.json @@ -22,8 +22,10 @@ }, "options": { "data": { - "allow_clip_sensor": "Virtu\u00e1lis szenzorok import\u00e1l\u00e1s\u00e1nak enged\u00e9lyez\u00e9se" - } + "allow_clip_sensor": "Virtu\u00e1lis szenzorok import\u00e1l\u00e1s\u00e1nak enged\u00e9lyez\u00e9se", + "allow_deconz_groups": "deCONZ csoportok import\u00e1l\u00e1s\u00e1nak enged\u00e9lyez\u00e9se" + }, + "title": "Extra be\u00e1ll\u00edt\u00e1si lehet\u0151s\u00e9gek a deCONZhoz" } }, "title": "deCONZ Zigbee gateway" diff --git a/homeassistant/components/dialogflow/.translations/ca.json b/homeassistant/components/dialogflow/.translations/ca.json index ffc10269776bf..f6dfc9399c280 100644 --- a/homeassistant/components/dialogflow/.translations/ca.json +++ b/homeassistant/components/dialogflow/.translations/ca.json @@ -1,16 +1,16 @@ { "config": { "abort": { - "not_internet_accessible": "La vostra inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per rebre missatges de Dialogflow.", + "not_internet_accessible": "La inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per rebre missatges de Dialogflow.", "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia." }, "create_entry": { - "default": "Per enviar esdeveniments a Home Assistant, haureu de configurar [integraci\u00f3 webhook de Dialogflow]({dialogflow_url}). \n\n Ompliu la informaci\u00f3 seg\u00fcent: \n\n - URL: `{webhook_url}` \n - M\u00e8tode: POST \n - Tipus de contingut: application/json\n\nVegeu [la documentaci\u00f3]({docs_url}) per a m\u00e9s detalls." + "default": "Per enviar esdeveniments a Home Assistant, haur\u00e0s de configurar la [integraci\u00f3 webhook de Dialogflow]({dialogflow_url}). \n\n Completa la seg\u00fcent informaci\u00f3: \n\n- URL: `{webhook_url}` \n- M\u00e8tode: POST \n- Tipus de contingut: application/json\n\nConsulta la [documentaci\u00f3]({docs_url}) per a m\u00e9s detalls." }, "step": { "user": { - "description": "Esteu segur que voleu configurar Dialogflow?", - "title": "Configureu el Webhook de Dialogflow" + "description": "Est\u00e0s segur que vols configurar Dialogflow?", + "title": "Configuraci\u00f3 del Webhook de Dialogflow" } }, "title": "Dialogflow" diff --git a/homeassistant/components/dialogflow/.translations/hu.json b/homeassistant/components/dialogflow/.translations/hu.json index 89e8205bb09ef..89889fd60481c 100644 --- a/homeassistant/components/dialogflow/.translations/hu.json +++ b/homeassistant/components/dialogflow/.translations/hu.json @@ -1,7 +1,12 @@ { "config": { + "abort": { + "not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a Dialogflow \u00fczenetek fogad\u00e1s\u00e1hoz.", + "one_instance_allowed": "Csak egyetlen konfigur\u00e1ci\u00f3 sz\u00fcks\u00e9ges." + }, "step": { "user": { + "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani az Dialogflowt?", "title": "Dialogflow Webhook be\u00e1ll\u00edt\u00e1sa" } }, diff --git a/homeassistant/components/homematicip_cloud/.translations/ca.json b/homeassistant/components/homematicip_cloud/.translations/ca.json index 9ad495c720aaf..a1c33a10e9301 100644 --- a/homeassistant/components/homematicip_cloud/.translations/ca.json +++ b/homeassistant/components/homematicip_cloud/.translations/ca.json @@ -7,9 +7,9 @@ }, "error": { "invalid_pin": "Codi PIN inv\u00e0lid, torna-ho a provar.", - "press_the_button": "Si us plau, premeu el bot\u00f3 blau.", - "register_failed": "Error al registrar, torneu-ho a provar.", - "timeout_button": "Temps d'espera per pr\u00e9mer el bot\u00f3 blau esgotat, torneu-ho a provar." + "press_the_button": "Si us plau, prem el bot\u00f3 blau.", + "register_failed": "Error al registrar, torna-ho a provar.", + "timeout_button": "El temps d'espera m\u00e0xim per pr\u00e9mer el bot\u00f3 blau s'ha esgotat, torna-ho a provar." }, "step": { "init": { @@ -18,11 +18,11 @@ "name": "Nom (opcional, s'utilitza com a nom prefix per a tots els dispositius)", "pin": "Codi PIN (opcional)" }, - "title": "Trieu el punt d'acc\u00e9s HomematicIP" + "title": "Tria el punt d'acc\u00e9s HomematicIP" }, "link": { - "description": "Premeu el bot\u00f3 blau del punt d'acc\u00e9s i el bot\u00f3 de enviar per registrar HomematicIP amb Home Assistent. \n\n![Ubicaci\u00f3 del bot\u00f3 al pont](/static/images/config_flows/config_homematicip_cloud.png)", - "title": "Enlla\u00e7ar punt d'acc\u00e9s" + "description": "Prem el bot\u00f3 blau del punt d'acc\u00e9s i el bot\u00f3 d'enviar per registrar HomematicIP amb Home Assistent. \n\n![Ubicaci\u00f3 del bot\u00f3 al pont](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Enlla\u00e7 amb punt d'acc\u00e9s" } }, "title": "HomematicIP Cloud" diff --git a/homeassistant/components/hue/.translations/ca.json b/homeassistant/components/hue/.translations/ca.json index 6c41eed5467ac..a37d4ef1518a6 100644 --- a/homeassistant/components/hue/.translations/ca.json +++ b/homeassistant/components/hue/.translations/ca.json @@ -3,24 +3,24 @@ "abort": { "all_configured": "Tots els enlla\u00e7os Philips Hue ja estan configurats", "already_configured": "L'enlla\u00e7 ja est\u00e0 configurat", - "cannot_connect": "No es pot connectar amb l'enlla\u00e7", + "cannot_connect": "No s'ha pogut connectar amb l'enlla\u00e7", "discover_timeout": "No s'han pogut descobrir enlla\u00e7os Hue", "no_bridges": "No s'han trobat enlla\u00e7os Philips Hue", "unknown": "S'ha produ\u00eft un error desconegut" }, "error": { "linking": "S'ha produ\u00eft un error desconegut al vincular.", - "register_failed": "No s'ha pogut registrar, torneu-ho a provar" + "register_failed": "No s'ha pogut registrar, torna-ho a provar" }, "step": { "init": { "data": { "host": "Amfitri\u00f3" }, - "title": "Tria l'enlla\u00e7 Hue" + "title": "Tria de l'enlla\u00e7 Hue" }, "link": { - "description": "Premeu el bot\u00f3 de l'ella\u00e7 per registrar Philips Hue amb Home Assistant. \n\n ![Ubicaci\u00f3 del bot\u00f3 al pont](/static/images/config_philips_hue.jpg)", + "description": "Prem el bot\u00f3 de l'enlla\u00e7 per registrar Philips Hue amb Home Assistant. \n\n ![Ubicaci\u00f3 del bot\u00f3 al pont](/static/images/config_philips_hue.jpg)", "title": "Vincular concentrador" } }, diff --git a/homeassistant/components/ifttt/.translations/ca.json b/homeassistant/components/ifttt/.translations/ca.json index aadd66902b633..597328a2ee400 100644 --- a/homeassistant/components/ifttt/.translations/ca.json +++ b/homeassistant/components/ifttt/.translations/ca.json @@ -1,16 +1,16 @@ { "config": { "abort": { - "not_internet_accessible": "La vostra inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per rebre missatges de IFTTT.", + "not_internet_accessible": "La inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per rebre missatges de IFTTT.", "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia." }, "create_entry": { - "default": "Per enviar esdeveniments a Home Assistant, necessitareu utilitzar l'acci\u00f3 \"Make a web resquest\" de [IFTTT Webhook applet]({applet_url}). \n\n Ompliu la informaci\u00f3 seg\u00fcent: \n\n - URL: `{webhook_url}` \n - Method: POST \n - Content Type: application/json \n\nVegeu [la documentaci\u00f3]({docs_url}) sobre com configurar els automatismes per gestionar les dades entrants." + "default": "Per enviar esdeveniments a Home Assistant, necessitar\u00e0s utilitzar l'acci\u00f3 \"Make a web resquest\" de [IFTTT Webhook applet]({applet_url}). \n\nCompleta la seg\u00fcent informaci\u00f3: \n\n- URL: `{webhook_url}` \n- Method: POST \n- Content Type: application/json \n\nConsulta la [documentaci\u00f3]({docs_url}) sobre com configurar els automatismes per gestionar dades entrants." }, "step": { "user": { - "description": "Esteu segur que voleu configurar IFTTT?", - "title": "Configureu la miniaplicaci\u00f3 Webhook de IFTTT" + "description": "Est\u00e0s segur que vols configurar IFTTT?", + "title": "Configuraci\u00f3 de la miniaplicaci\u00f3 Webhook de IFTTT" } }, "title": "IFTTT" diff --git a/homeassistant/components/ifttt/.translations/hu.json b/homeassistant/components/ifttt/.translations/hu.json index 6ecf654ff4784..3c4ec66e9a3e7 100644 --- a/homeassistant/components/ifttt/.translations/hu.json +++ b/homeassistant/components/ifttt/.translations/hu.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "not_internet_accessible": "A Home Assistant-nek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l az IFTTT \u00fczenetek fogad\u00e1s\u00e1hoz." + "not_internet_accessible": "A Home Assistant-nek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l az IFTTT \u00fczenetek fogad\u00e1s\u00e1hoz.", + "one_instance_allowed": "Csak egyetlen konfigur\u00e1ci\u00f3 sz\u00fcks\u00e9ges." }, "step": { "user": { diff --git a/homeassistant/components/ios/.translations/ca.json b/homeassistant/components/ios/.translations/ca.json index 1b1ed732ab3fb..dcbffdcebd0f5 100644 --- a/homeassistant/components/ios/.translations/ca.json +++ b/homeassistant/components/ios/.translations/ca.json @@ -5,7 +5,7 @@ }, "step": { "confirm": { - "description": "Voleu configurar el component Home Assistant iOS?", + "description": "Vols configurar el component Home Assistant iOS?", "title": "Home Assistant iOS" } }, diff --git a/homeassistant/components/lifx/.translations/ca.json b/homeassistant/components/lifx/.translations/ca.json index b3896d49e1d96..e8ef5bd31bc57 100644 --- a/homeassistant/components/lifx/.translations/ca.json +++ b/homeassistant/components/lifx/.translations/ca.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "Voleu configurar LIFX?", + "description": "Vols configurar LIFX?", "title": "LIFX" } }, diff --git a/homeassistant/components/luftdaten/.translations/ca.json b/homeassistant/components/luftdaten/.translations/ca.json index 1254b41bddf4b..b00c1b2e3e3b6 100644 --- a/homeassistant/components/luftdaten/.translations/ca.json +++ b/homeassistant/components/luftdaten/.translations/ca.json @@ -8,10 +8,10 @@ "step": { "user": { "data": { - "show_on_map": "Mostra al mapa", + "show_on_map": "Mostrar al mapa", "station_id": "Identificador del sensor Luftdaten" }, - "title": "Crear Luftdaten" + "title": "Configuraci\u00f3 de Luftdaten" } }, "title": "Luftdaten" diff --git a/homeassistant/components/mailgun/.translations/ca.json b/homeassistant/components/mailgun/.translations/ca.json index fcb087e68852f..f43467de7d9da 100644 --- a/homeassistant/components/mailgun/.translations/ca.json +++ b/homeassistant/components/mailgun/.translations/ca.json @@ -1,16 +1,16 @@ { "config": { "abort": { - "not_internet_accessible": "La vostra inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per rebre missatges de Mailgun.", + "not_internet_accessible": "La inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per rebre missatges de Mailgun.", "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia." }, "create_entry": { - "default": "Per enviar esdeveniments a Home Assistant, haureu de configurar [Webhooks amb Mailgun]({mailgun_url}). \n\n Ompliu la informaci\u00f3 seg\u00fcent: \n\n - URL: `{webhook_url}` \n - M\u00e8tode: POST \n - Tipus de contingut: application/x-www-form-urlencoded\n\nVegeu [la documentaci\u00f3]({docs_url}) sobre com configurar els automatismes per gestionar les dades entrants." + "default": "Per enviar esdeveniments a Home Assistant, haur\u00e0s de configurar [Webhooks amb Mailgun]({mailgun_url}). \n\nCompleta la seg\u00fcent informaci\u00f3: \n\n- URL: `{webhook_url}` \n- M\u00e8tode: POST \n- Tipus de contingut: application/x-www-form-urlencoded\n\nConsulta la [documentaci\u00f3]({docs_url}) sobre com configurar els automatismes per gestionar dades entrants." }, "step": { "user": { - "description": "Esteu segur que voleu configurar Mailgun?", - "title": "Configureu el Webhook de Mailgun" + "description": "Est\u00e0s segur que vols configurar Mailgun?", + "title": "Configuraci\u00f3 del Webhook de Mailgun" } }, "title": "Mailgun" diff --git a/homeassistant/components/mailgun/.translations/hu.json b/homeassistant/components/mailgun/.translations/hu.json new file mode 100644 index 0000000000000..975c106a26fc7 --- /dev/null +++ b/homeassistant/components/mailgun/.translations/hu.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a Mailgun \u00fczenetek fogad\u00e1s\u00e1hoz.", + "one_instance_allowed": "Csak egyetlen konfigur\u00e1ci\u00f3 sz\u00fcks\u00e9ges." + }, + "step": { + "user": { + "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani a Mailgunt?", + "title": "Mailgun Webhook be\u00e1ll\u00edt\u00e1sa" + } + }, + "title": "Mailgun" + } +} \ No newline at end of file diff --git a/homeassistant/components/mailgun/.translations/no.json b/homeassistant/components/mailgun/.translations/no.json index e12549105429c..91c616b69af78 100644 --- a/homeassistant/components/mailgun/.translations/no.json +++ b/homeassistant/components/mailgun/.translations/no.json @@ -5,7 +5,7 @@ "one_instance_allowed": "Kun \u00e9n enkelt forekomst er n\u00f8dvendig." }, "create_entry": { - "default": "For \u00e5 sende hendelser til Home Assistant, m\u00e5 du sette opp [Webhooks with Mailgun]({mailgun_url}).\n\nFyll ut f\u00f8lgende informasjon:\n\n- URL: `{webhook_url}`\n- Metode: POST\n- Innholdstype: application/x-www-form-urlencoded\n\nSe [dokumentasjonen]({docs_url}) om hvordan du konfigurerer automatiseringer for \u00e5 h\u00e5ndtere innkommende data." + "default": "For \u00e5 sende hendelser til Home Assistant, m\u00e5 du sette opp [Webhooks with Mailgun]({mailgun_url}).\n\nFyll ut f\u00f8lgende informasjon:\n\n- URL: `{webhook_url}`\n- Metode: POST\n- Innholdstype: application/json\n\nSe [dokumentasjonen]({docs_url}) om hvordan du konfigurerer automatiseringer for \u00e5 h\u00e5ndtere innkommende data." }, "step": { "user": { diff --git a/homeassistant/components/mqtt/.translations/ca.json b/homeassistant/components/mqtt/.translations/ca.json index 72a2636fb60a5..1fc3ea628bb86 100644 --- a/homeassistant/components/mqtt/.translations/ca.json +++ b/homeassistant/components/mqtt/.translations/ca.json @@ -4,25 +4,25 @@ "single_instance_allowed": "Nom\u00e9s es permet una \u00fanica configuraci\u00f3 de MQTT." }, "error": { - "cannot_connect": "No es pot connectar amb el broker." + "cannot_connect": "No s'ha pogut connectar amb el broker." }, "step": { "broker": { "data": { "broker": "Broker", - "discovery": "Habilita descobriment autom\u00e0tic", + "discovery": "Habilita el descobriment autom\u00e0tic", "password": "Contrasenya", "port": "Port", "username": "Nom d'usuari" }, - "description": "Introdu\u00efu la informaci\u00f3 de connexi\u00f3 del vostre broker MQTT.", + "description": "Introdueix la informaci\u00f3 de connexi\u00f3 del teu broker MQTT.", "title": "MQTT" }, "hassio_confirm": { "data": { - "discovery": "Habilita descobriment autom\u00e0tic" + "discovery": "Habilitar descobriment autom\u00e0tic" }, - "description": "Voleu configurar Home Assistant perqu\u00e8 es connecti amb el broker MQTT proporcionat pel complement de hass.io {addon}?", + "description": "Vols configurar Home Assistant perqu\u00e8 es connecti amb el broker MQTT proporcionat pel complement de hass.io {addon}?", "title": "Broker MQTT a trav\u00e9s del complement de Hass.io" } }, diff --git a/homeassistant/components/nest/.translations/ca.json b/homeassistant/components/nest/.translations/ca.json index e15d0106da8ce..179c8f20951f4 100644 --- a/homeassistant/components/nest/.translations/ca.json +++ b/homeassistant/components/nest/.translations/ca.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_setup": "Nom\u00e9s podeu configurar un \u00fanic compte Nest.", + "already_setup": "Nom\u00e9s pots configurar un \u00fanic compte Nest.", "authorize_url_fail": "S'ha produ\u00eft un error desconegut al generar l'URL d'autoritzaci\u00f3.", - "authorize_url_timeout": "Temps d'espera generant l'URL d'autoritzaci\u00f3 esgotat.", - "no_flows": "Necessiteu configurar Nest abans de poder autenticar-vos-hi. [Llegiu les instruccions](https://www.home-assistant.io/components/nest/)." + "authorize_url_timeout": "El temps d'espera m\u00e0xim per generar l'URL d'autoritzaci\u00f3 s'ha esgotat.", + "no_flows": "Necessites configurar Nest abans de poder autenticar-t'hi. Llegeix les [instruccions](https://www.home-assistant.io/components/nest/)." }, "error": { "internal_error": "Error intern al validar el codi", @@ -17,15 +17,15 @@ "data": { "flow_impl": "Prove\u00efdor" }, - "description": "Trieu a trav\u00e9s de quin prove\u00efdor d'autenticaci\u00f3 us voleu autenticar amb Nest.", + "description": "Tria a amb quin prove\u00efdor d'autenticaci\u00f3 vols autenticar-te amb Nest.", "title": "Prove\u00efdor d'autenticaci\u00f3" }, "link": { "data": { - "code": "Codi pin" + "code": "Codi PIN" }, - "description": "Per enlla\u00e7ar el vostre compte de Nest, [autoritzeu el vostre compte] ({url}). \n\nDespr\u00e9s de l'autoritzaci\u00f3, copieu i enganxeu el codi pin que es mostra a sota.", - "title": "Enlla\u00e7ar compte de Nest" + "description": "Per enlla\u00e7ar el teu compte de Nest, [autoritza el vostre compte]({url}). \n\nDespr\u00e9s de l'autoritzaci\u00f3, copia i enganxa el codi pin que es mostra a sota.", + "title": "Enlla\u00e7 amb el compte de Nest" } }, "title": "Nest" diff --git a/homeassistant/components/nest/.translations/hu.json b/homeassistant/components/nest/.translations/hu.json index aa99b46e5769d..e24c38f860861 100644 --- a/homeassistant/components/nest/.translations/hu.json +++ b/homeassistant/components/nest/.translations/hu.json @@ -2,9 +2,11 @@ "config": { "abort": { "already_setup": "Csak egy Nest-fi\u00f3kot konfigur\u00e1lhat.", + "authorize_url_fail": "Ismeretlen hiba t\u00f6rt\u00e9nt a hiteles\u00edt\u00e9si link gener\u00e1l\u00e1sa sor\u00e1n.", "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az \u00e9rv\u00e9nyes\u00edt\u00e9si url gener\u00e1l\u00e1sa sor\u00e1n." }, "error": { + "internal_error": "Bels\u0151 hiba t\u00f6rt\u00e9nt a k\u00f3d valid\u00e1l\u00e1s\u00e1n\u00e1l", "invalid_code": "\u00c9rv\u00e9nytelen k\u00f3d", "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a k\u00f3d \u00e9rv\u00e9nyes\u00edt\u00e9se sor\u00e1n.", "unknown": "Ismeretlen hiba t\u00f6rt\u00e9nt a k\u00f3d \u00e9rv\u00e9nyes\u00edt\u00e9se sor\u00e1n" @@ -14,6 +16,7 @@ "data": { "flow_impl": "Szolg\u00e1ltat\u00f3" }, + "description": "V\u00e1laszd ki, hogy melyik hiteles\u00edt\u00e9si szolg\u00e1ltat\u00f3n\u00e1l szeretn\u00e9d hiteles\u00edteni a Nestet.", "title": "Hiteles\u00edt\u00e9si Szolg\u00e1ltat\u00f3" }, "link": { diff --git a/homeassistant/components/openuv/.translations/ca.json b/homeassistant/components/openuv/.translations/ca.json index 4a6cf526921de..5cb9a8ce5a5ba 100644 --- a/homeassistant/components/openuv/.translations/ca.json +++ b/homeassistant/components/openuv/.translations/ca.json @@ -12,7 +12,7 @@ "latitude": "Latitud", "longitude": "Longitud" }, - "title": "Introdu\u00efu la vostra informaci\u00f3" + "title": "Introdueix la teva informaci\u00f3" } }, "title": "OpenUV" diff --git a/homeassistant/components/owntracks/.translations/ca.json b/homeassistant/components/owntracks/.translations/ca.json index 438148f414c55..c733f0f12cca3 100644 --- a/homeassistant/components/owntracks/.translations/ca.json +++ b/homeassistant/components/owntracks/.translations/ca.json @@ -4,12 +4,12 @@ "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia." }, "create_entry": { - "default": "\n\nPer Android: obre [l'app OwnTracks]({android_url}), ves a prefer\u00e8ncies -> connexi\u00f3, i posa els par\u00e0metres seguents:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\nPer iOS: obre [l'app OwnTracks]({ios_url}), clica l'icona (i) a dalt a l'esquerra -> configuraci\u00f3 (settings), i posa els par\u00e0metres settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\nVegeu [the documentation]({docs_url}) per a m\u00e9s informaci\u00f3." + "default": "\n\nPer Android: obre [l'app d'OwnTracks]({android_url}), ves a prefer\u00e8ncies -> connexi\u00f3, i posa els par\u00e0metres seguents:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\nPer iOS: obre [l'app d'OwnTracks]({ios_url}), clica l'icona (i) a dalt a l'esquerra -> configuraci\u00f3 (settings), i posa els par\u00e0metres seg\u00fcents:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\nConsulta [la documentaci\u00f3]({docs_url}) per a m\u00e9s informaci\u00f3." }, "step": { "user": { - "description": "Esteu segur que voleu configurar OwnTracks?", - "title": "Configureu OwnTracks" + "description": "Est\u00e0s segur que vols configurar l'OwnTracks?", + "title": "Configuraci\u00f3 d'OwnTracks" } }, "title": "OwnTracks" diff --git a/homeassistant/components/owntracks/.translations/hu.json b/homeassistant/components/owntracks/.translations/hu.json index 9c4e46a28bfe0..a82843bef53f2 100644 --- a/homeassistant/components/owntracks/.translations/hu.json +++ b/homeassistant/components/owntracks/.translations/hu.json @@ -1,5 +1,11 @@ { "config": { + "abort": { + "one_instance_allowed": "Csak egyetlen konfigur\u00e1ci\u00f3 sz\u00fcks\u00e9ges." + }, + "create_entry": { + "default": "\n\nAndroidon, nyisd meg [az OwnTracks appot]({android_url}), menj a preferences -> connectionre. V\u00e1ltoztasd meg a al\u00e1bbi be\u00e1ll\u00edt\u00e1sokat:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\niOS-en, nyisd meg [az OwnTracks appot]({ios_url}), kattints az (i) ikonra bal oldalon fel\u00fcl -> settings. V\u00e1ltoztasd meg az al\u00e1bbi be\u00e1ll\u00edt\u00e1sokat:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\nN\u00e9zd meg [a dokument\u00e1ci\u00f3t]({docs_url}) tov\u00e1bbi inform\u00e1ci\u00f3k\u00e9rt." + }, "step": { "user": { "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani az Owntracks-t?", diff --git a/homeassistant/components/point/.translations/ca.json b/homeassistant/components/point/.translations/ca.json index 6298b29f2689c..6a66735e6d094 100644 --- a/homeassistant/components/point/.translations/ca.json +++ b/homeassistant/components/point/.translations/ca.json @@ -1,29 +1,29 @@ { "config": { "abort": { - "already_setup": "Nom\u00e9s podeu configurar un compte de Point.", + "already_setup": "Nom\u00e9s pots configurar un compte de Point.", "authorize_url_fail": "S'ha produ\u00eft un error desconegut al generar l'URL d'autoritzaci\u00f3.", "authorize_url_timeout": "S'ha acabat el temps d'espera mentre \u00e9s generava l'URL d'autoritzaci\u00f3.", "external_setup": "Point s'ha configurat correctament des d'un altre lloc.", - "no_flows": "Necessiteu configurar Point abans de poder autenticar-vos-hi. [Llegiu les instruccions](https://www.home-assistant.io/components/point/)." + "no_flows": "Necessites configurar Point abans de poder autenticar-t'hi. [Llegiu les instruccions](https://www.home-assistant.io/components/point/)." }, "create_entry": { - "default": "Autenticaci\u00f3 exitosa amb Minut per als vostres dispositiu/s Point." + "default": "Autenticaci\u00f3 exitosa amb Minut per als teus dispositiu/s Point." }, "error": { - "follow_link": "Si us plau seguiu l'enlla\u00e7 i autentiqueu-vos abans de pr\u00e9mer Enviar", + "follow_link": "Si us plau v\u00e9s a l'enlla\u00e7 i autentica't abans de pr\u00e9mer Enviar", "no_token": "No s'ha autenticat amb Minut" }, "step": { "auth": { - "description": "Aneu a l'enlla\u00e7 seg\u00fcent i Accepta l'acc\u00e9s al vostre compte de Minut, despr\u00e9s torneu i premeu Enviar (a sota). \n\n[Enlla\u00e7]({authorization_url})", + "description": "V\u00e9s a l'enlla\u00e7 seg\u00fcent i Accepta l'acc\u00e9s al teu compte de Minut, despr\u00e9s torna i prem Enviar (a sota). \n\n[Enlla\u00e7]({authorization_url})", "title": "Autenticar Point" }, "user": { "data": { "flow_impl": "Prove\u00efdor" }, - "description": "Trieu a trav\u00e9s de quin prove\u00efdor d'autenticaci\u00f3 us voleu autenticar amb Point.", + "description": "Tria a trav\u00e9s de quin prove\u00efdor d'autenticaci\u00f3 vols autenticar-te amb Point.", "title": "Prove\u00efdor d'autenticaci\u00f3" } }, diff --git a/homeassistant/components/point/.translations/hu.json b/homeassistant/components/point/.translations/hu.json new file mode 100644 index 0000000000000..2d52069d5ba48 --- /dev/null +++ b/homeassistant/components/point/.translations/hu.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "auth": { + "title": "Point hiteles\u00edt\u00e9se" + }, + "user": { + "data": { + "flow_impl": "Szolg\u00e1ltat\u00f3" + }, + "description": "V\u00e1laszd ki, hogy melyik hiteles\u00edt\u00e9si szolg\u00e1ltat\u00f3n\u00e1l szeretn\u00e9d hiteles\u00edteni a Pointot.", + "title": "Hiteles\u00edt\u00e9si Szolg\u00e1ltat\u00f3" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/ca.json b/homeassistant/components/rainmachine/.translations/ca.json index 7a1459cff6b1d..60458f1469e8e 100644 --- a/homeassistant/components/rainmachine/.translations/ca.json +++ b/homeassistant/components/rainmachine/.translations/ca.json @@ -11,7 +11,7 @@ "password": "Contrasenya", "port": "Port" }, - "title": "Introdu\u00efu la vostra informaci\u00f3" + "title": "Introdueix la teva informaci\u00f3" } }, "title": "RainMachine" diff --git a/homeassistant/components/simplisafe/.translations/ca.json b/homeassistant/components/simplisafe/.translations/ca.json index 1662162c439f3..a02c3a5e28ea7 100644 --- a/homeassistant/components/simplisafe/.translations/ca.json +++ b/homeassistant/components/simplisafe/.translations/ca.json @@ -11,7 +11,7 @@ "password": "Contrasenya", "username": "Correu electr\u00f2nic" }, - "title": "Introdu\u00efu la vostra informaci\u00f3" + "title": "Introdueix la teva informaci\u00f3" } }, "title": "SimpliSafe" diff --git a/homeassistant/components/simplisafe/.translations/hu.json b/homeassistant/components/simplisafe/.translations/hu.json index 103bf4e18d0ae..613b5565470b2 100644 --- a/homeassistant/components/simplisafe/.translations/hu.json +++ b/homeassistant/components/simplisafe/.translations/hu.json @@ -1,6 +1,7 @@ { "config": { "error": { + "identifier_exists": "Fi\u00f3k m\u00e1r regisztr\u00e1lva van", "invalid_credentials": "\u00c9rv\u00e9nytelen hiteles\u00edt\u0151 adatok" }, "step": { @@ -11,6 +12,7 @@ }, "title": "T\u00f6ltsd ki az adataid" } - } + }, + "title": "SimpliSafe" } } \ No newline at end of file diff --git a/homeassistant/components/smhi/.translations/ca.json b/homeassistant/components/smhi/.translations/ca.json index 23b6a2934f08e..e265df40217d7 100644 --- a/homeassistant/components/smhi/.translations/ca.json +++ b/homeassistant/components/smhi/.translations/ca.json @@ -2,7 +2,7 @@ "config": { "error": { "name_exists": "El nom ja existeix", - "wrong_location": "Ubicaci\u00f3 nom\u00e9s a Su\u00e8cia" + "wrong_location": "La ubicaci\u00f3 ha d'estar a Su\u00e8cia" }, "step": { "user": { diff --git a/homeassistant/components/smhi/.translations/hu.json b/homeassistant/components/smhi/.translations/hu.json index 86fed8933ef49..425cf927631e9 100644 --- a/homeassistant/components/smhi/.translations/hu.json +++ b/homeassistant/components/smhi/.translations/hu.json @@ -1,14 +1,17 @@ { "config": { "error": { - "name_exists": "A n\u00e9v m\u00e1r l\u00e9tezik" + "name_exists": "A n\u00e9v m\u00e1r l\u00e9tezik", + "wrong_location": "Csak sv\u00e9dorsz\u00e1gi helysz\u00edn megengedett" }, "step": { "user": { "data": { "latitude": "Sz\u00e9less\u00e9g", - "longitude": "Hossz\u00fas\u00e1g" - } + "longitude": "Hossz\u00fas\u00e1g", + "name": "N\u00e9v" + }, + "title": "Helysz\u00edn Sv\u00e9dorsz\u00e1gban" } } } diff --git a/homeassistant/components/sonos/.translations/ca.json b/homeassistant/components/sonos/.translations/ca.json index a6f1f99a3790f..67fd26f1b5ab7 100644 --- a/homeassistant/components/sonos/.translations/ca.json +++ b/homeassistant/components/sonos/.translations/ca.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "Voleu configurar Sonos?", + "description": "Vols configurar Sonos?", "title": "Sonos" } }, diff --git a/homeassistant/components/tradfri/.translations/ca.json b/homeassistant/components/tradfri/.translations/ca.json index acbbb275fc313..22d70092f0d0f 100644 --- a/homeassistant/components/tradfri/.translations/ca.json +++ b/homeassistant/components/tradfri/.translations/ca.json @@ -4,8 +4,8 @@ "already_configured": "L'enlla\u00e7 ja est\u00e0 configurat" }, "error": { - "cannot_connect": "No es pot connectar amb la passarel\u00b7la d'enlla\u00e7", - "invalid_key": "Ha fallat el registre amb la clau proporcionada. Si aix\u00f2 continua passant, intenteu reiniciar la passarel\u00b7la d'enlla\u00e7.", + "cannot_connect": "No s'ha pogut connectar a la passarel\u00b7la d'enlla\u00e7", + "invalid_key": "Ha fallat el registre amb la clau proporcionada. Si aix\u00f2 continua passant, intenta reiniciar la passarel\u00b7la d'enlla\u00e7.", "timeout": "S'ha acabat el temps d'espera durant la validaci\u00f3 del codi." }, "step": { @@ -14,8 +14,8 @@ "host": "Amfitri\u00f3", "security_code": "Codi de seguretat" }, - "description": "Podeu trobar el codi de seguretat a la part posterior de la vostra passarel\u00b7la d'enlla\u00e7.", - "title": "Introdu\u00efu el codi de seguretat" + "description": "Pots trobar el codi de seguretat a la part posterior de la teva passarel\u00b7la d'enlla\u00e7.", + "title": "Introdueix el codi de seguretat" } }, "title": "IKEA TR\u00c5DFRI" diff --git a/homeassistant/components/tradfri/.translations/hu.json b/homeassistant/components/tradfri/.translations/hu.json index dc7c033d41de4..88ff9e6104b98 100644 --- a/homeassistant/components/tradfri/.translations/hu.json +++ b/homeassistant/components/tradfri/.translations/hu.json @@ -5,6 +5,7 @@ }, "error": { "cannot_connect": "Nem siker\u00fclt csatlakozni a gatewayhez.", + "invalid_key": "Nem siker\u00fclt regisztr\u00e1lni a megadott kulcs seg\u00edts\u00e9g\u00e9vel. Ha ez t\u00f6bbsz\u00f6r megt\u00f6rt\u00e9nik, pr\u00f3b\u00e1lja meg \u00fajraind\u00edtani a gatewayt.", "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a k\u00f3d \u00e9rv\u00e9nyes\u00edt\u00e9se sor\u00e1n." }, "step": { diff --git a/homeassistant/components/twilio/.translations/ca.json b/homeassistant/components/twilio/.translations/ca.json index 6f9e22bfd4030..324ab0dd69aa5 100644 --- a/homeassistant/components/twilio/.translations/ca.json +++ b/homeassistant/components/twilio/.translations/ca.json @@ -1,16 +1,16 @@ { "config": { "abort": { - "not_internet_accessible": "La vostra inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per rebre missatges de Twilio.", + "not_internet_accessible": "La inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per rebre missatges de Twilio.", "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia." }, "create_entry": { - "default": "Per enviar esdeveniments a Home Assistant, haureu de configurar [Webhooks amb Twilio]({twilio_url}).\n\n Ompliu la informaci\u00f3 seg\u00fcent: \n\n - URL: `{webhook_url}` \n - M\u00e8tode: POST \n - Tipus de contingut: application/x-www-form-urlencoded\n\nVegeu [la documentaci\u00f3]({docs_url}) sobre com configurar els automatismes per gestionar les dades entrants." + "default": "Per enviar esdeveniments a Home Assistant, haur\u00e0s de configurar [Webhooks amb Twilio]({twilio_url}).\n\nCompleta la seg\u00fcent informaci\u00f3 : \n\n- URL: `{webhook_url}` \n- M\u00e8tode: POST \n- Tipus de contingut: application/x-www-form-urlencoded\n\nConsulta la [documentaci\u00f3]({docs_url}) sobre com configurar els automatismes per gestionar dades entrants." }, "step": { "user": { - "description": "Esteu segur que voleu configurar Twilio?", - "title": "Configureu el Webhook de Twilio" + "description": "Est\u00e0s segur que vols configurar Twilio?", + "title": "Configuraci\u00f3 del Webhook de Twilio" } }, "title": "Twilio" diff --git a/homeassistant/components/twilio/.translations/hu.json b/homeassistant/components/twilio/.translations/hu.json new file mode 100644 index 0000000000000..257dd24f08232 --- /dev/null +++ b/homeassistant/components/twilio/.translations/hu.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "Csak egyetlen konfigur\u00e1ci\u00f3 sz\u00fcks\u00e9ges." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/ca.json b/homeassistant/components/unifi/.translations/ca.json index 77d859627dc9e..442d82d9a3f35 100644 --- a/homeassistant/components/unifi/.translations/ca.json +++ b/homeassistant/components/unifi/.translations/ca.json @@ -18,7 +18,7 @@ "username": "Nom d'usuari", "verify_ssl": "El controlador est\u00e0 utilitzant un certificat adequat" }, - "title": "Configura el controlador UniFi" + "title": "Configuraci\u00f3 del controlador UniFi" } }, "title": "Controlador UniFi" diff --git a/homeassistant/components/unifi/.translations/hu.json b/homeassistant/components/unifi/.translations/hu.json index 06104c6ed6c0c..4a664a40c74d0 100644 --- a/homeassistant/components/unifi/.translations/hu.json +++ b/homeassistant/components/unifi/.translations/hu.json @@ -10,8 +10,10 @@ "step": { "user": { "data": { + "host": "Host", "password": "Jelsz\u00f3", "port": "Port", + "site": "Site azonos\u00edt\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" } } diff --git a/homeassistant/components/upnp/.translations/ca.json b/homeassistant/components/upnp/.translations/ca.json index ab09dbc5bdade..5f2606a448fce 100644 --- a/homeassistant/components/upnp/.translations/ca.json +++ b/homeassistant/components/upnp/.translations/ca.json @@ -13,7 +13,7 @@ "user": { "data": { "enable_port_mapping": "Activa l'assignaci\u00f3 de ports per a Home Assistant", - "enable_sensors": "Afegiu sensors de tr\u00e0nsit", + "enable_sensors": "Afegeix sensors de tr\u00e0nsit", "igd": "UPnP/IGD" }, "title": "Opcions de configuraci\u00f3 per a UPnP/IGD" diff --git a/homeassistant/components/upnp/.translations/hu.json b/homeassistant/components/upnp/.translations/hu.json index fc0225cc534fa..466c80f9e5611 100644 --- a/homeassistant/components/upnp/.translations/hu.json +++ b/homeassistant/components/upnp/.translations/hu.json @@ -1,5 +1,9 @@ { "config": { + "error": { + "one": "hiba", + "other": "" + }, "step": { "init": { "title": "UPnP/IGD" diff --git a/homeassistant/components/zha/.translations/ca.json b/homeassistant/components/zha/.translations/ca.json index 1feac454c454f..635d0ecbde2f9 100644 --- a/homeassistant/components/zha/.translations/ca.json +++ b/homeassistant/components/zha/.translations/ca.json @@ -4,13 +4,13 @@ "single_instance_allowed": "Nom\u00e9s es permet una \u00fanica configuraci\u00f3 de ZHA." }, "error": { - "cannot_connect": "No es pot connectar amb el dispositiu ZHA." + "cannot_connect": "No s'ha pogut connectar amb el dispositiu ZHA." }, "step": { "user": { "data": { "radio_type": "Tipus de r\u00e0dio", - "usb_path": "Ruta del port USB amb el dispositiu" + "usb_path": "Ruta del port USB al dispositiu" }, "title": "ZHA" } diff --git a/homeassistant/components/zha/.translations/hu.json b/homeassistant/components/zha/.translations/hu.json new file mode 100644 index 0000000000000..11b2a9fc83356 --- /dev/null +++ b/homeassistant/components/zha/.translations/hu.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Csak egyetlen ZHA konfigur\u00e1ci\u00f3 megengedett." + }, + "error": { + "cannot_connect": "Nem lehet csatlakozni a ZHA eszk\u00f6zh\u00f6z." + }, + "step": { + "user": { + "data": { + "radio_type": "R\u00e1di\u00f3 t\u00edpusa", + "usb_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" + }, + "title": "ZHA" + } + }, + "title": "ZHA" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/ca.json b/homeassistant/components/zone/.translations/ca.json index 1676c8f390627..aa8296b92df2f 100644 --- a/homeassistant/components/zone/.translations/ca.json +++ b/homeassistant/components/zone/.translations/ca.json @@ -13,7 +13,7 @@ "passive": "Passiu", "radius": "Radi" }, - "title": "Defineix els par\u00e0metres de la zona" + "title": "Definici\u00f3 dels par\u00e0metres de la zona" } }, "title": "Zona" diff --git a/homeassistant/components/zwave/.translations/ca.json b/homeassistant/components/zwave/.translations/ca.json index 7849f34bbf979..bbf303a1f5e37 100644 --- a/homeassistant/components/zwave/.translations/ca.json +++ b/homeassistant/components/zwave/.translations/ca.json @@ -5,16 +5,16 @@ "one_instance_only": "El component nom\u00e9s admet una inst\u00e0ncia de Z-Wave" }, "error": { - "option_error": "Ha fallat la validaci\u00f3 de Z-Wave. \u00c9s correcta la ruta al port USB on hi ha la mem\u00f2ria?" + "option_error": "Ha fallat la validaci\u00f3 de Z-Wave. \u00c9s correcta la ruta al port USB on hi ha connectat el dispositiu?" }, "step": { "user": { "data": { - "network_key": "Clau de xarxa (deixeu-ho en blanc per generar-la autom\u00e0ticament)", + "network_key": "Clau de xarxa (deixa-ho en blanc per generar-la autom\u00e0ticament)", "usb_path": "Ruta del port USB" }, - "description": "Consulteu https://www.home-assistant.io/docs/z-wave/installation/ per obtenir informaci\u00f3 sobre les variables de configuraci\u00f3", - "title": "Configureu Z-Wave" + "description": "Consulta https://www.home-assistant.io/docs/z-wave/installation/ per obtenir informaci\u00f3 sobre les variables de configuraci\u00f3", + "title": "Configuraci\u00f3 de Z-Wave" } }, "title": "Z-Wave" diff --git a/homeassistant/components/zwave/.translations/hu.json b/homeassistant/components/zwave/.translations/hu.json index e2acc5f911530..e326c5152a617 100644 --- a/homeassistant/components/zwave/.translations/hu.json +++ b/homeassistant/components/zwave/.translations/hu.json @@ -8,7 +8,9 @@ "data": { "network_key": "H\u00e1l\u00f3zati kulcs (hagyja \u00fcresen az automatikus gener\u00e1l\u00e1shoz)", "usb_path": "USB el\u00e9r\u00e9si \u00fat" - } + }, + "description": "A konfigur\u00e1ci\u00f3s v\u00e1ltoz\u00f3kr\u00f3l az inform\u00e1ci\u00f3kat l\u00e1sd a https://www.home-assistant.io/docs/z-wave/installation/ oldalon.", + "title": "Z-Wave be\u00e1ll\u00edt\u00e1sa" } }, "title": "Z-Wave"