From 5b3e9399a92c8678a6f9af96a5f225fec16e676e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Nov 2018 20:53:44 +0100 Subject: [PATCH 001/254] 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 72fc2165d28..651a395b468 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 002/254] 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 5f1d61dd602..9c0df0f9f03 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 1b22f8e62d4..7180002430a 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -302,12 +302,6 @@ class HomeAssistantHTTP: 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 @@ class HomeAssistantHTTP: # 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 64ee7fb8a3f..1f89dc5e4ca 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -96,11 +96,7 @@ def setup_auth(app, trusted_networks, use_auth, 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 2a25de96edc..d6d7168ce6d 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -9,7 +9,7 @@ from aiohttp.web import middleware 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 @@ SCHEMA_IP_BAN_ENTRY = vol.Schema({ @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 @@ class IpBan: 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 f8adc815fde..27a8550ab8c 100644 --- a/homeassistant/components/http/real_ip.py +++ b/homeassistant/components/http/real_ip.py @@ -33,8 +33,4 @@ def setup_real_ip(app, use_x_forwarded_for, trusted_proxies): 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 2568a109244..b519b8e936d 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -9,7 +9,15 @@ 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 +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 a6a07928113..6624937da8d 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -16,6 +16,9 @@ from homeassistant.components.http.ban import ( 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 test_ip_bans_file_creation(hass, aiohttp_client): 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 003/254] 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 920f45a0c0e..19b182a4cd5 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 004/254] 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 9db7c138182..5c6d7e18075 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 005/254] 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 6f7bc56cca9..df8b5391359 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 @@ class BOMCurrentSensor(Entity): 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 4c517824bca..1ed54496c6f 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 50669f5a77d..fc2722f9742 100644 --- a/tests/components/sensor/test_bom.py +++ b/tests/components/sensor/test_bom.py @@ -6,11 +6,12 @@ from unittest.mock import patch 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 @@ class TestBOMWeatherSensor(unittest.TestCase): 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 006/254] 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 6c5fac074ba..1a6dbf97567 100644 --- a/homeassistant/components/neato.py +++ b/homeassistant/components/neato.py @@ -56,25 +56,68 @@ ACTION = { } 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 @@ ERRORS = { '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 007/254] 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 1a6dbf97567..c6ab06d6884 100644 --- a/homeassistant/components/neato.py +++ b/homeassistant/components/neato.py @@ -71,7 +71,7 @@ ERRORS = { '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 008/254] 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 | 172 ++++++++++++++---- homeassistant/components/ihc/const.py | 3 + homeassistant/components/light/ihc.py | 56 +++--- homeassistant/components/sensor/ihc.py | 56 ++---- homeassistant/components/switch/ihc.py | 45 ++--- 6 files changed, 214 insertions(+), 180 deletions(-) diff --git a/homeassistant/components/binary_sensor/ihc.py b/homeassistant/components/binary_sensor/ihc.py index 20937af6bfc..fb5b4c0bfc2 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 9b00f3bd789..052921ad37a 100644 --- a/homeassistant/components/ihc/__init__.py +++ b/homeassistant/components/ihc/__init__.py @@ -9,16 +9,18 @@ import os.path 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 @@ from homeassistant.helpers.typing import HomeAssistantType 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' -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, - }), + +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.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 @@ IHC_PLATFORMS = ('binary_sensor', 'light', 'sensor', 'switch') 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 setup_service_functions(hass: HomeAssistantType, ihc_controller): 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 b06746c8e7a..d6e4d0e0d4d 100644 --- a/homeassistant/components/ihc/const.py +++ b/homeassistant/components/ihc/const.py @@ -10,6 +10,9 @@ CONF_BINARY_SENSOR = 'binary_sensor' 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 da90a53c848..f80c9b2fd6f 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 f5140838a7a..f5a45599bb7 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 4ddafa228a7..e217d109cbc 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 009/254] 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 39644bd047b..5234dbaf29d 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -63,7 +63,7 @@ SCHEMA_GET_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ 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_UPDATE_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ 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_GET_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ 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 010/254] 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 | 121 +++++++++++++++++++---- 1 file changed, 100 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/camera/proxy.py b/homeassistant/components/camera/proxy.py index 83d87311646..48d324fcd3a 100644 --- a/homeassistant/components/camera/proxy.py +++ b/homeassistant/components/camera/proxy.py @@ -10,12 +10,13 @@ import logging 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_FORCE_RESIZE = 'force_resize' 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 _precheck_image(image, opts): + """Perform some pre-checks on the given image.""" + from PIL import Image + import io + + if not opts: + raise ValueError + try: + img = Image.open(io.BytesIO(image)) + except IOError: + _LOGGER.warning("Failed to open image") + raise ValueError + imgfmt = str(img.format) + if imgfmt not in ('PNG', 'JPEG'): + _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 - if not opts: + try: + img = _precheck_image(image, opts) + except ValueError: return image quality = opts.quality or DEFAULT_QUALITY new_width = opts.max_width - - try: - img = Image.open(io.BytesIO(image)) - except IOError: - return image - imgfmt = str(img.format) - if imgfmt not in ('PNG', 'JPEG'): - _LOGGER.debug("Image is of unsupported type: %s", imgfmt) - return image - (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 @@ class ProxyCamera(Camera): "{} - {}".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 @@ class ProxyCamera(Camera): 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 @@ class ProxyCamera(Camera): _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 @@ class ProxyCamera(Camera): 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 011/254] 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 48d324fcd3a..6e7ab9385bd 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 @@ class ProxyCamera(Camera): 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 012/254] 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 a2ed9eabebf..90a8b0b5813 100644 --- a/homeassistant/components/binary_sensor/point.py +++ b/homeassistant/components/binary_sensor/point.py @@ -7,6 +7,7 @@ https://home-assistant.io/components/binary_sensor.point/ 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 fcbd5ddb064..36215da7893 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -25,7 +25,7 @@ from .const import ( 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 4ddc81686b4..4581967bbf6 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 013/254] 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 2a6446092e5..7fa418f0b46 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 9365ba42cc1..c1ced3766c9 100644 --- a/homeassistant/components/binary_sensor/zha.py +++ b/homeassistant/components/binary_sensor/zha.py @@ -7,7 +7,8 @@ at https://home-assistant.io/components/binary_sensor.zha/ 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 @@ CLASS_MAPPING = { 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 @@ class BinarySensor(zha.Entity, BinarySensorDevice): """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 @@ class Remote(zha.Entity, BinarySensorDevice): 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 b5615f18d73..d948ba2ff5b 100644 --- a/homeassistant/components/fan/zha.py +++ b/homeassistant/components/fan/zha.py @@ -5,7 +5,8 @@ For more details on this platform, please refer to the documentation 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 @@ SPEED_TO_VALUE = {speed: i for i, speed in enumerate(SPEED_LIST)} 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 @@ class ZhaFan(zha.Entity, FanEntity): 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 56a1e9e5169..20c9faf2514 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -5,7 +5,9 @@ For more details on this platform, please refer to the documentation 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 @@ UNSUPPORTED_ATTRIBUTE = 0x86 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 @@ class Light(zha.Entity, light.Light): 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 9a9de0d6cf2..993b247a439 100644 --- a/homeassistant/components/sensor/zha.py +++ b/homeassistant/components/sensor/zha.py @@ -7,7 +7,8 @@ at https://home-assistant.io/components/sensor.zha/ 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 @@ DEPENDENCIES = ['zha'] 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 @@ class Sensor(zha.Entity): 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 @@ class ElectricalMeasurementSensor(Sensor): """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 68a94cc1ca5..b184d7baa5c 100644 --- a/homeassistant/components/switch/zha.py +++ b/homeassistant/components/switch/zha.py @@ -7,7 +7,8 @@ at https://home-assistant.io/components/switch.zha/ 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 @@ class Switch(zha.Entity, SwitchDevice): 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 228e589ab01..e54b7f7f657 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -7,15 +7,15 @@ https://home-assistant.io/components/zha/ 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 @@ class ApplicationListener: 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 @@ class ApplicationListener: 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 @@ class ApplicationListener: 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 @@ class ApplicationListener: {'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 88dee57aa70..2a7e35ff517 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 00000000000..d5e52e9277f --- /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 00000000000..1a10f249489 --- /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 00000000000..a16f29f447a --- /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 00000000000..9d07f546b7f --- /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 014/254] 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 7fecfbcd074..55a1527db8c 100644 --- a/homeassistant/components/weather/ipma.py +++ b/homeassistant/components/weather/ipma.py @@ -20,7 +20,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession 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 4581967bbf6..1fa86a9daf5 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 015/254] 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 a6a6ed46174..825f402aef2 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 016/254] [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 6c4047e2314..ee08e532ce7 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 @@ class LutronLight(LutronDevice, Light): 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 017/254] Convert device tracker init tests to async (#18640) --- tests/components/device_tracker/common.py | 31 + tests/components/device_tracker/test_init.py | 842 ++++++++++--------- 2 files changed, 457 insertions(+), 416 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 00000000000..b76eb9a8332 --- /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 93de359610f..6f0d881d257 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.""" +@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) - 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) +async def test_is_on(hass): + """Test is_on method.""" + entity_id = device_tracker.ENTITY_ID_FORMAT.format('test') - # pylint: disable=invalid-name - def tearDown(self): - """Stop everything that was started.""" - if os.path.isfile(self.yaml_devices): - os.remove(self.yaml_devices) + hass.states.async_set(entity_id, STATE_HOME) - self.hass.stop() + assert device_tracker.is_on(hass, entity_id) - def test_is_on(self): - """Test is_on method.""" - entity_id = device_tracker.ENTITY_ID_FORMAT.format('test') + hass.states.async_set(entity_id, STATE_NOT_HOME) - self.hass.states.set(entity_id, STATE_HOME) + assert not device_tracker.is_on(hass, entity_id) - assert device_tracker.is_on(self.hass, entity_id) - self.hass.states.set(entity_id, STATE_NOT_HOME) +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) == [] - assert not device_tracker.is_on(self.hass, entity_id) + 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' - # 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 = 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' - 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' +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 - 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) + +# 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'}) + + +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) - 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 + 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() - # 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' + assert STATE_HOME == \ + hass.states.get('device_tracker.dev1').state - 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) + scanner.leave_home('DEV1') - _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' + 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() - 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) + assert STATE_NOT_HOME == \ + hass.states.get('device_tracker.dev1').state - 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 +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' - @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'}) + 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) - def test_update_stale(self): - """Test stalled update.""" - scanner = get_component(self.hass, 'device_tracker.test').SCANNER - scanner.reset() - scanner.come_home('DEV1') + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) - register_time = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC) - scan_time = datetime(2015, 9, 15, 23, 1, tzinfo=dt_util.UTC) + attrs = hass.states.get(entity_id).attributes - 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 friendly_name == attrs.get(ATTR_FRIENDLY_NAME) + assert icon == attrs.get(ATTR_ICON) + assert picture == attrs.get(ATTR_ENTITY_PICTURE) - assert STATE_HOME == \ - self.hass.states.get('device_tracker.dev1').state - scanner.leave_home('DEV1') +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) - 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() + scanner = get_component(hass, 'device_tracker.test').SCANNER + scanner.reset() - assert STATE_NOT_HOME == \ - self.hass.states.get('device_tracker.dev1').state + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) - 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' + assert hass.states.get(entity_id).attributes.get(ATTR_HIDDEN) - 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) - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) +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) - attrs = self.hass.states.get(entity_id).attributes + scanner = get_component(hass, 'device_tracker.test').SCANNER + scanner.reset() - assert friendly_name == attrs.get(ATTR_FRIENDLY_NAME) - assert icon == attrs.get(ATTR_ICON) - assert picture == attrs.get(ATTR_ENTITY_PICTURE) + 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() - 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) + 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) - scanner = get_component(self.hass, 'device_tracker.test').SCANNER - scanner.reset() - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) - - 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) - - scanner = get_component(self.hass, 'device_tracker.test').SCANNER - scanner.reset() - - 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' - } +@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' } - 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) + } + 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) - mock_see.reset_mock() - params['dev_id'] += chr(233) # e' acute accent from icloud + 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) + 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) - 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 = [] - @callback - def listener(event): - """Record that our event got called.""" - test_events.append(event) +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 = [] - self.hass.bus.listen("device_tracker_new_device", listener) + @callback + def listener(event): + """Record that our event got called.""" + test_events.append(event) - device_tracker.see(self.hass, 'mac_1', host_name='hello') - device_tracker.see(self.hass, 'mac_1', host_name='hello') + hass.bus.async_listen("device_tracker_new_device", listener) - self.hass.block_till_done() + common.async_see(hass, 'mac_1', host_name='hello') + common.async_see(hass, 'mac_1', host_name='hello') - assert len(test_events) == 1 + await hass.async_block_till_done() - # Assert we can serialize the event - json.dumps(test_events[0].as_dict(), cls=JSONEncoder) + assert len(test_events) == 1 - assert test_events[0].data == { - 'entity_id': 'device_tracker.hello', - 'host_name': 'hello', - 'mac': 'MAC_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 } - # pylint: disable=invalid-name - def test_not_write_duplicate_yaml_keys(self): - """Test that the device tracker will not generate invalid YAML.""" + 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 setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) + 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() - device_tracker.see(self.hass, 'mac_1', host_name='hello') - device_tracker.see(self.hass, 'mac_2', host_name='hello') + 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 - self.hass.block_till_done() + scanner.leave_home('dev1') - config = device_tracker.load_config(self.yaml_devices, self.hass, - timedelta(seconds=0)) - assert len(config) == 2 + 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() - # 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) + 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 - 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 +@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, {}, []) - def test_see_state(self): - """Test device tracker see records state correctly.""" - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) + # MAC is not a string (but added) + await tracker.async_see(mac=567, host_name="Number MAC") - 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, - }, - } + # No device id or MAC(not added) + with pytest.raises(HomeAssistantError): + await tracker.async_see() + assert mock_warning.call_count == 0 - device_tracker.see(self.hass, **params) - self.hass.block_till_done() + # 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 - 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 + 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 018/254] 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 45650ece621..650d23fe1df 100644 --- a/homeassistant/components/shopping_list.py +++ b/homeassistant/components/shopping_list.py @@ -38,12 +38,19 @@ SERVICE_ITEM_SCHEMA = vol.Schema({ }) 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 async_setup(hass, config): 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 e64b9a5ae26..44714138eb3 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 019/254] 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 27a7f083fbe..212602aa72c 100644 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -41,7 +41,6 @@ SENSOR_TYPES = { '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 @@ class SystemMonitorSensor(Entity): 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 020/254] 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 be599cc295a..2800b689dc6 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers.entity_component import EntityComponent 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_CLASSES = [ 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 651a395b468..fc97e1bc52d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -177,6 +177,7 @@ DEVICE_CLASS_BATTERY = 'battery' 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 021/254] 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 66b10532664..72684c7ec13 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -827,12 +827,13 @@ class MqttAvailability(Entity): 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 @@ class MqttAvailability(Entity): 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 022/254] 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 5234dbaf29d..72b19235c30 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -31,6 +31,7 @@ FORMAT_JSON = 'json' 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 @@ 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, @@ -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 8211252a516..0659e3d8054 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 023/254] 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 15f2d24fa8a..25704eea0cc 100644 --- a/homeassistant/components/light/yeelight.py +++ b/homeassistant/components/light/yeelight.py @@ -76,6 +76,7 @@ EFFECT_CHRISTMAS = "Christmas" 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 @@ YEELIGHT_EFFECT_LIST = [ EFFECT_RGB, EFFECT_RANDOM_LOOP, EFFECT_FAST_RANDOM_LOOP, + EFFECT_LSD, EFFECT_SLOWDOWN, EFFECT_WHATSAPP, EFFECT_FACEBOOK, @@ -413,34 +415,30 @@ class YeelightLight(Light): 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 024/254] 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 72b19235c30..3e6958f35e2 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -56,7 +56,7 @@ SCHEMA_MIGRATE_CONFIG = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ 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 025/254] 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 b107710fea5..7436ffc41ea 100644 --- a/homeassistant/components/climate/mqtt.py +++ b/homeassistant/components/climate/mqtt.py @@ -22,7 +22,8 @@ from homeassistant.const import ( 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_MIN_TEMP = 'min_temp' 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_setup_entry(hass, config_entry, async_add_entities): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): _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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): _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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 61b481ed4db..894fc290c38 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 026/254] 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 ad1c0d1e3b8..1b9bb020ead 100644 --- a/homeassistant/components/alarm_control_panel/mqtt.py +++ b/homeassistant/components/alarm_control_panel/mqtt.py @@ -19,7 +19,7 @@ from homeassistant.const import ( 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 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, 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 64616718125..24f1b00ee90 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 027/254] Async tests for device tracker mqtt (#18680) --- tests/components/device_tracker/test_mqtt.py | 229 +++++++------ .../device_tracker/test_mqtt_json.py | 302 +++++++++--------- 2 files changed, 263 insertions(+), 268 deletions(-) diff --git a/tests/components/device_tracker/test_mqtt.py b/tests/components/device_tracker/test_mqtt.py index e760db151df..abfa32ca06b 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.""" +@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) - 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 +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 - 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: - 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, { + assert await async_setup_component(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 + assert mock_sp.call_count == 1 - def test_single_level_wildcard_topic(self): - """Test single level wildcard topic.""" - dev_id = 'paulus' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) - subscription = '/location/+/paulus' - topic = '/location/room/paulus' - location = 'work' - self.hass.config.components = set(['mqtt', 'zone']) - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'mqtt', - 'devices': {dev_id: subscription} - } - }) - fire_mqtt_message(self.hass, topic, location) - self.hass.block_till_done() - assert location == self.hass.states.get(entity_id).state +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' - 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' + 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 - 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_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' +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' - 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 + 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 - def test_multi_level_wildcard_topic_not_matching(self): - """Test not matching multi level wildcard topic.""" - dev_id = 'paulus' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) - subscription = '/location/#' - topic = '/somewhere/room/paulus' - location = 'work' - self.hass.config.components = set(['mqtt', 'zone']) - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'mqtt', - 'devices': {dev_id: subscription} - } - }) - fire_mqtt_message(self.hass, topic, location) - self.hass.block_till_done() - assert self.hass.states.get(entity_id) is None +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 44d687a4d45..252d40338fc 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 @@ LOCATION_MESSAGE_INCOMPLETE = { 'longitude': 2.0} -class TestComponentsDeviceTrackerJSONMQTT(unittest.TestCase): - """Test JSON MQTT device tracker platform.""" +@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) - 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 +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 - 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: - 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, { + 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: 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 + assert mock_sp.call_count == 1 - 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} - } - }) +async def test_json_message(hass): + """Test json location message.""" + dev_id = 'zanzito' + topic = 'location/zanzito' + location = json.dumps(LOCATION_MESSAGE) - 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] + 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 - def test_incomplete_message(self): - """Test receiving an incomplete message.""" - dev_id = 'zanzito' - topic = 'location/zanzito' - location = json.dumps(LOCATION_MESSAGE_INCOMPLETE) - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'mqtt_json', - 'devices': {dev_id: topic} - } - }) +async def test_non_json_message(hass, caplog): + """Test receiving a non JSON message.""" + dev_id = 'zanzito' + topic = 'location/zanzito' + location = 'home' - 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] + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt_json', + 'devices': {dev_id: topic} + } + }) - 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) + 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 - 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) +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 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 + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt_json', + 'devices': {dev_id: topic} + } + }) - 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) + 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 - 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() - 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) +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 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() - assert self.hass.states.get(entity_id) is None + 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 028/254] Async tests for owntracks device tracker (#18681) --- .../device_tracker/test_owntracks.py | 2180 +++++++++-------- 1 file changed, 1106 insertions(+), 1074 deletions(-) diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index eaf17fb53f4..2d7397692f8 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 @@ BAD_JSON_PREFIX = '--$this is bad json#--' 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") +@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) -class BaseMQTT(unittest.TestCase): - """Base MQTT assert functions.""" +@pytest.fixture +def context(hass, setup_comp): + """Set up the mocked context.""" + patcher = patch('homeassistant.components.device_tracker.' + 'DeviceTracker.async_update_config') + patcher.start() - hass = None + orig_context = owntracks.OwnTracksContext - 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() + context = None - 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 store_context(*args): + nonlocal context + context = orig_context(*args) + return context - 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, { + 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, + }})) + + def get_context(): + """Get the current context.""" + return context + + 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)) - 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 + async_fire_mqtt_message( + hass, EVENT_TOPIC, + json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE)) - 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) + 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 - 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') +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) - # 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') + assert len(context().mobile_beacons_active['greg_phone']) == \ + 0 - # 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') +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') - # 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 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') - # 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 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') - # 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') + # 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') - # 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') + # 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 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') + # 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') - # 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') + # 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') - 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') + # 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 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') + # 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') - # 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') + # 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') - # 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') +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') - # 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 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') - # 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') + # 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') - # 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') + # 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') - # 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') + # 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 - 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') + # 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) - 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']) + # 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') - 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 + # 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') - 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 + # 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') - 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 + # 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') - 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 + # 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']) - 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 +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 - 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() +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 - 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() + +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_cipher(): return len(TEST_SECRET_KEY), mock_decrypt -class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): - """Test the OwnTrack sensor.""" +@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() - # pylint: disable=invalid-name + patch_save = patch('homeassistant.components.device_tracker.' + 'DeviceTracker.async_update_config') + patch_save.start() - 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') + yield - self.patch_load = patch( - 'homeassistant.components.device_tracker.async_load_config', - return_value=mock_coro([])) - self.patch_load.start() + patch_load.stop() + patch_save.stop() - 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) +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) - 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) +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) - 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) +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) - 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 +@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 - @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, - }}) + 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, + }}) - self.send_message(LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) + await send_message(hass, LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE) + assert_location_latitude(hass, 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/#', - }}) - topic = 'mytracks/{}/{}'.format(USER, DEVICE) +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/#', + }}) - self.send_message(topic, LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) + topic = 'mytracks/{}/{}'.format(USER, DEVICE) - 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' - }, - }}) + await send_message(hass, topic, LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) - self.hass.states.set( - 'zone.inner', 'zoning', INNER_ZONE) - message = build_message({'desc': 'foo'}, REGION_GPS_ENTER_MESSAGE) - assert message['desc'] == 'foo' +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' + }, + }}) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') + hass.states.async_set( + 'zone.inner', 'zoning', INNER_ZONE) + + message = build_message({'desc': 'foo'}, REGION_GPS_ENTER_MESSAGE) + assert message['desc'] == 'foo' + + 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 029/254] Async tests for remaining device trackers (#18682) --- .../components/device_tracker/test_tplink.py | 32 +-- .../device_tracker/test_unifi_direct.py | 216 +++++++++--------- .../components/device_tracker/test_xiaomi.py | 202 ++++++++-------- 3 files changed, 213 insertions(+), 237 deletions(-) diff --git a/tests/components/device_tracker/test_tplink.py b/tests/components/device_tracker/test_tplink.py index b50d1c67511..8f226f449b0 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 @@ from homeassistant.const import (CONF_PLATFORM, CONF_PASSWORD, CONF_USERNAME, 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 6e2830eee52..1b1dc1a7cb5 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 @@ from homeassistant.const import (CONF_PLATFORM, CONF_PASSWORD, CONF_USERNAME, CONF_HOST) from tests.common import ( - get_test_home_assistant, assert_setup_component, - mock_component, load_fixture) + assert_setup_component, mock_component, load_fixture) + +scanner_path = 'homeassistant.components.device_tracker.' + \ + 'unifi_direct.UnifiDeviceScanner' -class TestComponentsDeviceTrackerUnifiDirect(unittest.TestCase): - """Tests for the Unifi direct device tracker platform.""" +@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) - 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', +@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) + 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]) + 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) - } + +@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") + } + 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') - 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) - } +@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 + 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') - 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) - } + +@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(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 + 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 - 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_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_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 9c7c13ee741..7b141159256 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 import DOMAIN, xiaomi as xiaomi 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 mocked_requests(*args, **kwargs): _LOGGER.debug('UNKNOWN ROUTE') -class TestXiaomiDeviceScanner(unittest.TestCase): - """Xiaomi device scanner test class.""" +@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' - 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() +@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' - @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) +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) - 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) +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) - 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('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 030/254] Async timer tests (#18683) --- tests/components/timer/test_init.py | 93 +++++++++++++---------------- 1 file changed, 40 insertions(+), 53 deletions(-) diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index afd2b1412dc..62a57efb040 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.components.timer import ( 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.""" +async def test_config(hass): + """Test config.""" + invalid_configs = [ + None, + 1, + {}, + {'name with space': None}, + ] - # pylint: disable=invalid-name - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() + for cfg in invalid_configs: + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) - # 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}, - ] +async def test_config_options(hass): + """Test configuration options.""" + count_start = len(hass.states.async_entity_ids()) - for cfg in invalid_configs: - assert not setup_component(self.hass, DOMAIN, {DOMAIN: cfg}) + _LOGGER.debug('ENTITIES @ start: %s', hass.states.async_entity_ids()) - 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, - } + 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 031/254] Async tests for counter (#18684) --- tests/components/counter/common.py | 18 -- tests/components/counter/test_init.py | 230 ++++++++++++-------------- 2 files changed, 110 insertions(+), 138 deletions(-) diff --git a/tests/components/counter/common.py b/tests/components/counter/common.py index 36d09979d0d..2fad06027fc 100644 --- a/tests/components/counter/common.py +++ b/tests/components/counter/common.py @@ -10,12 +10,6 @@ from homeassistant.core import callback 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 c8411bf2fde..97a39cdeb73 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.""" +async def test_config(hass): + """Test config.""" + invalid_configs = [ + None, + 1, + {}, + {'name with space': None}, + ] - # pylint: disable=invalid-name - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() + for cfg in invalid_configs: + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) - # 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}, - ] +async def test_config_options(hass): + """Test configuration options.""" + count_start = len(hass.states.async_entity_ids()) - for cfg in invalid_configs: - assert not setup_component(self.hass, DOMAIN, {DOMAIN: cfg}) + _LOGGER.debug('ENTITIES @ start: %s', hass.states.async_entity_ids()) - 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, - } + 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 await async_setup_component(hass, 'counter', config) + + entity_id = 'counter.test_1' + + state = hass.states.get(entity_id) + assert 0 == int(state.state) + + async_increment(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 1 == int(state.state) + + async_increment(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 2 == int(state.state) + + async_decrement(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 1 == int(state.state) + + async_reset(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 0 == int(state.state) + + +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_1' + entity_id = 'counter.test' - state = self.hass.states.get(entity_id) - assert 0 == 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 1 == 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 2 == 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 1 == int(state.state) - - reset(self.hass, entity_id) - self.hass.block_till_done() - - state = self.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, - } - } - } - - assert setup_component(self.hass, 'counter', config) - - entity_id = 'counter.test' - - state = self.hass.states.get(entity_id) - assert 10 == int(state.state) - - increment(self.hass, entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 15 == int(state.state) - - increment(self.hass, entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 20 == int(state.state) - - decrement(self.hass, entity_id) - self.hass.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 032/254] Async tests for mqtt switch (#18685) --- tests/components/switch/test_mqtt.py | 462 ++++++++++++++------------- 1 file changed, 241 insertions(+), 221 deletions(-) diff --git a/tests/components/switch/test_mqtt.py b/tests/components/switch/test_mqtt.py index 5cdd7d23063..4099a5b7951 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 import switch, mqtt 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 + } + }) - def test_controlling_state_via_topic(self): - """Test the controlling state via topic.""" - assert setup_component(self.hass, switch.DOMAIN, { + 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 + + +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': 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, { - 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 = self.hass.states.get('switch.test') - assert STATE_UNAVAILABLE == state.state + state = hass.states.get('switch.test') + assert STATE_OFF == state.state + assert not state.attributes.get(ATTR_ASSUMED_STATE) - fire_mqtt_message(self.hass, 'availability_topic', 'online') - self.hass.block_till_done() + async_fire_mqtt_message(hass, 'availability_topic', '0') + 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, 'availability_topic', 'offline') - 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_UNAVAILABLE == state.state + state = hass.states.get('switch.test') + assert STATE_UNAVAILABLE == state.state - fire_mqtt_message(self.hass, 'state-topic', '1') - 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_UNAVAILABLE == state.state + state = hass.states.get('switch.test') + assert STATE_ON == state.state - fire_mqtt_message(self.hass, 'availability_topic', 'online') - self.hass.block_till_done() - state = self.hass.states.get('switch.test') - assert STATE_ON == 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 + } + }) - 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, 'availability_topic', 'online') + 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_OFF == state.state + assert not state.attributes.get(ATTR_ASSUMED_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', 'offline') + 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_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, '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', 'good') - 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_ON == state.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 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_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, '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) - fire_mqtt_message(self.hass, 'state-topic', 'LOW') - self.hass.block_till_done() + 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 + 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 = hass.states.get('switch.test') + assert STATE_UNAVAILABLE == state.state + + async_fire_mqtt_message(hass, 'availability_topic', 'good') + await hass.async_block_till_done() + await hass.async_block_till_done() + + 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 033/254] 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 00000000000..3995309de42 --- /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 1fa86a9daf5..339c212f237 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 6ebc180908e..a73d80b199a 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 76a9e05de33..b0ad953e2b5 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -91,6 +91,7 @@ TEST_REQUIREMENTS = ( '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 00000000000..b539bdbfe7d --- /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 00000000000..674c0662197 --- /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 00000000000..05ad8371232 --- /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 00000000000..899ad4eed72 --- /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 034/254] 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 6be4fe183b7..5ea48614f6b 100644 --- a/homeassistant/components/climate/mill.py +++ b/homeassistant/components/climate/mill.py @@ -19,7 +19,7 @@ from homeassistant.const import ( 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 339c212f237..2b253523656 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 035/254] 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 de60f7eee93..296c6c8d75d 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2018.11.07'] +REQUIREMENTS = ['youtube_dl==2018.11.23'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 2b253523656..f12a41b75aa 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 036/254] 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 5b69c13afa6..587872db839 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -128,7 +128,8 @@ class MikrotikScanner(DeviceScanner): 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 037/254] 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 ddad6a43c75..7b02b51d0c0 100644 --- a/homeassistant/components/sensor/ruter.py +++ b/homeassistant/components/sensor/ruter.py @@ -28,7 +28,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 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 038/254] 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 | 33 +++++++++++++++--------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/tahoma.py b/homeassistant/components/tahoma.py index 366799b872c..645d67b3dc2 100644 --- a/homeassistant/components/tahoma.py +++ b/homeassistant/components/tahoma.py @@ -36,26 +36,27 @@ TAHOMA_COMPONENTS = [ ] TAHOMA_TYPES = { - 'rts:RollerShutterRTSComponent': 'cover', - 'rts:CurtainRTSComponent': 'cover', + 'io:ExteriorVenetianBlindIOComponent': 'cover', + 'io:HorizontalAwningIOComponent': 'cover', + 'io:LightIOSystemSensor': 'sensor', + 'io:OnOffLightIOComponent': 'switch', + 'io:RollerShutterGenericIOComponent': 'cover', + 'io:RollerShutterUnoIOComponent': 'cover', + 'io:RollerShutterVeluxIOComponent': 'cover', + 'io:RollerShutterWithLowSpeedManagementIOComponent': 'cover', + 'io:SomfyContactIOSystemSensor': 'sensor', + 'io:VerticalExteriorAwningIOComponent': 'cover', + 'io:WindowOpenerVeluxIOComponent': 'cover', + 'rtds:RTDSContactSensor': 'sensor', + 'rtds:RTDSMotionSensor': 'sensor', + 'rtds:RTDSSmokeSensor': 'smoke', 'rts:BlindRTSComponent': 'cover', - 'rts:VenetianBlindRTSComponent': 'cover', + 'rts:CurtainRTSComponent': 'cover', 'rts:DualCurtainRTSComponent': 'cover', 'rts:ExteriorVenetianBlindRTSComponent': 'cover', - 'io:ExteriorVenetianBlindIOComponent': 'cover', - 'io:RollerShutterUnoIOComponent': 'cover', - 'io:RollerShutterWithLowSpeedManagementIOComponent': 'cover', - 'io:RollerShutterVeluxIOComponent': 'cover', - 'io:RollerShutterGenericIOComponent': 'cover', - 'io:WindowOpenerVeluxIOComponent': 'cover', - 'io:LightIOSystemSensor': 'sensor', 'rts:GarageDoor4TRTSComponent': 'switch', - 'io:VerticalExteriorAwningIOComponent': 'cover', - 'io:HorizontalAwningIOComponent': 'cover', - 'io:OnOffLightIOComponent': 'switch', - 'rtds:RTDSSmokeSensor': 'smoke', - 'rtds:RTDSContactSensor': 'sensor', - 'rtds:RTDSMotionSensor': 'sensor' + '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 039/254] Async device sun light trigger tests (#18689) --- .../test_device_sun_light_trigger.py | 169 +++++++++--------- 1 file changed, 84 insertions(+), 85 deletions(-) diff --git a/tests/components/test_device_sun_light_trigger.py b/tests/components/test_device_sun_light_trigger.py index b9f63922ba3..f1ef6aa5dd0 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.""" +@pytest.fixture +def scanner(hass): + """Initialize components.""" + scanner = loader.get_component( + hass, 'device_tracker.test').get_scanner(None, None) - def setUp(self): # pylint: disable=invalid-name - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() + scanner.reset() + scanner.come_home('DEV1') - self.scanner = loader.get_component( - self.hass, 'device_tracker.test').get_scanner(None, None) + loader.get_component(hass, 'light.test').init() - 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, { + 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) - - self.hass.block_till_done() - - 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() - - assert light.is_on(self.hass) - - def test_lights_turn_off_when_everyone_leaves(self): - """Test lights turn off when everyone leaves the house.""" - common_light.turn_on(self.hass) - - self.hass.block_till_done() - - assert setup_component( - self.hass, device_sun_light_trigger.DOMAIN, { +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.states.set(device_tracker.ENTITY_ID_ALL_DEVICES, - STATE_NOT_HOME) + common_light.async_turn_off(hass) - self.hass.block_till_done() + await hass.async_block_till_done() - assert not 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_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 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_off_when_everyone_leaves(hass, scanner): + """Test lights turn off when everyone leaves the house.""" + common_light.async_turn_on(hass) - self.hass.block_till_done() - assert light.is_on(self.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_ALL_DEVICES, + STATE_NOT_HOME) + + await hass.async_block_till_done() + + assert not light.is_on(hass) + + +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) + + 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 040/254] Async cover template tests (#18690) --- tests/components/cover/test_template.py | 1287 +++++++++++------------ 1 file changed, 643 insertions(+), 644 deletions(-) diff --git a/tests/components/cover/test_template.py b/tests/components/cover/test_template.py index 3c820f1a0ac..4d46882c9ea 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 @@ from homeassistant.const import ( 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.""" +@pytest.fixture +def calls(hass): + """Track calls to a mock serivce.""" + return async_mock_service(hass, 'test', 'automation') - 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' - }, - } +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() + await hass.async_start() + await hass.async_block_till_done() - 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') - assert state.state == STATE_OPEN + state = 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 = hass.states.async_set('cover.test_state', STATE_CLOSED) + await hass.async_block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.state == STATE_CLOSED + state = 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' - }, - } + +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() + await hass.async_start() + await hass.async_block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.state == STATE_OPEN + state = 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' - }, - } + +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() + await hass.async_start() + await hass.async_block_till_done() - state = self.hass.states.set('cover.test', STATE_CLOSED) - self.hass.block_till_done() + state = hass.states.async_set('cover.test', STATE_CLOSED) + await hass.async_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() + 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 = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_position') == 42.0 - assert state.state == STATE_OPEN + state = 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 = 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 = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_position') == 0.0 - assert state.state == STATE_CLOSED + state = 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' - }, - } + +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() + await hass.async_start() + await hass.async_block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_tilt_position') == 42.0 + state = 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' - }, - } + +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() + await hass.async_start() + await hass.async_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 + 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 - 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 %}" - } + +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() + await hass.async_start() + await hass.async_block_till_done() - assert self.hass.states.all() == [] + assert hass.states.async_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 }}", - } + +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() + await hass.async_start() + await hass.async_block_till_done() - assert self.hass.states.all() == [] + assert hass.states.async_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' + +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' + }, + }, + } + } + }) + + 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' + }, + } + } + } + }) + + 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' + }, + } + } + } + }) + + 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', + }, + } + } + } + }) + + 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 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() + await hass.async_start() + await hass.async_block_till_done() - assert self.hass.states.all() == [] + 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 - 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.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() + await hass.async_start() + await hass.async_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 + 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() - 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' - }, - } + 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() + await hass.async_start() + await hass.async_block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.state == STATE_CLOSED + await hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() - self.hass.services.call( - DOMAIN, SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() + assert len(calls) == 1 - 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', - }, - } +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() + await hass.async_start() + await hass.async_block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.state == STATE_OPEN + await hass.services.async_call( + DOMAIN, SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() - self.hass.services.call( - DOMAIN, SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() + assert len(calls) == 1 - 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', - } - } - }) - 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 }}' - }, - }, - } +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', + }, } } - }) + } + }) + await hass.async_start() + await hass.async_block_till_done() - self.hass.start() - self.hass.block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_position') is None - 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 + 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 - 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 + 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 - 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 + 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 - 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', - }, - } +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', + }, } } - }) + } + }) + await hass.async_start() + await hass.async_block_till_done() - self.hass.start() - self.hass.block_till_done() + state = 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() + 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 - assert len(self.calls) == 1 + 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 - 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.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() + await hass.async_start() + await hass.async_block_till_done() - self.hass.services.call( - DOMAIN, SERVICE_OPEN_COVER_TILT, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('icon') == '' - assert len(self.calls) == 1 + state = hass.states.async_set('cover.test_state', STATE_OPEN) + await hass.async_block_till_done() - 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', - }, - } + 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() - self.hass.services.call( - DOMAIN, SERVICE_CLOSE_COVER_TILT, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('entity_picture') == '' - assert len(self.calls) == 1 + state = hass.states.async_set('cover.test_state', STATE_OPEN) + await hass.async_block_till_done() - 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', - }, - } - } - } - }) - self.hass.start() - self.hass.block_till_done() + state = hass.states.get('cover.test_template_cover') - 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', - }, - } - } - } - }) - 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 %}" - } - } - } - }) - - 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 %}" - } - } - } - }) - - self.hass.start() - self.hass.block_till_done() - - state = self.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 = self.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 041/254] 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 a9afc76e67c..40a6f48d889 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import ( 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 718e4f6fb0d..b9997345c36 100644 --- a/homeassistant/components/sensor/snmp.py +++ b/homeassistant/components/sensor/snmp.py @@ -16,7 +16,7 @@ from homeassistant.const import ( 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 62636b67003..e1da12d317e 100644 --- a/homeassistant/components/switch/snmp.py +++ b/homeassistant/components/switch/snmp.py @@ -14,7 +14,7 @@ from homeassistant.const import ( 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 f12a41b75aa..dd77bd72315 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 042/254] 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 e69dec37df2..7d8ef13d2bb 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -132,13 +132,15 @@ class AuthManager: 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 @@ class AuthManager: '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 867d5357a58..cf82c40a4d3 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -133,6 +133,33 @@ class AuthStore: 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 cefaabe7521..4b192c35898 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -8,6 +8,7 @@ import attr 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 @@ class User: 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 fd3cf81f029..9113f2b03a9 100644 --- a/homeassistant/auth/permissions/__init__.py +++ b/homeassistant/auth/permissions/__init__.py @@ -5,10 +5,8 @@ from typing import ( # noqa: F401 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,13 +20,20 @@ _LOGGER = logging.getLogger(__name__) class AbstractPermissions: """Default permissions class.""" - def check_entity(self, entity_id: str, key: str) -> bool: - """Test if we can access entity.""" + _cached_entity_func = None + + def _entity_func(self) -> Callable[[str, str], bool]: + """Return a function that can test entity access.""" raise NotImplementedError - def filter_states(self, states: List[State]) -> List[State]: - """Filter a list of states for what the user is allowed to see.""" - 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): @@ -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 89b9398628c..74a43246fd1 100644 --- a/homeassistant/auth/permissions/entities.py +++ b/homeassistant/auth/permissions/entities.py @@ -28,28 +28,28 @@ ENTITY_POLICY_SCHEMA = vol.Any(True, vol.Schema({ })) -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 compile_entities(policy: CategoryType) \ 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 compile_entities(policy: CategoryType) \ # 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 compile_entities(policy: CategoryType) \ 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 compile_entities(policy: CategoryType) \ 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 cbe404537eb..b001bcd0437 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -20,7 +20,8 @@ from homeassistant.const import ( 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 @@ class APIEntityStateView(HomeAssistantView): 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 @@ class APIEntityStateView(HomeAssistantView): @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 4c13cb799a6..6bfcaaa5d85 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -10,6 +10,7 @@ import os 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 b3b2587fc45..30d4ed0ab8d 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -14,6 +14,7 @@ from aiohttp.web_exceptions import HTTPUnauthorized, HTTPInternalServerError 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 @@ def request_handler_factory(view, handler): _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 5e0d9c7e88a..e8068f57286 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 33c164d12b4..40de5ca7334 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 fdc5440a9d5..00000000000 --- 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 c6a75fcb63d..d5056e220f0 100644 --- a/tests/common.py +++ b/tests/common.py @@ -14,7 +14,8 @@ from contextlib import contextmanager 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 @@ class MockUser(auth_models.User): 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 b519b8e936d..46d75a56ad6 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -80,11 +80,10 @@ def hass_ws_client(aiohttp_client): @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 4fd59dd3f7a..51fca931faa 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -5,6 +5,7 @@ from unittest.mock import patch, Mock 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 6f6b4e93068..3ebfa05a3d3 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -16,10 +16,12 @@ from tests.common import async_mock_service @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 043/254] :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 6d54324542a..c28527886b1 100644 --- a/homeassistant/components/influxdb.py +++ b/homeassistant/components/influxdb.py @@ -23,7 +23,7 @@ from homeassistant.helpers import state as state_helper 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 87e2bdb5c9c..0fc31ef273f 100644 --- a/homeassistant/components/sensor/influxdb.py +++ b/homeassistant/components/sensor/influxdb.py @@ -22,7 +22,7 @@ from homeassistant.util import Throttle _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 dd77bd72315..b0e317387f0 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 a73d80b199a..fc7a1443d95 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 044/254] 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 f7bd353f3d1..4d7e2c07eba 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -45,8 +45,8 @@ PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ 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 @@ PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ 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_setup_entry(hass, config_entry, async_add_entities): 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 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, 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 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, @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 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, @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 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, @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 045/254] 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 650d23fe1df..ad4680982b4 100644 --- a/homeassistant/components/shopping_list.py +++ b/homeassistant/components/shopping_list.py @@ -39,6 +39,7 @@ SERVICE_ITEM_SCHEMA = vol.Schema({ 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 @@ SCHEMA_WEBSOCKET_ADD_ITEM = \ 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 async_setup(hass, config): 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 44714138eb3..c2899f6b753 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 046/254] 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 | 144 +++++++++++++---------- 1 file changed, 81 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/light/fibaro.py b/homeassistant/components/light/fibaro.py index cfc28e12218..96069d50335 100644 --- a/homeassistant/components/light/fibaro.py +++ b/homeassistant/components/light/fibaro.py @@ -6,7 +6,8 @@ https://home-assistant.io/components/light.fibaro/ """ 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 @@ class FibaroLight(FibaroDevice, Light): 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 @@ class FibaroLight(FibaroDevice, Light): """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) + async with self._update_lock: + await self.hass.async_add_executor_job( + partial(self._turn_on, **kwargs)) - # 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) + def _turn_on(self, **kwargs): + """Really turn the light on.""" + if self._supported_flags & SUPPORT_BRIGHTNESS: + target_brightness = kwargs.get(ATTR_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 + # 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_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 047/254] 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 b968850668d..183dddf2c52 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -20,7 +20,7 @@ from homeassistant.components.alexa import smart_home as alexa_sh 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_RELAYER = 'relayer' 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 @@ CONFIG_SCHEMA = vol.Schema({ 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 @@ class Cloud: 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 @@ class Cloud: 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 @@ class Cloud: 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 00000000000..13575068a3e --- /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 00000000000..fdf7bb2a12e --- /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 abc72da796c..01d92c6f50f 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -6,6 +6,7 @@ REQUEST_TIMEOUT = 10 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 @@ SERVERS = { '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 7b509f4eae2..03a77c08d4b 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -3,6 +3,7 @@ import asyncio from functools import wraps import logging +import aiohttp import async_timeout import voluptuous as vol @@ -44,6 +45,20 @@ SCHEMA_WS_SUBSCRIPTION = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ }) +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 @@ _CLOUD_ERRORS = { 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 c5657ae9729..3c7275afa7a 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 @@ class CloudIoT: 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 @@ class CloudIoT: 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 @@ class CloudIoT: _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 @@ class CloudIoT: _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 d29b356cfc0..b2ed83fc6b2 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 @@ class CloudPreferences: 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 @@ class CloudPreferences: 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 ad23ba6f544..6742f33c72d 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 00000000000..d648ed43110 --- /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 00000000000..0ddb8ecce50 --- /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 00000000000..b65046331a7 --- /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 4abf5b8501d..57e92ba7628 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 44d56566f75..baf6747aead 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 c900fc3a7a8..10488779dd8 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 00000000000..8f528376cce --- /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 3c92aa9ecb957c1a1e35495eee571fb22f078230 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 26 Nov 2018 14:30:21 +0100 Subject: [PATCH 048/254] 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 508ffac6739..da234c3dd5d 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 95d675076da..a311daa6f9e 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 e76577a5dc8..dbda456587e 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 00000000000..9f6ad4f8d47 --- /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 00000000000..71f13959b41 --- /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 00000000000..c5e4a7b2e86 --- /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 00000000000..98fa79573b0 --- /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 00000000000..91a86f5e3db --- /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 00000000000..919956b8c34 --- /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 00000000000..9891ac50f48 --- /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 97a0e25d754..58782a1b421 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 95ba46597da..3ea631ec86c 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 049/254] 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 3768a59788e..c16907007cf 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import callback 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 b0e317387f0..59a29eb88b3 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 fc7a1443d95..7a107f2bb0a 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 050/254] 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 ada8bf78ab0..c7a37411f1e 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 5229d34b74c..ae1e3d1d51a 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -62,6 +62,12 @@ class TestComponentLogbook(unittest.TestCase): # 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 051/254] 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 204bc67b086..8d761c1e614 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 7a107f2bb0a..d204cfa7da9 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 052/254] 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 03be4c74d32..8eea3acb6ed 100644 --- a/homeassistant/auth/mfa_modules/notify.py +++ b/homeassistant/auth/mfa_modules/notify.py @@ -4,7 +4,7 @@ Sending HOTP through notify service """ 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 74a43246fd1..59bba468a59 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 1871861f291..78d13b9679f 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 053/254] 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 771a6a57f4f..53d1e9af807 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -3,6 +3,7 @@ import voluptuous as vol 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 1cb58591a0a..60e2caa54ac 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 @@ class ActiveConnection: 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 @@ class ActiveConnection: """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 8d452959ca5..fd8f7eb7b08 100644 --- a/homeassistant/components/websocket_api/const.py +++ b/homeassistant/components/websocket_api/const.py @@ -6,11 +6,12 @@ DOMAIN = 'websocket_api' 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 5f78790f5db..34250202a5e 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 b7825600cb1..51d98df7f60 100644 --- a/tests/components/websocket_api/conftest.py +++ b/tests/components/websocket_api/conftest.py @@ -9,9 +9,10 @@ from . import API_PASSWORD @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 84c29533859..b83d4051356 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 054/254] 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 111b9e7d39f..6cdb12b7157 100644 --- a/homeassistant/auth/providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -4,16 +4,19 @@ Support Legacy API password auth provider. 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 1f89dc5e4ca..0e943b33fb8 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -10,6 +10,7 @@ import jwt 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 @@ def setup_auth(app, trusted_networks, use_auth, 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 d15c7ccbb34..ab84dd2a3bc 100644 --- a/tests/components/alexa/test_intent.py +++ b/tests/components/alexa/test_intent.py @@ -23,7 +23,7 @@ NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" @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 alexa_client(loop, hass, aiohttp_client): }, } })) - 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 766075f8eb5..3cfb8068177 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 46d75a56ad6..110ba8d5ad6 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -4,6 +4,7 @@ from unittest.mock import patch 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 f9ad1c578de..435de6d1edf 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 2746abcf15c..979bfc28689 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 9f6441c5238..1c1afe711c6 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 3ebfa05a3d3..0bc89292855 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -16,12 +16,10 @@ from tests.common import async_mock_service @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 7934e016281..2aa1f499a76 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 test_http_processing_intent(hass, aiohttp_client): }) 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 9764af1592c..641dff3b4e6 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -515,13 +515,13 @@ class TestComponentHistory(unittest.TestCase): 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 c2899f6b753..1e89287bcc1 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 e7e7d158a31..61bb009ff8f 100644 --- a/tests/components/test_spaceapi.py +++ b/tests/components/test_spaceapi.py @@ -56,7 +56,7 @@ SENSOR_OUTPUT = { @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 5d48fd88127..6afd792be9c 100644 --- a/tests/components/test_system_log.py +++ b/tests/components/test_system_log.py @@ -14,9 +14,9 @@ BASIC_CONFIG = { } -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 @@ async def test_error_posted_as_event(hass): 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 c16fef3e059..e67cf7481cc 100644 --- a/tests/components/test_webhook.py +++ b/tests/components/test_webhook.py @@ -7,10 +7,10 @@ from homeassistant.setup import async_setup_component @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 055/254] 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 8710e7c60bc..19aeea5b22e 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 @@ import voluptuous as vol 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 @@ class Data: 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 84beb8cdd3f..d3fa27b9f5b 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 056/254] 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 f51cca8a276..92394fc026b 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.mqtt/ """ import logging -from typing import Optional import voluptuous as vol @@ -24,7 +23,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, 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 @@ PLATFORM_SCHEMA = vol.All(mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ 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_setup_entry(hass, config_entry, async_add_entities): 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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 26204ce6ebd..df47a6caf48 100644 --- a/tests/components/cover/test_mqtt.py +++ b/tests/components/cover/test_mqtt.py @@ -734,25 +734,29 @@ class TestCoverMQTT(unittest.TestCase): 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 @@ class TestCoverMQTT(unittest.TestCase): 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 @@ class TestCoverMQTT(unittest.TestCase): 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 @@ class TestCoverMQTT(unittest.TestCase): 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 @@ class TestCoverMQTT(unittest.TestCase): 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 @@ class TestCoverMQTT(unittest.TestCase): 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 @@ class TestCoverMQTT(unittest.TestCase): 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 @@ class TestCoverMQTT(unittest.TestCase): 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 057/254] Reconfigure MQTT fan component if discovery info is changed (#18177) --- homeassistant/components/fan/mqtt.py | 179 ++++++++++++++++----------- tests/components/fan/test_mqtt.py | 32 +++++ 2 files changed, 140 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/fan/mqtt.py index 1ff04cd913a..505a6e90720 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/fan/mqtt.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/fan.mqtt/ """ import logging -from typing import Optional import voluptuous as vol @@ -18,7 +17,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, - 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,40 +106,7 @@ 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), - { - key: config.get(key) for key in ( - CONF_STATE_TOPIC, - CONF_COMMAND_TOPIC, - CONF_SPEED_STATE_TOPIC, - CONF_SPEED_COMMAND_TOPIC, - CONF_OSCILLATION_STATE_TOPIC, - CONF_OSCILLATION_COMMAND_TOPIC, - ) - }, - { - 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), - { - STATE_ON: config.get(CONF_PAYLOAD_ON), - STATE_OFF: config.get(CONF_PAYLOAD_OFF), - OSCILLATE_ON_PAYLOAD: config.get(CONF_PAYLOAD_OSCILLATION_ON), - OSCILLATE_OFF_PAYLOAD: config.get(CONF_PAYLOAD_OSCILLATION_OFF), - 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), + config, discovery_hash, )]) @@ -149,43 +115,102 @@ 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): + def __init__(self, config, 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._optimistic_oscillation = ( - optimistic or 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 self._supported_features = 0 - self._supported_features |= (topic[CONF_OSCILLATION_STATE_TOPIC] - is not None and SUPPORT_OSCILLATE) - self._supported_features |= (topic[CONF_SPEED_STATE_TOPIC] - is not None and SUPPORT_SET_SPEED) - self._unique_id = unique_id - self._discovery_hash = discovery_hash + 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, + CONF_SPEED_STATE_TOPIC, + CONF_SPEED_COMMAND_TOPIC, + 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) + } + 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), + OSCILLATE_OFF_PAYLOAD: config.get(CONF_PAYLOAD_OSCILLATION_OFF), + SPEED_LOW: config.get(CONF_PAYLOAD_LOW_SPEED), + 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 = ( + optimistic or self._topic[CONF_OSCILLATION_STATE_TOPIC] is None) + self._optimistic_speed = ( + optimistic or self._topic[CONF_SPEED_STATE_TOPIC] is None) + + self._supported_features = 0 + self._supported_features |= (self._topic[CONF_OSCILLATION_STATE_TOPIC] + is not None and SUPPORT_OSCILLATE) + self._supported_features |= (self._topic[CONF_SPEED_STATE_TOPIC] + is not None and SUPPORT_SET_SPEED) + + 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 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 a3f76058c76..a3e8b0e9f32 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 058/254] 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 225ed07a622..68f49961cf9 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -16,7 +16,7 @@ from homeassistant.components import sensor 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 @@ PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ 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_setup_entry(hass, config_entry, async_add_entities): 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 @@ class MqttSensor(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 15042805a66..78de05e1ff3 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 059/254] 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 | 641 ++++++++++++------ homeassistant/components/climate/honeywell.py | 2 +- homeassistant/components/evohome.py | 130 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 502 insertions(+), 275 deletions(-) diff --git a/homeassistant/components/climate/evohome.py b/homeassistant/components/climate/evohome.py index f0631228fd8..fd58e6c01e8 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 @@ import logging 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_DAYOFF = 'DayOff' 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,171 +65,150 @@ EVO_STATE_TO_HA = { 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) -HA_OP_LIST = list(HA_STATE_TO_EVO) +# the Zones' opmode; their state is usually 'inherited' from the TCS +EVO_FOLLOW = 'FollowSchedule' +EVO_TEMPOVER = 'TemporaryOverride' +EVO_PERMOVER = 'PermanentOverride' -# 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' +# 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) -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 = [] + + 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)) + + entities = [controller] + zones + + async_add_entities(entities, update_before_add=False) -class EvoController(ClimateDevice): - """Base for a Honeywell evohome hub/Controller device. +class EvoClimateDevice(ClimateDevice): + """Base for a Honeywell evohome Climate device.""" - The Controller (aka TCS, temperature control system) is the parent of all - the child (CH/DHW) devices. - """ + # pylint: disable=no-member def __init__(self, evo_data, client, obj_ref): - """Initialize the evohome entity. - - Most read-only properties are set here. So are pseudo read-only, - for example name (which _could_ change between update()s). - """ - self.client = client + """Initialize the evohome entity.""" + self._client = client self._obj = obj_ref - self._id = obj_ref.systemId - self._name = evo_data['config']['locationInfo']['name'] - - 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 - - @property - def device_state_attributes(self): - """Return the device state attributes of the controller. - - This is operating mode state data that is not available otherwise, due - to the restrictions placed upon ClimateDevice properties, etc by HA. - """ - 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 + """Get the list of supported features of the device.""" + return self._supported_features @property def operation_list(self): """Return the list of available operations.""" - return HA_OP_LIST - - @property - def current_operation(self): - """Return the operation mode of the evohome entity.""" - return EVO_STATE_TO_HA.get(self._status['systemModeStatus']['mode']) - - @property - def target_temperature(self): - """Return the average target temperature of the Heating/DHW zones.""" - temps = [zone['setpointStatus']['targetHeatTemperature'] - for zone in self._status['zones']] - - avg_temp = round(sum(temps) / len(temps), 1) if temps else None - return avg_temp - - @property - def current_temperature(self): - """Return the average current temperature of the Heating/DHW zones.""" - tmp_list = [x for x in self._status['zones'] - if x['temperatureStatus']['isAvailable'] is True] - temps = [zone['temperatureStatus']['temperature'] for zone in tmp_list] - - avg_temp = round(sum(temps) / len(temps), 1) if temps else None - return avg_temp + return self._operation_list @property def temperature_unit(self): @@ -227,47 +218,313 @@ class EvoController(ClimateDevice): @property def precision(self): """Return the temperature precision to use in the frontend UI.""" - return PRECISION_TENTHS + 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 = {} + + 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 temp (setpoint) of a evohome entity.""" - return MIN_TEMP + """Return the minimum target temperature of a evohome Zone. + + The default is 5 (in Celsius), but it is configurable within 5-35. + """ + return self._config['setpointCapabilities']['minHeatSetpoint'] @property def max_temp(self): - """Return the maximum target temp (setpoint) of a evohome entity.""" - return MAX_TEMP + """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 is_on(self): - """Return true as evohome controllers are always on. + def target_temperature(self): + """Return the target temperature of the evohome Zone.""" + return self._status['setpointStatus']['targetHeatTemperature'] - Operating modes can include 'HeatingOff', but (for example) DHW would - remain on. + @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 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. + + 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']] + + avg_temp = round(sum(temps) / len(temps), 1) if temps else None + return avg_temp + + @property + def current_temperature(self): + """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] + + avg_temp = round(sum(temps) / len(temps), 1) if temps else None + return avg_temp + + @property + def is_on(self) -> bool: + """Return True as evohome Controllers are always 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 @@ class EvoController(ClimateDevice): 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 c445a495073..e0f104a84b1 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -20,7 +20,7 @@ from homeassistant.const import ( 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 397d3b9f6c0..40ba5b9b70f 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 @@ https://home-assistant.io/components/evohome/ """ # 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 @@ CONFIG_SCHEMA = vol.Schema({ 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. - One controller with 0+ heating zones (e.g. TRVs, relays) and, optionally, a - DHW controller. Does not work for US-based systems. +def setup(hass, hass_config): + """Create a (EMEA/EU-based) Honeywell evohome system. + + 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." + ) - finally: # Redact username, password as no longer needed. + 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 + + 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(), location = %s", tmp_loc) + _LOGGER.debug("setup(): evo_data['config']=%s", tmp_loc) - load_platform(hass, 'climate', DOMAIN, {}, config) + load_platform(hass, 'climate', DOMAIN, {}, hass_config) + + @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) + + hass.bus.listen(EVENT_HOMEASSISTANT_START, _first_update) return True diff --git a/requirements_all.txt b/requirements_all.txt index 59a29eb88b3..292cac63ee7 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 d204cfa7da9..c37429958b9 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 060/254] 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 00000000000..f835fecfeb4 --- /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 292cac63ee7..354b7ca908a 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 c37429958b9..7a4768e35e7 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 00000000000..f0383c221c4 --- /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 061/254] 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 | 99 ++--- .../nsw_rural_fire_service_feed.py | 109 ++---- .../geo_location/test_geo_json_events.py | 353 +++++++++--------- .../test_nsw_rural_fire_service_feed.py | 224 ++++++----- 4 files changed, 383 insertions(+), 402 deletions(-) diff --git a/homeassistant/components/geo_location/geo_json_events.py b/homeassistant/components/geo_location/geo_json_events.py index 74d1b036f6c..4d8c3b68edd 100644 --- a/homeassistant/components/geo_location/geo_json_events.py +++ b/homeassistant/components/geo_location/geo_json_events.py @@ -13,7 +13,8 @@ import voluptuous as vol 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 @@ SOURCE = 'geo_json_events' 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 setup_platform(hass, config, add_entities, discovery_info=None): 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) + self._hass, lambda now: self._feed_manager.update(), + self._scan_interval) - def _update(self): - """Update the feed and then update connected entities.""" - import geojson_client + def get_entry(self, external_id): + """Get feed entry by external id.""" + return self._feed_manager.feed_entries.get(external_id) - 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) + 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 @@ class GeoJsonLocationEvent(GeoLocationEvent): 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 1d2a7fadaff..5681e4a53ac 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 @@ from homeassistant.components.geo_location import ( 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 @@ VALID_CATEGORIES = [ 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 setup_platform(hass, config, add_entities, discovery_info=None): 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) + self._hass, lambda now: self._feed_manager.update(), + self._scan_interval) - def _update(self): - """Update the feed and then update connected entities.""" - import geojson_client + def get_entry(self, external_id): + """Get feed entry by external id.""" + return self._feed_manager.feed_entries.get(external_id) - 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) + 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 @@ class NswRuralFireServiceLocationEvent(GeoLocationEvent): @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 f476598adc9..46d1ed630c4 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 = { ] } +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 _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 - 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 +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)) - @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)) + # 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] + 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() - 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 = hass.states.async_all() + assert len(all_states) == 3 - all_states = self.hass.states.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 = 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 = 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_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 - 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] + async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + await hass.async_block_till_done() - # 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 = hass.states.async_all() + assert len(all_states) == 3 - 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 + async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL) + await hass.async_block_till_done() - # 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 = hass.states.async_all() + assert len(all_states) == 3 - 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 + async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL) + await hass.async_block_till_done() - # 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 = hass.states.async_all() + assert len(all_states) == 0 - 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. +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)) - # 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 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) + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG_WITH_CUSTOM_LOCATION) - # 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 + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() - def mock_delete_callback(entity): - original_delete_callback(entity) + all_states = hass.states.async_all() + assert len(all_states) == 1 - with patch('homeassistant.components.geo_location' - '.geo_json_events.GeoJsonLocationEvent' - '._delete_callback', - side_effect=mock_delete_callback, - autospec=True) as mocked_delete_callback: + assert mock_feed.call_args == call( + (15.1, 25.2), URL, filter_radius=200.0) - # 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 +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. - # 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() + # 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') - assert mocked_delete_callback.call_count == 1 - all_states = self.hass.states.all() - assert len(all_states) == 0 + # 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) - # 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() + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1] - all_states = self.hass.states.all() - assert len(all_states) == 1 + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() - # 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 = 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 - 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 + async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + await hass.async_block_till_done() - # Reset mocked method for the next test. - mocked_delete_callback.reset_mock() + 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 - 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() + # 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() - assert mocked_delete_callback.call_count == 1 - all_states = self.hass.states.all() - assert len(all_states) == 0 + 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 75397d27383..3254fd570ce 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 @@ from homeassistant.components.geo_location.nsw_rural_fire_service_feed import \ 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() - 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 - 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_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_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 - 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() - # 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 - 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() - # 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 - 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() - # 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 - 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 + + 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 062/254] 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 3146954ed62..6b58ced5989 100644 --- a/homeassistant/components/light/niko_home_control.py +++ b/homeassistant/components/light/niko_home_control.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/light.niko_home_control/ """ import logging -import socket import voluptuous as vol @@ -24,11 +23,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -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 @@ class NikoHomeControlLight(Light): """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 063/254] 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 587872db839..cddcd1f26ee 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -16,7 +16,7 @@ from homeassistant.components.device_tracker import ( 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 @@ class MikrotikScanner(DeviceScanner): 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 354b7ca908a..294f1cb1ebb 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 064/254] 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 00000000000..93f32cd2791 --- /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 92030c8617a..6c7b0e75301 100644 --- a/homeassistant/components/light/mqtt.py +++ b/homeassistant/components/light/mqtt/schema_basic.py @@ -9,7 +9,7 @@ import logging 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 @@ from homeassistant.const import ( 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 @@ DEFAULT_ON_COMMAND_TYPE = 'last' 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 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ }).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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 1ed43a6385a..43e0f655f0b 100644 --- a/homeassistant/components/light/mqtt_json.py +++ b/homeassistant/components/light/mqtt/schema_json.py @@ -14,14 +14,12 @@ 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, 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.restore_state import async_get_last_state 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_HS = 'hs' 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 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }).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 72cfd6b678c..082e4674cb9 100644 --- a/homeassistant/components/light/mqtt_template.py +++ b/homeassistant/components/light/mqtt/schema_template.py @@ -11,7 +11,7 @@ from homeassistant.core import callback 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_RED_TEMPLATE = 'red_template' 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 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }).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 d91ab6ee445..9ea3151c65c 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -27,31 +27,25 @@ SUPPORTED_COMPONENTS = [ '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', +] + +DEPRECATED_PLATFORM_TO_SCHEMA = { + 'mqtt': 'basic', + 'mqtt_json': 'json', + 'mqtt_template': 'template', } -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'], -} ALREADY_DISCOVERED = 'mqtt_discovered_components' DATA_CONFIG_ENTRY_LOCK = 'mqtt_config_entry_lock' @@ -216,12 +210,15 @@ async def async_start(hass: HomeAssistantType, discovery_topic, hass_config, 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_start(hass: HomeAssistantType, discovery_topic, hass_config, _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_start(hass: HomeAssistantType, discovery_topic, hass_config, 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 f09f3726252..c56835afc9f 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 03a3927472a..e509cd5718c 100644 --- a/tests/components/light/test_mqtt_json.py +++ b/tests/components/light/test_mqtt_json.py @@ -93,18 +93,19 @@ from homeassistant.setup import async_setup_component from homeassistant.const import ( STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE, ATTR_SUPPORTED_FEATURES) -import homeassistant.components.light as light +from 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 6bc0b4536ea..0d26d6edb12 100644 --- a/tests/components/light/test_mqtt_template.py +++ b/tests/components/light/test_mqtt_template.py @@ -31,11 +31,13 @@ from unittest.mock import patch 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 28438a5e4b3..217f26e71f7 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -79,45 +79,6 @@ class TestCheckConfig(unittest.TestCase): 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 065/254] 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 d8924c6c301..b5b349d5073 100644 --- a/homeassistant/components/netatmo.py +++ b/homeassistant/components/netatmo.py @@ -16,7 +16,7 @@ from homeassistant.helpers import discovery 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 f709e0169cf..2abaa801d68 100644 --- a/homeassistant/components/sensor/netatmo.py +++ b/homeassistant/components/sensor/netatmo.py @@ -304,6 +304,20 @@ class NetAtmoData: 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 @@ class NetAtmoData: 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 294f1cb1ebb..5b0a4d75550 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 066/254] 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 c653c1d03fd..d72c8d77a2b 100644 --- a/homeassistant/components/asuswrt.py +++ b/homeassistant/components/asuswrt.py @@ -14,7 +14,7 @@ from homeassistant.const import ( 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 4ca088fb1e2..876f0dfd559 100644 --- a/homeassistant/components/sensor/asuswrt.py +++ b/homeassistant/components/sensor/asuswrt.py @@ -68,7 +68,7 @@ class AsuswrtRXSensor(AsuswrtSensor): 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 @@ class AsuswrtTXSensor(AsuswrtSensor): 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 @@ class AsuswrtTotalRXSensor(AsuswrtSensor): 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 @@ class AsuswrtTotalTXSensor(AsuswrtSensor): 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 5b0a4d75550..5f19495c259 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 067/254] 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 f444974bc8d..bf0c72ec1c8 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -33,8 +33,6 @@ _LOGGER = logging.getLogger(__name__) 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 async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]): 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 6019b75bd98..7d3af71ac2b 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 068/254] Updated codeowners (#18746) --- CODEOWNERS | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CODEOWNERS b/CODEOWNERS index dabc3bbd4db..85f8d996fac 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 069/254] 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 4676e08a247..9bd5a1d8413 100644 --- a/homeassistant/components/sensor/tellduslive.py +++ b/homeassistant/components/sensor/tellduslive.py @@ -127,3 +127,8 @@ class TelldusLiveSensor(TelldusLiveEntity): """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 c2b7ba9ba0f..a6ba248b99b 100644 --- a/homeassistant/components/tellduslive.py +++ b/homeassistant/components/tellduslive.py @@ -9,12 +9,11 @@ import logging 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 @@ class TelldusLiveEntity(Entity): """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 070/254] 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 4a5c3258893..38c78bfdb3d 100644 --- a/homeassistant/components/climate/daikin.py +++ b/homeassistant/components/climate/daikin.py @@ -192,6 +192,11 @@ class DaikinClimate(ClimateDevice): """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 4fcd33bee26..e2e4572939d 100644 --- a/homeassistant/components/daikin.py +++ b/homeassistant/components/daikin.py @@ -132,3 +132,8 @@ class DaikinApi: _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 3445eb531aa..eae0c6e9614 100644 --- a/homeassistant/components/sensor/daikin.py +++ b/homeassistant/components/sensor/daikin.py @@ -71,6 +71,11 @@ class DaikinClimateSensor(Entity): 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 071/254] 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 00000000000..f64d97dfc0d --- /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 00000000000..a3b097c5d35 --- /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 5f19495c259..d86d02c8bba 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 072/254] 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 da7ccfb2db2..b9c575dbd5a 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 073/254] 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 d32dd91a3c1..e0d12e00e30 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 1568919a9b4..c8748ade00e 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -225,7 +225,10 @@ DEMO_DEVICES = [{ '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 047fad3574c..89e9090da98 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 42af1230eed..ef6ed7a4b8f 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 074/254] 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 dd0b36020a4..6d96192f075 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -420,7 +420,7 @@ async def async_setup_entry(hass, config_entry): 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 075/254] 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 aaa8e3a19f9..7f1f8f539eb 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -23,7 +23,7 @@ from homeassistant.helpers import config_validation as cv 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 d86d02c8bba..4b3277dee27 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 7a4768e35e7..8ea99fdeaed 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 43676fcaf402994f18735bbd71a62fca54567594 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Tue, 27 Nov 2018 12:41:25 -0700 Subject: [PATCH 076/254] 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 1f12abd3d4e..2073f680e00 100644 --- a/homeassistant/components/august.py +++ b/homeassistant/components/august.py @@ -11,7 +11,6 @@ import voluptuous as vol 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 @@ class AugustData: 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 077/254] 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 | 63 ++++--- homeassistant/components/fan/zha.py | 37 +++- homeassistant/components/light/zha.py | 69 +++++-- homeassistant/components/sensor/zha.py | 38 +++- homeassistant/components/switch/zha.py | 56 ++++-- .../components/zha/.translations/en.json | 21 +++ homeassistant/components/zha/__init__.py | 174 +++++++++++++----- 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 c1ced3766c9..087e7963c00 100644 --- a/homeassistant/components/binary_sensor/zha.py +++ b/homeassistant/components/binary_sensor/zha.py @@ -9,6 +9,10 @@ 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 +) _LOGGER = logging.getLogger(__name__) @@ -27,23 +31,43 @@ CLASS_MAPPING = { 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 - - 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) + """Old way of setting up Zigbee Home Automation binary sensors.""" + pass -async def _async_setup_iaszone(hass, config, async_add_entities, - discovery_info): +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] + + +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(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 d948ba2ff5b..4f8254672a8 100644 --- a/homeassistant/components/fan/zha.py +++ b/homeassistant/components/fan/zha.py @@ -7,6 +7,10 @@ at https://home-assistant.io/components/fan.zha/ 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 @@ SPEED_TO_VALUE = {speed: i for i, speed in enumerate(SPEED_LIST)} 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 20c9faf2514..67b65edb0a6 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -8,6 +8,10 @@ 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 +) import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) @@ -24,27 +28,54 @@ UNSUPPORTED_ATTRIBUTE = 0x86 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 + """Old way of setting up Zigbee Home Automation lights.""" + pass - 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) +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 993b247a439..97432b2512f 100644 --- a/homeassistant/components/sensor/zha.py +++ b/homeassistant/components/sensor/zha.py @@ -9,6 +9,10 @@ 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 +) from homeassistant.const import TEMP_CELSIUS from homeassistant.util.temperature import convert as convert_temperature @@ -19,13 +23,35 @@ DEPENDENCIES = ['zha'] 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 b184d7baa5c..d34ca5e71ba 100644 --- a/homeassistant/components/switch/zha.py +++ b/homeassistant/components/switch/zha.py @@ -6,9 +6,13 @@ at https://home-assistant.io/components/switch.zha/ """ 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 @@ DEPENDENCIES = ['zha'] async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Zigbee Home Automation switches.""" + """Old way of setting up Zigbee Home Automation switches.""" + pass + + +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]) + + unsub = async_dispatcher_connect( + hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover) + hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + + 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 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) - discovery_info = helpers.get_discovery_info(hass, discovery_info) - if discovery_info is None: - return - - 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 - ) - - async_add_entities([switch], update_before_add=True) + 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 00000000000..b6d7948c0b3 --- /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 e54b7f7f657..0fc2b978fbb 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -5,51 +5,47 @@ For more details about this component, please refer to the documentation at 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 @@ SERVICE_SCHEMAS = { # 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 @@ _LOGGER = logging.getLogger(__name__) 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 async_setup(hass, config): 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 @@ class ApplicationListener: 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 @@ class ApplicationListener: 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 @@ class ApplicationListener: '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 @@ class ApplicationListener: 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 00000000000..fa45194ea3f --- /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 2a7e35ff517..9efa847b50c 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 9d07f546b7f..f3e1a27dca2 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -5,28 +5,12 @@ 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 _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 00000000000..b6d7948c0b3 --- /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 42bc8b089da..acfa10acdef 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -158,6 +158,7 @@ FLOWS = [ '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 00000000000..23d26b50312 --- /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 00000000000..e46f1849fa1 --- /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 078/254] 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 162b49ef9b2..17b04fce867 100644 --- a/homeassistant/components/lupusec.py +++ b/homeassistant/components/lupusec.py @@ -16,7 +16,7 @@ from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, 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 4b3277dee27..8c78eaef2b5 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 079/254] 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 c9deca1309d..36c4a3109ba 100644 --- a/homeassistant/components/camera/push.py +++ b/homeassistant/components/camera/push.py @@ -5,35 +5,33 @@ For more details about this platform, please refer to the documentation 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 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 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 @@ class PushCamera(Camera): 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 @@ class PushCamera(Camera): 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 6d9688c10e6..be1d24ce34f 100644 --- a/tests/components/camera/test_push.py +++ b/tests/components/camera/test_push.py @@ -4,90 +4,51 @@ import io 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' + 'webhook_id': 'camera.config_test' }}) + await hass.async_block_till_done() + assert hass.states.get('camera.config_test') is not None + 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 + # wrong webhook files = {'image': io.BytesIO(b'fake')} - resp = await client.post('/api/camera_push/camera.wrong', data=files) + resp = await client.post('/api/webhood/camera.wrong', data=files) assert resp.status == 404 + # missing file + camera_state = hass.states.get('camera.config_test') + assert camera_state.state == 'idle' -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' - }}) + resp = await client.post('/api/webhook/camera.config_test') + assert resp.status == 200 # webhooks always return 200 - setup_auth(hass.http.app, [], True, api_password=None) - client = await aiohttp_client(hass.http.app) - - # wrong token - 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', - }}) - - setup_auth(hass.http.app, [], True, api_password=None) - client = await aiohttp_client(hass.http.app) - - # no token - files = {'image': io.BytesIO(b'fake')} - resp = await client.post('/api/camera_push/camera.config_test', - data=files) - assert resp.status == 401 - - # 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 080/254] 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 | 233 ++++++++----- 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 | 315 +++++++++--------- tests/helpers/test_storage.py | 18 +- tests/util/test_json.py | 21 +- 46 files changed, 493 insertions(+), 422 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/manual.py b/homeassistant/components/alarm_control_panel/manual.py index 362923a4ce2..0a79d74d686 100644 --- a/homeassistant/components/alarm_control_panel/manual.py +++ b/homeassistant/components/alarm_control_panel/manual.py @@ -21,7 +21,7 @@ from homeassistant.const import ( 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 @@ class ManualAlarm(alarm.AlarmControlPanel): 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 1b9bb020ead..5f0793ae58c 100644 --- a/homeassistant/components/alarm_control_panel/mqtt.py +++ b/homeassistant/components/alarm_control_panel/mqtt.py @@ -108,8 +108,7 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, 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 f8563071fbc..f44d044ecfa 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -21,7 +21,7 @@ from homeassistant.exceptions import HomeAssistantError 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 async_setup(hass, config): 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 @@ class AutomationEntity(ToggleEntity): 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 @@ class AutomationEntity(ToggleEntity): 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 4d7e2c07eba..acbad0d0419 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -102,8 +102,7 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, 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 212c4265d8a..ffab50c989d 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -23,7 +23,7 @@ from homeassistant.helpers import condition 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 @@ class GenericThermostat(ClimateDevice): 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 7436ffc41ea..bccf282f055 100644 --- a/homeassistant/components/climate/mqtt.py +++ b/homeassistant/components/climate/mqtt.py @@ -221,8 +221,7 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 d67c93c0d6e..228870489a2 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -10,9 +10,8 @@ import voluptuous as vol 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 @@ class Counter(Entity): 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 92394fc026b..94e2b948c48 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -205,8 +205,7 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 a43a7c93bdc..35ecaf71616 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -22,9 +22,8 @@ from homeassistant.components.zone.zone import async_active_zone 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 @@ class DeviceTracker: 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 @@ class Device(Entity): 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 505a6e90720..75be8e0277c 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/fan/mqtt.py @@ -151,8 +151,7 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 21d4cdc6e56..1773a55b3f1 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -38,20 +38,6 @@ SIGNIFICANT_DOMAINS = ('thermostat', 'climate') 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 18c9808c6d2..541e38202fc 100644 --- a/homeassistant/components/input_boolean.py +++ b/homeassistant/components/input_boolean.py @@ -15,7 +15,7 @@ from homeassistant.loader import bind_hass 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 @@ class InputBoolean(ToggleEntity): 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 df35ae53ba9..6ac9a24d044 100644 --- a/homeassistant/components/input_datetime.py +++ b/homeassistant/components/input_datetime.py @@ -11,9 +11,8 @@ import voluptuous as vol 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_setup(hass, config): 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 @@ class InputDatetime(Entity): 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 @@ class InputDatetime(Entity): # 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 f52b9add821..b6c6eab3cf5 100644 --- a/homeassistant/components/input_number.py +++ b/homeassistant/components/input_number.py @@ -11,9 +11,8 @@ import voluptuous as vol 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 @@ class InputNumber(Entity): 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 b8398e1be3d..cc9a73bf915 100644 --- a/homeassistant/components/input_select.py +++ b/homeassistant/components/input_select.py @@ -10,9 +10,8 @@ import voluptuous as vol 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 @@ class InputSelect(Entity): 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 956d9a6466d..8ac64b398f4 100644 --- a/homeassistant/components/input_text.py +++ b/homeassistant/components/input_text.py @@ -11,9 +11,8 @@ import voluptuous as vol 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 @@ class InputText(Entity): 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 2e2971cfdc2..3a0225d8d65 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ( 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 state(new_state): return decorator -class LimitlessLEDGroup(Light): +class LimitlessLEDGroup(Light, RestoreEntity): """Representation of a LimitessLED group.""" def __init__(self, group, config): @@ -189,7 +189,8 @@ class LimitlessLEDGroup(Light): 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 6c7b0e75301..6a151092ef0 100644 --- a/homeassistant/components/light/mqtt/schema_basic.py +++ b/homeassistant/components/light/mqtt/schema_basic.py @@ -22,7 +22,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) -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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 43e0f655f0b..55df6cbfd5e 100644 --- a/homeassistant/components/light/mqtt/schema_json.py +++ b/homeassistant/components/light/mqtt/schema_json.py @@ -25,7 +25,7 @@ from homeassistant.const import ( 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 @@ class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, Light): 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 082e4674cb9..81ef3e901dd 100644 --- a/homeassistant/components/light/mqtt/schema_template.py +++ b/homeassistant/components/light/mqtt/schema_template.py @@ -21,7 +21,7 @@ from homeassistant.components.mqtt import ( 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 @@ class MqttTemplate(MqttAvailability, Light): """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 b62382e6dd1..28849c88159 100644 --- a/homeassistant/components/lock/mqtt.py +++ b/homeassistant/components/lock/mqtt.py @@ -111,8 +111,7 @@ class MqttLock(MqttAvailability, MqttDiscoveryUpdate, LockDevice): 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 72684c7ec13..7ff32a79142 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -840,6 +840,7 @@ class MqttAvailability(Entity): 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 @@ class MqttDiscoveryUpdate(Entity): 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 ddb508d1282..c53fa051a27 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -28,7 +28,6 @@ import homeassistant.helpers.config_validation as cv 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 @@ CONFIG_SCHEMA = vol.Schema({ }, 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 761dc7c6a00..8e975c48574 100644 --- a/homeassistant/components/sensor/fastdotcom.py +++ b/homeassistant/components/sensor/fastdotcom.py @@ -10,9 +10,8 @@ import voluptuous as vol 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 setup_platform(hass, config, add_entities, discovery_info=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 @@ class SpeedtestSensor(Entity): 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 68f49961cf9..bd97cc0e90d 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -119,8 +119,7 @@ class MqttSensor(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 a08eec56e17..f834b51b064 100644 --- a/homeassistant/components/sensor/speedtest.py +++ b/homeassistant/components/sensor/speedtest.py @@ -11,9 +11,8 @@ import voluptuous as vol 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 setup_platform(hass, config, add_entities, discovery_info=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 @@ class SpeedtestSensor(Entity): 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 ad2b963629e..250fe36b700 100644 --- a/homeassistant/components/switch/mqtt.py +++ b/homeassistant/components/switch/mqtt.py @@ -24,7 +24,7 @@ from homeassistant.components import mqtt, switch 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 @@ class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 16dfc075409..3bbe2e69110 100644 --- a/homeassistant/components/switch/pilight.py +++ b/homeassistant/components/switch/pilight.py @@ -13,7 +13,7 @@ from homeassistant.components import pilight 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 @@ class _ReceiveHandle: 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 @@ class PilightSwitch(SwitchDevice): 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 c29df9db858..3f758edea86 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -12,9 +12,9 @@ import voluptuous as vol 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 @@ class Timer(Entity): 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 687ed0b6f8b..2d4ad68dbbe 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -363,10 +363,7 @@ class Entity: 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 @@ class Entity: 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 ec7b5579342..ece0fbd071a 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -346,8 +346,7 @@ class EntityPlatform: 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 eb88a3db369..51f1bd76c2a 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) + 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()) - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, remove_cache) + # Dump states periodically + async_track_time_interval( + self.hass, lambda *_: self.hass.async_create_task( + self.async_dump_states()), STATE_DUMP_INTERVAL) - last_run = last_recorder_run(hass) + # 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 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 + @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) - 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])) + @callback + def async_unregister_entity(self, entity_id: str) -> None: + """Unregister this entity from saving state.""" + self.entity_ids.remove(entity_id) -@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) +class RestoreEntity(Entity): + """Mixin class for restoring previous entity state.""" - if _RECORDER not in hass.config.components: - return None + 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) - 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 + 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) - 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 - - await entity.async_restore_state(**extract_info(state)) + 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 cfe73d6d147..5fbb7700458 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 @@ _LOGGER = logging.getLogger(__name__) @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 @@ async def async_migrator(hass, old_path, store, *, 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 @@ class Store: 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 @@ class Store: 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 @@ class Store: 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 @@ class Store: 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 @@ class Store: 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 b002c8e3147..8ca1c702b6c 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 d5056e220f0..86bc0643d65 100644 --- a/tests/common.py +++ b/tests/common.py @@ -114,8 +114,7 @@ def get_test_home_assistant(): # 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_test_home_assistant(loop): # 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 @@ def mock_storage(data=None): 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 9c549f00ee8..0a82dc3513d 100644 --- a/tests/components/emulated_hue/test_upnp.py +++ b/tests/components/emulated_hue/test_upnp.py @@ -6,10 +6,8 @@ from unittest.mock import patch 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 @@ BRIDGE_URL_BASE = 'http://127.0.0.1:{}'.format(BRIDGE_SERVER_PORT) + '{}' 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 @@ class TestEmulatedHue(unittest.TestCase): """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 c56835afc9f..3b4ff586c94 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 e509cd5718c..ae34cb6d827 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 0d26d6edb12..56030da43f2 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 93da4ec109b..d008f868466 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 sqlalchemy.pool import StaticPool 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 4099a5b7951..5cfefd7a0c8 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 641dff3b4e6..0c9062414e7 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 ae1e3d1d51a..4619dc7ec2e 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 15dda24a529..1ac48264d45 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): +async def test_caching_data(hass): """Test that we cache data.""" - mock_component(hass, 'recorder') - hass.state = CoreState.starting - 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 DATA_RESTORE_CACHE not in hass.data + assert mock_write_data.called -@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')] + # 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() - 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 - - states = [State('input_boolean.b1', '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 - - -@asyncio.coroutine -def test_cache_timeout(hass): - """Test that cache timeout returns none.""" - mock_component(hass, 'recorder') - hass.state = CoreState.starting - - states = [State('input_boolean.b1', 'on')] - - @asyncio.coroutine - def timeout_coro(): - raise asyncio.TimeoutError() - - 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 - - -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 - - init_recorder_component(hass) - - _add_data_in_last_run(hass, { - test_entity_id1: 'on', - test_entity_id2: 'off', - }) - - hass.block_till_done() - setup_component(hass, input_boolean.DOMAIN, { - input_boolean.DOMAIN: { - 'b1': None, - 'b2': None, - }}) - - hass.start() - - state = hass.states.get('input_boolean.b1') - assert state + assert state is not None + assert state.entity_id == 'input_boolean.b1' assert state.state == 'on' - state = hass.states.get('input_boolean.b2') - assert state - assert state.state == 'off' + # Assert that no data was written yet, since hass is still starting. + assert not mock_write_data.called - hass.stop() + # 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() + + # Assert that this session states were written + assert mock_write_data.called + + +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'), + ] + + 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() + + entity = RestoreEntity() + entity.hass = hass + entity.entity_id = 'input_boolean.b1' + await entity.async_added_to_hass() + + data = await RestoreStateData.async_get_instance(hass) + + 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() + + assert mock_write_data.called + + +async def test_load_error(hass): + """Test that we cache data.""" + entity = RestoreEntity() + entity.hass = hass + entity.entity_id = 'input_boolean.b1' + + with patch('homeassistant.helpers.storage.Store.async_load', + return_value=mock_coro(exception=HomeAssistantError)): + state = await entity.async_get_last_state() + + assert state is None diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py index 38b8a7cd380..7c713082372 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 414a9f400aa..a7df74d9225 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 @@ class TestJSON(unittest.TestCase): 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 081/254] 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 30d4ed0ab8d..c8f5d788dd2 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -45,8 +45,9 @@ class HomeAssistantView: """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 13be503a009..42c2c0a5751 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -13,11 +13,12 @@ from homeassistant.core import callback 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 @@ class WebSocketHandler: 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 ac0e23edd64..ed97af9c764 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 b83d4051356..dc9d0318fd1 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 082/254] 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 a3af5631a9c..04c084784c7 100644 --- a/homeassistant/components/sensor/dht.py +++ b/homeassistant/components/sensor/dht.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity import Entity 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 8c78eaef2b5..ff5779299d3 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 083/254] 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 5d1137c35e6..8552ed9efad 100644 --- a/tests/components/sensor/test_statistics.py +++ b/tests/components/sensor/test_statistics.py @@ -305,6 +305,7 @@ class TestStatisticsSensor(unittest.TestCase): '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 084/254] 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 183dddf2c52..fed812138d6 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -253,7 +253,7 @@ class Cloud: 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 b2ed83fc6b2..c4aa43c91d2 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -28,6 +28,7 @@ class CloudPreferences: 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 085/254] 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 35ecaf71616..16d9022c98f 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -181,6 +181,9 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): 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(hass: HomeAssistantType, config: ConfigType): 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_setup(hass: HomeAssistantType, config: ConfigType): 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 10f71450f69..ae2b9d6146b 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -7,55 +7,29 @@ https://home-assistant.io/components/device_tracker.owntracks/ 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 get_cipher(): 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 b9f379e7534..00000000000 --- 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 00000000000..a34077a0a83 --- /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 00000000000..a5da7f5fc48 --- /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 00000000000..88362946428 --- /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 00000000000..fcf7305d714 --- /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 acfa10acdef..5c6ced5756f 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -149,6 +149,7 @@ FLOWS = [ 'mqtt', 'nest', 'openuv', + 'owntracks', 'point', 'rainmachine', 'simplisafe', diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 057843834c0..cc7c4284f9c 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -4,7 +4,7 @@ import logging.handlers 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 ff5779299d3..9f094a387fe 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 2d7397692f8..6f457f30ed0 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -4,12 +4,11 @@ from asynctest import patch 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 context(hass, setup_comp): 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 00000000000..a95431913b2 --- /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 00000000000..079fdfafea0 --- /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 a49f30c6839..ee79c8b9e10 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 @@ LOCATION_MESSAGE = { } -@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 29712f40ebc..2e44ee539d7 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -9,7 +9,8 @@ import logging 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 aadf72d4453bd34a3600a97260ca964b2a687268 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Thu, 29 Nov 2018 01:01:56 -0700 Subject: [PATCH 086/254] 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 e7a35b5fdf0..e011121f4a2 100644 --- a/homeassistant/components/sensor/statistics.py +++ b/homeassistant/components/sensor/statistics.py @@ -120,7 +120,7 @@ class StatisticsSensor(Entity): 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 @@ class StatisticsSensor(Entity): 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 8552ed9efad..9b4e53dbab9 100644 --- a/tests/components/sensor/test_statistics.py +++ b/tests/components/sensor/test_statistics.py @@ -40,7 +40,7 @@ class TestStatisticsSensor(unittest.TestCase): 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 087/254] 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 7b3fe4ef04e..ca9be93c411 100644 --- a/homeassistant/components/tts/amazon_polly.py +++ b/homeassistant/components/tts/amazon_polly.py @@ -10,9 +10,10 @@ import voluptuous as vol 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_OUTPUT_FORMAT = 'output_format' 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 = ['8000', '16000', '22050'] 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 @@ SUPPORTED_TEXT_TYPES = ['text', 'ssml'] CONTENT_TYPE_EXTENSIONS = { 'audio/mpeg': 'mp3', 'audio/ogg': 'ogg', - 'audio/pcm': 'pcm' + 'audio/pcm': 'pcm', } DEFAULT_VOICE = 'Joanna' @@ -66,7 +89,7 @@ DEFAULT_TEXT_TYPE = 'text' DEFAULT_SAMPLE_RATES = { 'mp3': '22050', 'ogg_vorbis': '22050', - 'pcm': '16000' + 'pcm': '16000', } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -78,8 +101,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 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 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 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 @@ class AmazonPollyProvider(Provider): 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 088/254] 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 ad4680982b4..2ebd80c3de0 100644 --- a/homeassistant/components/shopping_list.py +++ b/homeassistant/components/shopping_list.py @@ -40,6 +40,7 @@ SERVICE_ITEM_SCHEMA = vol.Schema({ 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 @@ SCHEMA_WEBSOCKET_UPDATE_ITEM = \ 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 async_setup(hass, config): 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 1e89287bcc1..f4095b77316 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 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 089/254] 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 60da761cf75..65632f51494 100644 --- a/homeassistant/components/sensor/waterfurnace.py +++ b/homeassistant/components/sensor/waterfurnace.py @@ -42,6 +42,14 @@ SENSORS = [ "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 090/254] 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 ca9be93c411..e3f5b7407cd 100644 --- a/homeassistant/components/tts/amazon_polly.py +++ b/homeassistant/components/tts/amazon_polly.py @@ -37,9 +37,9 @@ SUPPORTED_VOICES = [ '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 091/254] 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 703f2bbbd17..997ecdd4c3d 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -152,7 +152,7 @@ class TibberSensorElPrice(Entity): 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 092/254] 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 a5da7f5fc48..0bb7a2390b7 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -191,7 +191,7 @@ class OwnTracksContext: 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 093/254] 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 e2ca3accdc9..23794abeba4 100644 --- a/homeassistant/components/switch/switchmate.py +++ b/homeassistant/components/switch/switchmate.py @@ -13,7 +13,7 @@ import homeassistant.helpers.config_validation as cv 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 9f094a387fe..c5b26e47e80 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 094/254] 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 c7a37411f1e..b6f434a82ad 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 4619dc7ec2e..5761ce8714b 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -242,9 +242,11 @@ class TestComponentLogbook(unittest.TestCase): 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 @@ class TestComponentLogbook(unittest.TestCase): 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 095/254] 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 7fa418f0b46..f894d1edd4a 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 00000000000..8718f3a9d74 --- /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 c5b26e47e80..d3157bc7c25 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 096/254] 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 e0d12e00e30..c0d496d2cfb 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -43,6 +43,7 @@ TRAIT_SCENE = PREFIX_TRAITS + 'Scene' 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_TEMPERATURE_SET_RANGE = ( 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 @@ class FanSpeedTrait(_Trait): 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 c2a736f531e..8a88e3bd74e 100644 --- a/homeassistant/components/media_player/demo.py +++ b/homeassistant/components/media_player/demo.py @@ -4,6 +4,7 @@ Demo implementation of the media player. 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 @@ from homeassistant.components.media_player import ( 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 @@ DEFAULT_SOUND_MODE = 'Dummy Music' 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 c8748ade00e..03cc327a5c5 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -141,7 +141,10 @@ DEMO_DEVICES = [{ '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 @@ DEMO_DEVICES = [{ '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 @@ DEMO_DEVICES = [{ '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 ef6ed7a4b8f..5bf7b2fe566 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 097/254] 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 88362946428..8cf19e84bcd 100644 --- a/homeassistant/components/owntracks/config_flow.py +++ b/homeassistant/components/owntracks/config_flow.py @@ -40,8 +40,8 @@ class OwnTracksFlow(config_entries.ConfigFlow): 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 098/254] 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 7ad0e453760..b4c869e7267 100644 --- a/homeassistant/components/sensor/seventeentrack.py +++ b/homeassistant/components/sensor/seventeentrack.py @@ -17,7 +17,7 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv 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 d3157bc7c25..e69f5e516de 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 099/254] 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 c0d496d2cfb..f2cb819fcc9 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -715,6 +715,8 @@ class FanSpeedTrait(_Trait): 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 100/254] 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 4dd3571e69c..15a3816c559 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -207,6 +207,13 @@ async def async_setup(hass, config): 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 async_setup(hass, config): 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 2bef8c0b53e..7562a38d268 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 101/254] 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 cf82c40a4d3..bad1bdcf913 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -462,10 +462,11 @@ class AuthStore: 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 b76d68fbeac..7e9df869a04 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 102/254] 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 4a671fc9512..efae9330365 100644 --- a/homeassistant/components/binary_sensor/rainmachine.py +++ b/homeassistant/components/binary_sensor/rainmachine.py @@ -74,7 +74,7 @@ class RainMachineBinarySensor(RainMachineEntity, BinarySensorDevice): 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 103/254] 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 5131b25510a..86a97bc291c 100644 --- a/homeassistant/components/sensor/rainmachine.py +++ b/homeassistant/components/sensor/rainmachine.py @@ -77,7 +77,7 @@ class RainMachineSensor(RainMachineEntity): 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 104/254] 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 c9dd19b4bc8..85bd5c3c018 100644 --- a/homeassistant/components/fibaro.py +++ b/homeassistant/components/fibaro.py @@ -103,29 +103,31 @@ class FibaroController(): """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 @@ class FibaroController(): 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 @@ class FibaroController(): # 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 @@ class FibaroController(): """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 a035725c67a09b852c4eaf34aaaa1e619b33f372 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Thu, 29 Nov 2018 15:15:48 -0700 Subject: [PATCH 105/254] 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 96c79053dff..bbf40c73070 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -134,6 +134,7 @@ async def async_setup(hass, config): 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 106/254] 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 b8774d76873..ff17b6d5e39 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -16,7 +16,7 @@ from homeassistant.helpers import discovery 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 e69f5e516de..578d0315e3b 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 107/254] 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 51f1bd76c2a..cabaf64d859 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_DUMP_INTERVAL = timedelta(minutes=15) 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 @@ class 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 @@ class 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 @@ class RestoreEntity(Entity): _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 86bc0643d65..db7ce6e3a17 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 1ac48264d45..e6693d2cf61 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -6,7 +6,7 @@ from homeassistant.core import CoreState, State 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 @@ from tests.common import mock_coro 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 108/254] 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 3995309de42..bce0acb5141 100644 --- a/homeassistant/components/sensor/awair.py +++ b/homeassistant/components/sensor/awair.py @@ -20,7 +20,7 @@ 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'] +REQUIREMENTS = ['python_awair==0.0.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 578d0315e3b..25c1ec3dae5 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 8ea99fdeaed..77e51c477a0 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 109/254] 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 00000000000..01fb22f675c --- /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 25c1ec3dae5..6519ab30379 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 77e51c477a0..ccfb277c721 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 b0ad953e2b5..e5840d62e17 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -46,6 +46,7 @@ TEST_REQUIREMENTS = ( '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 00000000000..20b50ce9ddd --- /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 00000000000..24eafe94b23 --- /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 110/254] 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 5e1da2595af..0d449083f72 100644 --- a/homeassistant/components/tts/google.py +++ b/homeassistant/components/tts/google.py @@ -17,7 +17,7 @@ import yarl 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 6519ab30379..413a89ea336 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 ccfb277c721..e7fed2cb686 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 111/254] 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 9ea3151c65c..8d5f28278d9 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -202,11 +202,8 @@ async def async_start(hass: HomeAssistantType, discovery_topic, hass_config, 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 88bd39ebfe2..71d179211a2 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 112/254] 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 b5b349d5073..50bd290797d 100644 --- a/homeassistant/components/netatmo.py +++ b/homeassistant/components/netatmo.py @@ -16,7 +16,7 @@ from homeassistant.helpers import discovery 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 413a89ea336..bc8740cc4c6 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 113/254] 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 00000000000..e10e6264643 --- /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 114/254] 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 75be8e0277c..8f3ec842829 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 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, @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 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, """ 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 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, """ 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 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 115/254] 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 bd97cc0e90d..7d0908c5645 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -48,8 +48,8 @@ PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ 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 @@ class MqttSensor(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttSensor(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, # 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 @@ class MqttSensor(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, _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 @@ class MqttSensor(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, @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 @@ class MqttSensor(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, @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 116/254] 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 96069d50335..7157dcfd31b 100644 --- a/homeassistant/components/light/fibaro.py +++ b/homeassistant/components/light/fibaro.py @@ -65,7 +65,8 @@ class FibaroLight(FibaroDevice, Light): 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 @@ class FibaroLight(FibaroDevice, Light): 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 117/254] 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 0e943b33fb8..ae6abf04c02 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 @@ def setup_auth(app, trusted_networks, use_auth, 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 @@ def setup_auth(app, trusted_networks, use_auth, 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 110ba8d5ad6..d3cbdba63b4 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 979bfc28689..222e8ced6e7 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -7,6 +7,7 @@ import pytest 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 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 118/254] Update of volvooncall component (#18702) --- .../components/binary_sensor/volvooncall.py | 21 +- .../components/device_tracker/volvooncall.py | 31 ++- homeassistant/components/lock/volvooncall.py | 17 +- .../components/sensor/volvooncall.py | 43 +---- .../components/switch/volvooncall.py | 22 +-- homeassistant/components/volvooncall.py | 180 ++++++++++++------ requirements_all.txt | 2 +- 7 files changed, 175 insertions(+), 141 deletions(-) diff --git a/homeassistant/components/binary_sensor/volvooncall.py b/homeassistant/components/binary_sensor/volvooncall.py index e70d3098874..e7092ff16d5 100644 --- a/homeassistant/components/binary_sensor/volvooncall.py +++ b/homeassistant/components/binary_sensor/volvooncall.py @@ -6,17 +6,19 @@ https://home-assistant.io/components/binary_sensor.volvooncall/ """ 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 7872f8f1f1c..395b539a065 100644 --- a/homeassistant/components/device_tracker/volvooncall.py +++ b/homeassistant/components/device_tracker/volvooncall.py @@ -7,33 +7,32 @@ https://home-assistant.io/components/device_tracker.volvooncall/ 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') + await async_see(dev_id=dev_id, + host_name=host_name, + source_type=SOURCE_TYPE_GPS, + gps=instrument.state, + icon='mdi:car') - dispatcher_connect(hass, SIGNAL_VEHICLE_SEEN, see_vehicle) - dispatcher_send(hass, SIGNAL_VEHICLE_SEEN, vehicle) + 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 58fa83cef30..83301aa3d4e 100644 --- a/homeassistant/components/lock/volvooncall.py +++ b/homeassistant/components/lock/volvooncall.py @@ -7,17 +7,18 @@ https://home-assistant.io/components/lock.volvooncall/ 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 a3f0c55b954..65b996a5bd5 100644 --- a/homeassistant/components/sensor/volvooncall.py +++ b/homeassistant/components/sensor/volvooncall.py @@ -6,19 +6,18 @@ https://home-assistant.io/components/sensor.volvooncall/ """ 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 42c753725ab..81abf7d0e6c 100644 --- a/homeassistant/components/switch/volvooncall.py +++ b/homeassistant/components/switch/volvooncall.py @@ -8,17 +8,18 @@ https://home-assistant.io/components/switch.volvooncall/ """ 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 0ce8870bedf..fe7ec460674 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -14,15 +14,17 @@ from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, 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_UPDATE_INTERVAL = 'update_interval' CONF_REGION = 'region' CONF_SERVICE_URL = 'service_url' CONF_SCANDINAVIAN_MILES = 'scandinavian_miles' +CONF_MUTABLE = 'mutable' -SIGNAL_VEHICLE_SEEN = '{}.vehicle_seen'.format(DOMAIN) +SIGNAL_STATE_UPDATED = '{}.updated'.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')} +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 @@ CONFIG_SCHEMA = vol.Schema({ 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) + data.vehicles.add(vehicle.vin) - def update_vehicle(vehicle): - """Receive updated information on vehicle.""" - state.vehicles[vehicle.vin] = vehicle - if vehicle.vin not in state.entities: - discover_vehicle(vehicle) + dashboard = vehicle.dashboard( + mutable=config[DOMAIN][CONF_MUTABLE], + scandinavian_miles=config[DOMAIN][CONF_SCANDINAVIAN_MILES]) - for entity in state.entities[vehicle.vin]: - entity.schedule_update_ha_state() + def is_enabled(attr): + """Return true if the user has enabled the resource.""" + return attr in config[DOMAIN].get(CONF_RESOURCES, [attr]) - dispatcher_send(hass, SIGNAL_VEHICLE_SEEN, vehicle) + for instrument in ( + instrument + for instrument in dashboard.instruments + if instrument.component in COMPONENTS and + is_enabled(instrument.slug_attr)): - def update(now): + 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 @@ class VolvoData: 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 _state(self): - return self._hass.data[DATA_KEY] + def instrument(self): + """Return corresponding instrument.""" + return self.data.instrument(self.vin, self.component, self.attribute) + + @property + 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 @@ class VolvoEntity(Entity): @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 bc8740cc4c6..8609f1aeda1 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 119/254] 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 442660c2daf..6f6d78ba73c 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 120/254] 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 | 50 ++++++- 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, 312 insertions(+), 186 deletions(-) diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py index 8eea3acb6ed..3c26f8b4bde 100644 --- a/homeassistant/auth/mfa_modules/notify.py +++ b/homeassistant/auth/mfa_modules/notify.py @@ -11,6 +11,7 @@ import voluptuous as vol 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 @@ class NotifySetupFlow(SetupFlow): _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 9ca4232b610..8828782c886 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -226,7 +226,11 @@ class LoginFlow(data_entry_flow.FlowHandler): 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 b001bcd0437..961350bfa89 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -9,7 +9,9 @@ import json 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 @@ from homeassistant.const import ( 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 @@ class APIDomainServicesView(HomeAssistantView): "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 c8f5d788dd2..beb5c647266 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -9,7 +9,9 @@ import json 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 @@ def request_handler_factory(view, handler): 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 0e01310115f..2cde7825734 100644 --- a/homeassistant/components/mqtt_eventstream.py +++ b/homeassistant/components/mqtt_eventstream.py @@ -13,7 +13,7 @@ from homeassistant.core import callback 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 async_setup(hass, config): ): 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 c53fa051a27..15de4c3f995 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -300,14 +300,24 @@ class Recorder(threading.Thread): 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 53d1e9af807..ff928b43873 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -3,7 +3,7 @@ import voluptuous as vol 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 fc97e1bc52d..eb53140339a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -163,7 +163,6 @@ EVENT_HOMEASSISTANT_CLOSE = 'homeassistant_close' 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 @@ ATTR_ID = 'id' # 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 1754a8b5014..2a40d604ee0 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -25,18 +25,18 @@ from typing import ( # noqa: F401 pylint: disable=unused-import 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 @@ class ServiceRegistry: """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 @@ class ServiceRegistry: 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 @@ class ServiceRegistry: 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 0613b7cb10c..5e2ab4988b1 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -58,3 +58,14 @@ class Unauthorized(HomeAssistantError): 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 ffe0b103fc9..748b5507824 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 462939af23a..3a023916741 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 @@ class TestDemoClimate(unittest.TestCase): """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 @@ class TestDemoClimate(unittest.TestCase): 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 @@ class TestDemoClimate(unittest.TestCase): """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 @@ class TestDemoClimate(unittest.TestCase): """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 @@ class TestDemoClimate(unittest.TestCase): """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 @@ class TestDemoClimate(unittest.TestCase): 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 2e942c5988c..2aeb1228aba 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 894fc290c38..7beb3887ae0 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 @@ class TestMQTTClimate(unittest.TestCase): 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 @@ class TestMQTTClimate(unittest.TestCase): 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 @@ class TestMQTTClimate(unittest.TestCase): 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 b83756f6ebb..5fa8ddcfe38 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 ed97af9c764..395849f066e 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 -import pytest +from unittest.mock import Mock -from homeassistant.components.http.view import HomeAssistantView +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 + + +@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 e986ac02065..b213cf0b5c1 100644 --- a/tests/components/media_player/test_demo.py +++ b/tests/components/media_player/test_demo.py @@ -3,6 +3,9 @@ import unittest 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 @@ class TestDemoMediaPlayer(unittest.TestCase): 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 @@ class TestDemoMediaPlayer(unittest.TestCase): 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 @@ class TestDemoMediaPlayer(unittest.TestCase): 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 @@ class TestDemoMediaPlayer(unittest.TestCase): 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 417cd42187f..c6a6b3036d9 100644 --- a/tests/components/media_player/test_monoprice.py +++ b/tests/components/media_player/test_monoprice.py @@ -223,7 +223,7 @@ class TestMonopriceMediaPlayer(unittest.TestCase): # 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 5d7afbde843..81e6a7b298d 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -113,11 +113,12 @@ class TestMQTTComponent(unittest.TestCase): """ 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 57397e21ba2..4c3f3bf3f73 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 @@ class TestNotifyDemo(unittest.TestCase): 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 76610421563..9fda58c37a3 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 0bc89292855..a88c828efe8 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -6,6 +6,7 @@ from unittest.mock import patch 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 a61cefe34f2..2a4d0fef09d 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 5761ce8714b..6a272991798 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -4,6 +4,9 @@ import logging 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 @@ class TestComponentLogbook(unittest.TestCase): 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 bc044999bdd..977cd966981 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 abaf7dd6d14..cb9f05ba47b 100644 --- a/tests/components/test_wake_on_lan.py +++ b/tests/components/test_wake_on_lan.py @@ -3,6 +3,7 @@ import asyncio 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 66116db8cda..d8c9c71935b 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 @@ class TestDemowater_heater(unittest.TestCase): """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 @@ class TestDemowater_heater(unittest.TestCase): 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 dc9d0318fd1..2406eefe08e 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -49,6 +49,25 @@ async def test_call_service(hass, websocket_client): 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 d4077345649..85cca89eefc 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -947,7 +947,7 @@ class TestZWaveServices(unittest.TestCase): 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 69cde6c1403..724233cbf98 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -21,7 +21,7 @@ from homeassistant.const import ( __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 @@ class TestServiceRegistry(unittest.TestCase): 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 test_service_executed_with_subservices(hass): 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 121/254] 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 93760405e08..1d0133739c3 100644 --- a/homeassistant/components/wemo.py +++ b/homeassistant/components/wemo.py @@ -15,7 +15,7 @@ from homeassistant.helpers import config_validation as cv 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 122/254] 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 8609f1aeda1..af828e051e5 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 123/254] 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 b4c869e7267..7e3f84f2d48 100644 --- a/homeassistant/components/sensor/seventeentrack.py +++ b/homeassistant/components/sensor/seventeentrack.py @@ -17,7 +17,7 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv 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 8609f1aeda1..249f4f30a65 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 124/254] 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 7e3f84f2d48..7c5dba3b0e1 100644 --- a/homeassistant/components/sensor/seventeentrack.py +++ b/homeassistant/components/sensor/seventeentrack.py @@ -25,6 +25,7 @@ ATTR_INFO_TEXT = 'info_text' 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 @@ class SeventeenTrackSummarySensor(Entity): @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 @@ class SeventeenTrackPackageSensor(Entity): 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 @@ class SeventeenTrackPackageSensor(Entity): @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 125/254] 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 7a1e240d82e..d8c67e372b2 100644 --- a/homeassistant/components/media_player/directv.py +++ b/homeassistant/components/media_player/directv.py @@ -162,8 +162,8 @@ class DirecTvDevice(MediaPlayerDevice): 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 3f4d4cb9f24..2174967eae5 100644 --- a/tests/components/media_player/common.py +++ b/tests/components/media_player/common.py @@ -11,9 +11,9 @@ from homeassistant.components.media_player import ( 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 00000000000..951f1319cc0 --- /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 126/254] 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 0fc2b978fbb..d67fbd02b8f 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -15,6 +15,7 @@ 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.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 a16f29f447a..a4454244364 100644 --- a/homeassistant/components/zha/entities/entity.py +++ b/homeassistant/components/zha/entities/entity.py @@ -7,6 +7,10 @@ https://home-assistant.io/components/zha/ 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 @@ class ZhaEntity(entity.Entity): 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 127/254] 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 66f289724be..6d6fb1ed200 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -4,6 +4,7 @@ import json 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 @@ ENV.filters['is_defined'] = fail_when_undefined 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 573a9f78b72..02331c400d3 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -274,6 +274,37 @@ class TestHelpersTemplate(unittest.TestCase): 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 128/254] 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 0e65ac77c1f..50e7c3d8fe2 100644 --- a/homeassistant/components/camera/uvc.py +++ b/homeassistant/components/camera/uvc.py @@ -10,12 +10,12 @@ import socket 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 @@ CONF_PASSWORD = 'password' 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 249f4f30a65..40cc0d39c43 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 e7fed2cb686..935757b37d7 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 b41cb9f865b..476e612eb06 100644 --- a/tests/components/camera/test_uvc.py +++ b/tests/components/camera/test_uvc.py @@ -55,7 +55,12 @@ class TestUVCSetup(unittest.TestCase): 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 @@ class TestUVCSetup(unittest.TestCase): 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 @@ class TestUVCSetup(unittest.TestCase): 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 129/254] 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 7822bcd58b7..8ec6a45b639 100644 --- a/homeassistant/components/sensor/rtorrent.py +++ b/homeassistant/components/sensor/rtorrent.py @@ -110,11 +110,11 @@ class RTorrentSensor(Entity): 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 130/254] 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 6d6fb1ed200..99eb0a9c034 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 131/254] 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 7b0d8e491d2..419ef6a11a1 100644 --- a/homeassistant/components/sensor/tautulli.py +++ b/homeassistant/components/sensor/tautulli.py @@ -19,7 +19,7 @@ import homeassistant.helpers.config_validation as cv 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 @@ class TautulliSensor(Entity): 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 132/254] 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 40cc0d39c43..0a4f41a09e0 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 133/254] 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 575d9688493..e700301d579 100644 --- a/homeassistant/components/device_tracker/googlehome.py +++ b/homeassistant/components/device_tracker/googlehome.py @@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import ( 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 @@ class GoogleHomeDeviceScanner(DeviceScanner): 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 40cc0d39c43..53818800a69 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 134/254] 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 e700301d579..dabb92a0751 100644 --- a/homeassistant/components/device_tracker/googlehome.py +++ b/homeassistant/components/device_tracker/googlehome.py @@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import ( 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 @@ class GoogleHomeDeviceScanner(DeviceScanner): 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 @@ class GoogleHomeDeviceScanner(DeviceScanner): 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 53818800a69..d7ecd986c52 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 135/254] 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 2abaa801d68..7590bccb543 100644 --- a/homeassistant/components/sensor/netatmo.py +++ b/homeassistant/components/sensor/netatmo.py @@ -63,6 +63,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 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 @@ class NetAtmoSensor(Entity): 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 @@ class NetAtmoSensor(Entity): """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 @@ class NetAtmoSensor(Entity): 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 @@ class NetAtmoSensor(Entity): 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 @@ class NetAtmoSensor(Entity): 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 @@ class NetAtmoSensor(Entity): 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 136/254] 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 1f83bffdcb6..a85a0c889d1 100644 --- a/homeassistant/components/binary_sensor/sense.py +++ b/homeassistant/components/binary_sensor/sense.py @@ -14,46 +14,48 @@ DEPENDENCIES = ['sense'] _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 6e9204b80e1..8ddeb3d2ecc 100644 --- a/homeassistant/components/sense.py +++ b/homeassistant/components/sense.py @@ -8,20 +8,20 @@ import logging 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 b494257beb7..58054272902 100644 --- a/homeassistant/components/sensor/sense.py +++ b/homeassistant/components/sensor/sense.py @@ -4,27 +4,31 @@ Support for monitoring a Sense energy sensor. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.sense/ """ +from datetime import timedelta import logging -from datetime import timedelta - +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 @@ class SensorConfig: # 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 setup_platform(hass, config, add_entities, discovery_info=None): 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 @@ class Sense(Entity): 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 137/254] 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 58054272902..2aff89c591a 100644 --- a/homeassistant/components/sensor/sense.py +++ b/homeassistant/components/sensor/sense.py @@ -134,5 +134,6 @@ class Sense(Entity): 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 138/254] 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 419ef6a11a1..29c11c934f9 100644 --- a/homeassistant/components/sensor/tautulli.py +++ b/homeassistant/components/sensor/tautulli.py @@ -90,9 +90,9 @@ class TautulliSensor(Entity): 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 139/254] 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 29c11c934f9..f47f0e5c382 100644 --- a/homeassistant/components/sensor/tautulli.py +++ b/homeassistant/components/sensor/tautulli.py @@ -91,7 +91,7 @@ class TautulliSensor(Entity): 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 140/254] 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 16c861a75fc..1b23d62e1f3 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 141/254] 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 16c861a75fc..360745872f2 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 142/254] 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 d576cdcc95e..599633ff5ff 100644 --- a/homeassistant/components/notify/slack.py +++ b/homeassistant/components/notify/slack.py @@ -17,7 +17,7 @@ from homeassistant.components.notify import ( 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 @@ ATTR_FILE_AUTH_DIGEST = 'digest' 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 @@ class SlackNotificationService(BaseNotificationService): '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 @@ class SlackNotificationService(BaseNotificationService): 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 40cc0d39c43..a306ea3f312 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 143/254] 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 2aff89c591a..769b3a9e148 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 144/254] 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 bf9957f36ee..3cfa0696644 100644 --- a/homeassistant/components/python_script.py +++ b/homeassistant/components/python_script.py @@ -18,7 +18,7 @@ from homeassistant.loader import bind_hass 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 40cc0d39c43..fd2a884916a 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 935757b37d7..5707847a789 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 145/254] 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 11f96591705..481cd9da3ea 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 40cc0d39c43..f0d276ca811 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 49147afdd70..68c830190ab 100755 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ REQUIRES = [ '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 146/254] 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 6e7ab9385bd..e5a0d672756 100644 --- a/homeassistant/components/camera/proxy.py +++ b/homeassistant/components/camera/proxy.py @@ -18,7 +18,7 @@ from homeassistant.util.async_ import run_coroutine_threadsafe 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 8f5b599bb88..6172963525e 100644 --- a/homeassistant/components/image_processing/tensorflow.py +++ b/homeassistant/components/image_processing/tensorflow.py @@ -20,7 +20,7 @@ from homeassistant.core import split_entity_id 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 40cc0d39c43..34a8a4bb161 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 147/254] 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 76a9d9318f2..16c2638f26e 100644 --- a/homeassistant/scripts/keyring.py +++ b/homeassistant/scripts/keyring.py @@ -5,7 +5,7 @@ import os 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 40cc0d39c43..221d35b4940 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 148/254] 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 94e2b948c48..55df204f275 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -145,8 +145,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): 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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, @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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, @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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, @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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, # 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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, """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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 149/254] 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 5f0793ae58c..2a91ac77a86 100644 --- a/homeassistant/components/alarm_control_panel/mqtt.py +++ b/homeassistant/components/alarm_control_panel/mqtt.py @@ -51,7 +51,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ 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_setup_entry(hass, config_entry, async_add_entities): 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 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, 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 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, 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 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, 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 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, @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 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, @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 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, 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 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, 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 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, 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 150/254] Small refactoring of MQTT climate (#18814) --- homeassistant/components/climate/mqtt.py | 136 +++++++++++------------ 1 file changed, 66 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/climate/mqtt.py b/homeassistant/components/climate/mqtt.py index bccf282f055..4995fa13b3a 100644 --- a/homeassistant/components/climate/mqtt.py +++ b/homeassistant/components/climate/mqtt.py @@ -183,11 +183,8 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): @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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): @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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): @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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): # 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): @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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): """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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): """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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): @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 151/254] 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 99eb0a9c034..2173f972cba 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -170,8 +170,10 @@ class Template: 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 152/254] 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 cabaf64d859..33b612b555a 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -165,13 +165,19 @@ class RestoreStateData(): 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 @@ class RestoreEntity(Entity): 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 @@ class RestoreEntity(Entity): 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 e6693d2cf61..b13bc87421b 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 153/254] 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 124ff88a9a3..ae8029e13f8 100644 --- a/homeassistant/components/binary_sensor/fibaro.py +++ b/homeassistant/components/binary_sensor/fibaro.py @@ -16,6 +16,8 @@ DEPENDENCIES = ['fibaro'] _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 85bd5c3c018..51d7dd2ef7e 100644 --- a/homeassistant/components/fibaro.py +++ b/homeassistant/components/fibaro.py @@ -43,7 +43,8 @@ FIBARO_TYPEMAP = { '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 154/254] 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 fed812138d6..329f83768ce 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -191,9 +191,9 @@ class Cloud: 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 e71756d9fee..f20a4106a16 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -16,8 +16,8 @@ class SmartHomeError(Exception): 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 f29e8bbae12..d688491fe89 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -15,6 +15,7 @@ from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES 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 async_register_http(hass, cfg): 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 66e7747e06a..36971224f92 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -11,6 +11,7 @@ from homeassistant.components.light.demo import DemoLight 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 5bf7b2fe566..e9169c9bbbe 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -25,6 +25,7 @@ from tests.common import async_mock_service 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 155/254] 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 997ecdd4c3d..245c98a76f0 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 2545417e033..4f6761f0b40 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -16,7 +16,7 @@ from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, CONF_ACCESS_TOKEN, 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 5f439e0dd07..06b49b6d514 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 156/254] 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 c3bd18fa9bb..be2806716a7 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -15,7 +15,6 @@ from aiohttp import web from aiohttp.hdrs import CONTENT_TYPE from aiohttp.web_exceptions import HTTPBadGateway -from homeassistant.const import CONTENT_TYPE_TEXT_PLAIN from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from .const import X_HASSIO @@ -63,8 +62,6 @@ class HassIOView(HomeAssistantView): 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 4370c011891..07db126312b 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 157/254] 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 b5d64f48dc7..a630a9ef1ad 100644 --- a/homeassistant/components/hdmi_cec.py +++ b/homeassistant/components/hdmi_cec.py @@ -320,38 +320,39 @@ def setup(hass: HomeAssistant, base_config): 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 cb4afadd058..d69d8a74ce6 100644 --- a/homeassistant/components/media_player/hdmi_cec.py +++ b/homeassistant/components/media_player/hdmi_cec.py @@ -13,7 +13,6 @@ from homeassistant.components.media_player import ( 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 @@ class CecPlayerDevice(CecDevice, MediaPlayerDevice): """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 b2697b4a2c4..1016e91d8d2 100644 --- a/homeassistant/components/switch/hdmi_cec.py +++ b/homeassistant/components/switch/hdmi_cec.py @@ -9,7 +9,6 @@ import logging 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 158/254] 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 245c98a76f0..d900067f98b 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -12,7 +12,6 @@ from datetime import timedelta 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 159/254] 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 7af5a730c43..73035a2da0d 100644 --- a/homeassistant/components/binary_sensor/tahoma.py +++ b/homeassistant/components/binary_sensor/tahoma.py @@ -41,6 +41,7 @@ class TahomaBinarySensor(TahomaDevice, BinarySensorDevice): self._state = None self._icon = None self._battery = None + self._available = False @property def is_on(self): @@ -71,6 +72,11 @@ class TahomaBinarySensor(TahomaDevice, BinarySensorDevice): 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 @@ class TahomaBinarySensor(TahomaDevice, BinarySensorDevice): 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 160/254] 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 6a151092ef0..4c648b5ddae 100644 --- a/homeassistant/components/light/mqtt/schema_basic.py +++ b/homeassistant/components/light/mqtt/schema_basic.py @@ -21,7 +21,7 @@ from homeassistant.const import ( 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 55df6cbfd5e..dd3c896532f 100644 --- a/homeassistant/components/light/mqtt/schema_json.py +++ b/homeassistant/components/light/mqtt/schema_json.py @@ -6,7 +6,6 @@ https://home-assistant.io/components/light.mqtt_json/ """ import json import logging -from typing import Optional import voluptuous as vol @@ -19,7 +18,7 @@ from homeassistant.components.light import ( 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 @@ PLATFORM_SCHEMA_JSON = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ 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 @@ class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, Light, 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 @@ class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, Light, 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 81ef3e901dd..e14e8e32be7 100644 --- a/homeassistant/components/light/mqtt/schema_template.py +++ b/homeassistant/components/light/mqtt/schema_template.py @@ -18,7 +18,7 @@ from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, STATE_ON, STATE_OFF 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 @@ PLATFORM_SCHEMA_TEMPLATE = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ 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 @@ class MqttTemplate(MqttAvailability, Light, RestoreEntity): 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 @@ class MqttTemplate(MqttAvailability, Light, RestoreEntity): 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 @@ class MqttTemplate(MqttAvailability, Light, RestoreEntity): 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 3b4ff586c94..9e4fa3ebc79 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 ae34cb6d827..8567dfd7921 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 56030da43f2..ce4a5f5a2e6 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 161/254] Reconfigure MQTT switch component if discovery info is changed (#18179) --- homeassistant/components/switch/mqtt.py | 136 ++++++++++++++---------- tests/components/switch/test_mqtt.py | 36 +++++++ 2 files changed, 113 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/switch/mqtt.py b/homeassistant/components/switch/mqtt.py index 250fe36b700..75da1f4cf74 100644 --- a/homeassistant/components/switch/mqtt.py +++ b/homeassistant/components/switch/mqtt.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.mqtt/ """ import logging -from typing import Optional import voluptuous as vol @@ -14,7 +13,7 @@ from homeassistant.components.mqtt import ( 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 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ 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_setup_entry(hass, config_entry, async_add_entities): 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, - payload_available, payload_not_available) - MqttDiscoveryUpdate.__init__(self, discovery_hash) - 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 + 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, + self.discovery_update) + MqttEntityDeviceInfo.__init__(self, device_config) 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 @@ class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, # 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 5cfefd7a0c8..a37572cc992 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 162/254] 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 | 64 ++---------- tests/conftest.py | 68 ++++++++++++- tests/helpers/test_aiohttp_client.py | 4 +- tests/test_config.py | 4 - 42 files changed, 307 insertions(+), 473 deletions(-) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 7d8ef13d2bb..e53385880e5 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -78,11 +78,6 @@ class AuthManager: 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 f2cfff1f342..4154ca337a3 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -14,6 +14,8 @@ from homeassistant.util.yaml import load_yaml, dump DOMAIN = 'config' DEPENDENCIES = ['http'] SECTIONS = ( + 'auth', + 'auth_provider_homeassistant', 'automation', 'config_entries', 'core', @@ -58,10 +60,6 @@ async def async_setup(hass, config): 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 c16907007cf..f8f7cb3b1ed 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 @@ class IndexView(HomeAssistantView): # 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 @@ class IndexView(HomeAssistantView): 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 6bfcaaa5d85..3c058281b0a 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 7180002430a..a6b9588fce3 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -200,14 +200,13 @@ class HomeAssistantHTTP: 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 ae6abf04c02..6cd211613ce 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 @@ def setup_auth(app, trusted_networks, use_auth, 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 @@ def setup_auth(app, trusted_networks, use_auth, 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 @@ def setup_auth(app, trusted_networks, use_auth, 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 fa93cc4ba4d..771606b935f 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -242,7 +242,7 @@ class HTML5PushCallbackView(HomeAssistantView): # 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 376575e3440..25aca9f8afa 100644 --- a/homeassistant/components/onboarding/__init__.py +++ b/homeassistant/components/onboarding/__init__.py @@ -14,10 +14,6 @@ STORAGE_VERSION = 1 @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 db41f3df06d..434775c9b9b 100644 --- a/homeassistant/components/websocket_api/auth.py +++ b/homeassistant/components/websocket_api/auth.py @@ -69,7 +69,7 @@ class AuthPhase: 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 @@ class AuthPhase: 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 d7871e82afc..592ec585421 100644 --- a/tests/components/alexa/test_flash_briefings.py +++ b/tests/components/alexa/test_flash_briefings.py @@ -21,7 +21,7 @@ NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" @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 alexa_client(loop, hass, aiohttp_client): }, } })) - 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 e28f7be4341..1193526d2be 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 a5f6a751b46..ff475376587 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -5,11 +5,11 @@ from homeassistant.bootstrap import async_setup_component 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 b981fced320..843bda0656c 100644 --- a/tests/components/camera/test_generic.py +++ b/tests/components/camera/test_generic.py @@ -7,7 +7,7 @@ from homeassistant.setup import async_setup_component @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 0a57512aabd..f2dbb294136 100644 --- a/tests/components/camera/test_local_file.py +++ b/tests/components/camera/test_local_file.py @@ -11,7 +11,7 @@ from tests.common import mock_registry @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 57e92ba7628..84d35f4bdd8 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 f7e348e8476..b5e0a8c9197 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 homeassistant.components.config import auth as auth_config 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 2c888dd2dd2..f97559a224f 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -6,12 +6,12 @@ from homeassistant.bootstrap import async_setup_component 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 @@ async def test_get_device_config(hass, aiohttp_client): 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 @@ async def test_update_device_config(hass, aiohttp_client): 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 67d7eebbfec..0b36cc6bc87 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 5b52b3d5711..4d9063d774b 100644 --- a/tests/components/config/test_core.py +++ b/tests/components/config/test_core.py @@ -8,14 +8,14 @@ from tests.common import mock_coro @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 100a18618e6..7f81b65540f 100644 --- a/tests/components/config/test_customize.py +++ b/tests/components/config/test_customize.py @@ -9,12 +9,12 @@ from homeassistant.config import DATA_CUSTOMIZE @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 test_get_entity(hass, aiohttp_client): @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 test_update_entity(hass, aiohttp_client): @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 06ba2ff1105..52c72c60860 100644 --- a/tests/components/config/test_group.py +++ b/tests/components/config/test_group.py @@ -11,12 +11,12 @@ VIEW_NAME = 'api:config:group:config' @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 test_get_device_config(hass, aiohttp_client): @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 test_update_device_config(hass, aiohttp_client): @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 85fbf0c2e5a..547bb612ee4 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 8aae5c0a28b..71ced80eac9 100644 --- a/tests/components/config/test_zwave.py +++ b/tests/components/config/test_zwave.py @@ -16,12 +16,12 @@ VIEW_NAME = 'api:config:zwave:device_config' @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 d3cbdba63b4..4903e8c6455 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -3,14 +3,12 @@ from unittest.mock import patch 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 @@ def hass_ws_client(aiohttp_client): 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 7cfef8f5219..a167a1e9fd4 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 925ba6d66db..582f112f69c 100644 --- a/tests/components/device_tracker/test_meraki.py +++ b/tests/components/device_tracker/test_meraki.py @@ -13,7 +13,7 @@ from homeassistant.components.device_tracker.meraki import URL @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 2e78e0441a3..9f386ceb904 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 6f6d78ba73c..c8044b1ad5e 100644 --- a/tests/components/geofency/test_init.py +++ b/tests/components/geofency/test_init.py @@ -105,7 +105,7 @@ BEACON_EXIT_CAR = { @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 51fca931faa..62e7278ba1f 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 222e8ced6e7..304bb4de997 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 2c69a5effa7..de0ee2f0b3e 100644 --- a/tests/components/mailbox/test_init.py +++ b/tests/components/mailbox/test_init.py @@ -9,7 +9,7 @@ import homeassistant.components.mailbox as mailbox @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 486300679b7..08210ecd9a2 100644 --- a/tests/components/notify/test_html5.py +++ b/tests/components/notify/test_html5.py @@ -49,7 +49,7 @@ REGISTER_URL = '/api/notify.html5' 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 @@ class TestHtml5Notify: 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 57a81a78da3..483b917a63e 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) + assert onboarding.async_is_onboarded(hass) - with patch('homeassistant.auth.AuthManager.active', return_value=True): - 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 6a272991798..b530c3dac3c 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -591,18 +591,18 @@ class TestComponentLogbook(unittest.TestCase): }, 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 49744421c72..68e7602b228 100644 --- a/tests/components/test_prometheus.py +++ b/tests/components/test_prometheus.py @@ -7,14 +7,14 @@ import homeassistant.components.prometheus as prometheus @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 64876dbea44..391004598e7 100644 --- a/tests/components/test_rss_feed_template.py +++ b/tests/components/test_rss_feed_template.py @@ -8,7 +8,7 @@ from homeassistant.setup import async_setup_component @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 70cbbc15c91..977b0669880 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.tts.demo import DemoProvider 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 @@ class TestTTS: 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 ed54b509aaa..4c0014e4783 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -13,7 +13,7 @@ from tests.common import mock_coro 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 2406eefe08e..78a5bf6d57e 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 @@ -201,18 +199,16 @@ async def test_call_service_context_with_user(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_OK + auth_msg = await ws.receive_json() + assert auth_msg['type'] == TYPE_AUTH_OK await ws.send_json({ 'id': 5, @@ -238,50 +234,6 @@ async def test_call_service_context_with_user(hass, aiohttp_client, 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 - }) - - 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'] - - 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 - - async def test_subscribe_requires_admin(websocket_client, hass_admin_user): """Test subscribing events without being admin.""" hass_admin_user.groups = [] diff --git a/tests/conftest.py b/tests/conftest.py index 84b72189a8d..82ae596fb48 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,10 +10,12 @@ import requests_mock as _requests_mock 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 @@ def mock_device_tracker_conf(): 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 699342381f9..5cd77eee707 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -14,7 +14,7 @@ from tests.common import get_test_home_assistant @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 056bf30efe5..0d248e2b170 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 87fb492b1435897c4a4341aaf1cb42f5907ebf4d Mon Sep 17 00:00:00 2001 From: emontnemery Date: Sun, 2 Dec 2018 19:12:03 +0100 Subject: [PATCH 163/254] 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 4995fa13b3a..098ff2867da 100644 --- a/homeassistant/components/climate/mqtt.py +++ b/homeassistant/components/climate/mqtt.py @@ -223,7 +223,6 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 164/254] 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 f894d1edd4a..9463e85c2a0 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 00000000000..50c664d9046 --- /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 00000000000..e1aa1664eba --- /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 00000000000..b612cd8dec7 --- /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 5f439e0dd07..89d011f0927 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 165/254] 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 877c8a1ddf6..25c7e1aa8ea 100644 --- a/homeassistant/components/lock/verisure.py +++ b/homeassistant/components/lock/verisure.py @@ -8,7 +8,8 @@ import logging 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 @@ class VerisureDoorlock(LockDevice): 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 @@ class VerisureDoorlock(LockDevice): """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 2f2fa194846..481aa331e41 100644 --- a/homeassistant/components/verisure.py +++ b/homeassistant/components/verisure.py @@ -28,6 +28,7 @@ CONF_DOOR_WINDOW = 'door_window' 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 @@ CONFIG_SCHEMA = vol.Schema({ 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 5707847a789..f62bb98fa88 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 e5840d62e17..82dab374e42 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -64,6 +64,7 @@ TEST_REQUIREMENTS = ( 'home-assistant-frontend', 'homematicip', 'influxdb', + 'jsonpath', 'libpurecoollink', 'libsoundtouch', 'luftdaten', @@ -110,6 +111,7 @@ TEST_REQUIREMENTS = ( '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 00000000000..03dd202e838 --- /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 166/254] 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 9463e85c2a0..ecfafa916e4 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 00000000000..cfbb8ac010c --- /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 00000000000..d76528c56f0 --- /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 89d011f0927..af32ab534d7 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 167/254] 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 bf934311303..c565a161b10 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -21,7 +21,7 @@ from homeassistant.const import ( 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 af32ab534d7..3fa9a265a62 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 168/254] 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 0676cec7fad..c764bfe8c21 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 5f7107f95ae..4fc77bd81cd 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 @@ async def async_hass_config_yaml(hass: HomeAssistant) -> 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 1e77454a8d5..ac341e8f58a 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -327,11 +327,6 @@ def check_ha_config_file(hass): 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 0d248e2b170..212fc247eb9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,8 +6,10 @@ import unittest 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 @@ from homeassistant.components.config.customize import ( 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 @@ class TestConfig(unittest.TestCase): ).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 169/254] 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 d72c8d77a2b..719e857c751 100644 --- a/homeassistant/components/asuswrt.py +++ b/homeassistant/components/asuswrt.py @@ -14,7 +14,7 @@ from homeassistant.const import ( 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 3fa9a265a62..2a1649e9b9a 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 170/254] 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 e53385880e5..3377bb2a6aa 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -185,6 +185,7 @@ class AuthManager: 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 4357ba1b1de..e950230f10a 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -870,3 +870,28 @@ async def test_async_remove_user(hass): 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 171/254] 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 a94f8c3bdf2..c4cefa2c2d1 100644 --- a/homeassistant/components/wink/__init__.py +++ b/homeassistant/components/wink/__init__.py @@ -690,6 +690,10 @@ class WinkDevice(Entity): @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 149eddaf4615e131e7c40d69ce89757b86b7b057 Mon Sep 17 00:00:00 2001 From: pbalogh77 Date: Mon, 3 Dec 2018 14:57:55 +0100 Subject: [PATCH 172/254] 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 51d7dd2ef7e..55f6f528622 100644 --- a/homeassistant/components/fibaro.py +++ b/homeassistant/components/fibaro.py @@ -27,7 +27,8 @@ ATTR_CURRENT_POWER_W = "current_power_w" 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 @@ class FibaroController(): """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 @@ class FibaroController(): 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 @@ class FibaroController(): 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 00000000000..7a36900f884 --- /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 173/254] 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 3e6958f35e2..49992bc6e39 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 174/254] Refactor script helper actions into their own methods (#18962) * Refactor script helper actions into their own methods * Lint * Lint --- homeassistant/helpers/script.py | 234 +++++++++++++++++++------------- tests/helpers/test_script.py | 85 ++++++++++++ 2 files changed, 227 insertions(+), 92 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 80d66f4fac8..088882df608 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -9,7 +9,7 @@ import voluptuous as vol 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_WAIT_TEMPLATE = 'wait_template' 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 @@ class Script(): 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 @@ class Script(): 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) - + 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) return + 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 - 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)) - - 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) - + # Set script to not-running. self._cur = -1 self.last_action = None if self._change_listener: @@ -198,6 +166,86 @@ class Script(): 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 @@ class Script(): 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 @@ class Script(): 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 @@ class Script(): 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 e5e62d2aed3..887a147c417 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -4,6 +4,10 @@ from datetime import timedelta 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 @@ class TestScriptHelper(unittest.TestCase): 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 175/254] 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 90a8b0b5813..29488d08130 100644 --- a/homeassistant/components/binary_sensor/point.py +++ b/homeassistant/components/binary_sensor/point.py @@ -7,10 +7,11 @@ https://home-assistant.io/components/binary_sensor.point/ 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 @@ EVENTS = { 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 36215da7893..6616d6b24ec 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -4,6 +4,7 @@ Support for Minut Point. 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 homeassistant.util.dt import as_local, parse_datetime, utc_from_timestamp 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 @@ _LOGGER = logging.getLogger(__name__) 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 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): _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 @@ class MinutPointClient(): _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 4ef21b57cd9..c6ba69a8083 100644 --- a/homeassistant/components/point/const.py +++ b/homeassistant/components/point/const.py @@ -12,4 +12,5 @@ CONF_WEBHOOK_URL = 'webhook_url' 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 0c099c8873e..1bb46827602 100644 --- a/homeassistant/components/sensor/point.py +++ b/homeassistant/components/sensor/point.py @@ -6,13 +6,15 @@ https://home-assistant.io/components/sensor.point/ """ 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 @@ SENSOR_TYPES = { 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 176/254] Fix MQTT re-subscription logic (#18953) * Fix MQTT re-subscription logic * Cleanup * Lint * Fix --- homeassistant/components/mqtt/subscription.py | 97 ++++++++++++++----- 1 file changed, 71 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py index 8be8d311d9b..26101f32f89 100644 --- a/homeassistant/components/mqtt/subscription.py +++ b/homeassistant/components/mqtt/subscription.py @@ -5,45 +5,90 @@ For more details about this component, please refer to the documentation at 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)) + 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 - 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) + # Go through all remaining subscriptions and unsubscribe them + for remaining in current_subscriptions.values(): + if remaining.unsubscribe_callback is not None: + remaining.unsubscribe_callback() - for key, (topic, unsub) in list(cur_state.items()): - if unsub is not None: - unsub() - - return sub_state + 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 177/254] 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 fe7ec460674..46c22c65e85 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -24,7 +24,7 @@ DOMAIN = 'volvooncall' 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 @@ async def async_setup(hass, config): 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 2a1649e9b9a..fadfe6491b6 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 178/254] 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 d900067f98b..2c921e95863 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -12,6 +12,7 @@ from datetime import timedelta 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 4f6761f0b40..27595dc09c7 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -16,7 +16,7 @@ from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, CONF_ACCESS_TOKEN, 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 async_setup(hass, config): 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 06b49b6d514..ee2f5991e15 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 179/254] 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 728b5967db1..77267fd7516 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 @@ class BlinkSyncModule(AlarmControlPanel): """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 46751ce5394..cd558f03684 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 @@ class BlinkBinarySensor(BinarySensorDevice): 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 62e73a52cc8..a56885a22a9 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import ( 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 510c2ab2563..e9047914456 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 804f83de4fd..6d3ca87c4ae 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 @@ class BlinkSensor(Entity): 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 fadfe6491b6..abd599c5f2c 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 180/254] 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 49992bc6e39..20792de8222 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 181/254] Update CODEOWNERS (#18976) --- CODEOWNERS | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CODEOWNERS b/CODEOWNERS index 85f8d996fac..61ff6ce7079 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 182/254] Add @danielhiversen as codeowner (#18979) --- CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CODEOWNERS b/CODEOWNERS index 61ff6ce7079..1bb62d154fd 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 183/254] 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 53f987c8b46..9682a4444aa 100644 --- a/homeassistant/components/switch/switchbot.py +++ b/homeassistant/components/switch/switchbot.py @@ -12,7 +12,7 @@ import homeassistant.helpers.config_validation as cv 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 a60c75be9e2..abae7ab19d0 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 184/254] 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 f44d044ecfa..4a2df399e0a 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -16,7 +16,8 @@ from homeassistant.core import CoreState 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 @@ class AutomationEntity(ToggleEntity, RestoreEntity): """ 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 84d92361541..72a4a8155e2 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -76,10 +76,14 @@ async def async_setup(hass, config): """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 b6f434a82ad..78da5733a06 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -17,7 +17,8 @@ from homeassistant.const import ( 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 16c9f65420c..54490af3cfa 100644 --- a/homeassistant/components/script.py +++ b/homeassistant/components/script.py @@ -14,7 +14,8 @@ import voluptuous as vol 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 @@ class ScriptEntity(ToggleEntity): 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 eb53140339a..b4a94d318f6 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -170,6 +170,8 @@ EVENT_SERVICE_REMOVED = 'service_removed' 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 28d4c0979c4..a01b48b9190 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 b530c3dac3c..321a16ae64e 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -10,9 +10,10 @@ import voluptuous as vol 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 5b7d0dfb70f..790d5c2e844 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 @@ class TestScriptComponent(unittest.TestCase): 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 185/254] 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 1a571960bc7..c16390302d6 100644 --- a/homeassistant/components/goalfeed.py +++ b/homeassistant/components/goalfeed.py @@ -12,8 +12,9 @@ import voluptuous as vol 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 abae7ab19d0..9f89234bec6 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 186/254] 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 e512d15fcdd..357a8c350bc 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 @@ from homeassistant.const import ( 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 9f89234bec6..8b4f94481cd 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 187/254] 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 20792de8222..36130e362cd 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 188/254] 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 c28527886b1..dfb41ddf617 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 5de6e164750..d74ec41b749 100644 --- a/tests/components/test_influxdb.py +++ b/tests/components/test_influxdb.py @@ -3,8 +3,6 @@ import datetime 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 @@ class TestInfluxDB(unittest.TestCase): 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 @@ class TestInfluxDB(unittest.TestCase): 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 @@ class TestInfluxDB(unittest.TestCase): 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 @@ class TestInfluxDB(unittest.TestCase): 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 @@ class TestInfluxDB(unittest.TestCase): 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 @@ class TestInfluxDB(unittest.TestCase): 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 @@ class TestInfluxDB(unittest.TestCase): 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 @@ class TestInfluxDB(unittest.TestCase): 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 @@ class TestInfluxDB(unittest.TestCase): } 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 @@ class TestInfluxDB(unittest.TestCase): } 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 @@ class TestInfluxDB(unittest.TestCase): 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 @@ class TestInfluxDB(unittest.TestCase): } 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 @@ class TestInfluxDB(unittest.TestCase): } 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 @@ class TestInfluxDB(unittest.TestCase): } 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 @@ class TestInfluxDB(unittest.TestCase): } 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 @@ class TestInfluxDB(unittest.TestCase): } 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 @@ class TestInfluxDB(unittest.TestCase): 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 189/254] 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 ecfafa916e4..10e07dc2da5 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 1bb62d154fd..659f434d14b 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 450a5e580bd..7f60e40c68b 100644 --- a/homeassistant/components/binary_sensor/tellduslive.py +++ b/homeassistant/components/binary_sensor/tellduslive.py @@ -9,8 +9,9 @@ https://home-assistant.io/components/binary_sensor.tellduslive/ """ 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 9d292d9e8b5..67affdae04e 100644 --- a/homeassistant/components/cover/tellduslive.py +++ b/homeassistant/components/cover/tellduslive.py @@ -8,8 +8,9 @@ https://home-assistant.io/components/cover.tellduslive/ """ 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 @@ class TelldusLiveCover(TelldusLiveEntity, CoverDevice): 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 07b5458fa45..8601fe3cf1f 100644 --- a/homeassistant/components/light/tellduslive.py +++ b/homeassistant/components/light/tellduslive.py @@ -8,9 +8,10 @@ https://home-assistant.io/components/light.tellduslive/ """ 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 9bd5a1d8413..4afff115b9d 100644 --- a/homeassistant/components/sensor/tellduslive.py +++ b/homeassistant/components/sensor/tellduslive.py @@ -6,7 +6,8 @@ https://home-assistant.io/components/sensor.tellduslive/ """ 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_DEW_POINT = 'dewp' 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_TYPES = { 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 @@ class TelldusLiveSensor(TelldusLiveEntity): @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 0263dfd8198..ed4f825f5ac 100644 --- a/homeassistant/components/switch/tellduslive.py +++ b/homeassistant/components/switch/tellduslive.py @@ -9,7 +9,8 @@ https://home-assistant.io/components/switch.tellduslive/ """ 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 @@ class TelldusLiveSwitch(TelldusLiveEntity, ToggleEntity): 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 a6ba248b99b..89e74464489 100644 --- a/homeassistant/components/tellduslive.py +++ b/homeassistant/components/tellduslive/__init__.py @@ -4,25 +4,22 @@ Support for Telldus Live. 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 @@ CONFIG_SCHEMA = vol.Schema({ }), }, extra=vol.ALLOW_EXTRA) - -ATTR_LAST_UPDATED = 'time_last_updated' - CONFIG_INSTRUCTIONS = """ To link your TelldusLive account: @@ -146,7 +140,7 @@ def setup(hass, config, session=None): 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 setup(hass, config, session=None): 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 @@ class TelldusLiveClient: 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 @@ class TelldusLiveClient: 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 @@ class TelldusLiveClient: 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 00000000000..a4ef33af518 --- /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 00000000000..88b7d47ad9d --- /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 190/254] Add unnecessary-pass for pylint-update (#18985) --- pylintrc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pylintrc b/pylintrc index be06f83e6f2..a88aabe1936 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 191/254] 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 c8044b1ad5e..ae90af61ced 100644 --- a/tests/components/geofency/test_init.py +++ b/tests/components/geofency/test_init.py @@ -104,6 +104,12 @@ BEACON_EXIT_CAR = { } +@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 ee79c8b9e10..ba362da905a 100644 --- a/tests/components/owntracks/test_init.py +++ b/tests/components/owntracks/test_init.py @@ -33,6 +33,12 @@ LOCATION_MESSAGE = { } +@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 192/254] 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 55f6f528622..dacf0c97edf 100644 --- a/homeassistant/components/fibaro.py +++ b/homeassistant/components/fibaro.py @@ -303,11 +303,14 @@ class FibaroDevice(Entity): 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 7157dcfd31b..636e4376ae2 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 @@ class FibaroLight(FibaroDevice, Light): 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 @@ class FibaroLight(FibaroDevice, Light): # 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 193/254] Sort import order of zha component. (#18993) --- homeassistant/components/binary_sensor/zha.py | 7 +++---- homeassistant/components/fan/zha.py | 16 +++++++------- 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 | 11 +++++----- homeassistant/components/zha/helpers.py | 5 +++-- 11 files changed, 45 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/binary_sensor/zha.py b/homeassistant/components/binary_sensor/zha.py index 087e7963c00..62c57f0288b 100644 --- a/homeassistant/components/binary_sensor/zha.py +++ b/homeassistant/components/binary_sensor/zha.py @@ -7,12 +7,11 @@ at https://home-assistant.io/components/binary_sensor.zha/ 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 4f8254672a8..d1731e89894 100644 --- a/homeassistant/components/fan/zha.py +++ b/homeassistant/components/fan/zha.py @@ -5,15 +5,15 @@ For more details on this platform, please refer to the documentation at https://home-assistant.io/components/fan.zha/ """ 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) + DOMAIN, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF, SUPPORT_SET_SPEED, + FanEntity) +from homeassistant.components.zha import helpers +from homeassistant.components.zha.const import ( + 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 67b65edb0a6..83448b39d9e 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -5,13 +5,13 @@ For more details on this platform, please refer to the documentation 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 97432b2512f..80aad9ac937 100644 --- a/homeassistant/components/sensor/zha.py +++ b/homeassistant/components/sensor/zha.py @@ -7,13 +7,12 @@ at https://home-assistant.io/components/sensor.zha/ 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 d34ca5e71ba..4dac3bfbb22 100644 --- a/homeassistant/components/switch/zha.py +++ b/homeassistant/components/switch/zha.py @@ -6,13 +6,12 @@ at https://home-assistant.io/components/switch.zha/ """ 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 d67fbd02b8f..fb909b6fedf 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -10,23 +10,22 @@ import os 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 fa45194ea3f..1c903ec3056 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 d5e52e9277f..c3c3ea163ed 100644 --- a/homeassistant/components/zha/entities/__init__.py +++ b/homeassistant/components/zha/entities/__init__.py @@ -6,5 +6,5 @@ https://home-assistant.io/components/zha/ """ # 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 1a10f249489..2d2a5d76b81 100644 --- a/homeassistant/components/zha/entities/device_entity.py +++ b/homeassistant/components/zha/entities/device_entity.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/zha/ """ 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 a4454244364..da8f615a665 100644 --- a/homeassistant/components/zha/entities/entity.py +++ b/homeassistant/components/zha/entities/entity.py @@ -4,13 +4,12 @@ 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 -from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.components.zha.const import ( - DOMAIN, DATA_ZHA, DATA_ZHA_BRIDGE_ID -) + 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.util import slugify class ZhaEntity(entity.Entity): diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index f3e1a27dca2..7ae6fbf2d22 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -4,9 +4,10 @@ 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 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 194/254] 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 46c22c65e85..b47c7f7cdf7 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -69,19 +69,19 @@ RESOURCES = [ '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 195/254] 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 5972ff52a8b..022a509ce06 100644 --- a/homeassistant/components/climate/toon.py +++ b/homeassistant/components/climate/toon.py @@ -15,6 +15,14 @@ from homeassistant.const import TEMP_CELSIUS 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 @@ class ThermostatDevice(ClimateDevice): @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 @@ class ThermostatDevice(ClimateDevice): 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 196/254] 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 19aeea5b22e..2c5a76d2c90 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -13,7 +13,6 @@ from homeassistant.exceptions import HomeAssistantError 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 @@ class Data: 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 197/254] 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 75da1f4cf74..19e72a9d021 100644 --- a/homeassistant/components/switch/mqtt.py +++ b/homeassistant/components/switch/mqtt.py @@ -86,18 +86,9 @@ class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, @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 @@ class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, @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 @@ class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 f54710c454f33ea7fe9683a2e352eb0811a1bd54 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 4 Dec 2018 21:25:18 +0100 Subject: [PATCH 198/254] 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 7ff32a79142..b403f296bd8 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -852,7 +852,8 @@ class MqttAvailability(Entity): """(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 199/254] 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 44d8a068ce2..21eefc238a0 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 DOMAIN as LUPUSEC_DOMAIN 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 @@ class LupusecAlarm(LupusecDevice, AlarmControlPanel): 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 17b04fce867..94cb3abc4a2 100644 --- a/homeassistant/components/lupusec.py +++ b/homeassistant/components/lupusec.py @@ -16,7 +16,7 @@ from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, 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 8b4f94481cd..99e0c2d5e39 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 200/254] 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 e7d60aaed6d..821182ec103 100644 --- a/homeassistant/components/device_tracker/bt_smarthub.py +++ b/homeassistant/components/device_tracker/bt_smarthub.py @@ -13,7 +13,7 @@ from homeassistant.components.device_tracker import ( 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 99e0c2d5e39..56f1dbcb709 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 201/254] 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 bad1bdcf913..c6078e03f63 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 homeassistant.util import dt as dt_util 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 @@ class AuthStore: 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 @@ class AuthStore: # 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 @@ class AuthStore: 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 @@ class AuthStore: 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 4b192c35898..588d80047be 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 @@ class User: 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 9113f2b03a9..63e76dd2496 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 @@ class AbstractPermissions: 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 59bba468a59..0073c952648 100644 --- a/homeassistant/auth/permissions/entities.py +++ b/homeassistant/auth/permissions/entities.py @@ -5,6 +5,7 @@ from typing import Callable, List, Union # noqa: F401 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 @@ SINGLE_ENTITY_SCHEMA = vol.Any(True, vol.Schema({ })) 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_VALUES_SCHEMA = vol.Any(True, vol.Schema({ 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 compile_entities(policy: CategoryType) \ 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 compile_entities(policy: CategoryType) \ 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 00000000000..7ad7d5521c5 --- /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 c40d14652ad..57c8bcf0af8 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -10,6 +10,7 @@ timer. from collections import OrderedDict from itertools import chain import logging +from typing import Optional import weakref import attr @@ -85,6 +86,11 @@ class EntityRegistry: """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 40de5ca7334..1fd70668f8b 100644 --- a/tests/auth/permissions/test_entities.py +++ b/tests/auth/permissions/test_entities.py @@ -4,12 +4,16 @@ import voluptuous as vol 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 ba6fe214146..f6a68f0865a 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 b02111e8d02..329124bc979 100644 --- a/tests/auth/test_models.py +++ b/tests/auth/test_models.py @@ -5,7 +5,12 @@ from homeassistant.auth import models, permissions 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 db7ce6e3a17..d7b28b3039a 100644 --- a/tests/common.py +++ b/tests/common.py @@ -384,6 +384,7 @@ class MockUser(auth_models.User): 'name': name, 'system_generated': system_generated, 'groups': groups or [], + 'perm_lookup': None, } if id is not None: kwargs['id'] = id @@ -401,7 +402,8 @@ class MockUser(auth_models.User): 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 a4e9a571943..8fca7df69c1 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 202/254] 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 baac86f4bf1..685bbb90cbb 100644 --- a/homeassistant/components/mychevy.py +++ b/homeassistant/components/mychevy.py @@ -16,7 +16,7 @@ from homeassistant.helpers import config_validation as cv 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 56f1dbcb709..24cba019966 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 203/254] 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 e9024131af8..0947afea141 100644 --- a/homeassistant/components/waterfurnace.py +++ b/homeassistant/components/waterfurnace.py @@ -19,13 +19,12 @@ from homeassistant.helpers import config_validation as cv 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 @@ NOTIFICATION_TITLE = 'WaterFurnace website status' 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 @@ class WaterFurnaceData(threading.Thread): 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 56f1dbcb709..ad5f2d9245d 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 204/254] 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 f8f7cb3b1ed..408f19436ce 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -250,7 +250,8 @@ async def async_setup(hass, config): 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 205/254] 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 481cd9da3ea..7236380d42a 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 56f1dbcb709..d252f75caaf 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 68c830190ab..f4da5411ed5 100755 --- a/setup.py +++ b/setup.py @@ -38,6 +38,8 @@ REQUIRES = [ '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 206/254] 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 fdf7bb2a12e..3c638d29166 100644 --- a/homeassistant/components/cloud/cloudhooks.py +++ b/homeassistant/components/cloud/cloudhooks.py @@ -18,7 +18,7 @@ class Cloudhooks: 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 3c7275afa7a..7d633a4b2ac 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 @@ class CloudIoT: 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 @@ class CloudIoT: 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 10488779dd8..b11de7da4e4 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -451,3 +451,51 @@ async def test_webhook_msg(hass): 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 207/254] 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 4c648b5ddae..74f3dbdec91 100644 --- a/homeassistant/components/light/mqtt/schema_basic.py +++ b/homeassistant/components/light/mqtt/schema_basic.py @@ -131,11 +131,7 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): ) } 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): @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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): @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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): """ 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 dd3c896532f..8a72f7b1f89 100644 --- a/homeassistant/components/light/mqtt/schema_json.py +++ b/homeassistant/components/light/mqtt/schema_json.py @@ -99,22 +99,14 @@ class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, Light, 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 @@ class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, Light, 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 @@ class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, Light, 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 @@ class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, Light, 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 @@ class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, Light, 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 @@ class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, Light, 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 @@ class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, Light, 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 @@ class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, Light, @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 @@ class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, Light, @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 @@ class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, Light, 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 @@ class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, Light, 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 @@ class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, Light, 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 @@ class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, Light, 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 @@ class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, Light, 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 e14e8e32be7..419472d1927 100644 --- a/homeassistant/components/light/mqtt/schema_template.py +++ b/homeassistant/components/light/mqtt/schema_template.py @@ -81,13 +81,9 @@ class MqttTemplate(MqttAvailability, MqttDiscoveryUpdate, 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 @@ -102,8 +98,9 @@ class MqttTemplate(MqttAvailability, MqttDiscoveryUpdate, Light, 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 @@ class MqttTemplate(MqttAvailability, MqttDiscoveryUpdate, Light, 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 @@ class MqttTemplate(MqttAvailability, MqttDiscoveryUpdate, Light, 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 @@ class MqttTemplate(MqttAvailability, MqttDiscoveryUpdate, Light, 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 @@ class MqttTemplate(MqttAvailability, MqttDiscoveryUpdate, Light, 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 @@ class MqttTemplate(MqttAvailability, MqttDiscoveryUpdate, Light, @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 @@ class MqttTemplate(MqttAvailability, MqttDiscoveryUpdate, Light, @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 @@ class MqttTemplate(MqttAvailability, MqttDiscoveryUpdate, Light, 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 @@ class MqttTemplate(MqttAvailability, MqttDiscoveryUpdate, Light, 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 @@ class MqttTemplate(MqttAvailability, MqttDiscoveryUpdate, Light, 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 208/254] 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 94248000601..8ac3cec6411 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers.typing import ConfigType # noqa 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 236825cefe0..f330ff75fc8 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 209/254] 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 0b4069ed664..b70c1ffbf28 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -150,7 +150,7 @@ def setup_plexserver( _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 setup_plexserver( _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 210/254] 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 0c1984b3bce..80be58c04e1 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 @@ class FireTVDevice(MediaPlayerDevice): 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 211/254] 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 1abd86ffd8a..c5c6ebcbc35 100644 --- a/homeassistant/components/device_tracker/xiaomi_miio.py +++ b/homeassistant/components/device_tracker/xiaomi_miio.py @@ -13,7 +13,7 @@ from homeassistant.components.device_tracker import ( 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 3462b0bc1eb..ca35f75b097 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -18,7 +18,7 @@ from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, 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 f2e8e120d53..9e650562fe8 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -21,7 +21,7 @@ from homeassistant.exceptions import PlatformNotReady 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 915f38745a4..a247cb3e914 100644 --- a/homeassistant/components/remote/xiaomi_miio.py +++ b/homeassistant/components/remote/xiaomi_miio.py @@ -22,7 +22,7 @@ from homeassistant.exceptions import PlatformNotReady 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 dddf7b23922..ef5ed1d5c38 100644 --- a/homeassistant/components/sensor/xiaomi_miio.py +++ b/homeassistant/components/sensor/xiaomi_miio.py @@ -14,7 +14,7 @@ from homeassistant.exceptions import PlatformNotReady 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 7e11f986b92..125f89f5040 100644 --- a/homeassistant/components/switch/xiaomi_miio.py +++ b/homeassistant/components/switch/xiaomi_miio.py @@ -17,7 +17,7 @@ from homeassistant.const import ( 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 a491b69ca2f..a6fe4943071 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-miio==0.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 f330ff75fc8..415259903c8 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 212/254] 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 a6fe4943071..943b487857f 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -45,6 +45,8 @@ FAN_SPEEDS = { '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 @@ class MiroboVacuum(StateVacuumDevice): self.consumable_state = None self.clean_history = None self.dnd_state = None + self.last_clean = None @property def name(self): @@ -248,6 +251,10 @@ class MiroboVacuum(StateVacuumDevice): 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 @@ class MiroboVacuum(StateVacuumDevice): 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 213/254] 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 928c2ab2027..6e1b8b68437 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -21,7 +21,8 @@ from homeassistant.helpers.entity import Entity 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 @@ PROGRAM_UPDATE_TOPIC = '{0}_program_update'.format(DOMAIN) 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 @@ SERVICE_STOP_ZONE_SCHEMA = vol.Schema({ 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 ecf497333cb..59b27fe0099 100644 --- a/homeassistant/components/rainmachine/config_flow.py +++ b/homeassistant/components/rainmachine/config_flow.py @@ -7,10 +7,10 @@ import voluptuous as vol 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 @@ class RainMachineFlowHandler(config_entries.ConfigFlow): 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 ec1f0436ccb..e0e79e8c160 100644 --- a/homeassistant/components/rainmachine/const.py +++ b/homeassistant/components/rainmachine/const.py @@ -10,5 +10,6 @@ DATA_CLIENT = 'client' 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 214/254] 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 b403f296bd8..6093be7d091 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -828,10 +828,10 @@ class MqttAvailability(Entity): 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 @@ class MqttAvailability(Entity): """(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 @@ class MqttAvailability(Entity): @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 215/254] 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 ca35f75b097..e6349782cd1 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -755,12 +755,13 @@ class XiaomiAirHumidifier(XiaomiGenericDevice): 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 216/254] 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 10e07dc2da5..8d98a0c23e0 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 e792045ec80..00000000000 --- 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 217/254] 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 9564a8d3df0..e3f426cc5c6 100644 --- a/homeassistant/components/media_player/vizio.py +++ b/homeassistant/components/media_player/vizio.py @@ -20,7 +20,7 @@ from homeassistant.const import ( 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 415259903c8..e3d958e8a7b 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 218/254] 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 f4318a0eb21..236352a9018 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 2efc23f78f6..223dc91a480 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 10eb9f5bc73..a3aa5491e23 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 3de7de9ddb3..51cb5419e90 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 a9b66314f31..3ff60254a6a 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 aa81c06d750..ffc10269776 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 00000000000..89e8205bb09 --- /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 f9a71747bd6..cf53f81bdb8 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 7bc785f2613..8625780e65c 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 7cc5943b830..9ad495c720a 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 ae67c616f3f..e1aec6162f4 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 4b2581dde65..b6e2ccce8ed 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 4245ce02c66..05d52d5c37e 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 f93fbe19078..aadd66902b6 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 2f033e4f4ee..bb54f7ef6cb 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 3c1d7b580e4..dc846993e2e 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 c78905b09c8..255b2efc91a 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 00000000000..48914a94465 --- /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 00000000000..6a242c441af --- /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 506a5c05485..d37aa3567d1 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 f31c4838a4d..fcb087e6885 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 0dd8cbdb47d..95897a25f15 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 62007a95809..b1828ee28ef 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 12dad4d8c7e..4eb12d7343c 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 7e35c219c45..9757716b1bf 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 bd7fc3f8191..38e261ab6bd 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 00000000000..438148f414c --- /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 00000000000..9c4e46a28bf --- /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 00000000000..ba264ad4b47 --- /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 00000000000..146fda64b1e --- /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 00000000000..9f86cd12cc4 --- /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 00000000000..134c49ecbbb --- /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 00000000000..91df7f5a8ea --- /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 00000000000..bb9c7f39c5b --- /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 00000000000..e7ae5593637 --- /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 00000000000..64a6935a9b2 --- /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 00000000000..d8c195cb277 --- /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 fcc9a92bd5e..0480b6d7195 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 00000000000..ff7f2cdcd56 --- /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 00000000000..8831696fcff --- /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 00000000000..bd0ac2f1218 --- /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 7d88bfeec42..6b5cb91cfeb 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 00000000000..2fbb55b2833 --- /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 00000000000..20f963d9dfb --- /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 4a714f18999..6eec3ef0eba 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 00000000000..10d05fadf93 --- /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 4ddf405e1ed..f685297890e 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 740fc1a8179..86fed8933ef 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 4726d57ad24..7811a31ebdb 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 c7fcfd50b56..c42ca6b7b2b 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 6f63614fdb7..6f9e22bfd40 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 028919bff90..8790c708008 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 e758a47064e..c195392be22 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 908c1c5d0c5..ca1a802a580 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 a2bf78a7f3e..fc0225cc534 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 5cb9a3f4a27..8e86c41366b 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 00000000000..1feac454c45 --- /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 b6d7948c0b3..f0da251f5eb 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 00000000000..ffeaf4588e6 --- /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 00000000000..37304c8c8fd --- /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 00000000000..e7a3c901c21 --- /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 00000000000..9db55494ba4 --- /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 00000000000..88d4b83ca0d --- /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 00000000000..8db9f20dc7b --- /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 00000000000..cd618072592 --- /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 00000000000..888b9be2bc7 --- /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 00000000000..8befb2ee114 --- /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 00000000000..24809a59e0b --- /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 f0619f2163c..dc408035d0f 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 b617a902374..7849f34bbf9 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 457bfd3baa8..b6856e4590a 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 219/254] 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 408f19436ce..43a4839bf43 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import callback 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 e3d958e8a7b..b4b6825f833 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 f62bb98fa88..f9baf85a062 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 220/254] 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 13575068a3e..c62768cc514 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 @@ def _check_token(func): 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 01d92c6f50f..a5019efaa8e 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -18,7 +18,7 @@ SERVERS = { '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 d662f3b1955..f5bf0b8a4f8 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -177,6 +177,11 @@ class AiohttpClientMockResponse: """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 221/254] 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 b47c7f7cdf7..75339171cbc 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -24,7 +24,7 @@ DOMAIN = 'volvooncall' 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 b4b6825f833..eebd9d006d5 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 222/254] 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 36130e362cd..0d9b6a6d9fe 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 223/254] 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 d5336217221..ee99e236fa9 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -13,12 +13,13 @@ import voluptuous as vol 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 @@ HM_DEVICE_TYPES = { '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_PORT = 2001 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 @@ DEVICE_SCHEMA = vol.Schema({ 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 @@ CONFIG_SCHEMA = vol.Schema({ 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 eebd9d006d5..5105eb8b532 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 f9baf85a062..c63fb9a1200 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 224/254] 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 dacf0c97edf..5813b194890 100644 --- a/homeassistant/components/fibaro.py +++ b/homeassistant/components/fibaro.py @@ -7,6 +7,7 @@ https://home-assistant.io/components/fibaro/ 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 @@ class FibaroController(): 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 @@ class FibaroController(): 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 @@ class FibaroController(): 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 @@ class FibaroController(): 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 @@ class FibaroDevice(Entity): 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 @@ class FibaroDevice(Entity): 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 225/254] 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 1995179ff5a..1f95414541c 100644 --- a/homeassistant/components/device_tracker/google_maps.py +++ b/homeassistant/components/device_tracker/google_maps.py @@ -19,7 +19,7 @@ from homeassistant.helpers.event import track_time_interval 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 5105eb8b532..2899ab0e988 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 226/254] 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 2c921e95863..0ba470ca778 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -191,7 +191,11 @@ class TibberSensorRT(Entity): 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 27595dc09c7..fce4312ad68 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -16,7 +16,7 @@ from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, CONF_ACCESS_TOKEN, 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 2899ab0e988..c56bf8888e5 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 4fd4e84b724c92dd035ba201ae3f6e84d41a1006 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 6 Dec 2018 09:34:21 +0100 Subject: [PATCH 227/254] Bumped version to 0.84.0b0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index b4a94d318f6..ae7460bfea9 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 84 -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 1b03a35fa1f0cd3a2da33fd330320e2768317a25 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 7 Dec 2018 07:12:59 +0100 Subject: [PATCH 228/254] 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 43a4839bf43..325ae2a1d30 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import callback 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 c56bf8888e5..e782320bf96 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 c63fb9a1200..85ee3e7c900 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 578fe371c6c4105e82115905bc789ed15e574c92 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 6 Dec 2018 10:38:26 +0100 Subject: [PATCH 229/254] 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 d688491fe89..f0294c3bcb2 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -48,7 +48,7 @@ def async_register_http(hass, cfg): 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 3eb646eb0dfefaa84343fac3f8c614749b712c7b Mon Sep 17 00:00:00 2001 From: Mike Miller Date: Thu, 6 Dec 2018 18:05:15 +0200 Subject: [PATCH 230/254] 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 2a61533a2b9..f06b853087f 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 3cfb8068177..ddf66d1c617 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 ff9427d46351e02618de8c75e54ad90dc36b8a73 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 7 Dec 2018 07:09:05 +0100 Subject: [PATCH 231/254] 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 0d9b6a6d9fe..f6a8a3fd688 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -48,6 +48,7 @@ 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.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 @@ def handle_yaml_errors(func): @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 da160066c34e728faffd207f2eec9cdaf2b864ae Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Fri, 7 Dec 2018 07:06:35 +0100 Subject: [PATCH 232/254] 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 52df3d47ca1..f2713197ed1 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -8,7 +8,7 @@ from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN 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 e782320bf96..8bcbd449635 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 393ada03120db66b74530a4bc78d82eb1abe8b14 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 7 Dec 2018 07:14:19 +0100 Subject: [PATCH 233/254] Bumped version to 0.84.0b1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index ae7460bfea9..0f59d771bde 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 84 -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 945b84a7dffee4a3d9e1f9e7ea33973e32b6f76b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 10 Dec 2018 09:53:53 +0100 Subject: [PATCH 234/254] 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 325ae2a1d30..0fe7a2dfa6e 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import callback 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 8bcbd449635..4c86c53608c 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 85ee3e7c900..6f766f9b07c 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 048f219a7ff3f2985e671e095f49dc8b3791c5e4 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 235/254] 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 ff17b6d5e39..73cabdfbae6 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -16,7 +16,7 @@ from homeassistant.helpers import discovery 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 4c86c53608c..240bc1f9db1 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 e6c224fa404de3ceb688c3ffd4ef71141573ec48 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 236/254] 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 fce4312ad68..8462b646a22 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -16,7 +16,7 @@ from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, CONF_ACCESS_TOKEN, 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 240bc1f9db1..1dd5be162a2 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 3528d865b75f5226982207da59b4a1d36c6b4d54 Mon Sep 17 00:00:00 2001 From: Nick Horvath Date: Fri, 7 Dec 2018 13:20:05 -0500 Subject: [PATCH 237/254] 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 3f27c91e7c5..b3c3b63bd84 100644 --- a/homeassistant/components/skybell.py +++ b/homeassistant/components/skybell.py @@ -14,7 +14,7 @@ from homeassistant.const import ( 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 1dd5be162a2..41246cf6758 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 76c26da4cb6cf4a6534b1a2ee05dfba989b928ac Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 10 Dec 2018 08:57:17 +0100 Subject: [PATCH 238/254] 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 | 675 ++------------- tests/components/lovelace/test_init.py | 812 ++---------------- 3 files changed, 177 insertions(+), 1313 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 0fe7a2dfa6e..5013a451adc 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -250,8 +250,7 @@ async def async_setup(hass, config): 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 f6a8a3fd688..68c322b3956 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -7,507 +7,139 @@ at https://www.home-assistant.io/lovelace/ 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' - -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), -}) - -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, }) -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) - 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) +class ConfigNotFound(HomeAssistantError): + """When no config available.""" 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) + # 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 + }) + + if mode == MODE_YAML: + hass.data[DOMAIN] = LovelaceYAML(hass) + else: + 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) - - 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) - - hass.components.websocket_api.async_register_command( - WS_TYPE_MOVE_CARD, websocket_lovelace_move_card, SCHEMA_MOVE_CARD) - - hass.components.websocket_api.async_register_command( - WS_TYPE_DELETE_CARD, websocket_lovelace_delete_card, - SCHEMA_DELETE_CARD) - - hass.components.websocket_api.async_register_command( - WS_TYPE_GET_VIEW, websocket_lovelace_get_view, SCHEMA_GET_VIEW) - - hass.components.websocket_api.async_register_command( - WS_TYPE_UPDATE_VIEW, websocket_lovelace_update_view, - SCHEMA_UPDATE_VIEW) - - hass.components.websocket_api.async_register_command( - WS_TYPE_ADD_VIEW, websocket_lovelace_add_view, SCHEMA_ADD_VIEW) - - hass.components.websocket_api.async_register_command( - WS_TYPE_MOVE_VIEW, websocket_lovelace_move_view, SCHEMA_MOVE_VIEW) - - hass.components.websocket_api.async_register_command( - WS_TYPE_DELETE_VIEW, websocket_lovelace_delete_view, - SCHEMA_DELETE_VIEW) - return True +class LovelaceStorage: + """Class to handle Storage based Lovelace config.""" + + def __init__(self, hass): + """Initialize Lovelace config based on storage helper.""" + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + self._data = None + + 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} + + config = self._data['config'] + + if config is None: + raise ConfigNotFound + + return config + + async def async_save(self, config): + """Save config.""" + self._data = {'config': config} + await self._store.async_save(config) + + +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): """Handle error with WebSocket calls.""" @wraps(func) @@ -518,19 +150,8 @@ def handle_yaml_errors(func): 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 @@ def handle_yaml_errors(func): @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 e296d14c6f8..ea856b464c3 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 -""" +from homeassistant.components import frontend, lovelace -def test_add_id(): - """Test if id is added.""" - yaml = YAML(typ='rt') +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' + } - 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() + # 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' - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - assert msg['result'] == {'hello': 'world'} + # 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_deprecated_lovelace_ui_not_found(hass, hass_ws_client): - """Test lovelace_ui command cannot find file.""" - await async_setup_component(hass, 'lovelace') +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) - with patch('homeassistant.components.lovelace.load_config', - side_effect=FileNotFoundError): + # 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': 5, - 'type': 'frontend/lovelace_config', + 'id': 7, + 'type': 'lovelace/config' }) - msg = await client.receive_json() + response = 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'] + assert response['success'] + assert response['result'] == {'hello': 'yo'} From a5072f0fe4b3b0ee5efd366a293b84e343b1362b 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 239/254] 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 16d9022c98f..202883713c7 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -383,7 +383,6 @@ class DeviceTracker: 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 18bc772cbbb74569883c50dca6d6f7460b7a4805 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 10 Dec 2018 09:55:19 +0100 Subject: [PATCH 240/254] Bumped version to 0.84.0b2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 0f59d771bde..52cb452c126 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 84 -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 55aaa894c335f2b764a885b9115a30f55d1c69c2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 10 Dec 2018 12:50:09 +0100 Subject: [PATCH 241/254] Updated frontend to 20181210.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 5013a451adc..5e9f96a4073 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import callback from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20181210.0'] +REQUIREMENTS = ['home-assistant-frontend==20181210.1'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 41246cf6758..366f9425b1e 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==20181210.0 +home-assistant-frontend==20181210.1 # homeassistant.components.zwave homeassistant-pyozw==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6f766f9b07c..0509f33ac87 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==20181210.0 +home-assistant-frontend==20181210.1 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From fa9fe4067a08b911bc3c581419ae498c6e12b535 Mon Sep 17 00:00:00 2001 From: Eric Nagley Date: Mon, 10 Dec 2018 06:31:52 -0500 Subject: [PATCH 242/254] Google assistant fix target temp for *F values. (#19083) * home-assistant/home-assistant#18524 : Add rounding to *F temps * home-assistant/home-assistant#18524 : Linting * simplify round behavior * fix trailing whitespace (thanks github editor) --- homeassistant/components/google_assistant/trait.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index f2cb819fcc9..e0776d4c636 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -588,8 +588,11 @@ class TemperatureSettingTrait(_Trait): max_temp = self.state.attributes[climate.ATTR_MAX_TEMP] if command == COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT: - temp = temp_util.convert(params['thermostatTemperatureSetpoint'], - TEMP_CELSIUS, unit) + temp = temp_util.convert( + params['thermostatTemperatureSetpoint'], TEMP_CELSIUS, + unit) + if unit == TEMP_FAHRENHEIT: + temp = round(temp) if temp < min_temp or temp > max_temp: raise SmartHomeError( @@ -607,6 +610,8 @@ class TemperatureSettingTrait(_Trait): temp_high = temp_util.convert( params['thermostatTemperatureSetpointHigh'], TEMP_CELSIUS, unit) + if unit == TEMP_FAHRENHEIT: + temp_high = round(temp_high) if temp_high < min_temp or temp_high > max_temp: raise SmartHomeError( @@ -615,7 +620,10 @@ class TemperatureSettingTrait(_Trait): "{} and {}".format(min_temp, max_temp)) temp_low = temp_util.convert( - params['thermostatTemperatureSetpointLow'], TEMP_CELSIUS, unit) + params['thermostatTemperatureSetpointLow'], TEMP_CELSIUS, + unit) + if unit == TEMP_FAHRENHEIT: + temp_low = round(temp_low) if temp_low < min_temp or temp_low > max_temp: raise SmartHomeError( From d7459c73e0fb4d42ae4fee6f229c7796e141c85b Mon Sep 17 00:00:00 2001 From: phnx Date: Fri, 7 Dec 2018 01:15:04 -0500 Subject: [PATCH 243/254] home-assistant/home-assistant#18645: Fix climate mode mapping. --- homeassistant/components/google_assistant/trait.py | 5 ++++- tests/components/google_assistant/__init__.py | 2 +- tests/components/google_assistant/test_google_assistant.py | 4 ++-- tests/components/google_assistant/test_trait.py | 6 +++--- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index e0776d4c636..3a46f529c35 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -517,7 +517,10 @@ class TemperatureSettingTrait(_Trait): climate.STATE_HEAT: 'heat', climate.STATE_COOL: 'cool', climate.STATE_OFF: 'off', - climate.STATE_AUTO: 'heatcool', + climate.STATE_AUTO: 'auto', + climate.STATE_FAN_ONLY: 'fan-only', + climate.STATE_DRY: 'dry', + climate.STATE_ECO: 'eco' } google_to_hass = {value: key for key, value in hass_to_google.items()} diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 03cc327a5c5..949960598d6 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -223,7 +223,7 @@ DEMO_DEVICES = [{ 'type': 'action.devices.types.THERMOSTAT', 'willReportState': False, 'attributes': { - 'availableThermostatModes': 'heat,cool,heatcool,off', + 'availableThermostatModes': 'heat,cool,auto,off', 'thermostatTemperatureUnit': 'C', }, }, { diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 89e9090da98..0da2781a01f 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -214,7 +214,7 @@ def test_query_climate_request(hass_fixture, assistant_client, auth_header): 'online': True, 'thermostatTemperatureSetpointHigh': 24, 'thermostatTemperatureAmbient': 23, - 'thermostatMode': 'heatcool', + 'thermostatMode': 'auto', 'thermostatTemperatureSetpointLow': 21 } assert devices['climate.hvac'] == { @@ -271,7 +271,7 @@ def test_query_climate_request_f(hass_fixture, assistant_client, auth_header): 'online': True, 'thermostatTemperatureSetpointHigh': -4.4, 'thermostatTemperatureAmbient': -5, - 'thermostatMode': 'heatcool', + 'thermostatMode': 'auto', 'thermostatTemperatureSetpointLow': -6.1, } assert devices['climate.hvac'] == { diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index e9169c9bbbe..cb709ed084c 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -679,11 +679,11 @@ async def test_temperature_setting_climate_range(hass): climate.ATTR_MAX_TEMP: 80 }), BASIC_CONFIG) assert trt.sync_attributes() == { - 'availableThermostatModes': 'off,cool,heat,heatcool', + 'availableThermostatModes': 'off,cool,heat,auto', 'thermostatTemperatureUnit': 'F', } assert trt.query_attributes() == { - 'thermostatMode': 'heatcool', + 'thermostatMode': 'auto', 'thermostatTemperatureAmbient': 21.1, 'thermostatHumidityAmbient': 25, 'thermostatTemperatureSetpointLow': 18.3, @@ -709,7 +709,7 @@ async def test_temperature_setting_climate_range(hass): calls = async_mock_service( hass, climate.DOMAIN, climate.SERVICE_SET_OPERATION_MODE) await trt.execute(trait.COMMAND_THERMOSTAT_SET_MODE, { - 'thermostatMode': 'heatcool', + 'thermostatMode': 'auto', }) assert len(calls) == 1 assert calls[0].data == { From d8f6331318f29cd2e6dd5165fa028fe9d6944a85 Mon Sep 17 00:00:00 2001 From: phnx Date: Fri, 7 Dec 2018 01:50:48 -0500 Subject: [PATCH 244/254] home-assistant/home-assistant#18645: Remove un-used constants. --- homeassistant/components/google_assistant/const.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index aca960f9c0a..bfeb0fcadf5 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -19,8 +19,6 @@ DEFAULT_EXPOSED_DOMAINS = [ 'media_player', 'scene', 'script', 'switch', 'vacuum', 'lock', ] DEFAULT_ALLOW_UNLOCK = False -CLIMATE_MODE_HEATCOOL = 'heatcool' -CLIMATE_SUPPORTED_MODES = {'heat', 'cool', 'off', 'on', CLIMATE_MODE_HEATCOOL} PREFIX_TYPES = 'action.devices.types.' TYPE_LIGHT = PREFIX_TYPES + 'LIGHT' From 0454a5fa3fd1a13e6cc4b9832947a28048d69175 Mon Sep 17 00:00:00 2001 From: phnx Date: Fri, 7 Dec 2018 10:00:56 -0500 Subject: [PATCH 245/254] home-assistant/home-assistant#18645: revert heat-cool -> auto change --- homeassistant/components/google_assistant/trait.py | 2 +- tests/components/google_assistant/__init__.py | 2 +- tests/components/google_assistant/test_google_assistant.py | 4 ++-- tests/components/google_assistant/test_trait.py | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 3a46f529c35..7153115e3ef 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -517,7 +517,7 @@ class TemperatureSettingTrait(_Trait): climate.STATE_HEAT: 'heat', climate.STATE_COOL: 'cool', climate.STATE_OFF: 'off', - climate.STATE_AUTO: 'auto', + climate.STATE_AUTO: 'heatcool', climate.STATE_FAN_ONLY: 'fan-only', climate.STATE_DRY: 'dry', climate.STATE_ECO: 'eco' diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 949960598d6..03cc327a5c5 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -223,7 +223,7 @@ DEMO_DEVICES = [{ 'type': 'action.devices.types.THERMOSTAT', 'willReportState': False, 'attributes': { - 'availableThermostatModes': 'heat,cool,auto,off', + 'availableThermostatModes': 'heat,cool,heatcool,off', 'thermostatTemperatureUnit': 'C', }, }, { diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 0da2781a01f..89e9090da98 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -214,7 +214,7 @@ def test_query_climate_request(hass_fixture, assistant_client, auth_header): 'online': True, 'thermostatTemperatureSetpointHigh': 24, 'thermostatTemperatureAmbient': 23, - 'thermostatMode': 'auto', + 'thermostatMode': 'heatcool', 'thermostatTemperatureSetpointLow': 21 } assert devices['climate.hvac'] == { @@ -271,7 +271,7 @@ def test_query_climate_request_f(hass_fixture, assistant_client, auth_header): 'online': True, 'thermostatTemperatureSetpointHigh': -4.4, 'thermostatTemperatureAmbient': -5, - 'thermostatMode': 'auto', + 'thermostatMode': 'heatcool', 'thermostatTemperatureSetpointLow': -6.1, } assert devices['climate.hvac'] == { diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index cb709ed084c..e9169c9bbbe 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -679,11 +679,11 @@ async def test_temperature_setting_climate_range(hass): climate.ATTR_MAX_TEMP: 80 }), BASIC_CONFIG) assert trt.sync_attributes() == { - 'availableThermostatModes': 'off,cool,heat,auto', + 'availableThermostatModes': 'off,cool,heat,heatcool', 'thermostatTemperatureUnit': 'F', } assert trt.query_attributes() == { - 'thermostatMode': 'auto', + 'thermostatMode': 'heatcool', 'thermostatTemperatureAmbient': 21.1, 'thermostatHumidityAmbient': 25, 'thermostatTemperatureSetpointLow': 18.3, @@ -709,7 +709,7 @@ async def test_temperature_setting_climate_range(hass): calls = async_mock_service( hass, climate.DOMAIN, climate.SERVICE_SET_OPERATION_MODE) await trt.execute(trait.COMMAND_THERMOSTAT_SET_MODE, { - 'thermostatMode': 'auto', + 'thermostatMode': 'heatcool', }) assert len(calls) == 1 assert calls[0].data == { From cb874fefbbf9a266c9189aa2fba667dc6c654f14 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 10 Dec 2018 12:24:56 +0100 Subject: [PATCH 246/254] Drop OwnTracks bad packets (#19161) --- .../components/owntracks/__init__.py | 22 ++++++++++----- tests/components/owntracks/test_init.py | 27 ++++++++++++++++--- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index 0bb7a2390b7..7dc88be9764 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -118,9 +118,18 @@ async def async_connect_mqtt(hass, component): async def handle_webhook(hass, webhook_id, request): - """Handle webhook callback.""" + """Handle webhook callback. + + iOS sets the "topic" as part of the payload. + Android does not set a topic but adds headers to the request. + """ context = hass.data[DOMAIN]['context'] - message = await request.json() + + try: + message = await request.json() + except ValueError: + _LOGGER.warning('Received invalid JSON from OwnTracks') + return json_response([]) # Android doesn't populate topic if 'topic' not in message: @@ -129,11 +138,10 @@ async def handle_webhook(hass, webhook_id, request): 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 - ) + _LOGGER.warning('No topic or user found in message. If on Android,' + ' set a username in Connection -> Identification') + # Keep it as a 200 response so the incorrect packet is discarded + return json_response([]) topic_base = re.sub('/#$', '', context.mqtt_topic) message['topic'] = '{}/{}/{}'.format(topic_base, user, device) diff --git a/tests/components/owntracks/test_init.py b/tests/components/owntracks/test_init.py index ba362da905a..3d2d8d03e7c 100644 --- a/tests/components/owntracks/test_init.py +++ b/tests/components/owntracks/test_init.py @@ -110,7 +110,7 @@ def test_handle_value_error(mock_client): @asyncio.coroutine -def test_returns_error_missing_username(mock_client): +def test_returns_error_missing_username(mock_client, caplog): """Test that an error is returned when username is missing.""" resp = yield from mock_client.post( '/api/webhook/owntracks_test', @@ -120,10 +120,29 @@ def test_returns_error_missing_username(mock_client): } ) - assert resp.status == 400 - + # Needs to be 200 or OwnTracks keeps retrying bad packet. + assert resp.status == 200 json = yield from resp.json() - assert json == {'error': 'You need to supply username.'} + assert json == [] + assert 'No topic or user found' in caplog.text + + +@asyncio.coroutine +def test_returns_error_incorrect_json(mock_client, caplog): + """Test that an error is returned when username is missing.""" + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + data='not json', + headers={ + 'X-Limit-d': 'Pixel', + } + ) + + # Needs to be 200 or OwnTracks keeps retrying bad packet. + assert resp.status == 200 + json = yield from resp.json() + assert json == [] + assert 'invalid JSON' in caplog.text @asyncio.coroutine From e3b10085111896ed19fa28d79a5dac4151c46e22 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 10 Dec 2018 12:25:08 +0100 Subject: [PATCH 247/254] Fix lovelace save (#19162) --- homeassistant/components/lovelace/__init__.py | 4 ++-- tests/components/lovelace/test_init.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 68c322b3956..e6f122bce19 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -101,8 +101,8 @@ class LovelaceStorage: async def async_save(self, config): """Save config.""" - self._data = {'config': config} - await self._store.async_save(config) + self._data['config'] = config + await self._store.async_save(self._data) class LovelaceYAML: diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index ea856b464c3..15548b28cfb 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -34,7 +34,7 @@ async def test_lovelace_from_storage(hass, hass_ws_client, hass_storage): response = await client.receive_json() assert response['success'] assert hass_storage[lovelace.STORAGE_KEY]['data'] == { - 'yo': 'hello' + 'config': {'yo': 'hello'} } # Load new config From 2da5a022856b8782117d78d104d1ace54bcc4481 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 10 Dec 2018 12:58:51 +0100 Subject: [PATCH 248/254] Add raw service data to event (#19163) --- homeassistant/core.py | 6 ++++-- tests/test_core.py | 27 ++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 2a40d604ee0..37d1134ef29 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1098,9 +1098,11 @@ class ServiceRegistry: raise ServiceNotFound(domain, service) from None if handler.schema: - service_data = handler.schema(service_data) + processed_data = handler.schema(service_data) + else: + processed_data = service_data - service_call = ServiceCall(domain, service, service_data, context) + service_call = ServiceCall(domain, service, processed_data, context) self._hass.bus.async_fire(EVENT_CALL_SERVICE, { ATTR_DOMAIN: domain.lower(), diff --git a/tests/test_core.py b/tests/test_core.py index 724233cbf98..5ee9f5cdb05 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -8,6 +8,7 @@ from unittest.mock import patch, MagicMock from datetime import datetime, timedelta from tempfile import TemporaryDirectory +import voluptuous as vol import pytz import pytest @@ -21,7 +22,7 @@ from homeassistant.const import ( __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_REGISTERED, EVENT_SERVICE_REMOVED, EVENT_CALL_SERVICE) from tests.common import get_test_home_assistant, async_mock_service @@ -1000,3 +1001,27 @@ async def test_service_executed_with_subservices(hass): assert len(calls) == 4 assert [call.service for call in calls] == [ 'outer', 'inner', 'inner', 'outer'] + + +async def test_service_call_event_contains_original_data(hass): + """Test that service call event contains original data.""" + events = [] + + @ha.callback + def callback(event): + events.append(event) + + hass.bus.async_listen(EVENT_CALL_SERVICE, callback) + + calls = async_mock_service(hass, 'test', 'service', vol.Schema({ + 'number': vol.Coerce(int) + })) + + await hass.services.async_call('test', 'service', { + 'number': '23' + }, blocking=True) + await hass.async_block_till_done() + assert len(events) == 1 + assert events[0].data['service_data']['number'] == '23' + assert len(calls) == 1 + assert calls[0].data['number'] == 23 From e94eb686a65c3e84aeada381a009051389929a53 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 10 Dec 2018 13:00:41 +0100 Subject: [PATCH 249/254] Bumped version to 0.84.0b3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 52cb452c126..4684ca347ed 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 84 -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 029d006bebb85f9eaf3a69b1c10410a3cbf09671 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 11 Dec 2018 10:29:55 +0100 Subject: [PATCH 250/254] Updated frontend to 20181211.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 5e9f96a4073..8caca591305 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import callback from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20181210.1'] +REQUIREMENTS = ['home-assistant-frontend==20181211.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 366f9425b1e..ee749157a7a 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==20181210.1 +home-assistant-frontend==20181211.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0509f33ac87..e3ad8015a4c 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==20181210.1 +home-assistant-frontend==20181211.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 From aa45ff83bd6882968ce5726223a9554246a4f38b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 11 Dec 2018 06:50:54 +0100 Subject: [PATCH 251/254] Fix cloud defaults (#19172) --- homeassistant/components/cloud/__init__.py | 3 +-- homeassistant/components/cloud/prefs.py | 8 +++----- tests/components/cloud/test_cloudhooks.py | 2 +- tests/components/cloud/test_iot.py | 2 +- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 329f83768ce..fd5b413043e 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -252,8 +252,7 @@ class Cloud: return json.loads(file.read()) info = await self.hass.async_add_job(load_config) - - await self.prefs.async_initialize(bool(info)) + await self.prefs.async_initialize() if info is None: return diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index c4aa43c91d2..32362df2fa9 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -16,19 +16,17 @@ class CloudPreferences: self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) self._prefs = None - async def async_initialize(self, logged_in): + async def async_initialize(self): """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_ENABLE_ALEXA: True, + PREF_ENABLE_GOOGLE: True, PREF_GOOGLE_ALLOW_UNLOCK: False, PREF_CLOUDHOOKS: {} } - await self._store.async_save(prefs) self._prefs = prefs diff --git a/tests/components/cloud/test_cloudhooks.py b/tests/components/cloud/test_cloudhooks.py index b65046331a7..9306a6c6ef3 100644 --- a/tests/components/cloud/test_cloudhooks.py +++ b/tests/components/cloud/test_cloudhooks.py @@ -17,7 +17,7 @@ def mock_cloudhooks(hass): 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)) + hass.loop.run_until_complete(cloud.prefs.async_initialize()) return cloudhooks.Cloudhooks(cloud) diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index b11de7da4e4..2133a803aef 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -411,7 +411,7 @@ async def test_refresh_token_expired(hass): 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_initialize() await cloud.prefs.async_update(cloudhooks={ 'hello': { 'webhook_id': 'mock-webhook-id', From e98476e02684234f1ea3bd38f5e9af6cb27e823b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 11 Dec 2018 10:33:58 +0100 Subject: [PATCH 252/254] Bumped version to 0.84.0b4 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 4684ca347ed..2d03be50cf7 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 84 -PATCH_VERSION = '0b3' +PATCH_VERSION = '0b4' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From a0bc96c20db0ebcea83ef7a6047a98e091fe230b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 12 Dec 2018 11:44:50 +0100 Subject: [PATCH 253/254] Revert PR #18602 (#19188) --- homeassistant/components/camera/mjpeg.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/camera/mjpeg.py b/homeassistant/components/camera/mjpeg.py index 5c6d7e18075..2819b0e6ec4 100644 --- a/homeassistant/components/camera/mjpeg.py +++ b/homeassistant/components/camera/mjpeg.py @@ -59,15 +59,21 @@ 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 = 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 = b'' + for chunk in stream: data += chunk + jpg_end = data.find(b'\xff\xd9') + + if jpg_end == -1: + continue + + jpg_start = data.find(b'\xff\xd8') + + if jpg_start == -1: + continue + + return data[jpg_start:jpg_end + 2] class MjpegCamera(Camera): From 404fbe388c1daa12aefca139b75bc7d854ff8090 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 12 Dec 2018 11:45:42 +0100 Subject: [PATCH 254/254] Bumped version to 0.84.0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 2d03be50cf7..cd71c6d994c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 84 -PATCH_VERSION = '0b4' +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3)