From 6499feffa38e2c8be079050fd3444549ebd999a7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 29 Jan 2020 16:30:03 -0800 Subject: [PATCH 01/54] Bumped version to 0.105.0b0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index facb365f75c..4a78a181ef7 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 105 -PATCH_VERSION = "0.dev0" +PATCH_VERSION = "0b0" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From 268430a61d32312141bf3f751c204183cb54aa77 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Thu, 30 Jan 2020 10:04:06 -0500 Subject: [PATCH 02/54] ZHA dependencies bump (#31295) * ZHA dependencies bump. * Bump bellows-homeassistant. --- homeassistant/components/zha/manifest.json | 6 +++--- requirements_all.txt | 6 +++--- requirements_test_all.txt | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index b436f677f6b..759cb4489fe 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,11 +4,11 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ - "bellows-homeassistant==0.12.0", + "bellows-homeassistant==0.13.1", "zha-quirks==0.0.31", "zigpy-deconz==0.7.0", - "zigpy-homeassistant==0.12.0", - "zigpy-xbee-homeassistant==0.8.0", + "zigpy-homeassistant==0.13.0", + "zigpy-xbee-homeassistant==0.9.0", "zigpy-zigate==0.5.1" ], "dependencies": [], diff --git a/requirements_all.txt b/requirements_all.txt index 576305196d3..49f6b6d4c71 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -299,7 +299,7 @@ beautifulsoup4==4.8.2 beewi_smartclim==0.0.7 # homeassistant.components.zha -bellows-homeassistant==0.12.0 +bellows-homeassistant==0.13.1 # homeassistant.components.bmw_connected_drive bimmer_connected==0.6.2 @@ -2130,10 +2130,10 @@ ziggo-mediabox-xl==1.1.0 zigpy-deconz==0.7.0 # homeassistant.components.zha -zigpy-homeassistant==0.12.0 +zigpy-homeassistant==0.13.0 # homeassistant.components.zha -zigpy-xbee-homeassistant==0.8.0 +zigpy-xbee-homeassistant==0.9.0 # homeassistant.components.zha zigpy-zigate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 21df9279c89..9e1c13297a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -112,7 +112,7 @@ av==6.1.2 axis==25 # homeassistant.components.zha -bellows-homeassistant==0.12.0 +bellows-homeassistant==0.13.1 # homeassistant.components.bom bomradarloop==0.1.3 @@ -699,10 +699,10 @@ zha-quirks==0.0.31 zigpy-deconz==0.7.0 # homeassistant.components.zha -zigpy-homeassistant==0.12.0 +zigpy-homeassistant==0.13.0 # homeassistant.components.zha -zigpy-xbee-homeassistant==0.8.0 +zigpy-xbee-homeassistant==0.9.0 # homeassistant.components.zha zigpy-zigate==0.5.1 From 9db2ad1fd7d4f8f5776f81b63802a5771b0584d4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Jan 2020 01:09:06 -0800 Subject: [PATCH 03/54] Add zones services.yaml (#31298) --- homeassistant/components/zone/services.yaml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 homeassistant/components/zone/services.yaml diff --git a/homeassistant/components/zone/services.yaml b/homeassistant/components/zone/services.yaml new file mode 100644 index 00000000000..550eee24fab --- /dev/null +++ b/homeassistant/components/zone/services.yaml @@ -0,0 +1,2 @@ +reload: + description: Reload the YAML-based zone configuration. From f55193c2dac8be478caf4a9cf0b18b8c136061a2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Jan 2020 01:06:17 -0800 Subject: [PATCH 04/54] Add zone to defaul config (#31303) --- homeassistant/components/default_config/manifest.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index c0a27b667c5..e19b1262b74 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -18,7 +18,8 @@ "sun", "system_health", "updater", - "zeroconf" + "zeroconf", + "zone" ], "codeowners": [] } From afe869bee97cd6dd62bd6e07887ddbe52ba4f3d5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Jan 2020 09:28:06 -0800 Subject: [PATCH 05/54] Handle service calls that do not refer entity IDs (#31317) --- homeassistant/helpers/script.py | 4 ++++ tests/helpers/test_script.py | 1 + 2 files changed, 5 insertions(+) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 378a6016c20..1cac4679d82 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -201,6 +201,10 @@ class Script: continue entity_ids = data.get(ATTR_ENTITY_ID) + + if entity_ids is None: + continue + if isinstance(entity_ids, str): entity_ids = [entity_ids] diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index b226ed15720..5e748e3adfe 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -1043,6 +1043,7 @@ async def test_referenced_entities(): "entity_id": "sensor.condition", "state": "100", }, + {"service": "test.script", "data": {"without": "entity_id"}}, {"scene": "scene.hello"}, {"event": "test_event"}, {"delay": "{{ delay_period }}"}, From af8b63fe31970cb84953926496f92cb1410c91f2 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 30 Jan 2020 18:30:59 +0100 Subject: [PATCH 06/54] Updated frontend to 20200130.0 (#31318) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 7dfcca4f019..6b16970c675 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20200129.0" + "home-assistant-frontend==20200130.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 22b6328a0db..7ce2d357f82 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,7 +11,7 @@ cryptography==2.8 defusedxml==0.6.0 distro==1.4.0 hass-nabucasa==0.31 -home-assistant-frontend==20200129.0 +home-assistant-frontend==20200130.0 importlib-metadata==1.4.0 jinja2>=2.10.3 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 49f6b6d4c71..d8bf0d47386 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -679,7 +679,7 @@ hole==0.5.0 holidays==0.9.12 # homeassistant.components.frontend -home-assistant-frontend==20200129.0 +home-assistant-frontend==20200130.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9e1c13297a1..137ca3ae9ba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -247,7 +247,7 @@ hole==0.5.0 holidays==0.9.12 # homeassistant.components.frontend -home-assistant-frontend==20200129.0 +home-assistant-frontend==20200130.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 From e91c32cb007c045aefe98110bc12e59c4d6f22d0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Jan 2020 09:47:16 -0800 Subject: [PATCH 07/54] Fix HTTP config serialization (#31319) --- homeassistant/components/http/__init__.py | 11 ++++++++++- tests/components/http/test_init.py | 15 ++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 58cfb4b9cc1..565f84fdb8a 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -166,7 +166,16 @@ async def async_setup(hass, config): # If we are set up successful, we store the HTTP settings for safe mode. store = storage.Store(hass, STORAGE_VERSION, STORAGE_KEY) - await store.async_save(conf) + + if CONF_TRUSTED_PROXIES in conf: + conf_to_save = dict(conf) + conf_to_save[CONF_TRUSTED_PROXIES] = [ + str(ip.network_address) for ip in conf_to_save[CONF_TRUSTED_PROXIES] + ] + else: + conf_to_save = conf + + await store.async_save(conf_to_save) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_server) diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 43a39302f4f..58e6d8824dd 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -1,4 +1,5 @@ """The tests for the Home Assistant HTTP component.""" +from ipaddress import ip_network import logging import unittest from unittest.mock import patch @@ -244,12 +245,16 @@ async def test_cors_defaults(hass): async def test_storing_config(hass, aiohttp_client, aiohttp_unused_port): """Test that we store last working config.""" - config = {http.CONF_SERVER_PORT: aiohttp_unused_port()} + config = { + http.CONF_SERVER_PORT: aiohttp_unused_port(), + "use_x_forwarded_for": True, + "trusted_proxies": ["192.168.1.100"], + } - await async_setup_component(hass, http.DOMAIN, {http.DOMAIN: config}) + assert await async_setup_component(hass, http.DOMAIN, {http.DOMAIN: config}) await hass.async_start() + restored = await hass.components.http.async_get_last_config() + restored["trusted_proxies"][0] = ip_network(restored["trusted_proxies"][0]) - assert await hass.components.http.async_get_last_config() == http.HTTP_SCHEMA( - config - ) + assert restored == http.HTTP_SCHEMA(config) From 202fd4197bbeda566e223ee20ef00f629b3ae049 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Jan 2020 09:48:00 -0800 Subject: [PATCH 08/54] Bumped version to 0.105.0b1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 4a78a181ef7..689a82dcf92 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 105 -PATCH_VERSION = "0b0" +PATCH_VERSION = "0b1" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From 6b95e98eebedfd80cd2458758c638b93d79ad7a4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Jan 2020 11:13:54 -0800 Subject: [PATCH 09/54] Guard Z-Wave light HS conversion on None (#31320) --- homeassistant/components/zwave/light.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zwave/light.py b/homeassistant/components/zwave/light.py index 9c582eba89a..b32daf71f54 100644 --- a/homeassistant/components/zwave/light.py +++ b/homeassistant/components/zwave/light.py @@ -380,7 +380,9 @@ class ZwaveColorLight(ZwaveDimmer): # white LED must be off in order for color to work self._white = 0 - if ATTR_WHITE_VALUE in kwargs or ATTR_HS_COLOR in kwargs: + if ( + ATTR_WHITE_VALUE in kwargs or ATTR_HS_COLOR in kwargs + ) and self._hs is not None: rgbw = "#" for colorval in color_util.color_hs_to_RGB(*self._hs): rgbw += format(colorval, "02x") From 73f27c728c1480665b8d173952422a2fddb6f2c5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Jan 2020 11:40:16 -0800 Subject: [PATCH 10/54] Fix wemo lights (#31323) --- homeassistant/components/wemo/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 8e43f47ef00..a615b3f5dfd 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -113,7 +113,7 @@ class WemoLight(Light): """Return the device info.""" return { "name": self.wemo.name, - "identifiers": {(WEMO_DOMAIN, self.wemo.serialnumber)}, + "identifiers": {(WEMO_DOMAIN, self.wemo.uniqueID)}, "model": self.wemo.model_name, "manufacturer": "Belkin", } From 3635c4df5018ae7423c4f8b2d4df5f1982baa320 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 31 Jan 2020 08:30:13 +0100 Subject: [PATCH 11/54] Emulated Hue: changed fallback device-type to fix Alexa compatibility issues (#30013) (#31330) * Emulated Hue: changed the reported fallback device-type to fix Alexa compatibility issues (#30013) * Emulated Hue: updated tests (#30013) --- homeassistant/components/emulated_hue/hue_api.py | 7 ++++--- tests/components/emulated_hue/test_hue_api.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 459a13c066c..118bf7e3eaa 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -704,9 +704,10 @@ def entity_to_json(config, entity): retval["modelid"] = "HASS123" retval["state"].update({HUE_API_STATE_BRI: state[STATE_BRIGHTNESS]}) else: - # On/off light (Zigbee Device ID: 0x0000) - # Supports groups, scenes and on/off control - retval["type"] = "On/off light" + # On/off plug-in unit (Zigbee Device ID: 0x0000) + # Supports groups and on/off control + # Used for compatibility purposes with Alexa instead of "On/off light" + retval["type"] = "On/off plug-in unit" retval["modelid"] = "HASS321" return retval diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 349d53aaee5..0ddc429b2d9 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -238,7 +238,7 @@ async def test_light_without_brightness_supported(hass_hue, hue_client): ) assert light_without_brightness_json["state"][HUE_API_STATE_ON] is True - assert light_without_brightness_json["type"] == "On/off light" + assert light_without_brightness_json["type"] == "On/off plug-in unit" async def test_light_without_brightness_can_be_turned_off(hass_hue, hue_client): From a53c3d10fefdc5cd38576e54742d4761612fdc3d Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 31 Jan 2020 01:28:54 -0600 Subject: [PATCH 12/54] Fix async bug in amcrest when registering services (#31334) --- homeassistant/components/amcrest/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 63daeb04731..f7814939e3a 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -256,7 +256,7 @@ def setup(hass, config): async_dispatcher_send(hass, service_signal(call.service, entity_id), *args) for service, params in CAMERA_SERVICES.items(): - hass.services.async_register(DOMAIN, service, async_service_handler, params[0]) + hass.services.register(DOMAIN, service, async_service_handler, params[0]) return True From c6baf026a724f7cae0bc0aefe1f963b5d81bab83 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 Jan 2020 00:32:43 -0800 Subject: [PATCH 13/54] Guard for callbacks in service helper (#31339) --- homeassistant/components/camera/__init__.py | 10 ++++------ homeassistant/helpers/service.py | 8 ++++++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 53c5cf16a98..b02874780e5 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -400,19 +400,17 @@ class Camera(Entity): """Turn off camera.""" raise NotImplementedError() - @callback - def async_turn_off(self): + async def async_turn_off(self): """Turn off camera.""" - return self.hass.async_add_job(self.turn_off) + await self.hass.async_add_job(self.turn_off) def turn_on(self): """Turn off camera.""" raise NotImplementedError() - @callback - def async_turn_on(self): + async def async_turn_on(self): """Turn off camera.""" - return self.hass.async_add_job(self.turn_on) + await self.hass.async_add_job(self.turn_on) def enable_motion_detection(self): """Enable motion detection in the camera.""" diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 89c2715a760..36bfd9c8cb0 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -370,9 +370,13 @@ async def _handle_service_platform_call( entity.async_set_context(context) if isinstance(func, str): - result = await hass.async_add_job(partial(getattr(entity, func), **data)) + result = hass.async_add_job(partial(getattr(entity, func), **data)) else: - result = await hass.async_add_job(func, entity, data) + result = hass.async_add_job(func, entity, data) + + # Guard because callback functions do not return a task when passed to async_add_job. + if result is not None: + result = await result if asyncio.iscoroutine(result): _LOGGER.error( From 7b3dc426737722d4bf916b1a819b2e4e29766f68 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 Jan 2020 01:58:27 -0800 Subject: [PATCH 14/54] Fix incorrect annotation async flock notify (#31342) * Fix incorrect annotation async flock notify * Update notify.py * Update notify.py Co-authored-by: Pascal Vizeli --- homeassistant/components/flock/notify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/flock/notify.py b/homeassistant/components/flock/notify.py index a71601ea2c4..107c837970d 100644 --- a/homeassistant/components/flock/notify.py +++ b/homeassistant/components/flock/notify.py @@ -16,7 +16,7 @@ _RESOURCE = "https://api.flock.com/hooks/sendMessage/" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_ACCESS_TOKEN): cv.string}) -async def get_service(hass, config, discovery_info=None): +async def async_get_service(hass, config, discovery_info=None): """Get the Flock notification service.""" access_token = config.get(CONF_ACCESS_TOKEN) url = f"{_RESOURCE}{access_token}" From 1aa322f2f04322af86d246f3a0704ef512ee53ea Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 31 Jan 2020 10:26:09 +0000 Subject: [PATCH 15/54] Bump version to 0.105.0b0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 689a82dcf92..8f7d7bbaeb0 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 105 -PATCH_VERSION = "0b1" +PATCH_VERSION = "0b2" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From 283cc5c8c38a16784708875a82ae3ce3bc70385b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 Jan 2020 14:47:40 -0800 Subject: [PATCH 16/54] Update Hue data fetching (#31338) * Refactor Hue Lights to use DataCoordinator * Redo how Hue updates data * Address comments * Inherit from Entity and remove pylint disable * Add tests for debounce --- homeassistant/components/hue/__init__.py | 4 +- homeassistant/components/hue/binary_sensor.py | 35 ++- homeassistant/components/hue/bridge.py | 9 + homeassistant/components/hue/const.py | 4 + homeassistant/components/hue/helpers.py | 8 +- homeassistant/components/hue/light.py | 277 +++++++----------- homeassistant/components/hue/sensor.py | 42 +-- homeassistant/components/hue/sensor_base.py | 178 +++++------ homeassistant/helpers/debounce.py | 77 +++++ homeassistant/helpers/event.py | 2 +- homeassistant/helpers/update_coordinator.py | 135 +++++++++ tests/components/hue/conftest.py | 11 + tests/components/hue/test_light.py | 30 +- tests/components/hue/test_sensor_base.py | 30 +- tests/helpers/test_debounce.py | 62 ++++ 15 files changed, 549 insertions(+), 355 deletions(-) create mode 100644 homeassistant/helpers/debounce.py create mode 100644 homeassistant/helpers/update_coordinator.py create mode 100644 tests/components/hue/conftest.py create mode 100644 tests/helpers/test_debounce.py diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py index 7349f4fe6a6..c8864e97607 100644 --- a/homeassistant/components/hue/__init__.py +++ b/homeassistant/components/hue/__init__.py @@ -122,7 +122,7 @@ async def async_setup_entry( if not await bridge.async_setup(): return False - hass.data[DOMAIN][host] = bridge + hass.data[DOMAIN][entry.entry_id] = bridge config = bridge.api.config # For backwards compat @@ -151,5 +151,5 @@ async def async_setup_entry( async def async_unload_entry(hass, entry): """Unload a config entry.""" - bridge = hass.data[DOMAIN].pop(entry.data["host"]) + bridge = hass.data[DOMAIN].pop(entry.entry_id) return await bridge.async_reset() diff --git a/homeassistant/components/hue/binary_sensor.py b/homeassistant/components/hue/binary_sensor.py index e4b7dd85e37..319f8f5fa19 100644 --- a/homeassistant/components/hue/binary_sensor.py +++ b/homeassistant/components/hue/binary_sensor.py @@ -6,27 +6,18 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_MOTION, BinarySensorDevice, ) -from homeassistant.components.hue.sensor_base import ( - GenericZLLSensor, - SensorManager, - async_setup_entry as shared_async_setup_entry, -) + +from .const import DOMAIN as HUE_DOMAIN +from .sensor_base import SENSOR_CONFIG_MAP, GenericZLLSensor PRESENCE_NAME_FORMAT = "{} motion" async def async_setup_entry(hass, config_entry, async_add_entities): """Defer binary sensor setup to the shared sensor module.""" - SensorManager.sensor_config_map.update( - { - TYPE_ZLL_PRESENCE: { - "binary": True, - "name_format": PRESENCE_NAME_FORMAT, - "class": HuePresence, - } - } - ) - await shared_async_setup_entry(hass, config_entry, async_add_entities, binary=True) + await hass.data[HUE_DOMAIN][ + config_entry.entry_id + ].sensor_manager.async_register_component(True, async_add_entities) class HuePresence(GenericZLLSensor, BinarySensorDevice): @@ -34,9 +25,6 @@ class HuePresence(GenericZLLSensor, BinarySensorDevice): device_class = DEVICE_CLASS_MOTION - async def _async_update_ha_state(self, *args, **kwargs): - await self.async_update_ha_state(self, *args, **kwargs) - @property def is_on(self): """Return true if the binary sensor is on.""" @@ -51,3 +39,14 @@ class HuePresence(GenericZLLSensor, BinarySensorDevice): if "sensitivitymax" in self.sensor.config: attributes["sensitivity_max"] = self.sensor.config["sensitivitymax"] return attributes + + +SENSOR_CONFIG_MAP.update( + { + TYPE_ZLL_PRESENCE: { + "binary": True, + "name_format": PRESENCE_NAME_FORMAT, + "class": HuePresence, + } + } +) diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index 58a744dd5b0..a153ed7a096 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -13,6 +13,7 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv from .const import DOMAIN, LOGGER from .errors import AuthenticationRequired, CannotConnect from .helpers import create_config_flow +from .sensor_base import SensorManager SERVICE_HUE_SCENE = "hue_activate_scene" ATTR_GROUP_NAME = "group_name" @@ -35,6 +36,9 @@ class HueBridge: self.authorized = False self.api = None self.parallel_updates_semaphore = None + # Jobs to be executed when API is reset. + self.reset_jobs = [] + self.sensor_manager = None @property def host(self): @@ -72,6 +76,7 @@ class HueBridge: return False self.api = bridge + self.sensor_manager = SensorManager(self) hass.async_create_task( hass.config_entries.async_forward_entry_setup(self.config_entry, "light") @@ -118,6 +123,9 @@ class HueBridge: self.hass.services.async_remove(DOMAIN, SERVICE_HUE_SCENE) + while self.reset_jobs: + self.reset_jobs.pop()() + # If setup was successful, we set api variable, forwarded entry and # register service results = await asyncio.gather( @@ -131,6 +139,7 @@ class HueBridge: self.config_entry, "sensor" ), ) + # None and True are OK return False not in results diff --git a/homeassistant/components/hue/const.py b/homeassistant/components/hue/const.py index d884389c0c1..e48cd4a8583 100644 --- a/homeassistant/components/hue/const.py +++ b/homeassistant/components/hue/const.py @@ -4,3 +4,7 @@ import logging LOGGER = logging.getLogger(__package__) DOMAIN = "hue" API_NUPNP = "https://www.meethue.com/api/nupnp" + +# How long to wait to actually do the refresh after requesting it. +# We wait some time so if we control multiple lights, we batch requests. +REQUEST_REFRESH_DELAY = 0.3 diff --git a/homeassistant/components/hue/helpers.py b/homeassistant/components/hue/helpers.py index 8a5fa973e4f..885677dc269 100644 --- a/homeassistant/components/hue/helpers.py +++ b/homeassistant/components/hue/helpers.py @@ -6,7 +6,7 @@ from homeassistant.helpers.entity_registry import async_get_registry as get_ent_ from .const import DOMAIN -async def remove_devices(hass, config_entry, api_ids, current): +async def remove_devices(bridge, api_ids, current): """Get items that are removed from api.""" removed_items = [] @@ -18,16 +18,16 @@ async def remove_devices(hass, config_entry, api_ids, current): entity = current[item_id] removed_items.append(item_id) await entity.async_remove() - ent_registry = await get_ent_reg(hass) + ent_registry = await get_ent_reg(bridge.hass) if entity.entity_id in ent_registry.entities: ent_registry.async_remove(entity.entity_id) - dev_registry = await get_dev_reg(hass) + dev_registry = await get_dev_reg(bridge.hass) device = dev_registry.async_get_device( identifiers={(DOMAIN, entity.device_id)}, connections=set() ) if device is not None: dev_registry.async_update_device( - device.id, remove_config_entry_id=config_entry.entry_id + device.id, remove_config_entry_id=bridge.config_entry.entry_id ) for item_id in removed_items: diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index 2a668779cb5..7ed2dcc84f2 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -1,14 +1,13 @@ """Support for the Philips Hue lights.""" import asyncio from datetime import timedelta +from functools import partial import logging import random -from time import monotonic import aiohue import async_timeout -from homeassistant.components import hue from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, @@ -28,8 +27,13 @@ from homeassistant.components.light import ( SUPPORT_TRANSITION, Light, ) +from homeassistant.core import callback +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import color +from .const import DOMAIN as HUE_DOMAIN, REQUEST_REFRESH_DELAY from .helpers import remove_devices SCAN_INTERVAL = timedelta(seconds=5) @@ -70,9 +74,40 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Hue lights from a config entry.""" - bridge = hass.data[hue.DOMAIN][config_entry.data["host"]] - cur_lights = {} - cur_groups = {} + bridge = hass.data[HUE_DOMAIN][config_entry.entry_id] + + light_coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + "light", + partial(async_safe_fetch, bridge, bridge.api.lights.update), + SCAN_INTERVAL, + Debouncer(bridge.hass, _LOGGER, REQUEST_REFRESH_DELAY, True), + ) + + # First do a refresh to see if we can reach the hub. + # Otherwise we will declare not ready. + await light_coordinator.async_refresh() + + if light_coordinator.failed_last_update: + raise PlatformNotReady + + update_lights = partial( + async_update_items, + bridge, + bridge.api.lights, + {}, + async_add_entities, + partial(HueLight, light_coordinator, bridge, False), + ) + + # We add a listener after fetching the data, so manually trigger listener + light_coordinator.async_add_listener(update_lights) + update_lights() + + bridge.reset_jobs.append( + lambda: light_coordinator.async_remove_listener(update_lights) + ) api_version = tuple(int(v) for v in bridge.api.config.apiversion.split(".")) @@ -81,168 +116,60 @@ async def async_setup_entry(hass, config_entry, async_add_entities): _LOGGER.warning("Please update your Hue bridge to support groups") allow_groups = False - # Hue updates all lights via a single API call. - # - # If we call a service to update 2 lights, we only want the API to be - # called once. - # - # The throttle decorator will return right away if a call is currently - # in progress. This means that if we are updating 2 lights, the first one - # is in the update method, the second one will skip it and assume the - # update went through and updates it's data, not good! - # - # The current mechanism will make sure that all lights will wait till - # the update call is done before writing their data to the state machine. - # - # An alternative approach would be to disable automatic polling by Home - # Assistant and take control ourselves. This works great for polling as now - # we trigger from 1 time update an update to all entities. However it gets - # tricky from inside async_turn_on and async_turn_off. - # - # If automatic polling is enabled, Home Assistant will call the entity - # update method after it is done calling all the services. This means that - # when we update, we know all commands have been processed. If we trigger - # the update from inside async_turn_on, the update will not capture the - # changes to the second entity until the next polling update because the - # throttle decorator will prevent the call. - - progress = None - light_progress = set() - group_progress = set() - - async def request_update(is_group, object_id): - """Request an update. - - We will only make 1 request to the server for updating at a time. If a - request is in progress, we will join the request that is in progress. - - This approach is possible because should_poll=True. That means that - Home Assistant will ask lights for updates during a polling cycle or - after it has called a service. - - We keep track of the lights that are waiting for the request to finish. - When new data comes in, we'll trigger an update for all non-waiting - lights. This covers the case where a service is called to enable 2 - lights but in the meanwhile some other light has changed too. - """ - nonlocal progress - - progress_set = group_progress if is_group else light_progress - progress_set.add(object_id) - - if progress is not None: - return await progress - - progress = asyncio.ensure_future(update_bridge()) - result = await progress - progress = None - light_progress.clear() - group_progress.clear() - return result - - async def update_bridge(): - """Update the values of the bridge. - - Will update lights and, if enabled, groups from the bridge. - """ - tasks = [] - tasks.append( - async_update_items( - hass, - config_entry, - bridge, - async_add_entities, - request_update, - False, - cur_lights, - light_progress, - ) - ) - - if allow_groups: - tasks.append( - async_update_items( - hass, - config_entry, - bridge, - async_add_entities, - request_update, - True, - cur_groups, - group_progress, - ) - ) - - await asyncio.wait(tasks) - - await update_bridge() - - -async def async_update_items( - hass, - config_entry, - bridge, - async_add_entities, - request_bridge_update, - is_group, - current, - progress_waiting, -): - """Update either groups or lights from the bridge.""" - if not bridge.authorized: + if not allow_groups: return - if is_group: - api_type = "group" - api = bridge.api.groups - else: - api_type = "light" - api = bridge.api.lights + group_coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + "group", + partial(async_safe_fetch, bridge, bridge.api.groups.update), + SCAN_INTERVAL, + Debouncer(bridge.hass, _LOGGER, REQUEST_REFRESH_DELAY, True), + ) + update_groups = partial( + async_update_items, + bridge, + bridge.api.groups, + {}, + async_add_entities, + partial(HueLight, group_coordinator, bridge, True), + ) + + group_coordinator.async_add_listener(update_groups) + await group_coordinator.async_refresh() + + bridge.reset_jobs.append( + lambda: group_coordinator.async_remove_listener(update_groups) + ) + + +async def async_safe_fetch(bridge, fetch_method): + """Safely fetch data.""" try: - start = monotonic() with async_timeout.timeout(4): - await bridge.async_request_call(api.update()) + return await bridge.async_request_call(fetch_method()) except aiohue.Unauthorized: await bridge.handle_unauthorized_error() - return - except (asyncio.TimeoutError, aiohue.AiohueException) as err: - _LOGGER.debug("Failed to fetch %s: %s", api_type, err) + raise UpdateFailed + except (asyncio.TimeoutError, aiohue.AiohueException): + raise UpdateFailed - if not bridge.available: - return - - _LOGGER.error("Unable to reach bridge %s (%s)", bridge.host, err) - bridge.available = False - - for item_id, item in current.items(): - if item_id not in progress_waiting: - item.async_schedule_update_ha_state() - - return - - finally: - _LOGGER.debug( - "Finished %s request in %.3f seconds", api_type, monotonic() - start - ) - - if not bridge.available: - _LOGGER.info("Reconnected to bridge %s", bridge.host) - bridge.available = True +@callback +def async_update_items(bridge, api, current, async_add_entities, create_item): + """Update items.""" new_items = [] for item_id in api: - if item_id not in current: - current[item_id] = HueLight( - api[item_id], request_bridge_update, bridge, is_group - ) + if item_id in current: + continue - new_items.append(current[item_id]) - elif item_id not in progress_waiting: - current[item_id].async_schedule_update_ha_state() + current[item_id] = create_item(api[item_id]) + new_items.append(current[item_id]) - await remove_devices(hass, config_entry, api, current) + bridge.hass.async_create_task(remove_devices(bridge, api, current)) if new_items: async_add_entities(new_items) @@ -251,10 +178,10 @@ async def async_update_items( class HueLight(Light): """Representation of a Hue light.""" - def __init__(self, light, request_bridge_update, bridge, is_group=False): + def __init__(self, coordinator, bridge, is_group, light): """Initialize the light.""" self.light = light - self.async_request_bridge_update = request_bridge_update + self.coordinator = coordinator self.bridge = bridge self.is_group = is_group @@ -289,6 +216,11 @@ class HueLight(Light): """Return the unique ID of this Hue light.""" return self.light.uniqueid + @property + def should_poll(self): + """No polling required.""" + return False + @property def device_id(self): """Return the ID of this Hue light.""" @@ -345,14 +277,10 @@ class HueLight(Light): @property def available(self): """Return if light is available.""" - return ( - self.bridge.available - and self.bridge.authorized - and ( - self.is_group - or self.bridge.allow_unreachable - or self.light.state["reachable"] - ) + return not self.coordinator.failed_last_update and ( + self.is_group + or self.bridge.allow_unreachable + or self.light.state["reachable"] ) @property @@ -379,7 +307,7 @@ class HueLight(Light): return None return { - "identifiers": {(hue.DOMAIN, self.device_id)}, + "identifiers": {(HUE_DOMAIN, self.device_id)}, "name": self.name, "manufacturer": self.light.manufacturername, # productname added in Hue Bridge API 1.24 @@ -387,9 +315,17 @@ class HueLight(Light): "model": self.light.productname or self.light.modelid, # Not yet exposed as properties in aiohue "sw_version": self.light.raw["swversion"], - "via_device": (hue.DOMAIN, self.bridge.api.config.bridgeid), + "via_device": (HUE_DOMAIN, self.bridge.api.config.bridgeid), } + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.coordinator.async_add_listener(self.async_write_ha_state) + + async def async_will_remove_from_hass(self): + """When entity will be removed from hass.""" + self.coordinator.async_remove_listener(self.async_write_ha_state) + async def async_turn_on(self, **kwargs): """Turn the specified or all lights on.""" command = {"on": True} @@ -440,6 +376,8 @@ class HueLight(Light): else: await self.bridge.async_request_call(self.light.set_state(**command)) + await self.coordinator.async_request_refresh() + async def async_turn_off(self, **kwargs): """Turn the specified or all lights off.""" command = {"on": False} @@ -463,9 +401,14 @@ class HueLight(Light): else: await self.bridge.async_request_call(self.light.set_state(**command)) + await self.coordinator.async_request_refresh() + async def async_update(self): - """Synchronize state with bridge.""" - await self.async_request_bridge_update(self.is_group, self.light.id) + """Update the entity. + + Only used by the generic entity update service. + """ + await self.coordinator.async_request_refresh() @property def device_state_attributes(self): diff --git a/homeassistant/components/hue/sensor.py b/homeassistant/components/hue/sensor.py index f2e02d49ecf..5fa2ed68389 100644 --- a/homeassistant/components/hue/sensor.py +++ b/homeassistant/components/hue/sensor.py @@ -1,11 +1,6 @@ """Hue sensor entities.""" from aiohue.sensors import TYPE_ZLL_LIGHTLEVEL, TYPE_ZLL_TEMPERATURE -from homeassistant.components.hue.sensor_base import ( - GenericZLLSensor, - SensorManager, - async_setup_entry as shared_async_setup_entry, -) from homeassistant.const import ( DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, @@ -13,27 +8,18 @@ from homeassistant.const import ( ) from homeassistant.helpers.entity import Entity +from .const import DOMAIN as HUE_DOMAIN +from .sensor_base import SENSOR_CONFIG_MAP, GenericZLLSensor + LIGHT_LEVEL_NAME_FORMAT = "{} light level" TEMPERATURE_NAME_FORMAT = "{} temperature" async def async_setup_entry(hass, config_entry, async_add_entities): """Defer sensor setup to the shared sensor module.""" - SensorManager.sensor_config_map.update( - { - TYPE_ZLL_LIGHTLEVEL: { - "binary": False, - "name_format": LIGHT_LEVEL_NAME_FORMAT, - "class": HueLightLevel, - }, - TYPE_ZLL_TEMPERATURE: { - "binary": False, - "name_format": TEMPERATURE_NAME_FORMAT, - "class": HueTemperature, - }, - } - ) - await shared_async_setup_entry(hass, config_entry, async_add_entities, binary=False) + await hass.data[HUE_DOMAIN][ + config_entry.entry_id + ].sensor_manager.async_register_component(False, async_add_entities) class GenericHueGaugeSensorEntity(GenericZLLSensor, Entity): @@ -91,3 +77,19 @@ class HueTemperature(GenericHueGaugeSensorEntity): return None return self.sensor.temperature / 100 + + +SENSOR_CONFIG_MAP.update( + { + TYPE_ZLL_LIGHTLEVEL: { + "binary": False, + "name_format": LIGHT_LEVEL_NAME_FORMAT, + "class": HueLightLevel, + }, + TYPE_ZLL_TEMPERATURE: { + "binary": False, + "name_format": TEMPERATURE_NAME_FORMAT, + "class": HueTemperature, + }, + } +) diff --git a/homeassistant/components/hue/sensor_base.py b/homeassistant/components/hue/sensor_base.py index f7882b102c0..3db07ba2e5b 100644 --- a/homeassistant/components/hue/sensor_base.py +++ b/homeassistant/components/hue/sensor_base.py @@ -2,22 +2,19 @@ import asyncio from datetime import timedelta import logging -from time import monotonic from aiohue import AiohueException, Unauthorized from aiohue.sensors import TYPE_ZLL_PRESENCE import async_timeout -from homeassistant.components import hue -from homeassistant.exceptions import NoEntitySpecifiedError -from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.util.dt import utcnow +from homeassistant.core import callback +from homeassistant.helpers import debounce, entity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .const import DOMAIN as HUE_DOMAIN, REQUEST_REFRESH_DELAY from .helpers import remove_devices -CURRENT_SENSORS_FORMAT = "{}_current_sensors" -SENSOR_MANAGER_FORMAT = "{}_sensor_manager" - +SENSOR_CONFIG_MAP = {} _LOGGER = logging.getLogger(__name__) @@ -29,22 +26,6 @@ def _device_id(aiohue_sensor): return device_id -async def async_setup_entry(hass, config_entry, async_add_entities, binary=False): - """Set up the Hue sensors from a config entry.""" - sensor_key = CURRENT_SENSORS_FORMAT.format(config_entry.data["host"]) - bridge = hass.data[hue.DOMAIN][config_entry.data["host"]] - hass.data[hue.DOMAIN].setdefault(sensor_key, {}) - - sm_key = SENSOR_MANAGER_FORMAT.format(config_entry.data["host"]) - manager = hass.data[hue.DOMAIN].get(sm_key) - if manager is None: - manager = SensorManager(hass, bridge, config_entry) - hass.data[hue.DOMAIN][sm_key] = manager - - manager.register_component(binary, async_add_entities) - await manager.start() - - class SensorManager: """Class that handles registering and updating Hue sensor entities. @@ -52,84 +33,60 @@ class SensorManager: """ SCAN_INTERVAL = timedelta(seconds=5) - sensor_config_map = {} - def __init__(self, hass, bridge, config_entry): + def __init__(self, bridge): """Initialize the sensor manager.""" - self.hass = hass self.bridge = bridge - self.config_entry = config_entry self._component_add_entities = {} - self._started = False + self.current = {} + self.coordinator = DataUpdateCoordinator( + bridge.hass, + _LOGGER, + "sensor", + self.async_update_data, + self.SCAN_INTERVAL, + debounce.Debouncer(bridge.hass, _LOGGER, REQUEST_REFRESH_DELAY, True), + ) - def register_component(self, binary, async_add_entities): + async def async_update_data(self): + """Update sensor data.""" + try: + with async_timeout.timeout(4): + return await self.bridge.async_request_call( + self.bridge.api.sensors.update() + ) + except Unauthorized: + await self.bridge.handle_unauthorized_error() + raise UpdateFailed + except (asyncio.TimeoutError, AiohueException): + raise UpdateFailed + + async def async_register_component(self, binary, async_add_entities): """Register async_add_entities methods for components.""" self._component_add_entities[binary] = async_add_entities - async def start(self): - """Start updating sensors from the bridge on a schedule.""" - # but only if it's not already started, and when we've got both - # async_add_entities methods - if self._started or len(self._component_add_entities) < 2: + if len(self._component_add_entities) < 2: return - self._started = True - _LOGGER.info( - "Starting sensor polling loop with %s second interval", - self.SCAN_INTERVAL.total_seconds(), + # We have all components available, start the updating. + self.coordinator.async_add_listener(self.async_update_items) + self.bridge.reset_jobs.append( + lambda: self.coordinator.async_remove_listener(self.async_update_items) ) + await self.coordinator.async_refresh() - async def async_update_bridge(now): - """Will update sensors from the bridge.""" - - # don't update when we are not authorized - if not self.bridge.authorized: - return - - await self.async_update_items() - - async_track_point_in_utc_time( - self.hass, async_update_bridge, utcnow() + self.SCAN_INTERVAL - ) - - await async_update_bridge(None) - - async def async_update_items(self): + @callback + def async_update_items(self): """Update sensors from the bridge.""" api = self.bridge.api.sensors - try: - start = monotonic() - with async_timeout.timeout(4): - await self.bridge.async_request_call(api.update()) - except Unauthorized: - await self.bridge.handle_unauthorized_error() + if len(self._component_add_entities) < 2: return - except (asyncio.TimeoutError, AiohueException) as err: - _LOGGER.debug("Failed to fetch sensor: %s", err) - - if not self.bridge.available: - return - - _LOGGER.error("Unable to reach bridge %s (%s)", self.bridge.host, err) - self.bridge.available = False - - return - - finally: - _LOGGER.debug( - "Finished sensor request in %.3f seconds", monotonic() - start - ) - - if not self.bridge.available: - _LOGGER.info("Reconnected to bridge %s", self.bridge.host) - self.bridge.available = True new_sensors = [] new_binary_sensors = [] primary_sensor_devices = {} - sensor_key = CURRENT_SENSORS_FORMAT.format(self.config_entry.data["host"]) - current = self.hass.data[hue.DOMAIN][sensor_key] + current = self.current # Physical Hue motion sensors present as three sensors in the API: a # presence sensor, a temperature sensor, and a light level sensor. Of @@ -155,11 +112,10 @@ class SensorManager: for item_id in api: existing = current.get(api[item_id].uniqueid) if existing is not None: - self.hass.async_create_task(existing.async_maybe_update_ha_state()) continue primary_sensor = None - sensor_config = self.sensor_config_map.get(api[item_id].type) + sensor_config = SENSOR_CONFIG_MAP.get(api[item_id].type) if sensor_config is None: continue @@ -177,22 +133,19 @@ class SensorManager: else: new_sensors.append(current[api[item_id].uniqueid]) - await remove_devices( - self.hass, - self.config_entry, - [value.uniqueid for value in api.values()], - current, + self.bridge.hass.async_create_task( + remove_devices( + self.bridge, [value.uniqueid for value in api.values()], current, + ) ) - async_add_sensor_entities = self._component_add_entities.get(False) - async_add_binary_entities = self._component_add_entities.get(True) - if new_sensors and async_add_sensor_entities: - async_add_sensor_entities(new_sensors) - if new_binary_sensors and async_add_binary_entities: - async_add_binary_entities(new_binary_sensors) + if new_sensors: + self._component_add_entities[False](new_sensors) + if new_binary_sensors: + self._component_add_entities[True](new_binary_sensors) -class GenericHueSensor: +class GenericHueSensor(entity.Entity): """Representation of a Hue sensor.""" should_poll = False @@ -230,10 +183,8 @@ class GenericHueSensor: @property def available(self): """Return if sensor is available.""" - return ( - self.bridge.available - and self.bridge.authorized - and (self.bridge.allow_unreachable or self.sensor.config["reachable"]) + return not self.bridge.sensor_manager.coordinator.failed_last_update and ( + self.bridge.allow_unreachable or self.sensor.config["reachable"] ) @property @@ -241,15 +192,24 @@ class GenericHueSensor: """Return detail of available software updates for this device.""" return self.primary_sensor.raw.get("swupdate", {}).get("state") - async def async_maybe_update_ha_state(self): - """Try to update Home Assistant with current state of entity. + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.bridge.sensor_manager.coordinator.async_add_listener( + self.async_write_ha_state + ) - But if it's not been added to hass yet, then don't throw an error. + async def async_will_remove_from_hass(self): + """When entity will be removed from hass.""" + self.bridge.sensor_manager.coordinator.async_remove_listener( + self.async_write_ha_state + ) + + async def async_update(self): + """Update the entity. + + Only used by the generic entity update service. """ - try: - await self._async_update_ha_state() - except (RuntimeError, NoEntitySpecifiedError): - _LOGGER.debug("Hue sensor update requested before it has been added.") + await self.bridge.sensor_manager.coordinator.coordinator.async_request_refresh() @property def device_info(self): @@ -258,12 +218,12 @@ class GenericHueSensor: Links individual entities together in the hass device registry. """ return { - "identifiers": {(hue.DOMAIN, self.device_id)}, + "identifiers": {(HUE_DOMAIN, self.device_id)}, "name": self.primary_sensor.name, "manufacturer": self.primary_sensor.manufacturername, "model": (self.primary_sensor.productname or self.primary_sensor.modelid), "sw_version": self.primary_sensor.swversion, - "via_device": (hue.DOMAIN, self.bridge.api.config.bridgeid), + "via_device": (HUE_DOMAIN, self.bridge.api.config.bridgeid), } diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py new file mode 100644 index 00000000000..5bacbdb7d11 --- /dev/null +++ b/homeassistant/helpers/debounce.py @@ -0,0 +1,77 @@ +"""Debounce helper.""" +import asyncio +from logging import Logger +from typing import Any, Awaitable, Callable, Optional + +from homeassistant.core import HomeAssistant, callback + + +class Debouncer: + """Class to rate limit calls to a specific command.""" + + def __init__( + self, + hass: HomeAssistant, + logger: Logger, + cooldown: float, + immediate: bool, + function: Optional[Callable[..., Awaitable[Any]]] = None, + ): + """Initialize debounce. + + immediate: indicate if the function needs to be called right away and + wait 0.3s until executing next invocation. + function: optional and can be instantiated later. + """ + self.hass = hass + self.logger = logger + self.function = function + self.cooldown = cooldown + self.immediate = immediate + self._timer_task: Optional[asyncio.TimerHandle] = None + self._execute_at_end_of_timer: bool = False + + async def async_call(self) -> None: + """Call the function.""" + assert self.function is not None + + if self._timer_task: + if not self._execute_at_end_of_timer: + self._execute_at_end_of_timer = True + + return + + if self.immediate: + await self.hass.async_add_job(self.function) # type: ignore + else: + self._execute_at_end_of_timer = True + + self._timer_task = self.hass.loop.call_later( + self.cooldown, + lambda: self.hass.async_create_task(self._handle_timer_finish()), + ) + + async def _handle_timer_finish(self) -> None: + """Handle a finished timer.""" + assert self.function is not None + + self._timer_task = None + + if not self._execute_at_end_of_timer: + return + + self._execute_at_end_of_timer = False + + try: + await self.hass.async_add_job(self.function) # type: ignore + except Exception: # pylint: disable=broad-except + self.logger.exception("Unexpected exception from %s", self.function) + + @callback + def async_cancel(self) -> None: + """Cancel any scheduled call.""" + if self._timer_task: + self._timer_task.cancel() + self._timer_task = None + + self._execute_at_end_of_timer = False diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index b3c8af6f50c..74faca6a1d2 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -225,7 +225,7 @@ track_point_in_time = threaded_listener_factory(async_track_point_in_time) @callback @bind_hass def async_track_point_in_utc_time( - hass: HomeAssistant, action: Callable[..., None], point_in_time: datetime + hass: HomeAssistant, action: Callable[..., Any], point_in_time: datetime ) -> CALLBACK_TYPE: """Add a listener that fires once after a specific point in UTC time.""" # Ensure point_in_time is UTC diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py new file mode 100644 index 00000000000..dc990637e31 --- /dev/null +++ b/homeassistant/helpers/update_coordinator.py @@ -0,0 +1,135 @@ +"""Helpers to help coordinate updates.""" +import asyncio +from datetime import datetime, timedelta +import logging +from time import monotonic +from typing import Any, Awaitable, Callable, List, Optional + +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util.dt import utcnow + +from .debounce import Debouncer + + +class UpdateFailed(Exception): + """Raised when an update has failed.""" + + +class DataUpdateCoordinator: + """Class to manage fetching data from single endpoint.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + name: str, + update_method: Callable[[], Awaitable], + update_interval: timedelta, + request_refresh_debouncer: Debouncer, + ): + """Initialize global data updater.""" + self.hass = hass + self.logger = logger + self.name = name + self.update_method = update_method + self.update_interval = update_interval + + self.data: Optional[Any] = None + + self._listeners: List[CALLBACK_TYPE] = [] + self._unsub_refresh: Optional[CALLBACK_TYPE] = None + self._request_refresh_task: Optional[asyncio.TimerHandle] = None + self.failed_last_update = False + self._debounced_refresh = request_refresh_debouncer + request_refresh_debouncer.function = self._async_do_refresh + + @callback + def async_add_listener(self, update_callback: CALLBACK_TYPE) -> None: + """Listen for data updates.""" + schedule_refresh = not self._listeners + + self._listeners.append(update_callback) + + # This is the first listener, set up interval. + if schedule_refresh: + self._schedule_refresh() + + @callback + def async_remove_listener(self, update_callback: CALLBACK_TYPE) -> None: + """Remove data update.""" + self._listeners.remove(update_callback) + + if not self._listeners and self._unsub_refresh: + self._unsub_refresh() + self._unsub_refresh = None + + async def async_refresh(self) -> None: + """Refresh the data.""" + if self._unsub_refresh: + self._unsub_refresh() + self._unsub_refresh = None + + await self._async_do_refresh() + + @callback + def _schedule_refresh(self) -> None: + """Schedule a refresh.""" + if self._unsub_refresh: + self._unsub_refresh() + self._unsub_refresh = None + + self._unsub_refresh = async_track_point_in_utc_time( + self.hass, self._handle_refresh_interval, utcnow() + self.update_interval + ) + + async def _handle_refresh_interval(self, _now: datetime) -> None: + """Handle a refresh interval occurrence.""" + self._unsub_refresh = None + await self._async_do_refresh() + + async def async_request_refresh(self) -> None: + """Request a refresh. + + Refresh will wait a bit to see if it can batch them. + """ + await self._debounced_refresh.async_call() + + async def _async_do_refresh(self) -> None: + """Time to update.""" + if self._unsub_refresh: + self._unsub_refresh() + self._unsub_refresh = None + + self._debounced_refresh.async_cancel() + + try: + start = monotonic() + self.data = await self.update_method() + + except UpdateFailed as err: + if not self.failed_last_update: + self.logger.error("Error fetching %s data: %s", self.name, err) + self.failed_last_update = True + + except Exception as err: # pylint: disable=broad-except + self.failed_last_update = True + self.logger.exception( + "Unexpected error fetching %s data: %s", self.name, err + ) + + else: + if self.failed_last_update: + self.failed_last_update = False + self.logger.info("Fetching %s data recovered") + + finally: + self.logger.debug( + "Finished fetching %s data in %.3f seconds", + self.name, + monotonic() - start, + ) + self._schedule_refresh() + + for update_callback in self._listeners: + update_callback() diff --git a/tests/components/hue/conftest.py b/tests/components/hue/conftest.py new file mode 100644 index 00000000000..49cd953a697 --- /dev/null +++ b/tests/components/hue/conftest.py @@ -0,0 +1,11 @@ +"""Test helpers for Hue.""" +from unittest.mock import patch + +import pytest + + +@pytest.fixture(autouse=True) +def no_request_delay(): + """Make the request refresh delay 0 for instant tests.""" + with patch("homeassistant.components.hue.light.REQUEST_REFRESH_DELAY", 0): + yield diff --git a/tests/components/hue/test_light.py b/tests/components/hue/test_light.py index 0f3e197b979..df3fe5f8998 100644 --- a/tests/components/hue/test_light.py +++ b/tests/components/hue/test_light.py @@ -179,11 +179,13 @@ LIGHT_GAMUT_TYPE = "A" def mock_bridge(hass): """Mock a Hue bridge.""" bridge = Mock( + hass=hass, available=True, authorized=True, allow_unreachable=False, allow_groups=False, api=Mock(), + reset_jobs=[], spec=hue.HueBridge, ) bridge.mock_requests = [] @@ -218,7 +220,6 @@ def mock_bridge(hass): async def setup_bridge(hass, mock_bridge): """Load the Hue light platform with the provided bridge.""" hass.config.components.add(hue.DOMAIN) - hass.data[hue.DOMAIN] = {"mock-host": mock_bridge} config_entry = config_entries.ConfigEntry( 1, hue.DOMAIN, @@ -228,6 +229,8 @@ async def setup_bridge(hass, mock_bridge): config_entries.CONN_CLASS_LOCAL_POLL, system_options={}, ) + mock_bridge.config_entry = config_entry + hass.data[hue.DOMAIN] = {config_entry.entry_id: mock_bridge} await hass.config_entries.async_forward_entry_setup(config_entry, "light") # To flush out the service call to update the group await hass.async_block_till_done() @@ -363,8 +366,8 @@ async def test_new_group_discovered(hass, mock_bridge): await hass.services.async_call( "light", "turn_on", {"entity_id": "light.group_1"}, blocking=True ) - # 2x group update, 2x light update, 1 turn on request - assert len(mock_bridge.mock_requests) == 5 + # 2x group update, 1x light update, 1 turn on request + assert len(mock_bridge.mock_requests) == 4 assert len(hass.states.async_all()) == 3 new_group = hass.states.get("light.group_3") @@ -443,8 +446,8 @@ async def test_group_removed(hass, mock_bridge): "light", "turn_on", {"entity_id": "light.group_1"}, blocking=True ) - # 2x group update, 2x light update, 1 turn on request - assert len(mock_bridge.mock_requests) == 5 + # 2x group update, 1x light update, 1 turn on request + assert len(mock_bridge.mock_requests) == 4 assert len(hass.states.async_all()) == 1 group = hass.states.get("light.group_1") @@ -524,8 +527,8 @@ async def test_other_group_update(hass, mock_bridge): await hass.services.async_call( "light", "turn_on", {"entity_id": "light.group_1"}, blocking=True ) - # 2x group update, 2x light update, 1 turn on request - assert len(mock_bridge.mock_requests) == 5 + # 2x group update, 1x light update, 1 turn on request + assert len(mock_bridge.mock_requests) == 4 assert len(hass.states.async_all()) == 2 group_2 = hass.states.get("light.group_2") @@ -599,7 +602,6 @@ async def test_update_timeout(hass, mock_bridge): await setup_bridge(hass, mock_bridge) assert len(mock_bridge.mock_requests) == 0 assert len(hass.states.async_all()) == 0 - assert mock_bridge.available is False async def test_update_unauthorized(hass, mock_bridge): @@ -701,7 +703,7 @@ def test_available(): colorgamuttype=LIGHT_GAMUT_TYPE, colorgamut=LIGHT_GAMUT, ), - request_bridge_update=None, + coordinator=Mock(failed_last_update=False), bridge=Mock(allow_unreachable=False), is_group=False, ) @@ -715,7 +717,7 @@ def test_available(): colorgamuttype=LIGHT_GAMUT_TYPE, colorgamut=LIGHT_GAMUT, ), - request_bridge_update=None, + coordinator=Mock(failed_last_update=False), bridge=Mock(allow_unreachable=True), is_group=False, ) @@ -729,7 +731,7 @@ def test_available(): colorgamuttype=LIGHT_GAMUT_TYPE, colorgamut=LIGHT_GAMUT, ), - request_bridge_update=None, + coordinator=Mock(failed_last_update=False), bridge=Mock(allow_unreachable=False), is_group=True, ) @@ -746,7 +748,7 @@ def test_hs_color(): colorgamuttype=LIGHT_GAMUT_TYPE, colorgamut=LIGHT_GAMUT, ), - request_bridge_update=None, + coordinator=Mock(failed_last_update=False), bridge=Mock(), is_group=False, ) @@ -760,7 +762,7 @@ def test_hs_color(): colorgamuttype=LIGHT_GAMUT_TYPE, colorgamut=LIGHT_GAMUT, ), - request_bridge_update=None, + coordinator=Mock(failed_last_update=False), bridge=Mock(), is_group=False, ) @@ -774,7 +776,7 @@ def test_hs_color(): colorgamuttype=LIGHT_GAMUT_TYPE, colorgamut=LIGHT_GAMUT, ), - request_bridge_update=None, + coordinator=Mock(failed_last_update=False), bridge=Mock(), is_group=False, ) diff --git a/tests/components/hue/test_sensor_base.py b/tests/components/hue/test_sensor_base.py index ad927767c30..78255116831 100644 --- a/tests/components/hue/test_sensor_base.py +++ b/tests/components/hue/test_sensor_base.py @@ -1,7 +1,6 @@ """Philips Hue sensors platform tests.""" import asyncio from collections import deque -import datetime import logging from unittest.mock import Mock @@ -252,16 +251,19 @@ SENSOR_RESPONSE = { } -def create_mock_bridge(): +def create_mock_bridge(hass): """Create a mock Hue bridge.""" bridge = Mock( + hass=hass, available=True, authorized=True, allow_unreachable=False, allow_groups=False, api=Mock(), + reset_jobs=[], spec=hue.HueBridge, ) + bridge.sensor_manager = hue_sensor_base.SensorManager(bridge) bridge.mock_requests = [] # We're using a deque so we can schedule multiple responses # and also means that `popleft()` will blow up if we get more updates @@ -289,13 +291,7 @@ def create_mock_bridge(): @pytest.fixture def mock_bridge(hass): """Mock a Hue bridge.""" - return create_mock_bridge() - - -@pytest.fixture -def increase_scan_interval(hass): - """Increase the SCAN_INTERVAL to prevent unexpected scans during tests.""" - hue_sensor_base.SensorManager.SCAN_INTERVAL = datetime.timedelta(days=365) + return create_mock_bridge(hass) async def setup_bridge(hass, mock_bridge, hostname=None): @@ -303,7 +299,6 @@ async def setup_bridge(hass, mock_bridge, hostname=None): if hostname is None: hostname = "mock-host" hass.config.components.add(hue.DOMAIN) - hass.data[hue.DOMAIN] = {hostname: mock_bridge} config_entry = config_entries.ConfigEntry( 1, hue.DOMAIN, @@ -313,6 +308,8 @@ async def setup_bridge(hass, mock_bridge, hostname=None): config_entries.CONN_CLASS_LOCAL_POLL, system_options={}, ) + mock_bridge.config_entry = config_entry + hass.data[hue.DOMAIN] = {config_entry.entry_id: mock_bridge} await hass.config_entries.async_forward_entry_setup(config_entry, "binary_sensor") await hass.config_entries.async_forward_entry_setup(config_entry, "sensor") # and make sure it completes before going further @@ -330,7 +327,7 @@ async def test_no_sensors(hass, mock_bridge): async def test_sensors_with_multiple_bridges(hass, mock_bridge): """Test the update_items function with some sensors.""" - mock_bridge_2 = create_mock_bridge() + mock_bridge_2 = create_mock_bridge(hass) mock_bridge_2.mock_sensor_responses.append( { "1": PRESENCE_SENSOR_3_PRESENT, @@ -412,11 +409,7 @@ async def test_new_sensor_discovered(hass, mock_bridge): mock_bridge.mock_sensor_responses.append(new_sensor_response) # Force updates to run again - sm_key = hue_sensor_base.SENSOR_MANAGER_FORMAT.format("mock-host") - sm = hass.data[hue.DOMAIN][sm_key] - await sm.async_update_items() - - # To flush out the service call to update the group + await mock_bridge.sensor_manager.coordinator.async_refresh() await hass.async_block_till_done() assert len(mock_bridge.mock_requests) == 2 @@ -443,9 +436,7 @@ async def test_sensor_removed(hass, mock_bridge): mock_bridge.mock_sensor_responses.append({k: SENSOR_RESPONSE[k] for k in keys}) # Force updates to run again - sm_key = hue_sensor_base.SENSOR_MANAGER_FORMAT.format("mock-host") - sm = hass.data[hue.DOMAIN][sm_key] - await sm.async_update_items() + await mock_bridge.sensor_manager.coordinator.async_refresh() # To flush out the service call to update the group await hass.async_block_till_done() @@ -466,7 +457,6 @@ async def test_update_timeout(hass, mock_bridge): await setup_bridge(hass, mock_bridge) assert len(mock_bridge.mock_requests) == 0 assert len(hass.states.async_all()) == 0 - assert mock_bridge.available is False async def test_update_unauthorized(hass, mock_bridge): diff --git a/tests/helpers/test_debounce.py b/tests/helpers/test_debounce.py new file mode 100644 index 00000000000..d7629a393a9 --- /dev/null +++ b/tests/helpers/test_debounce.py @@ -0,0 +1,62 @@ +"""Tests for debounce.""" +from asynctest import CoroutineMock + +from homeassistant.helpers import debounce + + +async def test_immediate_works(hass): + """Test immediate works.""" + calls = [] + debouncer = debounce.Debouncer( + hass, None, 0.01, True, CoroutineMock(side_effect=lambda: calls.append(None)) + ) + + await debouncer.async_call() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is False + + await debouncer.async_call() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is True + + debouncer.async_cancel() + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + + await debouncer.async_call() + assert len(calls) == 2 + await debouncer._handle_timer_finish() + assert len(calls) == 2 + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + + +async def test_not_immediate_works(hass): + """Test immediate works.""" + calls = [] + debouncer = debounce.Debouncer( + hass, None, 0.01, False, CoroutineMock(side_effect=lambda: calls.append(None)) + ) + + await debouncer.async_call() + assert len(calls) == 0 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is True + + await debouncer.async_call() + assert len(calls) == 0 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is True + + debouncer.async_cancel() + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + + await debouncer.async_call() + assert len(calls) == 0 + await debouncer._handle_timer_finish() + assert len(calls) == 1 + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False From ec3dc3dd16d8a56203989e9514f74332f697bfb9 Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Fri, 31 Jan 2020 18:25:54 +0200 Subject: [PATCH 17/54] Upgrade pysma, fix #27154 (#31346) --- homeassistant/components/sma/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json index 1c4b98c2911..a56fe7ab151 100644 --- a/homeassistant/components/sma/manifest.json +++ b/homeassistant/components/sma/manifest.json @@ -2,7 +2,7 @@ "domain": "sma", "name": "SMA Solar", "documentation": "https://www.home-assistant.io/integrations/sma", - "requirements": ["pysma==0.3.4"], + "requirements": ["pysma==0.3.5"], "dependencies": [], "codeowners": ["@kellerza"] } diff --git a/requirements_all.txt b/requirements_all.txt index d8bf0d47386..1e5ce4a80ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1497,7 +1497,7 @@ pysher==1.0.1 pysignalclirestapi==0.1.4 # homeassistant.components.sma -pysma==0.3.4 +pysma==0.3.5 # homeassistant.components.smartthings pysmartapp==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 137ca3ae9ba..7f52743fe59 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -513,7 +513,7 @@ pyps4-2ndscreen==1.0.6 pyqwikswitch==0.93 # homeassistant.components.sma -pysma==0.3.4 +pysma==0.3.5 # homeassistant.components.smartthings pysmartapp==0.3.2 From 7ee741d424a84d6489dbe2307d8606692f98f95a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Jan 2020 17:27:16 +0100 Subject: [PATCH 18/54] Partially Revert "Deprecate hide_if_away from device_tracker (#30833) (#31348) --- .../components/device_tracker/legacy.py | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 6d343de8cb2..da3c945bc86 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -519,24 +519,21 @@ async def async_load_config( This method is a coroutine. """ - dev_schema = vol.All( - cv.deprecated(CONF_AWAY_HIDE, invalidation_version="0.107.0"), - vol.Schema( - { - vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_ICON, default=None): vol.Any(None, cv.icon), - vol.Optional("track", default=False): cv.boolean, - vol.Optional(CONF_MAC, default=None): vol.Any( - None, vol.All(cv.string, vol.Upper) - ), - vol.Optional(CONF_AWAY_HIDE, default=DEFAULT_AWAY_HIDE): cv.boolean, - vol.Optional("gravatar", default=None): vol.Any(None, cv.string), - vol.Optional("picture", default=None): vol.Any(None, cv.string), - vol.Optional(CONF_CONSIDER_HOME, default=consider_home): vol.All( - cv.time_period, cv.positive_timedelta - ), - } - ), + dev_schema = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_ICON, default=None): vol.Any(None, cv.icon), + vol.Optional("track", default=False): cv.boolean, + vol.Optional(CONF_MAC, default=None): vol.Any( + None, vol.All(cv.string, vol.Upper) + ), + vol.Optional(CONF_AWAY_HIDE, default=DEFAULT_AWAY_HIDE): cv.boolean, + vol.Optional("gravatar", default=None): vol.Any(None, cv.string), + vol.Optional("picture", default=None): vol.Any(None, cv.string), + vol.Optional(CONF_CONSIDER_HOME, default=consider_home): vol.All( + cv.time_period, cv.positive_timedelta + ), + } ) result = [] try: From f26cb83fd58fde9618dadb0c41898a8c4b1c7998 Mon Sep 17 00:00:00 2001 From: ochlocracy <5885236+ochlocracy@users.noreply.github.com> Date: Fri, 31 Jan 2020 17:14:43 -0500 Subject: [PATCH 19/54] Protect for unknown state attributes. (#31354) --- .../components/alexa/capabilities.py | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 8b93b911fc4..02ebdf785cd 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -1326,14 +1326,20 @@ class AlexaRangeController(AlexaCapability): if name != "rangeValue": raise UnsupportedProperty(name) + # Return None for unavailable and unknown states. + # Allows the Alexa.EndpointHealth Interface to handle the unavailable state in a stateReport. + if self.entity.state in (STATE_UNAVAILABLE, STATE_UNKNOWN, None): + return None + # Fan Speed if self.instance == f"{fan.DOMAIN}.{fan.ATTR_SPEED}": - speed_list = self.entity.attributes[fan.ATTR_SPEED_LIST] - speed = self.entity.attributes[fan.ATTR_SPEED] - speed_index = next( - (i for i, v in enumerate(speed_list) if v == speed), None - ) - return speed_index + speed_list = self.entity.attributes.get(fan.ATTR_SPEED_LIST) + speed = self.entity.attributes.get(fan.ATTR_SPEED) + if speed_list is not None and speed is not None: + speed_index = next( + (i for i, v in enumerate(speed_list) if v == speed), None + ) + return speed_index # Cover Position if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": @@ -1349,12 +1355,13 @@ class AlexaRangeController(AlexaCapability): # Vacuum Fan Speed if self.instance == f"{vacuum.DOMAIN}.{vacuum.ATTR_FAN_SPEED}": - speed_list = self.entity.attributes[vacuum.ATTR_FAN_SPEED_LIST] - speed = self.entity.attributes[vacuum.ATTR_FAN_SPEED] - speed_index = next( - (i for i, v in enumerate(speed_list) if v == speed), None - ) - return speed_index + speed_list = self.entity.attributes.get(vacuum.ATTR_FAN_SPEED_LIST) + speed = self.entity.attributes.get(vacuum.ATTR_FAN_SPEED) + if speed_list is not None and speed is not None: + speed_index = next( + (i for i, v in enumerate(speed_list) if v == speed), None + ) + return speed_index return None From 8f8468f0160c10440f98ac3ec75cfa6792760299 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Fri, 31 Jan 2020 13:55:06 -0500 Subject: [PATCH 20/54] bump quirks (#31355) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 759cb4489fe..f7f70db590a 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ "bellows-homeassistant==0.13.1", - "zha-quirks==0.0.31", + "zha-quirks==0.0.32", "zigpy-deconz==0.7.0", "zigpy-homeassistant==0.13.0", "zigpy-xbee-homeassistant==0.9.0", diff --git a/requirements_all.txt b/requirements_all.txt index 1e5ce4a80ef..a4cb2e684b1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2118,7 +2118,7 @@ zengge==0.2 zeroconf==0.24.4 # homeassistant.components.zha -zha-quirks==0.0.31 +zha-quirks==0.0.32 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7f52743fe59..f5f30a518d8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -693,7 +693,7 @@ yahooweather==0.10 zeroconf==0.24.4 # homeassistant.components.zha -zha-quirks==0.0.31 +zha-quirks==0.0.32 # homeassistant.components.zha zigpy-deconz==0.7.0 From 25d6bc348c964999a20242e07034c4f0124e6fe2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 Jan 2020 14:01:25 -0800 Subject: [PATCH 21/54] Fix wemo device types for lights (#31360) --- homeassistant/components/wemo/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index a615b3f5dfd..7d2cf9afc43 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -114,7 +114,7 @@ class WemoLight(Light): return { "name": self.wemo.name, "identifiers": {(WEMO_DOMAIN, self.wemo.uniqueID)}, - "model": self.wemo.model_name, + "model": self.wemo.device_type, "manufacturer": "Belkin", } From 7ef352701c91e2620033eb74a797e4cf78613cad Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 Jan 2020 14:48:26 -0800 Subject: [PATCH 22/54] Bumped version to 0.105.0b3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 8f7d7bbaeb0..b9d2f833b41 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 105 -PATCH_VERSION = "0b2" +PATCH_VERSION = "0b3" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From e1fd46d6db5c3a940e2ebbc6e1b72a0f4197376a Mon Sep 17 00:00:00 2001 From: Dan Lehman <53992354+DanTLehman@users.noreply.github.com> Date: Sat, 1 Feb 2020 16:42:37 +1100 Subject: [PATCH 23/54] Updated wemo lights fix for #31360 (#31369) --- homeassistant/components/wemo/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 7d2cf9afc43..5988019e66f 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -114,7 +114,7 @@ class WemoLight(Light): return { "name": self.wemo.name, "identifiers": {(WEMO_DOMAIN, self.wemo.uniqueID)}, - "model": self.wemo.device_type, + "model": type(self.wemo).__name__, "manufacturer": "Belkin", } From d382b0ba4236565ad9bc5bfae64f7d2131cad3c6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Feb 2020 00:29:29 -0800 Subject: [PATCH 24/54] Bumped version to 0.105.0b4 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index b9d2f833b41..b340146bae3 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 105 -PATCH_VERSION = "0b3" +PATCH_VERSION = "0b4" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From 11fcb2cc7fc742f8da22f7072f18ec3d5a764cf8 Mon Sep 17 00:00:00 2001 From: springstan <46536646+springstan@users.noreply.github.com> Date: Sat, 1 Feb 2020 10:01:42 +0100 Subject: [PATCH 25/54] Fix auto_bypass in alarmdecoder (#30961) * Fix auto_bypass in alarmdecoder * Address review comments: used dict[key] and renamed variable * Use dict[key] for required or optional config keys with default values --- homeassistant/components/alarmdecoder/__init__.py | 11 +++++++---- .../components/alarmdecoder/alarm_control_panel.py | 14 +++++++++----- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/alarmdecoder/__init__.py b/homeassistant/components/alarmdecoder/__init__.py index 833156e98b2..a990de9bf98 100644 --- a/homeassistant/components/alarmdecoder/__init__.py +++ b/homeassistant/components/alarmdecoder/__init__.py @@ -118,11 +118,12 @@ def setup(hass, config): conf = config.get(DOMAIN) restart = False - device = conf.get(CONF_DEVICE) - display = conf.get(CONF_PANEL_DISPLAY) + device = conf[CONF_DEVICE] + display = conf[CONF_PANEL_DISPLAY] + auto_bypass = conf[CONF_AUTO_BYPASS] zones = conf.get(CONF_ZONES) - device_type = device.get(CONF_DEVICE_TYPE) + device_type = device[CONF_DEVICE_TYPE] host = DEFAULT_DEVICE_HOST port = DEFAULT_DEVICE_PORT path = DEFAULT_DEVICE_PATH @@ -204,7 +205,9 @@ def setup(hass, config): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_alarmdecoder) - load_platform(hass, "alarm_control_panel", DOMAIN, conf, config) + load_platform( + hass, "alarm_control_panel", DOMAIN, {CONF_AUTO_BYPASS: auto_bypass}, config + ) if zones: load_platform(hass, "binary_sensor", DOMAIN, {CONF_ZONES: zones}, config) diff --git a/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant/components/alarmdecoder/alarm_control_panel.py index 70f3e67e15b..e217bcb6cf9 100644 --- a/homeassistant/components/alarmdecoder/alarm_control_panel.py +++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) import homeassistant.helpers.config_validation as cv -from . import DATA_AD, DOMAIN, SIGNAL_PANEL_MESSAGE +from . import CONF_AUTO_BYPASS, DATA_AD, DOMAIN, SIGNAL_PANEL_MESSAGE _LOGGER = logging.getLogger(__name__) @@ -35,13 +35,17 @@ ALARM_KEYPRESS_SCHEMA = vol.Schema({vol.Required(ATTR_KEYPRESS): cv.string}) def setup_platform(hass, config, add_entities, discovery_info=None): """Set up for AlarmDecoder alarm panels.""" - device = AlarmDecoderAlarmPanel(discovery_info["autobypass"]) - add_entities([device]) + if discovery_info is None: + return + + auto_bypass = discovery_info[CONF_AUTO_BYPASS] + entity = AlarmDecoderAlarmPanel(auto_bypass) + add_entities([entity]) def alarm_toggle_chime_handler(service): """Register toggle chime handler.""" code = service.data.get(ATTR_CODE) - device.alarm_toggle_chime(code) + entity.alarm_toggle_chime(code) hass.services.register( DOMAIN, @@ -53,7 +57,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): def alarm_keypress_handler(service): """Register keypress handler.""" keypress = service.data[ATTR_KEYPRESS] - device.alarm_keypress(keypress) + entity.alarm_keypress(keypress) hass.services.register( DOMAIN, From 59a9ca71ceb99f4b4ab02051d7415335ac244cf7 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 1 Feb 2020 17:08:49 +0100 Subject: [PATCH 26/54] deCONZ - Add support for new switch type (#31362) --- homeassistant/components/deconz/const.py | 2 +- homeassistant/components/deconz/light.py | 9 ++++++--- tests/components/deconz/test_light.py | 19 ++++++++++++++++--- tests/components/deconz/test_switch.py | 13 ++++++++++++- 4 files changed, 35 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index e951e61fde7..293e0d9719c 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -47,7 +47,7 @@ DAMPERS = ["Level controllable output"] WINDOW_COVERS = ["Window covering device"] COVER_TYPES = DAMPERS + WINDOW_COVERS -POWER_PLUGS = ["On/Off plug-in unit", "Smart plug"] +POWER_PLUGS = ["On/Off light", "On/Off plug-in unit", "Smart plug"] SIRENS = ["Warning device"] SWITCH_TYPES = POWER_PLUGS + SIRENS diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 15d3b828741..ee22c86c44a 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -90,9 +90,12 @@ class DeconzLight(DeconzDevice, Light): """Set up light.""" super().__init__(device, gateway) - self._features = SUPPORT_BRIGHTNESS - self._features |= SUPPORT_FLASH - self._features |= SUPPORT_TRANSITION + self._features = 0 + + if self._device.brightness is not None: + self._features |= SUPPORT_BRIGHTNESS + self._features |= SUPPORT_FLASH + self._features |= SUPPORT_TRANSITION if self._device.ct is not None: self._features |= SUPPORT_COLOR_TEMP diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index 8658eed3eb5..fbe3dd0bb32 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -59,6 +59,12 @@ LIGHTS = { "state": {"reachable": True}, "uniqueid": "00:00:00:00:00:00:00:02-00", }, + "4": { + "name": "On off light", + "state": {"on": True, "reachable": True}, + "type": "On and Off light", + "uniqueid": "00:00:00:00:00:00:00:03-00", + }, } @@ -91,18 +97,25 @@ async def test_lights_and_groups(hass): assert "light.light_group" in gateway.deconz_ids assert "light.empty_group" not in gateway.deconz_ids assert "light.on_off_switch" not in gateway.deconz_ids - # 4 entities - assert len(hass.states.async_all()) == 4 + assert "light.on_off_light" in gateway.deconz_ids + + assert len(hass.states.async_all()) == 5 rgb_light = hass.states.get("light.rgb_light") assert rgb_light.state == "on" assert rgb_light.attributes["brightness"] == 255 assert rgb_light.attributes["hs_color"] == (224.235, 100.0) assert rgb_light.attributes["is_deconz_group"] is False + assert rgb_light.attributes["supported_features"] == 61 tunable_white_light = hass.states.get("light.tunable_white_light") assert tunable_white_light.state == "on" assert tunable_white_light.attributes["color_temp"] == 2500 + assert tunable_white_light.attributes["supported_features"] == 2 + + on_off_light = hass.states.get("light.on_off_light") + assert on_off_light.state == "on" + assert on_off_light.attributes["supported_features"] == 0 light_group = hass.states.get("light.light_group") assert light_group.state == "on" @@ -219,7 +232,7 @@ async def test_disable_light_groups(hass): assert "light.empty_group" not in gateway.deconz_ids assert "light.on_off_switch" not in gateway.deconz_ids # 3 entities - assert len(hass.states.async_all()) == 3 + assert len(hass.states.async_all()) == 4 rgb_light = hass.states.get("light.rgb_light") assert rgb_light is not None diff --git a/tests/components/deconz/test_switch.py b/tests/components/deconz/test_switch.py index 553e4f1f167..bb48a6243c6 100644 --- a/tests/components/deconz/test_switch.py +++ b/tests/components/deconz/test_switch.py @@ -38,6 +38,13 @@ SWITCHES = { "state": {"reachable": True}, "uniqueid": "00:00:00:00:00:00:00:03-00", }, + "5": { + "id": "On off relay id", + "name": "On off relay", + "state": {"on": True, "reachable": True}, + "type": "On/Off light", + "uniqueid": "00:00:00:00:00:00:00:04-00", + }, } @@ -68,7 +75,8 @@ async def test_switches(hass): assert "switch.smart_plug" in gateway.deconz_ids assert "switch.warning_device" in gateway.deconz_ids assert "switch.unsupported_switch" not in gateway.deconz_ids - assert len(hass.states.async_all()) == 4 + assert "switch.on_off_relay" in gateway.deconz_ids + assert len(hass.states.async_all()) == 5 on_off_switch = hass.states.get("switch.on_off_switch") assert on_off_switch.state == "on" @@ -79,6 +87,9 @@ async def test_switches(hass): warning_device = hass.states.get("switch.warning_device") assert warning_device.state == "on" + on_off_relay = hass.states.get("switch.on_off_relay") + assert on_off_relay.state == "on" + state_changed_event = { "t": "event", "e": "changed", From 3b9306556819d6fc6f82c0f15f042124d31ea1ca Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 2 Feb 2020 15:01:52 -0800 Subject: [PATCH 27/54] Add dump service to MQTT integration (#31370) * Add dump service to MQTT integration * Lint --- homeassistant/components/mqtt/__init__.py | 39 +++++++++++++++++++-- homeassistant/components/mqtt/services.yaml | 11 ++++++ tests/components/mqtt/test_init.py | 25 +++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index a6db90382bf..f64c643f0f4 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -36,7 +36,7 @@ from homeassistant.exceptions import ( HomeAssistantError, Unauthorized, ) -from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers import config_validation as cv, event, template from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType, HomeAssistantType, ServiceDataType @@ -68,6 +68,7 @@ DATA_MQTT_CONFIG = "mqtt_config" DATA_MQTT_HASS_CONFIG = "mqtt_hass_config" SERVICE_PUBLISH = "publish" +SERVICE_DUMP = "dump" CONF_EMBEDDED = "embedded" @@ -651,7 +652,7 @@ async def async_setup_entry(hass, entry): if result == CONNECTION_FAILED_RECOVERABLE: raise ConfigEntryNotReady - async def async_stop_mqtt(event: Event): + async def async_stop_mqtt(_event: Event): """Stop MQTT component.""" await hass.data[DATA_MQTT].async_disconnect() @@ -683,6 +684,40 @@ async def async_setup_entry(hass, entry): DOMAIN, SERVICE_PUBLISH, async_publish_service, schema=MQTT_PUBLISH_SCHEMA ) + async def async_dump_service(call: ServiceCall): + """Handle MQTT dump service calls.""" + messages = [] + + @callback + def collect_msg(msg): + messages.append((msg.topic, msg.payload.replace("\n", ""))) + + unsub = await async_subscribe(hass, call.data["topic"], collect_msg) + + def write_dump(): + with open(hass.config.path("mqtt_dump.txt"), "wt") as fp: + for msg in messages: + fp.write(",".join(msg) + "\n") + + async def finish_dump(_): + """Write dump to file.""" + unsub() + await hass.async_add_executor_job(write_dump) + + event.async_call_later(hass, call.data["duration"], finish_dump) + + hass.services.async_register( + DOMAIN, + SERVICE_DUMP, + async_dump_service, + schema=vol.Schema( + { + vol.Required("topic"): valid_subscribe_topic, + vol.Optional("duration", default=5): int, + } + ), + ) + if conf.get(CONF_DISCOVERY): await _async_setup_discovery( hass, conf, hass.data[DATA_MQTT_HASS_CONFIG], entry diff --git a/homeassistant/components/mqtt/services.yaml b/homeassistant/components/mqtt/services.yaml index e338e21802a..77b3e3b27a1 100644 --- a/homeassistant/components/mqtt/services.yaml +++ b/homeassistant/components/mqtt/services.yaml @@ -24,3 +24,14 @@ publish: description: If message should have the retain flag set. example: true default: false + +dump: + description: Dump messages on a topic selector to the 'mqtt_dump.txt' file in your config folder. + fields: + topic: + description: topic to listen to + example: "openzwave/#" + duration: + description: how long we should listen for messages in seconds + example: 5 + default: 5 diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 682aacdb746..dc79cb8a2e7 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -1,4 +1,5 @@ """The tests for the MQTT component.""" +from datetime import timedelta import ssl import unittest from unittest import mock @@ -16,10 +17,12 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow from tests.common import ( MockConfigEntry, async_fire_mqtt_message, + async_fire_time_changed, async_mock_mqtt_component, fire_mqtt_message, get_test_home_assistant, @@ -803,3 +806,25 @@ async def test_mqtt_ws_subscription(hass, hass_ws_client): await client.send_json({"id": 8, "type": "unsubscribe_events", "subscription": 5}) response = await client.receive_json() assert response["success"] + + +async def test_dump_service(hass): + """Test that we can dump a topic.""" + await async_mock_mqtt_component(hass) + + mock_open = mock.mock_open() + + await hass.services.async_call( + "mqtt", "dump", {"topic": "bla/#", "duration": 3}, blocking=True + ) + async_fire_mqtt_message(hass, "bla/1", "test1") + async_fire_mqtt_message(hass, "bla/2", "test2") + + with mock.patch("homeassistant.components.mqtt.open", mock_open): + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) + await hass.async_block_till_done() + + writes = mock_open.return_value.write.mock_calls + assert len(writes) == 2 + assert writes[0][1][0] == "bla/1,test1\n" + assert writes[1][1][0] == "bla/2,test2\n" From d91f9fc2f5c4312c540e62373597ed164f1d5a53 Mon Sep 17 00:00:00 2001 From: ochlocracy <5885236+ochlocracy@users.noreply.github.com> Date: Sat, 1 Feb 2020 19:44:40 -0500 Subject: [PATCH 28/54] Filter int in fan speed_list when yielding RangeController in Alexa (#31375) * Allow for int in fan speed_list. * Test for int in fan speed_list. * prevent yielding preset for int labels. --- .../components/alexa/capabilities.py | 8 ++++++-- tests/components/alexa/test_smart_home.py | 19 ++++++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 02ebdf785cd..eb1474aed7e 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -1386,12 +1386,16 @@ class AlexaRangeController(AlexaCapability): precision=1, ) for index, speed in enumerate(speed_list): - labels = [speed.replace("_", " ")] + labels = [] + if isinstance(speed, str): + labels.append(speed.replace("_", " ")) if index == 1: labels.append(AlexaGlobalCatalog.VALUE_MINIMUM) if index == max_value: labels.append(AlexaGlobalCatalog.VALUE_MAXIMUM) - self._resource.add_preset(value=index, labels=labels) + + if len(labels) > 0: + self._resource.add_preset(value=index, labels=labels) return self._resource.serialize_capability_resources() diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 588192e6c3a..ca6b1e1ccb6 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -669,7 +669,7 @@ async def test_fan_range(hass): { "friendly_name": "Test fan 5", "supported_features": 1, - "speed_list": ["off", "low", "medium", "high", "turbo", "warp_speed"], + "speed_list": ["off", "low", "medium", "high", "turbo", 5, "warp_speed"], "speed": "medium", }, ) @@ -705,7 +705,7 @@ async def test_fan_range(hass): supported_range = configuration["supportedRange"] assert supported_range["minimumValue"] == 0 - assert supported_range["maximumValue"] == 5 + assert supported_range["maximumValue"] == 6 assert supported_range["precision"] == 1 presets = configuration["presets"] @@ -737,8 +737,10 @@ async def test_fan_range(hass): }, } in presets + assert {"rangeValue": 5} not in presets + assert { - "rangeValue": 5, + "rangeValue": 6, "presetResources": { "friendlyNames": [ {"@type": "text", "value": {"text": "warp speed", "locale": "en-US"}}, @@ -767,6 +769,17 @@ async def test_fan_range(hass): payload={"rangeValue": 5}, instance="fan.speed", ) + assert call.data["speed"] == 5 + + call, _ = await assert_request_calls_service( + "Alexa.RangeController", + "SetRangeValue", + "fan#test_5", + "fan.set_speed", + hass, + payload={"rangeValue": 6}, + instance="fan.speed", + ) assert call.data["speed"] == "warp_speed" await assert_range_changes( From aaea55efede50c187abbf6ecd212435b349b0448 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 1 Feb 2020 18:11:05 +0100 Subject: [PATCH 29/54] deCONZ - Services normalize bridge id (#31378) * Services should also make sure to normalize bridge id since users do not know to manage themselves --- homeassistant/components/deconz/services.py | 12 ++++++------ requirements_test_pre_commit.txt | 1 + 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py index f893b9880fd..f1b19c79fce 100644 --- a/homeassistant/components/deconz/services.py +++ b/homeassistant/components/deconz/services.py @@ -1,4 +1,5 @@ """deCONZ services.""" +from pydeconz.utils import normalize_bridge_id import voluptuous as vol from homeassistant.helpers import config_validation as cv @@ -97,15 +98,14 @@ async def async_configure_service(hass, data): See Dresden Elektroniks REST API documentation for details: http://dresden-elektronik.github.io/deconz-rest-doc/rest/ """ - bridgeid = data.get(CONF_BRIDGE_ID) + gateway = get_master_gateway(hass) + if CONF_BRIDGE_ID in data: + gateway = hass.data[DOMAIN][normalize_bridge_id(data[CONF_BRIDGE_ID])] + field = data.get(SERVICE_FIELD, "") entity_id = data.get(SERVICE_ENTITY) data = data[SERVICE_DATA] - gateway = get_master_gateway(hass) - if bridgeid: - gateway = hass.data[DOMAIN][bridgeid] - if entity_id: try: field = gateway.deconz_ids[entity_id] + field @@ -120,7 +120,7 @@ async def async_refresh_devices_service(hass, data): """Refresh available devices from deCONZ.""" gateway = get_master_gateway(hass) if CONF_BRIDGE_ID in data: - gateway = hass.data[DOMAIN][data[CONF_BRIDGE_ID]] + gateway = hass.data[DOMAIN][normalize_bridge_id(data[CONF_BRIDGE_ID])] groups = set(gateway.api.groups.keys()) lights = set(gateway.api.lights.keys()) diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 8af2cbb6123..87ff3604dd6 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -2,6 +2,7 @@ bandit==1.6.2 black==19.10b0 +codespell==v1.16.0 flake8-docstrings==1.5.0 flake8==3.7.9 isort==v4.3.21 From fb26dd3028264da19d7abdad09116295743039b1 Mon Sep 17 00:00:00 2001 From: springstan <46536646+springstan@users.noreply.github.com> Date: Sat, 1 Feb 2020 22:02:39 +0100 Subject: [PATCH 30/54] Revert "Bump alarmdecoder to 1.13.9 (#30303)" (#31385) This reverts commit f11d39f8ebf281a2069dc9f01777869c59405be4. --- homeassistant/components/alarmdecoder/manifest.json | 4 +++- requirements_all.txt | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alarmdecoder/manifest.json b/homeassistant/components/alarmdecoder/manifest.json index fd0e79cef8a..f146f6f4a7e 100644 --- a/homeassistant/components/alarmdecoder/manifest.json +++ b/homeassistant/components/alarmdecoder/manifest.json @@ -2,7 +2,9 @@ "domain": "alarmdecoder", "name": "AlarmDecoder", "documentation": "https://www.home-assistant.io/integrations/alarmdecoder", - "requirements": ["alarmdecoder==1.13.9"], + "requirements": [ + "alarmdecoder==1.13.2" + ], "dependencies": [], "codeowners": [] } diff --git a/requirements_all.txt b/requirements_all.txt index a4cb2e684b1..a3f6606eb9a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -208,7 +208,7 @@ airly==0.0.2 aladdin_connect==0.3 # homeassistant.components.alarmdecoder -alarmdecoder==1.13.9 +alarmdecoder==1.13.2 # homeassistant.components.alpha_vantage alpha_vantage==2.1.2 From 5b7a65c5eaff17a6a4eab1a4a822f995157bdb0d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 2 Feb 2020 15:36:39 -0800 Subject: [PATCH 31/54] Fix service annotations (#31402) * Fix service annotations * Filter area_id from service data * Fix services not accepting entities * Typo --- .../components/input_select/__init__.py | 17 +++-- .../components/media_player/__init__.py | 65 ++++++++++++++----- homeassistant/helpers/config_validation.py | 4 +- homeassistant/helpers/service.py | 9 ++- tests/helpers/test_service.py | 12 +++- 5 files changed, 78 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py index 26a07e600f3..6044375d8a8 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -143,11 +143,15 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: ) component.async_register_entity_service( - SERVICE_SELECT_NEXT, {}, lambda entity, call: entity.async_offset_index(1) + SERVICE_SELECT_NEXT, + {}, + callback(lambda entity, call: entity.async_offset_index(1)), ) component.async_register_entity_service( - SERVICE_SELECT_PREVIOUS, {}, lambda entity, call: entity.async_offset_index(-1) + SERVICE_SELECT_PREVIOUS, + {}, + callback(lambda entity, call: entity.async_offset_index(-1)), ) component.async_register_entity_service( @@ -248,7 +252,8 @@ class InputSelect(RestoreEntity): """Return unique id for the entity.""" return self._config[CONF_ID] - async def async_select_option(self, option): + @callback + def async_select_option(self, option): """Select new option.""" if option not in self._options: _LOGGER.warning( @@ -260,14 +265,16 @@ class InputSelect(RestoreEntity): self._current_option = option self.async_write_ha_state() - async def async_offset_index(self, offset): + @callback + def async_offset_index(self, offset): """Offset current index.""" current_index = self._options.index(self._current_option) new_index = (current_index + offset) % len(self._options) self._current_option = self._options[new_index] self.async_write_ha_state() - async def async_set_options(self, options): + @callback + def async_set_options(self, options): """Set options.""" self._current_option = options[0] self._config[CONF_OPTIONS] = options diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 28951df545a..2911a143a3c 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -173,6 +173,23 @@ SCHEMA_WEBSOCKET_GET_THUMBNAIL = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.exten ) +def _rename_keys(**keys): + """Create validator that renames keys. + + Necessary because the service schema names do not match the command parameters. + + Async friendly. + """ + + def rename(value): + for to_key, from_key in keys.items(): + if from_key in value: + value[to_key] = value.pop(from_key) + return value + + return rename + + async def async_setup(hass, config): """Track states and offer events for media_players.""" component = hass.data[DOMAIN] = EntityComponent( @@ -238,30 +255,39 @@ async def async_setup(hass, config): ) component.async_register_entity_service( SERVICE_VOLUME_SET, - {vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float}, - lambda entity, call: entity.async_set_volume_level( - volume=call.data[ATTR_MEDIA_VOLUME_LEVEL] + vol.All( + cv.make_entity_service_schema( + {vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float} + ), + _rename_keys(volume=ATTR_MEDIA_VOLUME_LEVEL), ), + "async_set_volume_level", [SUPPORT_VOLUME_SET], ) component.async_register_entity_service( SERVICE_VOLUME_MUTE, - {vol.Required(ATTR_MEDIA_VOLUME_MUTED): cv.boolean}, - lambda entity, call: entity.async_mute_volume( - mute=call.data[ATTR_MEDIA_VOLUME_MUTED] + vol.All( + cv.make_entity_service_schema( + {vol.Required(ATTR_MEDIA_VOLUME_MUTED): cv.boolean} + ), + _rename_keys(mute=ATTR_MEDIA_VOLUME_MUTED), ), + "async_mute_volume", [SUPPORT_VOLUME_MUTE], ) component.async_register_entity_service( SERVICE_MEDIA_SEEK, - { - vol.Required(ATTR_MEDIA_SEEK_POSITION): vol.All( - vol.Coerce(float), vol.Range(min=0) - ) - }, - lambda entity, call: entity.async_media_seek( - position=call.data[ATTR_MEDIA_SEEK_POSITION] + vol.All( + cv.make_entity_service_schema( + { + vol.Required(ATTR_MEDIA_SEEK_POSITION): vol.All( + vol.Coerce(float), vol.Range(min=0) + ) + } + ), + _rename_keys(position=ATTR_MEDIA_SEEK_POSITION), ), + "async_media_seek", [SUPPORT_SEEK], ) component.async_register_entity_service( @@ -278,12 +304,15 @@ async def async_setup(hass, config): ) component.async_register_entity_service( SERVICE_PLAY_MEDIA, - MEDIA_PLAYER_PLAY_MEDIA_SCHEMA, - lambda entity, call: entity.async_play_media( - media_type=call.data[ATTR_MEDIA_CONTENT_TYPE], - media_id=call.data[ATTR_MEDIA_CONTENT_ID], - enqueue=call.data.get(ATTR_MEDIA_ENQUEUE), + vol.All( + cv.make_entity_service_schema(MEDIA_PLAYER_PLAY_MEDIA_SCHEMA), + _rename_keys( + media_type=ATTR_MEDIA_CONTENT_TYPE, + media_id=ATTR_MEDIA_CONTENT_ID, + enqueue=ATTR_MEDIA_ENQUEUE, + ), ), + "async_play_media", [SUPPORT_PLAY_MEDIA], ) component.async_register_entity_service( diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index e357a2ba622..852948220de 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -724,6 +724,8 @@ PLATFORM_SCHEMA = vol.Schema( PLATFORM_SCHEMA_BASE = PLATFORM_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) +ENTITY_SERVICE_FIELDS = (ATTR_ENTITY_ID, ATTR_AREA_ID) + def make_entity_service_schema( schema: dict, *, extra: int = vol.PREVENT_EXTRA @@ -738,7 +740,7 @@ def make_entity_service_schema( }, extra=extra, ), - has_at_least_one_key(ATTR_ENTITY_ID, ATTR_AREA_ID), + has_at_least_one_key(*ENTITY_SERVICE_FIELDS), ) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 36bfd9c8cb0..b30cab3fbd4 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -283,7 +283,11 @@ async def entity_service_call(hass, platforms, func, call, required_features=Non # If the service function is a string, we'll pass it the service call data if isinstance(func, str): - data = {key: val for key, val in call.data.items() if key != ATTR_ENTITY_ID} + data = { + key: val + for key, val in call.data.items() + if key not in cv.ENTITY_SERVICE_FIELDS + } # If the service function is not a string, we pass the service call else: data = call @@ -323,6 +327,7 @@ async def entity_service_call(hass, platforms, func, call, required_features=Non for platform in platforms: platform_entities = [] for entity in platform.entities.values(): + if entity.entity_id not in entity_ids: continue @@ -380,7 +385,7 @@ async def _handle_service_platform_call( if asyncio.iscoroutine(result): _LOGGER.error( - "Service %s for %s incorrectly returns a coroutine object. Await result instead in service handler. Report bug to component author.", + "Service %s for %s incorrectly returns a coroutine object. Await result instead in service handler. Report bug to integration author.", func, entity.entity_id, ) diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 8d28bc73b88..d90842d1b71 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -320,14 +320,20 @@ async def test_call_with_sync_func(hass, mock_entities): async def test_call_with_sync_attr(hass, mock_entities): """Test invoking sync service calls.""" - mock_entities["light.kitchen"].sync_method = Mock() + mock_method = mock_entities["light.kitchen"].sync_method = Mock() await service.entity_service_call( hass, [Mock(entities=mock_entities)], "sync_method", - ha.ServiceCall("test_domain", "test_service", {"entity_id": "light.kitchen"}), + ha.ServiceCall( + "test_domain", + "test_service", + {"entity_id": "light.kitchen", "area_id": "abcd"}, + ), ) - assert mock_entities["light.kitchen"].sync_method.call_count == 1 + assert mock_method.call_count == 1 + # We pass empty kwargs because both entity_id and area_id are filtered out + assert mock_method.mock_calls[0][2] == {} async def test_call_context_user_not_exist(hass): From a54d5f0bc42b26b5d5b8ec5963809269f58ae1e6 Mon Sep 17 00:00:00 2001 From: FrengerH Date: Sun, 2 Feb 2020 12:54:59 +0100 Subject: [PATCH 32/54] deCONZ - Fix magic cube awake gesture (#31403) --- homeassistant/components/deconz/deconz_event.py | 2 +- tests/components/deconz/test_deconz_event.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/deconz/deconz_event.py b/homeassistant/components/deconz/deconz_event.py index 527e8d2ab7a..98a85a707bd 100644 --- a/homeassistant/components/deconz/deconz_event.py +++ b/homeassistant/components/deconz/deconz_event.py @@ -50,7 +50,7 @@ class DeconzEvent(DeconzBase): CONF_EVENT: self._device.state, } - if self._device.gesture: + if self._device.gesture is not None: data[CONF_GESTURE] = self._device.gesture self.gateway.hass.bus.async_fire(CONF_DECONZ_EVENT, data) diff --git a/tests/components/deconz/test_deconz_event.py b/tests/components/deconz/test_deconz_event.py index 69584f630d6..349b359d9b8 100644 --- a/tests/components/deconz/test_deconz_event.py +++ b/tests/components/deconz/test_deconz_event.py @@ -101,7 +101,7 @@ async def test_deconz_events(hass): mock_listener = Mock() unsub = hass.bus.async_listen(CONF_DECONZ_EVENT, mock_listener) - gateway.api.sensors["4"].async_update({"state": {"gesture": 2}}) + gateway.api.sensors["4"].async_update({"state": {"gesture": 0}}) await hass.async_block_till_done() assert len(mock_listener.mock_calls) == 1 @@ -109,7 +109,7 @@ async def test_deconz_events(hass): "id": "switch_4", "unique_id": "00:00:00:00:00:00:00:04", "event": 1000, - "gesture": 2, + "gesture": 0, } unsub() From 3cbd426c522f630bad93e0e4f005bb138323f4c5 Mon Sep 17 00:00:00 2001 From: akasma74 Date: Sun, 2 Feb 2020 16:23:13 +0000 Subject: [PATCH 33/54] Fix rflink commands containing equals sign (#31412) * new lib verson available * new rflink lib version * new rflink lib version --- homeassistant/components/rflink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rflink/manifest.json b/homeassistant/components/rflink/manifest.json index 28aea1adc31..77b6413f994 100644 --- a/homeassistant/components/rflink/manifest.json +++ b/homeassistant/components/rflink/manifest.json @@ -2,7 +2,7 @@ "domain": "rflink", "name": "RFLink", "documentation": "https://www.home-assistant.io/integrations/rflink", - "requirements": ["rflink==0.0.50"], + "requirements": ["rflink==0.0.51"], "dependencies": [], "codeowners": [] } diff --git a/requirements_all.txt b/requirements_all.txt index a3f6606eb9a..fec294f1fc5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1750,7 +1750,7 @@ restrictedpython==5.0 rfk101py==0.0.1 # homeassistant.components.rflink -rflink==0.0.50 +rflink==0.0.51 # homeassistant.components.ring ring_doorbell==0.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f5f30a518d8..0c0222fc62b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -576,7 +576,7 @@ regenmaschine==1.5.1 restrictedpython==5.0 # homeassistant.components.rflink -rflink==0.0.50 +rflink==0.0.51 # homeassistant.components.ring ring_doorbell==0.6.0 From 2c1b4652150a78f31da33b3f4482f10c55d88dfc Mon Sep 17 00:00:00 2001 From: Martin Date: Sun, 2 Feb 2020 23:52:00 +0100 Subject: [PATCH 34/54] Emulated Hue + Alexa: Fix devices not discovered and error response (#30013 & #29899) (#31413) * Revert "Emulated Hue: changed the reported fallback device-type to fix Alexa compatibility issues (#30013)" This reverts commit ddc8d9e25c0c8fd4073c0c516de9fa096cceb9bc. * Revert "Emulated Hue: updated tests (#30013)" This reverts commit 90df461e752fd6ecc1dc65bae0eba17f26a82f5f. * Emulated Hue + Alexa: changed the fallback device-type again to "Dimmable Light" (#30013) after collective debugging; fixed brightness for on/off-devices and scripts to prevent "device malfunction" response from Alexa (#29899) * Emulated Hue + Alexa: lint (#30013, #29899) --- .../components/emulated_hue/hue_api.py | 28 +++++++++---------- tests/components/emulated_hue/test_hue_api.py | 2 +- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 118bf7e3eaa..56e76b1d499 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -688,27 +688,25 @@ def entity_to_json(config, entity): retval["state"].update( {HUE_API_STATE_COLORMODE: "ct", HUE_API_STATE_CT: state[STATE_COLOR_TEMP]} ) - elif ( - entity_features - & ( - SUPPORT_BRIGHTNESS - | SUPPORT_SET_POSITION - | SUPPORT_SET_SPEED - | SUPPORT_VOLUME_SET - | SUPPORT_TARGET_TEMPERATURE - ) - ) or entity.domain == script.DOMAIN: + elif entity_features & ( + SUPPORT_BRIGHTNESS + | SUPPORT_SET_POSITION + | SUPPORT_SET_SPEED + | SUPPORT_VOLUME_SET + | SUPPORT_TARGET_TEMPERATURE + ): # Dimmable light (Zigbee Device ID: 0x0100) # Supports groups, scenes, on/off and dimming retval["type"] = "Dimmable light" retval["modelid"] = "HASS123" retval["state"].update({HUE_API_STATE_BRI: state[STATE_BRIGHTNESS]}) else: - # On/off plug-in unit (Zigbee Device ID: 0x0000) - # Supports groups and on/off control - # Used for compatibility purposes with Alexa instead of "On/off light" - retval["type"] = "On/off plug-in unit" - retval["modelid"] = "HASS321" + # Dimmable light (Zigbee Device ID: 0x0100) + # Supports groups, scenes, on/off and dimming + # Reports fixed brightness for compatibility with Alexa. + retval["type"] = "Dimmable light" + retval["modelid"] = "HASS123" + retval["state"].update({HUE_API_STATE_BRI: HUE_API_STATE_BRI_MAX}) return retval diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 0ddc429b2d9..51c3da7f08d 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -238,7 +238,7 @@ async def test_light_without_brightness_supported(hass_hue, hue_client): ) assert light_without_brightness_json["state"][HUE_API_STATE_ON] is True - assert light_without_brightness_json["type"] == "On/off plug-in unit" + assert light_without_brightness_json["type"] == "Dimmable light" async def test_light_without_brightness_can_be_turned_off(hass_hue, hue_client): From 67e7541016f4c22c30bbc851c7fc2fcd095e9319 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 2 Feb 2020 23:48:13 +0100 Subject: [PATCH 35/54] Fix device name Google Assistant when using aliases (#31416) * Fix device name Google Assistant when using aliases * Adjust cloud tests --- homeassistant/components/google_assistant/helpers.py | 2 +- tests/components/cloud/test_client.py | 2 +- tests/components/google_assistant/__init__.py | 5 ++++- tests/components/google_assistant/test_smart_home.py | 5 ++++- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 983f638656d..f1b7a89bffe 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -399,7 +399,7 @@ class GoogleEntity: # use aliases aliases = entity_config.get(CONF_ALIASES) if aliases: - device["name"]["nicknames"] = aliases + device["name"]["nicknames"] = [name] + aliases if self.config.is_local_sdk_active: device["otherDeviceIds"] = [{"deviceId": self.entity_id}] diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index 2338f0eaa1e..50402af2bd1 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -121,7 +121,7 @@ async def test_handler_google_actions(hass): device = devices[0] assert device["id"] == "switch.test" assert device["name"]["name"] == "Config name" - assert device["name"]["nicknames"] == ["Config alias"] + assert device["name"]["nicknames"] == ["Config name", "Config alias"] assert device["type"] == "action.devices.types.SWITCH" assert device["roomHint"] == "living room" diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 9ef0599d394..c0b5aa7b193 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -104,7 +104,10 @@ DEMO_DEVICES = [ }, { "id": "light.ceiling_lights", - "name": {"name": "Roof Lights", "nicknames": ["top lights", "ceiling lights"]}, + "name": { + "name": "Roof Lights", + "nicknames": ["Roof Lights", "top lights", "ceiling lights"], + }, "traits": [ "action.devices.traits.OnOff", "action.devices.traits.Brightness", diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index b3467eae326..aa073c699f8 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -92,7 +92,10 @@ async def test_sync_message(hass): "devices": [ { "id": "light.demo_light", - "name": {"name": "Demo Light", "nicknames": ["Hello", "World"]}, + "name": { + "name": "Demo Light", + "nicknames": ["Demo Light", "Hello", "World"], + }, "traits": [ trait.TRAIT_BRIGHTNESS, trait.TRAIT_ONOFF, From 9dfc00898be42cede883996551d54e595232a55a Mon Sep 17 00:00:00 2001 From: Josh Bendavid Date: Sun, 2 Feb 2020 23:50:30 +0100 Subject: [PATCH 36/54] always call set_volume with integer values (#31418) --- homeassistant/components/webostv/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 0e98bd8e703..99df9fd17ce 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -316,7 +316,7 @@ class LgWebOSMediaPlayerEntity(MediaPlayerDevice): @cmd async def async_set_volume_level(self, volume): """Set volume level, range 0..1.""" - tv_volume = volume * 100 + tv_volume = int(round(volume * 100)) await self._client.set_volume(tv_volume) @cmd From 4f79ec0c78c6671a40960c5b6e0f33d4d1147233 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 2 Feb 2020 15:39:51 -0800 Subject: [PATCH 37/54] Bumped version to 0.105.0b5 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index b340146bae3..c0b1c7424cc 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 105 -PATCH_VERSION = "0b4" +PATCH_VERSION = "0b5" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From 04cb2e9fd5d96da7ea805ea8d07eca3c17b60559 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Thu, 30 Jan 2020 20:21:51 +0200 Subject: [PATCH 38/54] Rework Mikrotik device scanning following Unifi (#27484) * rework device scanning, add tests * update requirements and coverage * fix description comments * update tests, fix disabled entity updates * rework device scanning, add tests * update requirements and coverage * fix description comments * update tests, fix disabled entity updates * update librouteros to 3.0.0 * fix sorting * fix sorting 2 * revert to 2.3.0 as 3.0.0 requires code update * fix requirements * apply fixes * fix tests * update hub.py and fix tests * fix test_hub_setup_failed * rebased on dev and update librouteros to 3.0.0 * fixed test_config_flow * fixed tests * fix test_config_flow --- .coveragerc | 3 +- CODEOWNERS | 1 + .../components/mikrotik/.translations/en.json | 37 ++ homeassistant/components/mikrotik/__init__.py | 187 ++------ .../components/mikrotik/config_flow.py | 120 +++++ homeassistant/components/mikrotik/const.py | 40 +- .../components/mikrotik/device_tracker.py | 301 ++++++------- homeassistant/components/mikrotik/errors.py | 10 + homeassistant/components/mikrotik/hub.py | 413 ++++++++++++++++++ .../components/mikrotik/manifest.json | 13 +- .../components/mikrotik/strings.json | 37 ++ homeassistant/generated/config_flows.py | 1 + requirements_test_all.txt | 3 + tests/components/mikrotik/__init__.py | 133 ++++++ tests/components/mikrotik/test_config_flow.py | 208 +++++++++ .../mikrotik/test_device_tracker.py | 118 +++++ tests/components/mikrotik/test_hub.py | 179 ++++++++ tests/components/mikrotik/test_init.py | 83 ++++ 18 files changed, 1546 insertions(+), 341 deletions(-) create mode 100644 homeassistant/components/mikrotik/.translations/en.json create mode 100644 homeassistant/components/mikrotik/config_flow.py create mode 100644 homeassistant/components/mikrotik/errors.py create mode 100644 homeassistant/components/mikrotik/hub.py create mode 100644 homeassistant/components/mikrotik/strings.json create mode 100644 tests/components/mikrotik/__init__.py create mode 100644 tests/components/mikrotik/test_config_flow.py create mode 100644 tests/components/mikrotik/test_device_tracker.py create mode 100644 tests/components/mikrotik/test_hub.py create mode 100644 tests/components/mikrotik/test_init.py diff --git a/.coveragerc b/.coveragerc index b936c9c514c..693959684f1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -420,7 +420,8 @@ omit = homeassistant/components/metoffice/weather.py homeassistant/components/microsoft/tts.py homeassistant/components/miflora/sensor.py - homeassistant/components/mikrotik/* + homeassistant/components/mikrotik/hub.py + homeassistant/components/mikrotik/device_tracker.py homeassistant/components/mill/climate.py homeassistant/components/mill/const.py homeassistant/components/minio/* diff --git a/CODEOWNERS b/CODEOWNERS index cbf4f3ad1e9..6983d13fc8b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -209,6 +209,7 @@ homeassistant/components/met/* @danielhiversen homeassistant/components/meteo_france/* @victorcerutti @oncleben31 homeassistant/components/meteoalarm/* @rolfberkenbosch homeassistant/components/miflora/* @danielhiversen @ChristianKuehnel +homeassistant/components/mikrotik/* @engrbm87 homeassistant/components/mill/* @danielhiversen homeassistant/components/min_max/* @fabaff homeassistant/components/minio/* @tkislan diff --git a/homeassistant/components/mikrotik/.translations/en.json b/homeassistant/components/mikrotik/.translations/en.json new file mode 100644 index 00000000000..590563993d6 --- /dev/null +++ b/homeassistant/components/mikrotik/.translations/en.json @@ -0,0 +1,37 @@ +{ + "config": { + "title": "Mikrotik", + "step": { + "user": { + "title": "Set up Mikrotik Router", + "data": { + "name": "Name", + "host": "Host", + "username": "Username", + "password": "Password", + "port": "Port", + "verify_ssl": "Use ssl" + } + } + }, + "error": { + "name_exists": "Name exists", + "cannot_connect": "Connection Unsuccessful", + "wrong_credentials": "Wrong Credentials" + }, + "abort": { + "already_configured": "Mikrotik is already configured" + } + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "Enable ARP ping", + "force_dhcp": "Force scanning using DHCP", + "detection_time": "Consider home interval" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py index 8c21b2e1c35..9a8ee7bdb45 100644 --- a/homeassistant/components/mikrotik/__init__.py +++ b/homeassistant/components/mikrotik/__init__.py @@ -1,43 +1,28 @@ -"""The mikrotik component.""" -import logging -import ssl - -from librouteros import connect -from librouteros.exceptions import LibRouterosError -from librouteros.login import plain as login_plain, token as login_token +"""The Mikrotik component.""" import voluptuous as vol -from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_HOST, - CONF_METHOD, + CONF_NAME, CONF_PASSWORD, CONF_PORT, - CONF_SSL, CONF_USERNAME, + CONF_VERIFY_SSL, ) from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.discovery import load_platform from .const import ( + ATTR_MANUFACTURER, CONF_ARP_PING, - CONF_ENCODING, - CONF_LOGIN_METHOD, - CONF_TRACK_DEVICES, - DEFAULT_ENCODING, + CONF_DETECTION_TIME, + CONF_FORCE_DHCP, + DEFAULT_API_PORT, + DEFAULT_DETECTION_TIME, + DEFAULT_NAME, DOMAIN, - HOSTS, - IDENTITY, - MIKROTIK_SERVICES, - MTK_LOGIN_PLAIN, - MTK_LOGIN_TOKEN, - NAME, ) - -_LOGGER = logging.getLogger(__name__) - -MTK_DEFAULT_API_PORT = "8728" -MTK_DEFAULT_API_SSL_PORT = "8729" +from .hub import MikrotikHub MIKROTIK_SCHEMA = vol.All( vol.Schema( @@ -45,13 +30,14 @@ MIKROTIK_SCHEMA = vol.All( vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_METHOD): cv.string, - vol.Optional(CONF_LOGIN_METHOD): vol.Any(MTK_LOGIN_PLAIN, MTK_LOGIN_TOKEN), - vol.Optional(CONF_PORT): cv.port, - vol.Optional(CONF_SSL, default=False): cv.boolean, - vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, - vol.Optional(CONF_TRACK_DEVICES, default=True): cv.boolean, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_API_PORT): cv.port, + vol.Optional(CONF_VERIFY_SSL, default=False): cv.boolean, vol.Optional(CONF_ARP_PING, default=False): cv.boolean, + vol.Optional(CONF_FORCE_DHCP, default=False): cv.boolean, + vol.Optional( + CONF_DETECTION_TIME, default=DEFAULT_DETECTION_TIME + ): cv.time_period, } ) ) @@ -61,124 +47,45 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass, config): - """Set up the Mikrotik component.""" - hass.data[DOMAIN] = {HOSTS: {}} +async def async_setup(hass, config): + """Import the Mikrotik component from config.""" - for device in config[DOMAIN]: - host = device[CONF_HOST] - use_ssl = device.get(CONF_SSL) - user = device.get(CONF_USERNAME) - password = device.get(CONF_PASSWORD, "") - login = device.get(CONF_LOGIN_METHOD) - encoding = device.get(CONF_ENCODING) - track_devices = device.get(CONF_TRACK_DEVICES) - - if CONF_PORT in device: - port = device.get(CONF_PORT) - else: - if use_ssl: - port = MTK_DEFAULT_API_SSL_PORT - else: - port = MTK_DEFAULT_API_PORT - - if login == MTK_LOGIN_PLAIN: - login_method = login_plain - else: - login_method = login_token - - try: - api = MikrotikClient( - host, use_ssl, port, user, password, login_method, encoding + if DOMAIN in config: + for entry in config[DOMAIN]: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=entry + ) ) - api.connect_to_device() - hass.data[DOMAIN][HOSTS][host] = {"config": device, "api": api} - except LibRouterosError as api_error: - _LOGGER.error("Mikrotik %s error %s", host, api_error) - continue - if track_devices: - hass.data[DOMAIN][HOSTS][host][DEVICE_TRACKER] = True - load_platform(hass, DEVICE_TRACKER, DOMAIN, None, config) - - if not hass.data[DOMAIN][HOSTS]: - return False return True -class MikrotikClient: - """Handle all communication with the Mikrotik API.""" +async def async_setup_entry(hass, config_entry): + """Set up the Mikrotik component.""" - def __init__(self, host, use_ssl, port, user, password, login_method, encoding): - """Initialize the Mikrotik Client.""" - self._host = host - self._use_ssl = use_ssl - self._port = port - self._user = user - self._password = password - self._login_method = login_method - self._encoding = encoding - self._ssl_wrapper = None - self.hostname = None - self._client = None - self._connected = False + hub = MikrotikHub(hass, config_entry) + if not await hub.async_setup(): + return False - def connect_to_device(self): - """Connect to Mikrotik device.""" - self._connected = False - _LOGGER.debug("[%s] Connecting to Mikrotik device", self._host) + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = hub + device_registry = await hass.helpers.device_registry.async_get_registry() + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(DOMAIN, hub.serial_num)}, + manufacturer=ATTR_MANUFACTURER, + model=hub.model, + name=hub.hostname, + sw_version=hub.firmware, + ) - kwargs = { - "encoding": self._encoding, - "login_methods": self._login_method, - "port": self._port, - } + return True - if self._use_ssl: - if self._ssl_wrapper is None: - ssl_context = ssl.create_default_context() - ssl_context.check_hostname = False - ssl_context.verify_mode = ssl.CERT_NONE - self._ssl_wrapper = ssl_context.wrap_socket - kwargs["ssl_wrapper"] = self._ssl_wrapper - try: - self._client = connect(self._host, self._user, self._password, **kwargs) - self._connected = True - except LibRouterosError as api_error: - _LOGGER.error("Mikrotik %s: %s", self._host, api_error) - self._client = None - return False +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + await hass.config_entries.async_forward_entry_unload(config_entry, "device_tracker") - self.hostname = self.get_hostname() - _LOGGER.info("Mikrotik Connected to %s (%s)", self.hostname, self._host) - return self._connected + hass.data[DOMAIN].pop(config_entry.entry_id) - def get_hostname(self): - """Return device host name.""" - data = list(self.command(MIKROTIK_SERVICES[IDENTITY])) - return data[0][NAME] if data else None - - def connected(self): - """Return connected boolean.""" - return self._connected - - def command(self, cmd, params=None): - """Retrieve data from Mikrotik API.""" - if not self._connected or not self._client: - if not self.connect_to_device(): - return None - try: - if params: - response = self._client(cmd=cmd, **params) - else: - response = self._client(cmd=cmd) - except LibRouterosError as api_error: - _LOGGER.error( - "Mikrotik %s failed to retrieve data. cmd=[%s] Error: %s", - self._host, - cmd, - api_error, - ) - return None - return response if response else None + return True diff --git a/homeassistant/components/mikrotik/config_flow.py b/homeassistant/components/mikrotik/config_flow.py new file mode 100644 index 00000000000..c1a41abf0d0 --- /dev/null +++ b/homeassistant/components/mikrotik/config_flow.py @@ -0,0 +1,120 @@ +"""Config flow for Mikrotik.""" +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import callback + +from .const import ( + CONF_ARP_PING, + CONF_DETECTION_TIME, + CONF_FORCE_DHCP, + DEFAULT_API_PORT, + DEFAULT_DETECTION_TIME, + DEFAULT_NAME, + DOMAIN, +) +from .errors import CannotConnect, LoginError +from .hub import get_api + + +class MikrotikFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Mikrotik config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return MikrotikOptionsFlowHandler(config_entry) + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + if user_input is not None: + for entry in self.hass.config_entries.async_entries(DOMAIN): + if entry.data[CONF_HOST] == user_input[CONF_HOST]: + return self.async_abort(reason="already_configured") + if entry.data[CONF_NAME] == user_input[CONF_NAME]: + errors[CONF_NAME] = "name_exists" + break + + try: + await self.hass.async_add_executor_job(get_api, self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except LoginError: + errors[CONF_USERNAME] = "wrong_credentials" + errors[CONF_PASSWORD] = "wrong_credentials" + + if not errors: + return self.async_create_entry( + title=user_input[CONF_NAME], data=user_input + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_PORT, default=DEFAULT_API_PORT): int, + vol.Optional(CONF_VERIFY_SSL, default=False): bool, + } + ), + errors=errors, + ) + + async def async_step_import(self, import_config): + """Import Miktortik from config.""" + + import_config[CONF_DETECTION_TIME] = import_config[CONF_DETECTION_TIME].seconds + return await self.async_step_user(user_input=import_config) + + +class MikrotikOptionsFlowHandler(config_entries.OptionsFlow): + """Handle Mikrotik options.""" + + def __init__(self, config_entry): + """Initialize Mikrotik options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the Mikrotik options.""" + return await self.async_step_device_tracker() + + async def async_step_device_tracker(self, user_input=None): + """Manage the device tracker options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + options = { + vol.Optional( + CONF_FORCE_DHCP, + default=self.config_entry.options.get(CONF_FORCE_DHCP, False), + ): bool, + vol.Optional( + CONF_ARP_PING, + default=self.config_entry.options.get(CONF_ARP_PING, False), + ): bool, + vol.Optional( + CONF_DETECTION_TIME, + default=self.config_entry.options.get( + CONF_DETECTION_TIME, DEFAULT_DETECTION_TIME + ), + ): int, + } + + return self.async_show_form( + step_id="device_tracker", data_schema=vol.Schema(options) + ) diff --git a/homeassistant/components/mikrotik/const.py b/homeassistant/components/mikrotik/const.py index bd26b02fe1b..d66a441aaf7 100644 --- a/homeassistant/components/mikrotik/const.py +++ b/homeassistant/components/mikrotik/const.py @@ -1,32 +1,38 @@ """Constants used in the Mikrotik components.""" DOMAIN = "mikrotik" -MIKROTIK = DOMAIN -HOSTS = "hosts" -MTK_LOGIN_PLAIN = "plain" -MTK_LOGIN_TOKEN = "token" +DEFAULT_NAME = "Mikrotik" +DEFAULT_API_PORT = 8728 +DEFAULT_DETECTION_TIME = 300 + +ATTR_MANUFACTURER = "Mikrotik" +ATTR_SERIAL_NUMBER = "serial-number" +ATTR_FIRMWARE = "current-firmware" +ATTR_MODEL = "model" CONF_ARP_PING = "arp_ping" -CONF_TRACK_DEVICES = "track_devices" -CONF_LOGIN_METHOD = "login_method" -CONF_ENCODING = "encoding" -DEFAULT_ENCODING = "utf-8" +CONF_FORCE_DHCP = "force_dhcp" +CONF_DETECTION_TIME = "detection_time" + NAME = "name" INFO = "info" IDENTITY = "identity" ARP = "arp" + +CAPSMAN = "capsman" DHCP = "dhcp" WIRELESS = "wireless" -CAPSMAN = "capsman" +IS_WIRELESS = "is_wireless" MIKROTIK_SERVICES = { - INFO: "/system/routerboard/getall", - IDENTITY: "/system/identity/getall", ARP: "/ip/arp/getall", - DHCP: "/ip/dhcp-server/lease/getall", - WIRELESS: "/interface/wireless/registration-table/getall", CAPSMAN: "/caps-man/registration-table/getall", + DHCP: "/ip/dhcp-server/lease/getall", + IDENTITY: "/system/identity/getall", + INFO: "/system/routerboard/getall", + WIRELESS: "/interface/wireless/registration-table/getall", + IS_WIRELESS: "/interface/wireless/print", } ATTR_DEVICE_TRACKER = [ @@ -34,16 +40,8 @@ ATTR_DEVICE_TRACKER = [ "mac-address", "ssid", "interface", - "host-name", - "last-seen", - "rx-signal", "signal-strength", - "tx-ccq", "signal-to-noise", - "wmm-enabled", - "authentication-type", - "encryption", - "tx-rate-set", "rx-rate", "tx-rate", "uptime", diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index 92fcfac4ae4..e7c5e5655a0 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -1,191 +1,142 @@ """Support for Mikrotik routers as device tracker.""" import logging -from homeassistant.components.device_tracker import ( +from homeassistant.components.device_tracker.config_entry import ScannerEntity +from homeassistant.components.device_tracker.const import ( DOMAIN as DEVICE_TRACKER, - DeviceScanner, + SOURCE_TYPE_ROUTER, ) -from homeassistant.const import CONF_METHOD -from homeassistant.util import slugify +from homeassistant.core import callback +from homeassistant.helpers import entity_registry +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.dispatcher import async_dispatcher_connect +import homeassistant.util.dt as dt_util -from .const import ( - ARP, - ATTR_DEVICE_TRACKER, - CAPSMAN, - CONF_ARP_PING, - DHCP, - HOSTS, - MIKROTIK, - MIKROTIK_SERVICES, - WIRELESS, -) +from .const import ATTR_MANUFACTURER, DOMAIN _LOGGER = logging.getLogger(__name__) -def get_scanner(hass, config): - """Validate the configuration and return MikrotikScanner.""" - for host in hass.data[MIKROTIK][HOSTS]: - if DEVICE_TRACKER not in hass.data[MIKROTIK][HOSTS][host]: - continue - hass.data[MIKROTIK][HOSTS][host].pop(DEVICE_TRACKER, None) - api = hass.data[MIKROTIK][HOSTS][host]["api"] - config = hass.data[MIKROTIK][HOSTS][host]["config"] - hostname = api.get_hostname() - scanner = MikrotikScanner(api, host, hostname, config) - return scanner if scanner.success_init else None +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up device tracker for Mikrotik component.""" + hub = hass.data[DOMAIN][config_entry.entry_id] + + tracked = {} + + registry = await entity_registry.async_get_registry(hass) + + # Restore clients that is not a part of active clients list. + for entity in registry.entities.values(): + + if ( + entity.config_entry_id == config_entry.entry_id + and entity.domain == DEVICE_TRACKER + ): + + if ( + entity.unique_id in hub.api.devices + or entity.unique_id not in hub.api.all_devices + ): + continue + hub.api.restore_device(entity.unique_id) + + @callback + def update_hub(): + """Update the status of the device.""" + update_items(hub, async_add_entities, tracked) + + async_dispatcher_connect(hass, hub.signal_update, update_hub) + + update_hub() -class MikrotikScanner(DeviceScanner): - """This class queries a Mikrotik device.""" +@callback +def update_items(hub, async_add_entities, tracked): + """Update tracked device state from the hub.""" + new_tracked = [] + for mac, device in hub.api.devices.items(): + if mac not in tracked: + tracked[mac] = MikrotikHubTracker(device, hub) + new_tracked.append(tracked[mac]) - def __init__(self, api, host, hostname, config): - """Initialize the scanner.""" - self.api = api - self.config = config - self.host = host - self.hostname = hostname - self.method = config.get(CONF_METHOD) - self.arp_ping = config.get(CONF_ARP_PING) - self.dhcp = None - self.devices_arp = {} - self.devices_dhcp = {} - self.device_tracker = None - self.success_init = self.api.connected() - - def get_extra_attributes(self, device): - """ - Get extra attributes of a device. - - Some known extra attributes that may be returned in the device tuple - include MAC address (mac), network device (dev), IP address - (ip), reachable status (reachable), associated router - (host), hostname if known (hostname) among others. - """ - return self.device_tracker.get(device) or {} - - def get_device_name(self, device): - """Get name for a device.""" - host = self.device_tracker.get(device, {}) - return host.get("host_name") - - def scan_devices(self): - """Scan for new devices and return a list with found device MACs.""" - self.update_device_tracker() - return list(self.device_tracker) - - def get_method(self): - """Determine the device tracker polling method.""" - if self.method: - _LOGGER.debug( - "Mikrotik %s: Manually selected polling method %s", - self.host, - self.method, - ) - return self.method - - capsman = self.api.command(MIKROTIK_SERVICES[CAPSMAN]) - if not capsman: - _LOGGER.debug( - "Mikrotik %s: Not a CAPsMAN controller. " - "Trying local wireless interfaces", - (self.host), - ) - else: - return CAPSMAN - - wireless = self.api.command(MIKROTIK_SERVICES[WIRELESS]) - if not wireless: - _LOGGER.info( - "Mikrotik %s: Wireless adapters not found. Try to " - "use DHCP lease table as presence tracker source. " - "Please decrease lease time as much as possible", - self.host, - ) - return DHCP - - return WIRELESS - - def update_device_tracker(self): - """Update device_tracker from Mikrotik API.""" - self.device_tracker = {} - if not self.method: - self.method = self.get_method() - - data = self.api.command(MIKROTIK_SERVICES[self.method]) - if data is None: - return - - if self.method != DHCP: - dhcp = self.api.command(MIKROTIK_SERVICES[DHCP]) - if dhcp is not None: - self.devices_dhcp = load_mac(dhcp) - - arp = self.api.command(MIKROTIK_SERVICES[ARP]) - self.devices_arp = load_mac(arp) - - for device in data: - mac = device.get("mac-address") - if self.method == DHCP: - if "active-address" not in device: - continue - - if self.arp_ping and self.devices_arp: - if mac not in self.devices_arp: - continue - ip_address = self.devices_arp[mac]["address"] - interface = self.devices_arp[mac]["interface"] - if not self.do_arp_ping(ip_address, interface): - continue - - attrs = {} - if mac in self.devices_dhcp and "host-name" in self.devices_dhcp[mac]: - hostname = self.devices_dhcp[mac].get("host-name") - if hostname: - attrs["host_name"] = hostname - - if self.devices_arp and mac in self.devices_arp: - attrs["ip_address"] = self.devices_arp[mac].get("address") - - for attr in ATTR_DEVICE_TRACKER: - if attr in device and device[attr] is not None: - attrs[slugify(attr)] = device[attr] - attrs["scanner_type"] = self.method - attrs["scanner_host"] = self.host - attrs["scanner_hostname"] = self.hostname - self.device_tracker[mac] = attrs - - def do_arp_ping(self, ip_address, interface): - """Attempt to arp ping MAC address via interface.""" - params = { - "arp-ping": "yes", - "interval": "100ms", - "count": 3, - "interface": interface, - "address": ip_address, - } - cmd = "/ping" - data = self.api.command(cmd, params) - if data is not None: - status = 0 - for result in data: - if "status" in result: - _LOGGER.debug( - "Mikrotik %s arp_ping error: %s", self.host, result["status"] - ) - status += 1 - if status == len(data): - return None - return data + if new_tracked: + async_add_entities(new_tracked) -def load_mac(devices=None): - """Load dictionary using MAC address as key.""" - if not devices: +class MikrotikHubTracker(ScannerEntity): + """Representation of network device.""" + + def __init__(self, device, hub): + """Initialize the tracked device.""" + self.device = device + self.hub = hub + self.unsub_dispatcher = None + + @property + def is_connected(self): + """Return true if the client is connected to the network.""" + if ( + self.device.last_seen + and (dt_util.utcnow() - self.device.last_seen) + < self.hub.option_detection_time + ): + return True + return False + + @property + def source_type(self): + """Return the source type of the client.""" + return SOURCE_TYPE_ROUTER + + @property + def name(self) -> str: + """Return the name of the client.""" + return self.device.name + + @property + def unique_id(self) -> str: + """Return a unique identifier for this device.""" + return self.device.mac + + @property + def available(self) -> bool: + """Return if controller is available.""" + return self.hub.available + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + if self.is_connected: + return self.device.attrs return None - mac_devices = {} - for device in devices: - if "mac-address" in device: - mac = device.pop("mac-address") - mac_devices[mac] = device - return mac_devices + + @property + def device_info(self): + """Return a client description for device registry.""" + info = { + "connections": {(CONNECTION_NETWORK_MAC, self.device.mac)}, + "manufacturer": ATTR_MANUFACTURER, + "identifiers": {(DOMAIN, self.device.mac)}, + "name": self.name, + "via_device": (DOMAIN, self.hub.serial_num), + } + return info + + async def async_added_to_hass(self): + """Client entity created.""" + _LOGGER.debug("New network device tracker %s (%s)", self.name, self.unique_id) + self.unsub_dispatcher = async_dispatcher_connect( + self.hass, self.hub.signal_update, self.async_write_ha_state + ) + + async def async_update(self): + """Synchronize state with hub.""" + _LOGGER.debug( + "Updating Mikrotik tracked client %s (%s)", self.entity_id, self.unique_id + ) + await self.hub.request_update() + + async def will_remove_from_hass(self): + """Disconnect from dispatcher.""" + if self.unsub_dispatcher: + self.unsub_dispatcher() diff --git a/homeassistant/components/mikrotik/errors.py b/homeassistant/components/mikrotik/errors.py new file mode 100644 index 00000000000..22cd63d7468 --- /dev/null +++ b/homeassistant/components/mikrotik/errors.py @@ -0,0 +1,10 @@ +"""Errors for the Mikrotik component.""" +from homeassistant.exceptions import HomeAssistantError + + +class CannotConnect(HomeAssistantError): + """Unable to connect to the hub.""" + + +class LoginError(HomeAssistantError): + """Component got logged out.""" diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py new file mode 100644 index 00000000000..2243b6cc5ce --- /dev/null +++ b/homeassistant/components/mikrotik/hub.py @@ -0,0 +1,413 @@ +"""The Mikrotik router class.""" +from datetime import timedelta +import logging +import socket +import ssl + +import librouteros +from librouteros.login import plain as login_plain, token as login_token + +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.util import slugify +import homeassistant.util.dt as dt_util + +from .const import ( + ARP, + ATTR_DEVICE_TRACKER, + ATTR_FIRMWARE, + ATTR_MODEL, + ATTR_SERIAL_NUMBER, + CAPSMAN, + CONF_ARP_PING, + CONF_DETECTION_TIME, + CONF_FORCE_DHCP, + DEFAULT_DETECTION_TIME, + DHCP, + IDENTITY, + INFO, + IS_WIRELESS, + MIKROTIK_SERVICES, + NAME, + WIRELESS, +) +from .errors import CannotConnect, LoginError + +_LOGGER = logging.getLogger(__name__) + + +class Device: + """Represents a network device.""" + + def __init__(self, mac, params): + """Initialize the network device.""" + self._mac = mac + self._params = params + self._last_seen = None + self._attrs = {} + self._wireless_params = None + + @property + def name(self): + """Return device name.""" + return self._params.get("host-name", self.mac) + + @property + def mac(self): + """Return device mac.""" + return self._mac + + @property + def last_seen(self): + """Return device last seen.""" + return self._last_seen + + @property + def attrs(self): + """Return device attributes.""" + attr_data = self._wireless_params if self._wireless_params else self._params + for attr in ATTR_DEVICE_TRACKER: + if attr in attr_data: + self._attrs[slugify(attr)] = attr_data[attr] + self._attrs["ip_address"] = self._params.get("active-address") + return self._attrs + + def update(self, wireless_params=None, params=None, active=False): + """Update Device params.""" + if wireless_params: + self._wireless_params = wireless_params + if params: + self._params = params + if active: + self._last_seen = dt_util.utcnow() + + +class MikrotikData: + """Handle all communication with the Mikrotik API.""" + + def __init__(self, hass, config_entry, api): + """Initialize the Mikrotik Client.""" + self.hass = hass + self.config_entry = config_entry + self.api = api + self._host = self.config_entry.data[CONF_HOST] + self.all_devices = {} + self.devices = {} + self.available = True + self.support_wireless = bool(self.command(MIKROTIK_SERVICES[IS_WIRELESS])) + self.hostname = None + self.model = None + self.firmware = None + self.serial_number = None + + @staticmethod + def load_mac(devices=None): + """Load dictionary using MAC address as key.""" + if not devices: + return None + mac_devices = {} + for device in devices: + if "mac-address" in device: + mac = device["mac-address"] + mac_devices[mac] = device + return mac_devices + + @property + def arp_enabled(self): + """Return arp_ping option setting.""" + return self.config_entry.options[CONF_ARP_PING] + + @property + def force_dhcp(self): + """Return force_dhcp option setting.""" + return self.config_entry.options[CONF_FORCE_DHCP] + + def get_info(self, param): + """Return device model name.""" + cmd = IDENTITY if param == NAME else INFO + data = list(self.command(MIKROTIK_SERVICES[cmd])) + return data[0].get(param) if data else None + + def get_hub_details(self): + """Get Hub info.""" + self.hostname = self.get_info(NAME) + self.model = self.get_info(ATTR_MODEL) + self.firmware = self.get_info(ATTR_FIRMWARE) + self.serial_number = self.get_info(ATTR_SERIAL_NUMBER) + + def connect_to_hub(self): + """Connect to hub.""" + try: + self.api = get_api(self.hass, self.config_entry.data) + self.available = True + return True + except (LoginError, CannotConnect): + self.available = False + return False + + def get_list_from_interface(self, interface): + """Get devices from interface.""" + result = list(self.command(MIKROTIK_SERVICES[interface])) + return self.load_mac(result) if result else {} + + def restore_device(self, mac): + """Restore a missing device after restart.""" + self.devices[mac] = Device(mac, self.all_devices[mac]) + + def update_devices(self): + """Get list of devices with latest status.""" + arp_devices = {} + wireless_devices = {} + device_list = {} + try: + self.all_devices = self.get_list_from_interface(DHCP) + if self.support_wireless: + _LOGGER.debug("wireless is supported") + for interface in [CAPSMAN, WIRELESS]: + wireless_devices = self.get_list_from_interface(interface) + if wireless_devices: + _LOGGER.debug("Scanning wireless devices using %s", interface) + break + + if self.support_wireless and not self.force_dhcp: + device_list = wireless_devices + else: + device_list = self.all_devices + _LOGGER.debug("Falling back to DHCP for scanning devices") + + if self.arp_enabled: + arp_devices = self.get_list_from_interface(ARP) + + # get new hub firmware version if updated + self.firmware = self.get_info(ATTR_FIRMWARE) + + except (CannotConnect, socket.timeout, socket.error): + self.available = False + return + + if not device_list: + return + + for mac, params in device_list.items(): + if mac not in self.devices: + self.devices[mac] = Device(mac, self.all_devices.get(mac, {})) + else: + self.devices[mac].update(params=self.all_devices.get(mac, {})) + + if mac in wireless_devices: + # if wireless is supported then wireless_params are params + self.devices[mac].update( + wireless_params=wireless_devices[mac], active=True + ) + continue + # for wired devices or when forcing dhcp check for active-address + if not params.get("active-address"): + self.devices[mac].update(active=False) + continue + # ping check the rest of active devices if arp ping is enabled + active = True + if self.arp_enabled and mac in arp_devices: + active = self.do_arp_ping( + params.get("active-address"), arp_devices[mac].get("interface") + ) + self.devices[mac].update(active=active) + + def do_arp_ping(self, ip_address, interface): + """Attempt to arp ping MAC address via interface.""" + _LOGGER.debug("pinging - %s", ip_address) + params = { + "arp-ping": "yes", + "interval": "100ms", + "count": 3, + "interface": interface, + "address": ip_address, + } + cmd = "/ping" + data = list(self.command(cmd, params)) + if data is not None: + status = 0 + for result in data: + if "status" in result: + status += 1 + if status == len(data): + _LOGGER.debug( + "Mikrotik %s - %s arp_ping timed out", ip_address, interface + ) + return False + return True + + def command(self, cmd, params=None): + """Retrieve data from Mikrotik API.""" + try: + _LOGGER.info("Running command %s", cmd) + if params: + response = self.api(cmd=cmd, **params) + else: + response = self.api(cmd=cmd) + except ( + librouteros.exceptions.ConnectionClosed, + socket.error, + socket.timeout, + ) as api_error: + _LOGGER.error("Mikrotik %s connection error %s", self._host, api_error) + raise CannotConnect + except librouteros.exceptions.ProtocolError as api_error: + _LOGGER.warning( + "Mikrotik %s failed to retrieve data. cmd=[%s] Error: %s", + self._host, + cmd, + api_error, + ) + return None + + return response if response else None + + def update(self): + """Update device_tracker from Mikrotik API.""" + if not self.available or not self.api: + if not self.connect_to_hub(): + return + _LOGGER.debug("updating network devices for host: %s", self._host) + self.update_devices() + + +class MikrotikHub: + """Mikrotik Hub Object.""" + + def __init__(self, hass, config_entry): + """Initialize the Mikrotik Client.""" + self.hass = hass + self.config_entry = config_entry + self._mk_data = None + self.progress = None + + @property + def host(self): + """Return the host of this hub.""" + return self.config_entry.data[CONF_HOST] + + @property + def hostname(self): + """Return the hostname of the hub.""" + return self._mk_data.hostname + + @property + def model(self): + """Return the model of the hub.""" + return self._mk_data.model + + @property + def firmware(self): + """Return the firware of the hub.""" + return self._mk_data.firmware + + @property + def serial_num(self): + """Return the serial number of the hub.""" + return self._mk_data.serial_number + + @property + def available(self): + """Return if the hub is connected.""" + return self._mk_data.available + + @property + def option_detection_time(self): + """Config entry option defining number of seconds from last seen to away.""" + return timedelta(seconds=self.config_entry.options[CONF_DETECTION_TIME]) + + @property + def signal_update(self): + """Event specific per Mikrotik entry to signal updates.""" + return f"mikrotik-update-{self.host}" + + @property + def api(self): + """Represent Mikrotik data object.""" + return self._mk_data + + async def async_add_options(self): + """Populate default options for Mikrotik.""" + if not self.config_entry.options: + options = { + CONF_ARP_PING: self.config_entry.data.pop(CONF_ARP_PING, False), + CONF_FORCE_DHCP: self.config_entry.data.pop(CONF_FORCE_DHCP, False), + CONF_DETECTION_TIME: self.config_entry.data.pop( + CONF_DETECTION_TIME, DEFAULT_DETECTION_TIME + ), + } + + self.hass.config_entries.async_update_entry( + self.config_entry, options=options + ) + + async def request_update(self): + """Request an update.""" + if self.progress is not None: + await self.progress + return + + self.progress = self.hass.async_create_task(self.async_update()) + await self.progress + + self.progress = None + + async def async_update(self): + """Update Mikrotik devices information.""" + await self.hass.async_add_executor_job(self._mk_data.update) + async_dispatcher_send(self.hass, self.signal_update) + + async def async_setup(self): + """Set up the Mikrotik hub.""" + try: + api = await self.hass.async_add_executor_job( + get_api, self.hass, self.config_entry.data + ) + except CannotConnect: + raise ConfigEntryNotReady + except LoginError: + return False + + self._mk_data = MikrotikData(self.hass, self.config_entry, api) + await self.async_add_options() + await self.hass.async_add_executor_job(self._mk_data.get_hub_details) + await self.hass.async_add_executor_job(self._mk_data.update) + + self.hass.async_create_task( + self.hass.config_entries.async_forward_entry_setup( + self.config_entry, "device_tracker" + ) + ) + return True + + +def get_api(hass, entry): + """Connect to Mikrotik hub.""" + _LOGGER.debug("Connecting to Mikrotik hub [%s]", entry[CONF_HOST]) + + _login_method = (login_plain, login_token) + kwargs = {"login_methods": _login_method, "port": entry["port"]} + + if entry[CONF_VERIFY_SSL]: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + _ssl_wrapper = ssl_context.wrap_socket + kwargs["ssl_wrapper"] = _ssl_wrapper + + try: + api = librouteros.connect( + entry[CONF_HOST], entry[CONF_USERNAME], entry[CONF_PASSWORD], **kwargs, + ) + _LOGGER.debug("Connected to %s successfully", entry[CONF_HOST]) + return api + except ( + librouteros.exceptions.LibRouterosError, + socket.error, + socket.timeout, + ) as api_error: + _LOGGER.error("Mikrotik %s error: %s", entry[CONF_HOST], api_error) + if "invalid user name or password" in str(api_error): + raise LoginError + raise CannotConnect diff --git a/homeassistant/components/mikrotik/manifest.json b/homeassistant/components/mikrotik/manifest.json index 932df2edd29..72f98a11709 100644 --- a/homeassistant/components/mikrotik/manifest.json +++ b/homeassistant/components/mikrotik/manifest.json @@ -1,8 +1,13 @@ { "domain": "mikrotik", - "name": "MikroTik", + "name": "Mikrotik", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mikrotik", - "requirements": ["librouteros==3.0.0"], + "requirements": [ + "librouteros==3.0.0" + ], "dependencies": [], - "codeowners": [] -} + "codeowners": [ + "@engrbm87" + ] +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/strings.json b/homeassistant/components/mikrotik/strings.json new file mode 100644 index 00000000000..590563993d6 --- /dev/null +++ b/homeassistant/components/mikrotik/strings.json @@ -0,0 +1,37 @@ +{ + "config": { + "title": "Mikrotik", + "step": { + "user": { + "title": "Set up Mikrotik Router", + "data": { + "name": "Name", + "host": "Host", + "username": "Username", + "password": "Password", + "port": "Port", + "verify_ssl": "Use ssl" + } + } + }, + "error": { + "name_exists": "Name exists", + "cannot_connect": "Connection Unsuccessful", + "wrong_credentials": "Wrong Credentials" + }, + "abort": { + "already_configured": "Mikrotik is already configured" + } + }, + "options": { + "step": { + "device_tracker": { + "data": { + "arp_ping": "Enable ARP ping", + "force_dhcp": "Force scanning using DHCP", + "detection_time": "Consider home interval" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 70fc4355061..cf77dae7fb2 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -54,6 +54,7 @@ FLOWS = [ "luftdaten", "mailgun", "met", + "mikrotik", "mobile_app", "mqtt", "neato", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0c0222fc62b..93d29daba4a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -283,6 +283,9 @@ keyrings.alt==3.4.0 # homeassistant.components.dyson libpurecool==0.6.0 +# homeassistant.components.mikrotik +librouteros==3.0.0 + # homeassistant.components.soundtouch libsoundtouch==0.7.2 diff --git a/tests/components/mikrotik/__init__.py b/tests/components/mikrotik/__init__.py new file mode 100644 index 00000000000..ae8013eff4b --- /dev/null +++ b/tests/components/mikrotik/__init__.py @@ -0,0 +1,133 @@ +"""Tests for the Mikrotik component.""" +from homeassistant.components import mikrotik + +MOCK_DATA = { + mikrotik.CONF_NAME: "Mikrotik", + mikrotik.CONF_HOST: "0.0.0.0", + mikrotik.CONF_USERNAME: "user", + mikrotik.CONF_PASSWORD: "pass", + mikrotik.CONF_PORT: 8278, + mikrotik.CONF_VERIFY_SSL: False, +} + +MOCK_OPTIONS = { + mikrotik.CONF_ARP_PING: False, + mikrotik.const.CONF_FORCE_DHCP: False, + mikrotik.CONF_DETECTION_TIME: mikrotik.DEFAULT_DETECTION_TIME, +} + +DEVICE_1_DHCP = { + ".id": "*1A", + "address": "0.0.0.1", + "mac-address": "00:00:00:00:00:01", + "active-address": "0.0.0.1", + "host-name": "Device_1", + "comment": "Mobile", +} +DEVICE_2_DHCP = { + ".id": "*1B", + "address": "0.0.0.2", + "mac-address": "00:00:00:00:00:02", + "active-address": "0.0.0.2", + "host-name": "Device_2", + "comment": "PC", +} +DEVICE_1_WIRELESS = { + ".id": "*264", + "interface": "wlan1", + "mac-address": "00:00:00:00:00:01", + "ap": False, + "wds": False, + "bridge": False, + "rx-rate": "72.2Mbps-20MHz/1S/SGI", + "tx-rate": "72.2Mbps-20MHz/1S/SGI", + "packets": "59542,17464", + "bytes": "17536671,2966351", + "frames": "59542,17472", + "frame-bytes": "17655785,2862445", + "hw-frames": "78935,38395", + "hw-frame-bytes": "25636019,4063445", + "tx-frames-timed-out": 0, + "uptime": "5h49m36s", + "last-activity": "170ms", + "signal-strength": "-62@1Mbps", + "signal-to-noise": 52, + "signal-strength-ch0": -63, + "signal-strength-ch1": -69, + "strength-at-rates": "-62@1Mbps 16s330ms,-64@6Mbps 13s560ms,-65@HT20-3 52m6s30ms,-66@HT20-4 52m4s350ms,-66@HT20-5 51m58s580ms,-65@HT20-6 51m24s780ms,-65@HT20-7 5s680ms", + "tx-ccq": 93, + "p-throughput": 54928, + "last-ip": "0.0.0.1", + "802.1x-port-enabled": True, + "authentication-type": "wpa2-psk", + "encryption": "aes-ccm", + "group-encryption": "aes-ccm", + "management-protection": False, + "wmm-enabled": True, + "tx-rate-set": "OFDM:6-54 BW:1x SGI:1x HT:0-7", +} + +DEVICE_2_WIRELESS = { + ".id": "*265", + "interface": "wlan1", + "mac-address": "00:00:00:00:00:02", + "ap": False, + "wds": False, + "bridge": False, + "rx-rate": "72.2Mbps-20MHz/1S/SGI", + "tx-rate": "72.2Mbps-20MHz/1S/SGI", + "packets": "59542,17464", + "bytes": "17536671,2966351", + "frames": "59542,17472", + "frame-bytes": "17655785,2862445", + "hw-frames": "78935,38395", + "hw-frame-bytes": "25636019,4063445", + "tx-frames-timed-out": 0, + "uptime": "5h49m36s", + "last-activity": "170ms", + "signal-strength": "-62@1Mbps", + "signal-to-noise": 52, + "signal-strength-ch0": -63, + "signal-strength-ch1": -69, + "strength-at-rates": "-62@1Mbps 16s330ms,-64@6Mbps 13s560ms,-65@HT20-3 52m6s30ms,-66@HT20-4 52m4s350ms,-66@HT20-5 51m58s580ms,-65@HT20-6 51m24s780ms,-65@HT20-7 5s680ms", + "tx-ccq": 93, + "p-throughput": 54928, + "last-ip": "0.0.0.2", + "802.1x-port-enabled": True, + "authentication-type": "wpa2-psk", + "encryption": "aes-ccm", + "group-encryption": "aes-ccm", + "management-protection": False, + "wmm-enabled": True, + "tx-rate-set": "OFDM:6-54 BW:1x SGI:1x HT:0-7", +} +DHCP_DATA = [DEVICE_1_DHCP, DEVICE_2_DHCP] + +WIRELESS_DATA = [DEVICE_1_WIRELESS] + +ARP_DATA = [ + { + ".id": "*1", + "address": "0.0.0.1", + "mac-address": "00:00:00:00:00:01", + "interface": "bridge", + "published": False, + "invalid": False, + "DHCP": True, + "dynamic": True, + "complete": True, + "disabled": False, + }, + { + ".id": "*2", + "address": "0.0.0.2", + "mac-address": "00:00:00:00:00:02", + "interface": "bridge", + "published": False, + "invalid": False, + "DHCP": True, + "dynamic": True, + "complete": True, + "disabled": False, + }, +] diff --git a/tests/components/mikrotik/test_config_flow.py b/tests/components/mikrotik/test_config_flow.py new file mode 100644 index 00000000000..25f541e9287 --- /dev/null +++ b/tests/components/mikrotik/test_config_flow.py @@ -0,0 +1,208 @@ +"""Test Mikrotik setup process.""" +from datetime import timedelta +from unittest.mock import patch + +import librouteros +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components import mikrotik +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VERIFY_SSL, +) + +from tests.common import MockConfigEntry + +DEMO_USER_INPUT = { + CONF_NAME: "Home router", + CONF_HOST: "0.0.0.0", + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_PORT: 8278, + CONF_VERIFY_SSL: False, +} + +DEMO_CONFIG = { + CONF_NAME: "Home router", + CONF_HOST: "0.0.0.0", + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_PORT: 8278, + CONF_VERIFY_SSL: False, + mikrotik.const.CONF_FORCE_DHCP: False, + mikrotik.CONF_ARP_PING: False, + mikrotik.CONF_DETECTION_TIME: timedelta(seconds=30), +} + +DEMO_CONFIG_ENTRY = { + CONF_NAME: "Home router", + CONF_HOST: "0.0.0.0", + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_PORT: 8278, + CONF_VERIFY_SSL: False, + mikrotik.const.CONF_FORCE_DHCP: False, + mikrotik.CONF_ARP_PING: False, + mikrotik.CONF_DETECTION_TIME: 30, +} + + +@pytest.fixture(name="api") +def mock_mikrotik_api(): + """Mock an api.""" + with patch("librouteros.connect"): + yield + + +@pytest.fixture(name="auth_error") +def mock_api_authentication_error(): + """Mock an api.""" + with patch( + "librouteros.connect", + side_effect=librouteros.exceptions.TrapError("invalid user name or password"), + ): + yield + + +@pytest.fixture(name="conn_error") +def mock_api_connection_error(): + """Mock an api.""" + with patch( + "librouteros.connect", side_effect=librouteros.exceptions.ConnectionClosed + ): + yield + + +async def test_import(hass, api): + """Test import step.""" + result = await hass.config_entries.flow.async_init( + mikrotik.DOMAIN, context={"source": "import"}, data=DEMO_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Home router" + assert result["data"][CONF_NAME] == "Home router" + assert result["data"][CONF_HOST] == "0.0.0.0" + assert result["data"][CONF_USERNAME] == "username" + assert result["data"][CONF_PASSWORD] == "password" + assert result["data"][CONF_PORT] == 8278 + assert result["data"][CONF_VERIFY_SSL] is False + + +async def test_flow_works(hass, api): + """Test config flow.""" + + result = await hass.config_entries.flow.async_init( + mikrotik.DOMAIN, context={"source": "user"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=DEMO_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Home router" + assert result["data"][CONF_NAME] == "Home router" + assert result["data"][CONF_HOST] == "0.0.0.0" + assert result["data"][CONF_USERNAME] == "username" + assert result["data"][CONF_PASSWORD] == "password" + assert result["data"][CONF_PORT] == 8278 + + +async def test_options(hass): + """Test updating options.""" + entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=DEMO_CONFIG_ENTRY) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "device_tracker" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + mikrotik.CONF_DETECTION_TIME: 30, + mikrotik.CONF_ARP_PING: True, + mikrotik.const.CONF_FORCE_DHCP: False, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + mikrotik.CONF_DETECTION_TIME: 30, + mikrotik.CONF_ARP_PING: True, + mikrotik.const.CONF_FORCE_DHCP: False, + } + + +async def test_host_already_configured(hass, auth_error): + """Test host already configured.""" + + entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=DEMO_CONFIG_ENTRY) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + mikrotik.DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=DEMO_USER_INPUT + ) + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_name_exists(hass, api): + """Test name already configured.""" + + entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=DEMO_CONFIG_ENTRY) + entry.add_to_hass(hass) + user_input = DEMO_USER_INPUT.copy() + user_input[CONF_HOST] = "0.0.0.1" + + result = await hass.config_entries.flow.async_init( + mikrotik.DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=user_input + ) + + assert result["type"] == "form" + assert result["errors"] == {CONF_NAME: "name_exists"} + + +async def test_connection_error(hass, conn_error): + """Test error when connection is unsuccesful.""" + + result = await hass.config_entries.flow.async_init( + mikrotik.DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=DEMO_USER_INPUT + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_wrong_credentials(hass, auth_error): + """Test error when credentials are wrong.""" + + result = await hass.config_entries.flow.async_init( + mikrotik.DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=DEMO_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == { + CONF_USERNAME: "wrong_credentials", + CONF_PASSWORD: "wrong_credentials", + } diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py new file mode 100644 index 00000000000..643f94a5ad5 --- /dev/null +++ b/tests/components/mikrotik/test_device_tracker.py @@ -0,0 +1,118 @@ +"""The tests for the Mikrotik device tracker platform.""" +from datetime import timedelta + +from homeassistant.components import mikrotik +import homeassistant.components.device_tracker as device_tracker +from homeassistant.helpers import entity_registry +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from . import DEVICE_2_WIRELESS, DHCP_DATA, MOCK_DATA, MOCK_OPTIONS, WIRELESS_DATA +from .test_hub import setup_mikrotik_entry + +from tests.common import MockConfigEntry, patch + +DEFAULT_DETECTION_TIME = timedelta(seconds=300) + + +def mock_command(self, cmd, params=None): + """Mock the Mikrotik command method.""" + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIRELESS]: + return True + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.DHCP]: + return DHCP_DATA + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIRELESS]: + return WIRELESS_DATA + return {} + + +async def test_platform_manually_configured(hass): + """Test that nothing happens when configuring mikrotik through device tracker platform.""" + assert ( + await async_setup_component( + hass, + device_tracker.DOMAIN, + {device_tracker.DOMAIN: {"platform": "mikrotik"}}, + ) + is False + ) + assert mikrotik.DOMAIN not in hass.data + + +async def test_device_trackers(hass): + """Test device_trackers created by mikrotik.""" + + # test devices are added from wireless list only + hub = await setup_mikrotik_entry(hass) + + device_1 = hass.states.get("device_tracker.device_1") + assert device_1 is not None + assert device_1.state == "home" + device_2 = hass.states.get("device_tracker.device_2") + assert device_2 is None + + with patch.object(mikrotik.hub.MikrotikData, "command", new=mock_command): + # test device_2 is added after connecting to wireless network + WIRELESS_DATA.append(DEVICE_2_WIRELESS) + + await hub.async_update() + await hass.async_block_till_done() + + device_2 = hass.states.get("device_tracker.device_2") + assert device_2 is not None + assert device_2.state == "home" + + # test state remains home if last_seen consider_home_interval + del WIRELESS_DATA[1] # device 2 is removed from wireless list + hub.api.devices["00:00:00:00:00:02"]._last_seen = dt_util.utcnow() - timedelta( + minutes=4 + ) + await hub.async_update() + await hass.async_block_till_done() + + device_2 = hass.states.get("device_tracker.device_2") + assert device_2.state != "not_home" + + # test state changes to away if last_seen > consider_home_interval + hub.api.devices["00:00:00:00:00:02"]._last_seen = dt_util.utcnow() - timedelta( + minutes=5 + ) + await hub.async_update() + await hass.async_block_till_done() + + device_2 = hass.states.get("device_tracker.device_2") + assert device_2.state == "not_home" + + +async def test_restoring_devices(hass): + """Test restoring existing device_tracker entities if not detected on startup.""" + config_entry = MockConfigEntry( + domain=mikrotik.DOMAIN, data=MOCK_DATA, options=MOCK_OPTIONS + ) + config_entry.add_to_hass(hass) + + registry = await entity_registry.async_get_registry(hass) + registry.async_get_or_create( + device_tracker.DOMAIN, + mikrotik.DOMAIN, + "00:00:00:00:00:01", + suggested_object_id="device_1", + config_entry=config_entry, + ) + registry.async_get_or_create( + device_tracker.DOMAIN, + mikrotik.DOMAIN, + "00:00:00:00:00:02", + suggested_object_id="device_2", + config_entry=config_entry, + ) + + await setup_mikrotik_entry(hass) + + # test device_2 which is not in wireless list is restored + device_1 = hass.states.get("device_tracker.device_1") + assert device_1 is not None + assert device_1.state == "home" + device_2 = hass.states.get("device_tracker.device_2") + assert device_2 is not None + assert device_2.state == "not_home" diff --git a/tests/components/mikrotik/test_hub.py b/tests/components/mikrotik/test_hub.py new file mode 100644 index 00000000000..fc37c9113ae --- /dev/null +++ b/tests/components/mikrotik/test_hub.py @@ -0,0 +1,179 @@ +"""Test Mikrotik hub.""" +from asynctest import patch +import librouteros + +from homeassistant import config_entries +from homeassistant.components import mikrotik + +from . import ARP_DATA, DHCP_DATA, MOCK_DATA, MOCK_OPTIONS, WIRELESS_DATA + +from tests.common import MockConfigEntry + + +async def setup_mikrotik_entry(hass, **kwargs): + """Set up Mikrotik intergation successfully.""" + support_wireless = kwargs.get("support_wireless", True) + dhcp_data = kwargs.get("dhcp_data", DHCP_DATA) + wireless_data = kwargs.get("wireless_data", WIRELESS_DATA) + + def mock_command(self, cmd, params=None): + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIRELESS]: + return support_wireless + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.DHCP]: + return dhcp_data + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.WIRELESS]: + return wireless_data + if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.ARP]: + return ARP_DATA + return {} + + config_entry = MockConfigEntry( + domain=mikrotik.DOMAIN, data=MOCK_DATA, options=MOCK_OPTIONS + ) + config_entry.add_to_hass(hass) + + if "force_dhcp" in kwargs: + config_entry.options["force_dhcp"] = True + + if "arp_ping" in kwargs: + config_entry.options["arp_ping"] = True + + with patch("librouteros.connect"), patch.object( + mikrotik.hub.MikrotikData, "command", new=mock_command + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return hass.data[mikrotik.DOMAIN][config_entry.entry_id] + + +async def test_hub_setup_successful(hass): + """Successful setup of Mikrotik hub.""" + with patch( + "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup", + return_value=True, + ) as forward_entry_setup: + hub = await setup_mikrotik_entry(hass) + + assert hub.config_entry.data == { + mikrotik.CONF_NAME: "Mikrotik", + mikrotik.CONF_HOST: "0.0.0.0", + mikrotik.CONF_USERNAME: "user", + mikrotik.CONF_PASSWORD: "pass", + mikrotik.CONF_PORT: 8278, + mikrotik.CONF_VERIFY_SSL: False, + } + assert hub.config_entry.options == { + mikrotik.hub.CONF_FORCE_DHCP: False, + mikrotik.CONF_ARP_PING: False, + mikrotik.CONF_DETECTION_TIME: 300, + } + + assert hub.api.available is True + assert hub.signal_update == "mikrotik-update-0.0.0.0" + assert forward_entry_setup.mock_calls[0][1] == (hub.config_entry, "device_tracker") + + +async def test_hub_setup_failed(hass): + """Failed setup of Mikrotik hub.""" + + config_entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA) + config_entry.add_to_hass(hass) + # error when connection fails + with patch( + "librouteros.connect", side_effect=librouteros.exceptions.ConnectionClosed + ): + + await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.state == config_entries.ENTRY_STATE_SETUP_RETRY + + # error when username or password is invalid + config_entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup" + ) as forward_entry_setup, patch( + "librouteros.connect", + side_effect=librouteros.exceptions.TrapError("invalid user name or password"), + ): + + result = await hass.config_entries.async_setup(config_entry.entry_id) + + assert result is False + assert len(forward_entry_setup.mock_calls) == 0 + + +async def test_update_failed(hass): + """Test failing to connect during update.""" + + hub = await setup_mikrotik_entry(hass) + + with patch.object( + mikrotik.hub.MikrotikData, "command", side_effect=mikrotik.errors.CannotConnect + ): + await hub.async_update() + + assert hub.api.available is False + + +async def test_hub_not_support_wireless(hass): + """Test updating hub devices when hub doesn't support wireless interfaces.""" + + # test that the devices are constructed from dhcp data + + hub = await setup_mikrotik_entry(hass, support_wireless=False) + + assert hub.api.devices["00:00:00:00:00:01"]._params == DHCP_DATA[0] + assert hub.api.devices["00:00:00:00:00:01"]._wireless_params is None + assert hub.api.devices["00:00:00:00:00:02"]._params == DHCP_DATA[1] + assert hub.api.devices["00:00:00:00:00:02"]._wireless_params is None + + +async def test_hub_support_wireless(hass): + """Test updating hub devices when hub support wireless interfaces.""" + + # test that the device list is from wireless data list + + hub = await setup_mikrotik_entry(hass) + + assert hub.api.support_wireless is True + assert hub.api.devices["00:00:00:00:00:01"]._params == DHCP_DATA[0] + assert hub.api.devices["00:00:00:00:00:01"]._wireless_params == WIRELESS_DATA[0] + + # devices not in wireless list will not be added + assert "00:00:00:00:00:02" not in hub.api.devices + + +async def test_force_dhcp(hass): + """Test updating hub devices with forced dhcp method.""" + + # test that the devices are constructed from dhcp data + + hub = await setup_mikrotik_entry(hass, force_dhcp=True) + + assert hub.api.support_wireless is True + assert hub.api.devices["00:00:00:00:00:01"]._params == DHCP_DATA[0] + assert hub.api.devices["00:00:00:00:00:01"]._wireless_params == WIRELESS_DATA[0] + + # devices not in wireless list are added from dhcp + assert hub.api.devices["00:00:00:00:00:02"]._params == DHCP_DATA[1] + assert hub.api.devices["00:00:00:00:00:02"]._wireless_params is None + + +async def test_arp_ping(hass): + """Test arp ping devices to confirm they are connected.""" + + # test device show as home if arp ping returns value + with patch.object(mikrotik.hub.MikrotikData, "do_arp_ping", return_value=True): + hub = await setup_mikrotik_entry(hass, arp_ping=True, force_dhcp=True) + + assert hub.api.devices["00:00:00:00:00:01"].last_seen is not None + assert hub.api.devices["00:00:00:00:00:02"].last_seen is not None + + # test device show as away if arp ping times out + with patch.object(mikrotik.hub.MikrotikData, "do_arp_ping", return_value=False): + hub = await setup_mikrotik_entry(hass, arp_ping=True, force_dhcp=True) + + assert hub.api.devices["00:00:00:00:00:01"].last_seen is not None + # this device is not wireless so it will show as away + assert hub.api.devices["00:00:00:00:00:02"].last_seen is None diff --git a/tests/components/mikrotik/test_init.py b/tests/components/mikrotik/test_init.py new file mode 100644 index 00000000000..bf2b19c735c --- /dev/null +++ b/tests/components/mikrotik/test_init.py @@ -0,0 +1,83 @@ +"""Test Mikrotik setup process.""" +from unittest.mock import Mock, patch + +from homeassistant.components import mikrotik +from homeassistant.setup import async_setup_component + +from . import MOCK_DATA + +from tests.common import MockConfigEntry, mock_coro + + +async def test_setup_with_no_config(hass): + """Test that we do not discover anything or try to set up a hub.""" + assert await async_setup_component(hass, mikrotik.DOMAIN, {}) is True + assert mikrotik.DOMAIN not in hass.data + + +async def test_successful_config_entry(hass): + """Test config entry successfull setup.""" + entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA,) + entry.add_to_hass(hass) + mock_registry = Mock() + + with patch.object(mikrotik, "MikrotikHub") as mock_hub, patch( + "homeassistant.helpers.device_registry.async_get_registry", + return_value=mock_coro(mock_registry), + ): + mock_hub.return_value.async_setup.return_value = mock_coro(True) + mock_hub.return_value.serial_num = "12345678" + mock_hub.return_value.model = "RB750" + mock_hub.return_value.hostname = "mikrotik" + mock_hub.return_value.firmware = "3.65" + assert await mikrotik.async_setup_entry(hass, entry) is True + + assert len(mock_hub.mock_calls) == 2 + p_hass, p_entry = mock_hub.mock_calls[0][1] + + assert p_hass is hass + assert p_entry is entry + + assert len(mock_registry.mock_calls) == 1 + assert mock_registry.mock_calls[0][2] == { + "config_entry_id": entry.entry_id, + "connections": {("mikrotik", "12345678")}, + "manufacturer": mikrotik.ATTR_MANUFACTURER, + "model": "RB750", + "name": "mikrotik", + "sw_version": "3.65", + } + + +async def test_hub_fail_setup(hass): + """Test that a failed setup will not store the hub.""" + entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA,) + entry.add_to_hass(hass) + + with patch.object(mikrotik, "MikrotikHub") as mock_hub: + mock_hub.return_value.async_setup.return_value = mock_coro(False) + assert await mikrotik.async_setup_entry(hass, entry) is False + + assert mikrotik.DOMAIN not in hass.data + + +async def test_unload_entry(hass): + """Test being able to unload an entry.""" + entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA,) + entry.add_to_hass(hass) + + with patch.object(mikrotik, "MikrotikHub") as mock_hub, patch( + "homeassistant.helpers.device_registry.async_get_registry", + return_value=mock_coro(Mock()), + ): + mock_hub.return_value.async_setup.return_value = mock_coro(True) + mock_hub.return_value.serial_num = "12345678" + mock_hub.return_value.model = "RB750" + mock_hub.return_value.hostname = "mikrotik" + mock_hub.return_value.firmware = "3.65" + assert await mikrotik.async_setup_entry(hass, entry) is True + + assert len(mock_hub.return_value.mock_calls) == 1 + + assert await mikrotik.async_unload_entry(hass, entry) + assert entry.entry_id not in hass.data[mikrotik.DOMAIN] From 13aae8b5ec0ce39583032eeb51ce710315827c63 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 2 Feb 2020 20:31:39 -0800 Subject: [PATCH 39/54] Bumped version to 0.105.0b6 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index c0b1c7424cc..5df3dbc2fa5 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 105 -PATCH_VERSION = "0b5" +PATCH_VERSION = "0b6" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From 9bb8b2bc0023d59178c0627f6af09bfc3d7024f1 Mon Sep 17 00:00:00 2001 From: Yarmo Mackenbach Date: Mon, 3 Feb 2020 13:39:45 +0000 Subject: [PATCH 40/54] Update NSAPI to 3.0.2 (#30971) * Bump NSAPI version to 3.0.1 * Compatibility with NSAPI 3.0.1 response * Removed commented code * Obsolete setups receive an upgrade notification * Bump NS-API to 3.0.2 * Assign platform values directly * Removed obsolete config warning * Improved reference to obsolete password --- .../nederlandse_spoorwegen/manifest.json | 2 +- .../nederlandse_spoorwegen/sensor.py | 55 ++++++++++++------- requirements_all.txt | 2 +- 3 files changed, 38 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/nederlandse_spoorwegen/manifest.json b/homeassistant/components/nederlandse_spoorwegen/manifest.json index 92231bd460c..8718843d73d 100644 --- a/homeassistant/components/nederlandse_spoorwegen/manifest.json +++ b/homeassistant/components/nederlandse_spoorwegen/manifest.json @@ -2,7 +2,7 @@ "domain": "nederlandse_spoorwegen", "name": "Nederlandse Spoorwegen (NS)", "documentation": "https://www.home-assistant.io/integrations/nederlandse_spoorwegen", - "requirements": ["nsapi==3.0.0"], + "requirements": ["nsapi==3.0.2"], "dependencies": [], "codeowners": ["@YarmoM"] } diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index 5477aaf0e2b..df37fad2aa3 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -51,7 +51,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): requests.exceptions.ConnectionError, requests.exceptions.HTTPError, ) as error: - _LOGGER.error("Couldn't fetch stations, API password correct?: %s", error) + _LOGGER.error("Couldn't fetch stations, API key correct?: %s", error) return sensors = [] @@ -127,20 +127,16 @@ class NSDepartureSensor(Entity): # Static attributes attributes = { "going": self._trips[0].going, - "departure_time_planned": self._trips[0].departure_time_planned.strftime( - "%H:%M" - ), + "departure_time_planned": None, "departure_time_actual": None, "departure_delay": False, "departure_platform_planned": self._trips[0].departure_platform_planned, - "departure_platform_actual": None, - "arrival_time_planned": self._trips[0].arrival_time_planned.strftime( - "%H:%M" - ), + "departure_platform_actual": self._trips[0].departure_platform_actual, + "arrival_time_planned": None, "arrival_time_actual": None, "arrival_delay": False, - "arrival_platform_platform": self._trips[0].arrival_platform_planned, - "arrival_platform_actual": None, + "arrival_platform_planned": self._trips[0].arrival_platform_planned, + "arrival_platform_actual": self._trips[0].arrival_platform_actual, "next": None, "status": self._trips[0].status.lower(), "transfers": self._trips[0].nr_transfers, @@ -149,25 +145,46 @@ class NSDepartureSensor(Entity): ATTR_ATTRIBUTION: ATTRIBUTION, } - # Departure attributes + # Planned departure attributes + if self._trips[0].departure_time_planned is not None: + attributes["departure_time_planned"] = self._trips[ + 0 + ].departure_time_planned.strftime("%H:%M") + + # Actual departure attributes if self._trips[0].departure_time_actual is not None: attributes["departure_time_actual"] = self._trips[ 0 ].departure_time_actual.strftime("%H:%M") - attributes["departure_delay"] = True - attributes["departure_platform_actual"] = self._trips[ - 0 - ].departure_platform_actual - # Arrival attributes + # Delay departure attributes + if ( + attributes["departure_time_planned"] + and attributes["departure_time_actual"] + and attributes["departure_time_planned"] + != attributes["departure_time_actual"] + ): + attributes["departure_delay"] = True + + # Planned arrival attributes + if self._trips[0].arrival_time_planned is not None: + attributes["arrival_time_planned"] = self._trips[ + 0 + ].arrival_time_planned.strftime("%H:%M") + + # Actual arrival attributes if self._trips[0].arrival_time_actual is not None: attributes["arrival_time_actual"] = self._trips[ 0 ].arrival_time_actual.strftime("%H:%M") + + # Delay arrival attributes + if ( + attributes["arrival_time_planned"] + and attributes["arrival_time_actual"] + and attributes["arrival_time_planned"] != attributes["arrival_time_actual"] + ): attributes["arrival_delay"] = True - attributes["arrival_platform_actual"] = self._trips[ - 0 - ].arrival_platform_actual # Next attributes if self._trips[1].departure_time_actual is not None: diff --git a/requirements_all.txt b/requirements_all.txt index fec294f1fc5..a494fc645f4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -905,7 +905,7 @@ niko-home-control==0.2.1 niluclient==0.1.2 # homeassistant.components.nederlandse_spoorwegen -nsapi==3.0.0 +nsapi==3.0.2 # homeassistant.components.nsw_fuel_station nsw-fuel-api-client==1.0.10 From 2f2146c989c280d59e461aa5d115979f34d16002 Mon Sep 17 00:00:00 2001 From: escoand Date: Mon, 3 Feb 2020 20:34:02 +0100 Subject: [PATCH 41/54] Samsung TV refinements (#31248) * use st not deviceType * show model in flow title * Update strings.json * add re-auth to entity * add re-auth to config_flow * handle auth popup better * use media player domain const * fix tests * rename not_found to not_successful * authz not authn * Update media_player.py * Update config_flow.py * Update media_player.py * Update test_media_player.py * finalize re-auth * fix ssd tests * better naming * fix ip-address-mock serialization * fix turn_on_action serialization * add type of hass object * fix acces denied test * remove half-added typing * async get ip address * fix pylint --- .../components/samsungtv/__init__.py | 10 ++- .../components/samsungtv/config_flow.py | 70 +++++++++++-------- homeassistant/components/samsungtv/const.py | 2 +- .../components/samsungtv/manifest.json | 2 +- .../components/samsungtv/media_player.py | 35 ++++++++-- .../components/samsungtv/strings.json | 11 +-- homeassistant/generated/ssdp.py | 2 +- .../components/samsungtv/test_config_flow.py | 28 ++++---- tests/components/samsungtv/test_init.py | 8 ++- .../components/samsungtv/test_media_player.py | 41 ++++++++--- 10 files changed, 138 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 5647b407bfb..bc49dc3156d 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -3,6 +3,7 @@ import socket import voluptuous as vol +from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT import homeassistant.helpers.config_validation as cv @@ -41,7 +42,14 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass, config): """Set up the Samsung TV integration.""" if DOMAIN in config: + hass.data[DOMAIN] = {} for entry_config in config[DOMAIN]: + ip_address = await hass.async_add_executor_job( + socket.gethostbyname, entry_config[CONF_HOST] + ) + hass.data[DOMAIN][ip_address] = { + CONF_ON_ACTION: entry_config.get(CONF_ON_ACTION) + } hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": "import"}, data=entry_config @@ -54,7 +62,7 @@ async def async_setup(hass, config): async def async_setup_entry(hass, entry): """Set up the Samsung TV platform.""" hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, "media_player") + hass.config_entries.async_forward_entry_setup(entry, MP_DOMAIN) ) return True diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index 0bf39cc248b..debe7349b6c 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -9,7 +9,6 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components.ssdp import ( ATTR_SSDP_LOCATION, - ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_MANUFACTURER, ATTR_UPNP_MODEL_NAME, ATTR_UPNP_UDN, @@ -24,20 +23,13 @@ from homeassistant.const import ( ) # pylint:disable=unused-import -from .const import ( - CONF_MANUFACTURER, - CONF_MODEL, - CONF_ON_ACTION, - DOMAIN, - LOGGER, - METHODS, -) +from .const import CONF_MANUFACTURER, CONF_MODEL, DOMAIN, LOGGER, METHODS DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str, vol.Required(CONF_NAME): str}) RESULT_AUTH_MISSING = "auth_missing" RESULT_SUCCESS = "success" -RESULT_NOT_FOUND = "not_found" +RESULT_NOT_SUCCESSFUL = "not_successful" RESULT_NOT_SUPPORTED = "not_supported" @@ -63,23 +55,21 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._method = None self._model = None self._name = None - self._on_script = None self._port = None self._title = None - self._uuid = None + self._id = None def _get_entry(self): return self.async_create_entry( title=self._title, data={ CONF_HOST: self._host, - CONF_ID: self._uuid, + CONF_ID: self._id, CONF_IP_ADDRESS: self._ip, CONF_MANUFACTURER: self._manufacturer, CONF_METHOD: self._method, CONF_MODEL: self._model, CONF_NAME: self._name, - CONF_ON_ACTION: self._on_script, CONF_PORT: self._port, }, ) @@ -94,7 +84,8 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): "host": self._host, "method": method, "port": self._port, - "timeout": 1, + # We need this high timeout because waiting for auth popup is just an open socket + "timeout": 31, } try: LOGGER.debug("Try config: %s", config) @@ -108,15 +99,14 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except UnhandledResponse: LOGGER.debug("Working but unsupported config: %s", config) return RESULT_NOT_SUPPORTED - except (OSError): - LOGGER.debug("Failing config: %s", config) + except OSError as err: + LOGGER.debug("Failing config: %s, error: %s", config, err) LOGGER.debug("No working config found") - return RESULT_NOT_FOUND + return RESULT_NOT_SUCCESSFUL async def async_step_import(self, user_input=None): """Handle configuration by yaml file.""" - self._on_script = user_input.get(CONF_ON_ACTION) self._port = user_input.get(CONF_PORT) return await self.async_step_user(user_input) @@ -133,7 +123,8 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._host = user_input.get(CONF_HOST) self._ip = self.context[CONF_IP_ADDRESS] = ip_address - self._title = user_input.get(CONF_NAME) + self._name = user_input.get(CONF_NAME) + self._title = self._name result = await self.hass.async_add_executor_job(self._try_connect) @@ -150,24 +141,27 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._host = host self._ip = self.context[CONF_IP_ADDRESS] = ip_address - self._manufacturer = user_input[ATTR_UPNP_MANUFACTURER] - self._model = user_input[ATTR_UPNP_MODEL_NAME] - self._name = user_input[ATTR_UPNP_FRIENDLY_NAME] - if self._name.startswith("[TV]"): - self._name = self._name[4:] - self._title = f"{self._name} ({self._model})" - self._uuid = user_input[ATTR_UPNP_UDN] - if self._uuid.startswith("uuid:"): - self._uuid = self._uuid[5:] + self._manufacturer = user_input.get(ATTR_UPNP_MANUFACTURER) + self._model = user_input.get(ATTR_UPNP_MODEL_NAME) + self._name = f"Samsung {self._model}" + self._id = user_input.get(ATTR_UPNP_UDN) + self._title = self._model + + # probably access denied + if self._id is None: + return self.async_abort(reason=RESULT_AUTH_MISSING) + if self._id.startswith("uuid:"): + self._id = self._id[5:] config_entry = await self.async_set_unique_id(ip_address) if config_entry: - config_entry.data[CONF_ID] = self._uuid + config_entry.data[CONF_ID] = self._id config_entry.data[CONF_MANUFACTURER] = self._manufacturer config_entry.data[CONF_MODEL] = self._model self.hass.config_entries.async_update_entry(config_entry) return self.async_abort(reason="already_configured") + self.context["title_placeholders"] = {"model": self._model} return await self.async_step_confirm() async def async_step_confirm(self, user_input=None): @@ -182,3 +176,19 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="confirm", description_placeholders={"model": self._model} ) + + async def async_step_reauth(self, user_input=None): + """Handle configuration by re-auth.""" + self._host = user_input[CONF_HOST] + self._id = user_input.get(CONF_ID) + self._ip = user_input[CONF_IP_ADDRESS] + self._manufacturer = user_input.get(CONF_MANUFACTURER) + self._model = user_input.get(CONF_MODEL) + self._name = user_input.get(CONF_NAME) + self._port = user_input.get(CONF_PORT) + self._title = self._model or self._name + + await self.async_set_unique_id(self._ip) + self.context["title_placeholders"] = {"model": self._title} + + return await self.async_step_confirm() diff --git a/homeassistant/components/samsungtv/const.py b/homeassistant/components/samsungtv/const.py index 7cf71e406cb..ea893390a5b 100644 --- a/homeassistant/components/samsungtv/const.py +++ b/homeassistant/components/samsungtv/const.py @@ -4,7 +4,7 @@ import logging LOGGER = logging.getLogger(__package__) DOMAIN = "samsungtv" -DEFAULT_NAME = "Samsung TV Remote" +DEFAULT_NAME = "Samsung TV" CONF_MANUFACTURER = "manufacturer" CONF_MODEL = "model" diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 0d0a360fc20..3adc3b52eb3 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -7,7 +7,7 @@ ], "ssdp": [ { - "deviceType": "urn:samsung.com:device:RemoteControlReceiver:1" + "st": "urn:samsung.com:device:RemoteControlReceiver:1" } ], "dependencies": [], diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index aca54838a99..8de42d157b7 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -23,7 +23,9 @@ from homeassistant.components.media_player.const import ( from homeassistant.const import ( CONF_HOST, CONF_ID, + CONF_IP_ADDRESS, CONF_METHOD, + CONF_NAME, CONF_PORT, STATE_OFF, STATE_ON, @@ -59,8 +61,16 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Samsung TV from a config entry.""" - turn_on_action = config_entry.data.get(CONF_ON_ACTION) - on_script = Script(hass, turn_on_action) if turn_on_action else None + ip_address = config_entry.data[CONF_IP_ADDRESS] + on_script = None + if ( + DOMAIN in hass.data + and ip_address in hass.data[DOMAIN] + and CONF_ON_ACTION in hass.data[DOMAIN][ip_address] + and hass.data[DOMAIN][ip_address][CONF_ON_ACTION] + ): + turn_on_action = hass.data[DOMAIN][ip_address][CONF_ON_ACTION] + on_script = Script(hass, turn_on_action) async_add_entities([SamsungTVDevice(config_entry, on_script)]) @@ -70,12 +80,11 @@ class SamsungTVDevice(MediaPlayerDevice): def __init__(self, config_entry, on_script): """Initialize the Samsung device.""" self._config_entry = config_entry - self._name = config_entry.title - self._uuid = config_entry.data.get(CONF_ID) self._manufacturer = config_entry.data.get(CONF_MANUFACTURER) self._model = config_entry.data.get(CONF_MODEL) + self._name = config_entry.data.get(CONF_NAME) self._on_script = on_script - self._update_listener = None + self._uuid = config_entry.data.get(CONF_ID) # Assume that the TV is not muted self._muted = False # Assume that the TV is in Play mode @@ -88,7 +97,7 @@ class SamsungTVDevice(MediaPlayerDevice): # Generate a configuration for the Samsung library self._config = { "name": "HomeAssistant", - "description": self._name, + "description": "HomeAssistant", "id": "ha.component.samsung", "method": config_entry.data[CONF_METHOD], "port": config_entry.data.get(CONF_PORT), @@ -124,7 +133,19 @@ class SamsungTVDevice(MediaPlayerDevice): """Create or return a remote control instance.""" if self._remote is None: # We need to create a new instance to reconnect. - self._remote = SamsungRemote(self._config.copy()) + try: + self._remote = SamsungRemote(self._config.copy()) + # This is only happening when the auth was switched to DENY + # A removed auth will lead to socket timeout because waiting for auth popup is just an open socket + except samsung_exceptions.AccessDenied: + self.hass.async_create_task( + self.hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "reauth"}, + data=self._config_entry.data, + ) + ) + raise return self._remote diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index ee762503e5c..2e36062669f 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -1,10 +1,11 @@ { "config": { + "flow_title": "Samsung TV: {model}", "title": "Samsung TV", "step": { "user": { "title": "Samsung TV", - "description": "Enter your Samsung TV information. If you never connected Home Assistant before you should see a popup on your TV asking for authentication.", + "description": "Enter your Samsung TV information. If you never connected Home Assistant before you should see a popup on your TV asking for authorization.", "data": { "host": "Host or IP address", "name": "Name" @@ -12,15 +13,15 @@ }, "confirm": { "title": "Samsung TV", - "description": "Do you want to set up Samsung TV {model}? If you never connected Home Assistant before you should see a popup on your TV asking for authentication. Manual configurations for this TV will be overwritten." + "description": "Do you want to set up Samsung TV {model}? If you never connected Home Assistant before you should see a popup on your TV asking for authorization. Manual configurations for this TV will be overwritten." } }, "abort": { "already_in_progress": "Samsung TV configuration is already in progress.", "already_configured": "This Samsung TV is already configured.", - "auth_missing": "Home Assistant is not authenticated to connect to this Samsung TV.", - "not_found": "No supported Samsung TV devices found on the network.", - "not_supported": "This Samsung TV devices is currently not supported." + "auth_missing": "Home Assistant is not authorized to connect to this Samsung TV. Please check your TV's settings to authorize Home Assistant.", + "not_successful": "Unable to connect to this Samsung TV device.", + "not_supported": "This Samsung TV device is currently not supported." } } } diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 83f375f031b..bea04484b11 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -38,7 +38,7 @@ SSDP = { ], "samsungtv": [ { - "deviceType": "urn:samsung.com:device:RemoteControlReceiver:1" + "st": "urn:samsung.com:device:RemoteControlReceiver:1" } ], "sonos": [ diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index ce6741f0703..9c8ec3a9a09 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -42,7 +42,7 @@ AUTODETECT_WEBSOCKET = { "method": "websocket", "port": None, "host": "fake_host", - "timeout": 1, + "timeout": 31, } AUTODETECT_LEGACY = { "name": "HomeAssistant", @@ -51,7 +51,7 @@ AUTODETECT_LEGACY = { "method": "legacy", "port": None, "host": "fake_host", - "timeout": 1, + "timeout": 31, } @@ -87,7 +87,7 @@ async def test_user(hass, remote): assert result["type"] == "create_entry" assert result["title"] == "fake_name" assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] is None + assert result["data"][CONF_NAME] == "fake_name" assert result["data"][CONF_MANUFACTURER] is None assert result["data"][CONF_MODEL] is None assert result["data"][CONF_ID] is None @@ -123,19 +123,19 @@ async def test_user_not_supported(hass): assert result["reason"] == "not_supported" -async def test_user_not_found(hass): - """Test starting a flow by user but no device found.""" +async def test_user_not_successful(hass): + """Test starting a flow by user but no connection found.""" with patch( "homeassistant.components.samsungtv.config_flow.Remote", side_effect=OSError("Boom"), ), patch("homeassistant.components.samsungtv.config_flow.socket"): - # device not found + # device not connectable result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA ) assert result["type"] == "abort" - assert result["reason"] == "not_found" + assert result["reason"] == "not_successful" async def test_user_already_configured(hass, remote): @@ -170,9 +170,9 @@ async def test_ssdp(hass, remote): result["flow_id"], user_input="whatever" ) assert result["type"] == "create_entry" - assert result["title"] == "fake_name (fake_model)" + assert result["title"] == "fake_model" assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "fake_name" + assert result["data"][CONF_NAME] == "Samsung fake_model" assert result["data"][CONF_MANUFACTURER] == "fake_manufacturer" assert result["data"][CONF_MODEL] == "fake_model" assert result["data"][CONF_ID] == "fake_uuid" @@ -193,9 +193,9 @@ async def test_ssdp_noprefix(hass, remote): result["flow_id"], user_input="whatever" ) assert result["type"] == "create_entry" - assert result["title"] == "fake2_name (fake2_model)" + assert result["title"] == "fake2_model" assert result["data"][CONF_HOST] == "fake2_host" - assert result["data"][CONF_NAME] == "fake2_name" + assert result["data"][CONF_NAME] == "Samsung fake2_model" assert result["data"][CONF_MANUFACTURER] == "fake2_manufacturer" assert result["data"][CONF_MODEL] == "fake2_model" assert result["data"][CONF_ID] == "fake2_uuid" @@ -245,7 +245,7 @@ async def test_ssdp_not_supported(hass): assert result["reason"] == "not_supported" -async def test_ssdp_not_found(hass): +async def test_ssdp_not_successful(hass): """Test starting a flow from discovery but no device found.""" with patch( "homeassistant.components.samsungtv.config_flow.Remote", @@ -264,7 +264,7 @@ async def test_ssdp_not_found(hass): result["flow_id"], user_input="whatever" ) assert result["type"] == "abort" - assert result["reason"] == "not_found" + assert result["reason"] == "not_successful" async def test_ssdp_already_in_progress(hass, remote): @@ -380,7 +380,7 @@ async def test_autodetect_none(hass, remote): DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA ) assert result["type"] == "abort" - assert result["reason"] == "not_found" + assert result["reason"] == "not_successful" assert remote.call_count == 2 assert remote.call_args_list == [ call(AUTODETECT_WEBSOCKET), diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py index 55ec52b56ae..cd31434e6b0 100644 --- a/tests/components/samsungtv/test_init.py +++ b/tests/components/samsungtv/test_init.py @@ -32,7 +32,7 @@ MOCK_CONFIG = { } REMOTE_CALL = { "name": "HomeAssistant", - "description": MOCK_CONFIG[SAMSUNGTV_DOMAIN][0][CONF_NAME], + "description": "HomeAssistant", "id": "ha.component.samsung", "method": "websocket", "port": MOCK_CONFIG[SAMSUNGTV_DOMAIN][0][CONF_PORT], @@ -44,11 +44,13 @@ REMOTE_CALL = { @pytest.fixture(name="remote") def remote_fixture(): """Patch the samsungctl Remote.""" - with patch("homeassistant.components.samsungtv.socket"), patch( + with patch("homeassistant.components.samsungtv.socket") as socket1, patch( "homeassistant.components.samsungtv.config_flow.socket" - ), patch("homeassistant.components.samsungtv.config_flow.Remote"), patch( + ) as socket2, patch("homeassistant.components.samsungtv.config_flow.Remote"), patch( "homeassistant.components.samsungtv.media_player.SamsungRemote" ) as remote: + socket1.gethostbyname.return_value = "FAKE_IP_ADDRESS" + socket2.gethostbyname.return_value = "FAKE_IP_ADDRESS" yield remote diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index 2b9f379515d..ba245ce7d6f 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -75,15 +75,17 @@ MOCK_CONFIG_NOTURNON = { @pytest.fixture(name="remote") def remote_fixture(): """Patch the samsungctl Remote.""" - with patch("homeassistant.components.samsungtv.config_flow.socket"), patch( - "homeassistant.components.samsungtv.config_flow.Remote" - ), patch( + with patch("homeassistant.components.samsungtv.config_flow.Remote"), patch( + "homeassistant.components.samsungtv.config_flow.socket" + ) as socket1, patch( "homeassistant.components.samsungtv.media_player.SamsungRemote" ) as remote_class, patch( "homeassistant.components.samsungtv.socket" - ): + ) as socket2: remote = mock.Mock() remote_class.return_value = remote + socket1.gethostbyname.return_value = "FAKE_IP_ADDRESS" + socket2.gethostbyname.return_value = "FAKE_IP_ADDRESS" yield remote @@ -135,11 +137,12 @@ async def test_update_on(hass, remote, mock_now): async def test_update_off(hass, remote, mock_now): """Testing update tv off.""" + await setup_samsungtv(hass, MOCK_CONFIG) + with patch( "homeassistant.components.samsungtv.media_player.SamsungRemote", side_effect=[OSError("Boom"), mock.DEFAULT], - ), patch("homeassistant.components.samsungtv.config_flow.socket"): - await setup_samsungtv(hass, MOCK_CONFIG) + ): next_update = mock_now + timedelta(minutes=5) with patch("homeassistant.util.dt.utcnow", return_value=next_update): @@ -150,13 +153,35 @@ async def test_update_off(hass, remote, mock_now): assert state.state == STATE_OFF +async def test_update_access_denied(hass, remote, mock_now): + """Testing update tv unhandled response exception.""" + await setup_samsungtv(hass, MOCK_CONFIG) + + with patch( + "homeassistant.components.samsungtv.media_player.SamsungRemote", + side_effect=exceptions.AccessDenied("Boom"), + ): + + 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() + + assert [ + flow + for flow in hass.config_entries.flow.async_progress() + if flow["context"]["source"] == "reauth" + ] + + async def test_update_unhandled_response(hass, remote, mock_now): """Testing update tv unhandled response exception.""" + await setup_samsungtv(hass, MOCK_CONFIG) + with patch( "homeassistant.components.samsungtv.media_player.SamsungRemote", side_effect=[exceptions.UnhandledResponse("Boom"), mock.DEFAULT], - ), patch("homeassistant.components.samsungtv.config_flow.socket"): - await setup_samsungtv(hass, MOCK_CONFIG) + ): next_update = mock_now + timedelta(minutes=5) with patch("homeassistant.util.dt.utcnow", return_value=next_update): From 1008ab20ba1f40735674e5723a4a25c2e56b5015 Mon Sep 17 00:00:00 2001 From: quthla Date: Mon, 3 Feb 2020 23:09:25 +0100 Subject: [PATCH 42/54] Fix theme color (#31366) --- homeassistant/components/frontend/__init__.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 8039b9947e7..fdea21fe91e 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -342,10 +342,12 @@ def _async_setup_themes(hass, themes): """Update theme_color in manifest.""" name = hass.data[DATA_DEFAULT_THEME] themes = hass.data[DATA_THEMES] - if name != DEFAULT_THEME and PRIMARY_COLOR in themes[name]: - MANIFEST_JSON["theme_color"] = themes[name][PRIMARY_COLOR] - else: - MANIFEST_JSON["theme_color"] = DEFAULT_THEME_COLOR + MANIFEST_JSON["theme_color"] = DEFAULT_THEME_COLOR + if name != DEFAULT_THEME: + MANIFEST_JSON["theme_color"] = themes[name].get( + "app-header-background-color", + themes[name].get(PRIMARY_COLOR, DEFAULT_THEME_COLOR), + ) hass.bus.async_fire( EVENT_THEMES_UPDATED, {"themes": themes, "default_theme": name} ) From 10d5ce24f6f0cee415b43e7e588b0077c20afdee Mon Sep 17 00:00:00 2001 From: Bas Nijholt Date: Mon, 3 Feb 2020 23:22:47 +0100 Subject: [PATCH 43/54] Keep track of the derivative for unit_time (#31397) * keep track of the derivative for unit_time In this way, you will get a better estimate of the derivate during the timescale that is relavant to the sensor. This solved a problem where sensors have a low output resolution. For example a temperature sensor that can only be integer numbers. It might report many values that are the same and then suddenly go up one value. Only in that moment (with the current implementation) the derivative will be finite. With my proposed implementation, this problem will not occur, because it takes the average derivative of the last `unit_time`. * only loop as much as needed * treat the special case of 1 entry * add option time_window * use cv.time_period * fix comment * set time_window=0 by default * rephrase comment * use timedelta for time_window * fix the "G" unit_prefix and add more prefixes https://en.wikipedia.org/wiki/Unit_prefix * add debugging lines * simplify logic * fix bug where the there was a division of unit_time instead of multiplication * simplify tests * add test_data_moving_average_for_discrete_sensor * fix test_dataSet6 * improve readability of the tests * better explain the test * remove debugging log lines --- homeassistant/components/derivative/sensor.py | 60 +++-- tests/components/derivative/test_sensor.py | 205 ++++++------------ 2 files changed, 113 insertions(+), 152 deletions(-) diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 177d1258f3c..5e68b268685 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -27,9 +27,19 @@ CONF_ROUND_DIGITS = "round" CONF_UNIT_PREFIX = "unit_prefix" CONF_UNIT_TIME = "unit_time" CONF_UNIT = "unit" +CONF_TIME_WINDOW = "time_window" # SI Metric prefixes -UNIT_PREFIXES = {None: 1, "k": 10 ** 3, "G": 10 ** 6, "T": 10 ** 9} +UNIT_PREFIXES = { + None: 1, + "n": 1e-9, + "µ": 1e-6, + "m": 1e-3, + "k": 1e3, + "M": 1e6, + "G": 1e9, + "T": 1e12, +} # SI Time prefixes UNIT_TIME = {"s": 1, "min": 60, "h": 60 * 60, "d": 24 * 60 * 60} @@ -37,6 +47,7 @@ UNIT_TIME = {"s": 1, "min": 60, "h": 60 * 60, "d": 24 * 60 * 60} ICON = "mdi:chart-line" DEFAULT_ROUND = 3 +DEFAULT_TIME_WINDOW = 0 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -46,6 +57,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_UNIT_PREFIX, default=None): vol.In(UNIT_PREFIXES), vol.Optional(CONF_UNIT_TIME, default="h"): vol.In(UNIT_TIME), vol.Optional(CONF_UNIT): cv.string, + vol.Optional(CONF_TIME_WINDOW, default=DEFAULT_TIME_WINDOW): cv.time_period, } ) @@ -53,12 +65,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the derivative sensor.""" derivative = DerivativeSensor( - config[CONF_SOURCE], - config.get(CONF_NAME), - config[CONF_ROUND_DIGITS], - config[CONF_UNIT_PREFIX], - config[CONF_UNIT_TIME], - config.get(CONF_UNIT), + source_entity=config[CONF_SOURCE], + name=config.get(CONF_NAME), + round_digits=config[CONF_ROUND_DIGITS], + unit_prefix=config[CONF_UNIT_PREFIX], + unit_time=config[CONF_UNIT_TIME], + unit_of_measurement=config.get(CONF_UNIT), + time_window=config[CONF_TIME_WINDOW], ) async_add_entities([derivative]) @@ -75,11 +88,13 @@ class DerivativeSensor(RestoreEntity): unit_prefix, unit_time, unit_of_measurement, + time_window, ): """Initialize the derivative sensor.""" self._sensor_source_id = source_entity self._round_digits = round_digits self._state = 0 + self._state_list = [] # List of tuples with (timestamp, sensor_value) self._name = name if name is not None else f"{source_entity} derivative" @@ -93,6 +108,7 @@ class DerivativeSensor(RestoreEntity): self._unit_prefix = UNIT_PREFIXES[unit_prefix] self._unit_time = UNIT_TIME[unit_time] + self._time_window = time_window.total_seconds() async def async_added_to_hass(self): """Handle entity which will be added.""" @@ -114,6 +130,19 @@ class DerivativeSensor(RestoreEntity): ): return + now = new_state.last_updated + # Filter out the tuples that are older than (and outside of the) `time_window` + self._state_list = [ + (timestamp, state) + for timestamp, state in self._state_list + if (now - timestamp).total_seconds() < self._time_window + ] + # It can happen that the list is now empty, in that case + # we use the old_state, because we cannot do anything better. + if len(self._state_list) == 0: + self._state_list.append((old_state.last_updated, old_state.state)) + self._state_list.append((new_state.last_updated, new_state.state)) + if self._unit_of_measurement is None: unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) self._unit_of_measurement = self._unit_template.format( @@ -122,13 +151,16 @@ class DerivativeSensor(RestoreEntity): try: # derivative of previous measures. - gradient = 0 - elapsed_time = ( - new_state.last_updated - old_state.last_updated - ).total_seconds() - gradient = Decimal(new_state.state) - Decimal(old_state.state) - derivative = gradient / ( - Decimal(elapsed_time) * (self._unit_prefix * self._unit_time) + last_time, last_value = self._state_list[-1] + first_time, first_value = self._state_list[0] + + elapsed_time = (last_time - first_time).total_seconds() + delta_value = Decimal(last_value) - Decimal(first_value) + derivative = ( + delta_value + / Decimal(elapsed_time) + / Decimal(self._unit_prefix) + * Decimal(self._unit_time) ) assert isinstance(derivative, Decimal) except ValueError as err: diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index 8893319ab36..05ce55223d0 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -38,26 +38,30 @@ async def test_state(hass): assert state.attributes.get("unit_of_measurement") == "kW" -async def test_dataSet1(hass): - """Test derivative sensor state.""" - config = { - "sensor": { - "platform": "derivative", - "name": "power", - "source": "sensor.energy", - "unit_time": "s", - "round": 2, - } +async def _setup_sensor(hass, config): + default_config = { + "platform": "derivative", + "name": "power", + "source": "sensor.energy", + "round": 2, } + config = {"sensor": dict(default_config, **config)} assert await async_setup_component(hass, "sensor", config) entity_id = config["sensor"]["source"] hass.states.async_set(entity_id, 0, {}) await hass.async_block_till_done() + return config, entity_id + + +async def setup_tests(hass, config, times, values, expected_state): + """Test derivative sensor state.""" + config, entity_id = await _setup_sensor(hass, config) + # Testing a energy sensor with non-monotonic intervals and values - for time, value in [(20, 10), (30, 30), (40, 5), (50, 0)]: + for time, value in zip(times, values): now = dt_util.utcnow() + timedelta(seconds=time) with patch("homeassistant.util.dt.utcnow", return_value=now): hass.states.async_set(entity_id, value, {}, force_update=True) @@ -66,163 +70,88 @@ async def test_dataSet1(hass): state = hass.states.get("sensor.power") assert state is not None - assert round(float(state.state), config["sensor"]["round"]) == -0.5 + assert round(float(state.state), config["sensor"]["round"]) == expected_state + + return state + + +async def test_dataSet1(hass): + """Test derivative sensor state.""" + await setup_tests( + hass, + {"unit_time": "s"}, + times=[20, 30, 40, 50], + values=[10, 30, 5, 0], + expected_state=-0.5, + ) async def test_dataSet2(hass): """Test derivative sensor state.""" - config = { - "sensor": { - "platform": "derivative", - "name": "power", - "source": "sensor.energy", - "unit_time": "s", - "round": 2, - } - } - - assert await async_setup_component(hass, "sensor", config) - - entity_id = config["sensor"]["source"] - hass.states.async_set(entity_id, 0, {}) - await hass.async_block_till_done() - - # Testing a energy sensor with non-monotonic intervals and values - for time, value in [(20, 5), (30, 0)]: - now = dt_util.utcnow() + timedelta(seconds=time) - with patch("homeassistant.util.dt.utcnow", return_value=now): - hass.states.async_set(entity_id, value, {}, force_update=True) - await hass.async_block_till_done() - - state = hass.states.get("sensor.power") - assert state is not None - - assert round(float(state.state), config["sensor"]["round"]) == -0.5 + await setup_tests( + hass, {"unit_time": "s"}, times=[20, 30], values=[5, 0], expected_state=-0.5 + ) async def test_dataSet3(hass): """Test derivative sensor state.""" - config = { - "sensor": { - "platform": "derivative", - "name": "power", - "source": "sensor.energy", - "unit_time": "s", - "round": 2, - } - } - - assert await async_setup_component(hass, "sensor", config) - - entity_id = config["sensor"]["source"] - hass.states.async_set(entity_id, 0, {}) - await hass.async_block_till_done() - - # Testing a energy sensor with non-monotonic intervals and values - for time, value in [(20, 5), (30, 10)]: - now = dt_util.utcnow() + timedelta(seconds=time) - with patch("homeassistant.util.dt.utcnow", return_value=now): - hass.states.async_set(entity_id, value, {}, force_update=True) - await hass.async_block_till_done() - - state = hass.states.get("sensor.power") - assert state is not None - - assert round(float(state.state), config["sensor"]["round"]) == 0.5 + state = await setup_tests( + hass, {"unit_time": "s"}, times=[20, 30], values=[5, 10], expected_state=0.5 + ) assert state.attributes.get("unit_of_measurement") == "/s" async def test_dataSet4(hass): """Test derivative sensor state.""" - config = { - "sensor": { - "platform": "derivative", - "name": "power", - "source": "sensor.energy", - "unit_time": "s", - "round": 2, - } - } - - assert await async_setup_component(hass, "sensor", config) - - entity_id = config["sensor"]["source"] - hass.states.async_set(entity_id, 0, {}) - await hass.async_block_till_done() - - # Testing a energy sensor with non-monotonic intervals and values - for time, value in [(20, 5), (30, 5)]: - now = dt_util.utcnow() + timedelta(seconds=time) - with patch("homeassistant.util.dt.utcnow", return_value=now): - hass.states.async_set(entity_id, value, {}, force_update=True) - await hass.async_block_till_done() - - state = hass.states.get("sensor.power") - assert state is not None - - assert round(float(state.state), config["sensor"]["round"]) == 0 + await setup_tests( + hass, {"unit_time": "s"}, times=[20, 30], values=[5, 5], expected_state=0 + ) async def test_dataSet5(hass): """Test derivative sensor state.""" - config = { - "sensor": { - "platform": "derivative", - "name": "power", - "source": "sensor.energy", - "unit_time": "s", - "round": 2, - } - } - - assert await async_setup_component(hass, "sensor", config) - - entity_id = config["sensor"]["source"] - hass.states.async_set(entity_id, 0, {}) - await hass.async_block_till_done() - - # Testing a energy sensor with non-monotonic intervals and values - for time, value in [(20, 10), (30, -10)]: - now = dt_util.utcnow() + timedelta(seconds=time) - with patch("homeassistant.util.dt.utcnow", return_value=now): - hass.states.async_set(entity_id, value, {}, force_update=True) - await hass.async_block_till_done() - - state = hass.states.get("sensor.power") - assert state is not None - - assert round(float(state.state), config["sensor"]["round"]) == -2 + await setup_tests( + hass, {"unit_time": "s"}, times=[20, 30], values=[10, -10], expected_state=-2 + ) async def test_dataSet6(hass): """Test derivative sensor state.""" - config = { - "sensor": { - "platform": "derivative", - "name": "power", - "source": "sensor.energy", - "round": 2, - } - } + await setup_tests(hass, {}, times=[0, 60], values=[0, 1 / 60], expected_state=1) - assert await async_setup_component(hass, "sensor", config) - entity_id = config["sensor"]["source"] - hass.states.async_set(entity_id, 0, {}) - await hass.async_block_till_done() +async def test_data_moving_average_for_discrete_sensor(hass): + """Test derivative sensor state.""" + # We simulate the following situation: + # The temperature rises 1 °C per minute for 1 hour long. + # There is a data point every second, however, the sensor returns + # the temperature rounded down to an integer value. + # We use a time window of 10 minutes and therefore we can expect + # (because the true derivative is 1 °C/min) an error of less than 10%. - # Testing a energy sensor with non-monotonic intervals and values - for time, value in [(20, 0), (30, 36000)]: + temperature_values = [] + for temperature in range(60): + temperature_values += [temperature] * 60 + time_window = 600 + + times = list(range(len(temperature_values))) + config, entity_id = await _setup_sensor( + hass, {"time_window": {"seconds": time_window}, "unit_time": "min", "round": 1} + ) # two minute window + + for time, value in zip(times, temperature_values): now = dt_util.utcnow() + timedelta(seconds=time) with patch("homeassistant.util.dt.utcnow", return_value=now): hass.states.async_set(entity_id, value, {}, force_update=True) await hass.async_block_till_done() - state = hass.states.get("sensor.power") - assert state is not None - - assert round(float(state.state), config["sensor"]["round"]) == 1 + if time_window < time < times[-1] - time_window: + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + # Test that the error is never more than + # (time_window_in_minutes / true_derivative * 100) = 10% + assert abs(1 - derivative) <= 0.1 async def test_prefix(hass): From af75a4bc85aad2da0a3c8acc7ea2568697688fcb Mon Sep 17 00:00:00 2001 From: etheralm <8655564+etheralm@users.noreply.github.com> Date: Tue, 4 Feb 2020 17:23:08 +0100 Subject: [PATCH 44/54] Update libpurecool upstream library to latest version (#31457) * Update upstream library to latest version * update version in requirements_all.txt * update version in requirements_all.txt --- homeassistant/components/dyson/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/dyson/manifest.json b/homeassistant/components/dyson/manifest.json index 4fc49b4ca60..f6c0c187c8c 100644 --- a/homeassistant/components/dyson/manifest.json +++ b/homeassistant/components/dyson/manifest.json @@ -2,7 +2,7 @@ "domain": "dyson", "name": "Dyson", "documentation": "https://www.home-assistant.io/integrations/dyson", - "requirements": ["libpurecool==0.6.0"], + "requirements": ["libpurecool==0.6.1"], "dependencies": [], "codeowners": ["@etheralm"] } diff --git a/requirements_all.txt b/requirements_all.txt index a494fc645f4..29ad55ff7d8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -769,7 +769,7 @@ konnected==0.1.5 lakeside==0.12 # homeassistant.components.dyson -libpurecool==0.6.0 +libpurecool==0.6.1 # homeassistant.components.foscam libpyfoscam==1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 93d29daba4a..791e47ea80a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -281,7 +281,7 @@ keyring==20.0.0 keyrings.alt==3.4.0 # homeassistant.components.dyson -libpurecool==0.6.0 +libpurecool==0.6.1 # homeassistant.components.mikrotik librouteros==3.0.0 From e5b6fbf37423c1c3be5236a3937624b169402ff4 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 4 Feb 2020 17:07:09 +0100 Subject: [PATCH 45/54] Updated frontend to 20200130.1 (#31460) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- requirements_test_pre_commit.txt | 1 - 5 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 6b16970c675..09bd35ba89b 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20200130.0" + "home-assistant-frontend==20200130.1" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7ce2d357f82..41e00c5d8de 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,7 +11,7 @@ cryptography==2.8 defusedxml==0.6.0 distro==1.4.0 hass-nabucasa==0.31 -home-assistant-frontend==20200130.0 +home-assistant-frontend==20200130.1 importlib-metadata==1.4.0 jinja2>=2.10.3 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 29ad55ff7d8..0f44cb7705c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -679,7 +679,7 @@ hole==0.5.0 holidays==0.9.12 # homeassistant.components.frontend -home-assistant-frontend==20200130.0 +home-assistant-frontend==20200130.1 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 791e47ea80a..33d81e4b24d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -247,7 +247,7 @@ hole==0.5.0 holidays==0.9.12 # homeassistant.components.frontend -home-assistant-frontend==20200130.0 +home-assistant-frontend==20200130.1 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 87ff3604dd6..8af2cbb6123 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -2,7 +2,6 @@ bandit==1.6.2 black==19.10b0 -codespell==v1.16.0 flake8-docstrings==1.5.0 flake8==3.7.9 isort==v4.3.21 From d411ae250317069f726ea80a83ed3a81b4250a97 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 4 Feb 2020 09:29:39 -0800 Subject: [PATCH 46/54] Bumped version to 0.105.0b7 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 5df3dbc2fa5..e321d3f8ba3 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 105 -PATCH_VERSION = "0b6" +PATCH_VERSION = "0b7" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From 250951895038de614922a5f3a624e026beabf330 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 4 Feb 2020 14:31:03 -0500 Subject: [PATCH 47/54] Update vizio host check to handle entries that don't have port (#31463) * Update vizio host check to handle entries that don't have port * add comment explaining no_port test for future * remove _name_is_same function and support user updating name in config * Update strings.json Co-authored-by: Paulus Schoutsen --- homeassistant/components/vizio/config_flow.py | 30 +++++++--- homeassistant/components/vizio/strings.json | 4 +- tests/components/vizio/test_config_flow.py | 56 ++++++++++++++++++- 3 files changed, 78 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index 5500ec3db94..04f70da4a8c 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -53,6 +53,11 @@ def _get_config_flow_schema(input_dict: Dict[str, Any] = None) -> vol.Schema: ) +def _host_is_same(host1: str, host2: str) -> bool: + """Check if host1 and host2 are the same.""" + return host1.split(":")[0] == host2.split(":")[0] + + class VizioOptionsConfigFlow(config_entries.OptionsFlow): """Handle Transmission client options.""" @@ -108,7 +113,7 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # Check if new config entry matches any existing config entries for entry in self.hass.config_entries.async_entries(DOMAIN): - if entry.data[CONF_HOST] == user_input[CONF_HOST]: + if _host_is_same(entry.data[CONF_HOST], user_input[CONF_HOST]): errors[CONF_HOST] = "host_exists" break @@ -165,24 +170,31 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Import a config entry from configuration.yaml.""" # Check if new config entry matches any existing config entries for entry in self.hass.config_entries.async_entries(DOMAIN): - if entry.data[CONF_HOST] == import_config[CONF_HOST] and entry.data[ - CONF_NAME - ] == import_config.get(CONF_NAME): + if _host_is_same(entry.data[CONF_HOST], import_config[CONF_HOST]): updated_options = {} + updated_name = {} + + if entry.data[CONF_NAME] != import_config[CONF_NAME]: + updated_name[CONF_NAME] = import_config[CONF_NAME] if entry.data[CONF_VOLUME_STEP] != import_config[CONF_VOLUME_STEP]: updated_options[CONF_VOLUME_STEP] = import_config[CONF_VOLUME_STEP] - if updated_options: + if updated_options or updated_name: new_data = entry.data.copy() - new_data.update(updated_options) new_options = entry.options.copy() - new_options.update(updated_options) + + if updated_name: + new_data.update(updated_name) + + if updated_options: + new_data.update(updated_options) + new_options.update(updated_options) self.hass.config_entries.async_update_entry( entry=entry, data=new_data, options=new_options, ) - return self.async_abort(reason="updated_options") + return self.async_abort(reason="updated_entry") return self.async_abort(reason="already_setup") @@ -199,7 +211,7 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # Check if new config entry matches any existing config entries and abort if so for entry in self.hass.config_entries.async_entries(DOMAIN): - if entry.data[CONF_HOST] == discovery_info[CONF_HOST]: + if _host_is_same(entry.data[CONF_HOST], discovery_info[CONF_HOST]): return self.async_abort(reason="already_setup") # Set default name to discovered device name by stripping zeroconf service diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index 305e49d56f8..64b2fb5f936 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -21,7 +21,7 @@ "abort": { "already_setup": "This entry has already been setup.", "already_setup_with_diff_host_and_name": "This entry appears to have already been setup with a different host and name based on its serial number. Please remove any old entries from your configuration.yaml and from the Integrations menu before reattempting to add this device.", - "updated_options": "This entry has already been setup but the options defined in the config do not match the previously imported options values so the config entry has been updated accordingly." + "updated_entry": "This entry has already been setup but the name and/or options defined in the config do not match the previously imported config so the config entry has been updated accordingly." } }, "options": { @@ -35,4 +35,4 @@ } } } -} \ No newline at end of file +} diff --git a/tests/components/vizio/test_config_flow.py b/tests/components/vizio/test_config_flow.py index c82c7a8de0f..cf6cdb6afdb 100644 --- a/tests/components/vizio/test_config_flow.py +++ b/tests/components/vizio/test_config_flow.py @@ -231,6 +231,30 @@ async def test_user_host_already_configured( assert result["errors"] == {CONF_HOST: "host_exists"} +async def test_user_host_already_configured_no_port( + hass: HomeAssistantType, + vizio_connect: pytest.fixture, + vizio_bypass_setup: pytest.fixture, +) -> None: + """Test host is already configured during user setup when existing entry has no port.""" + # Mock entry without port so we can test that the same entry WITH a port will fail + no_port_entry = MOCK_SPEAKER_CONFIG.copy() + no_port_entry[CONF_HOST] = no_port_entry[CONF_HOST].split(":")[0] + entry = MockConfigEntry( + domain=DOMAIN, data=no_port_entry, options={CONF_VOLUME_STEP: VOLUME_STEP} + ) + entry.add_to_hass(hass) + fail_entry = MOCK_SPEAKER_CONFIG.copy() + fail_entry[CONF_NAME] = "newtestname" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=fail_entry + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_HOST: "host_exists"} + + async def test_user_name_already_configured( hass: HomeAssistantType, vizio_connect: pytest.fixture, @@ -394,13 +418,43 @@ async def test_import_flow_update_options( ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "updated_options" + assert result["reason"] == "updated_entry" assert ( hass.config_entries.async_get_entry(entry_id).options[CONF_VOLUME_STEP] == VOLUME_STEP + 1 ) +async def test_import_flow_update_name( + hass: HomeAssistantType, + vizio_connect: pytest.fixture, + vizio_bypass_update: pytest.fixture, +) -> None: + """Test import config flow with updated name.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=vol.Schema(VIZIO_SCHEMA)(MOCK_IMPORT_VALID_TV_CONFIG), + ) + await hass.async_block_till_done() + + assert result["result"].data[CONF_NAME] == NAME + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + entry_id = result["result"].entry_id + + updated_config = MOCK_IMPORT_VALID_TV_CONFIG.copy() + updated_config[CONF_NAME] = NAME2 + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=vol.Schema(VIZIO_SCHEMA)(updated_config), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "updated_entry" + assert hass.config_entries.async_get_entry(entry_id).data[CONF_NAME] == NAME2 + + async def test_zeroconf_flow( hass: HomeAssistantType, vizio_connect: pytest.fixture, From 6d7989892689e594d25d22fb1bdb77b4ad37263e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 4 Feb 2020 14:57:15 -0800 Subject: [PATCH 48/54] Fix coordinator reference (#31467) --- homeassistant/components/hue/sensor_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hue/sensor_base.py b/homeassistant/components/hue/sensor_base.py index 3db07ba2e5b..f57b0f98d30 100644 --- a/homeassistant/components/hue/sensor_base.py +++ b/homeassistant/components/hue/sensor_base.py @@ -209,7 +209,7 @@ class GenericHueSensor(entity.Entity): Only used by the generic entity update service. """ - await self.bridge.sensor_manager.coordinator.coordinator.async_request_refresh() + await self.bridge.sensor_manager.coordinator.async_request_refresh() @property def device_info(self): From 2d393b8f8b06052c1f768abf66cf1fb6cb6d0233 Mon Sep 17 00:00:00 2001 From: Quentame Date: Wed, 5 Feb 2020 00:26:47 +0100 Subject: [PATCH 49/54] Fix iCloud device battery level can be None (#31468) --- homeassistant/components/icloud/account.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py index afa1ad092a2..af7963d8dc1 100644 --- a/homeassistant/components/icloud/account.py +++ b/homeassistant/components/icloud/account.py @@ -331,14 +331,13 @@ class IcloudDevice: device_status = DEVICE_STATUS_CODES.get(self._status[DEVICE_STATUS], "error") self._attrs[ATTR_DEVICE_STATUS] = device_status - if self._status[DEVICE_BATTERY_STATUS] != "Unknown": - self._battery_level = int(self._status.get(DEVICE_BATTERY_LEVEL, 0) * 100) - self._battery_status = self._status[DEVICE_BATTERY_STATUS] - low_power_mode = self._status[DEVICE_LOW_POWER_MODE] - + self._battery_status = self._status[DEVICE_BATTERY_STATUS] + self._attrs[ATTR_BATTERY_STATUS] = self._battery_status + device_battery_level = self._status.get(DEVICE_BATTERY_LEVEL, 0) + if self._battery_status != "Unknown" and device_battery_level is not None: + self._battery_level = int(device_battery_level * 100) self._attrs[ATTR_BATTERY] = self._battery_level - self._attrs[ATTR_BATTERY_STATUS] = self._battery_status - self._attrs[ATTR_LOW_POWER_MODE] = low_power_mode + self._attrs[ATTR_LOW_POWER_MODE] = self._status[DEVICE_LOW_POWER_MODE] if ( self._status[DEVICE_LOCATION] From 8b6b8f1994d3ba7fac69352d0c507d358dd20961 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 5 Feb 2020 07:52:21 -0800 Subject: [PATCH 50/54] Automation device/entity extraction to include triggers + conditions (#31474) * Add support for extracting triggers * Add support for extracting triggers * Fix test --- .../components/automation/__init__.py | 172 ++++++++++++------ tests/components/automation/test_init.py | 24 ++- tests/components/search/test_init.py | 4 +- 3 files changed, 143 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 45f892d783e..528a314dd7b 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -1,16 +1,19 @@ """Allow to set up simple automation rules via the config file.""" -from functools import partial import importlib import logging -from typing import Any, Awaitable, Callable, List +from typing import Any, Awaitable, Callable, List, Optional, Set import voluptuous as vol +from homeassistant.components import sun from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_NAME, + CONF_DEVICE_ID, + CONF_ENTITY_ID, CONF_ID, CONF_PLATFORM, + CONF_ZONE, EVENT_AUTOMATION_TRIGGERED, EVENT_HOMEASSISTANT_START, SERVICE_RELOAD, @@ -130,7 +133,7 @@ def automations_with_entity(hass: HomeAssistant, entity_id: str) -> List[str]: results = [] for automation_entity in component.entities: - if entity_id in automation_entity.action_script.referenced_entities: + if entity_id in automation_entity.referenced_entities: results.append(automation_entity.entity_id) return results @@ -149,7 +152,7 @@ def entities_in_automation(hass: HomeAssistant, entity_id: str) -> List[str]: if automation_entity is None: return [] - return list(automation_entity.action_script.referenced_entities) + return list(automation_entity.referenced_entities) @callback @@ -163,7 +166,7 @@ def automations_with_device(hass: HomeAssistant, device_id: str) -> List[str]: results = [] for automation_entity in component.entities: - if device_id in automation_entity.action_script.referenced_devices: + if device_id in automation_entity.referenced_devices: results.append(automation_entity.entity_id) return results @@ -182,7 +185,7 @@ def devices_in_automation(hass: HomeAssistant, entity_id: str) -> List[str]: if automation_entity is None: return [] - return list(automation_entity.action_script.referenced_devices) + return list(automation_entity.referenced_devices) async def async_setup(hass, config): @@ -232,7 +235,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity): self, automation_id, name, - async_attach_triggers, + trigger_config, cond_func, action_script, hidden, @@ -241,7 +244,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity): """Initialize an automation entity.""" self._id = automation_id self._name = name - self._async_attach_triggers = async_attach_triggers + self._trigger_config = trigger_config self._async_detach_triggers = None self._cond_func = cond_func self.action_script = action_script @@ -249,6 +252,8 @@ class AutomationEntity(ToggleEntity, RestoreEntity): self._hidden = hidden self._initial_state = initial_state self._is_enabled = False + self._referenced_entities: Optional[Set[str]] = None + self._referenced_devices: Optional[Set[str]] = None @property def name(self): @@ -280,6 +285,45 @@ class AutomationEntity(ToggleEntity, RestoreEntity): """Return True if entity is on.""" return self._async_detach_triggers is not None or self._is_enabled + @property + def referenced_devices(self): + """Return a set of referenced devices.""" + if self._referenced_devices is not None: + return self._referenced_devices + + referenced = self.action_script.referenced_devices + + if self._cond_func is not None: + for conf in self._cond_func.config: + referenced |= condition.async_extract_devices(conf) + + for conf in self._trigger_config: + device = _trigger_extract_device(conf) + if device is not None: + referenced.add(device) + + self._referenced_devices = referenced + return referenced + + @property + def referenced_entities(self): + """Return a set of referenced entities.""" + if self._referenced_entities is not None: + return self._referenced_entities + + referenced = self.action_script.referenced_entities + + if self._cond_func is not None: + for conf in self._cond_func.config: + referenced |= condition.async_extract_entities(conf) + + for conf in self._trigger_config: + for entity_id in _trigger_extract_entities(conf): + referenced.add(entity_id) + + self._referenced_entities = referenced + return referenced + async def async_added_to_hass(self) -> None: """Startup with initial state or previous state.""" await super().async_added_to_hass() @@ -330,7 +374,11 @@ class AutomationEntity(ToggleEntity, RestoreEntity): This method is a coroutine. """ - if not skip_condition and not self._cond_func(variables): + if ( + not skip_condition + and self._cond_func is not None + and not self._cond_func(variables) + ): return # Create a new context referring to the old context. @@ -373,9 +421,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity): # HomeAssistant is starting up if self.hass.state != CoreState.not_running: - self._async_detach_triggers = await self._async_attach_triggers( - self.async_trigger - ) + self._async_detach_triggers = await self._async_attach_triggers() self.async_write_ha_state() return @@ -385,9 +431,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity): if not self._is_enabled or self._async_detach_triggers is not None: return - self._async_detach_triggers = await self._async_attach_triggers( - self.async_trigger - ) + self._async_detach_triggers = await self._async_attach_triggers() self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, async_enable_automation @@ -407,6 +451,38 @@ class AutomationEntity(ToggleEntity, RestoreEntity): self.async_write_ha_state() + async def _async_attach_triggers(self): + """Set up the triggers.""" + removes = [] + info = {"name": self._name} + + for conf in self._trigger_config: + platform = importlib.import_module( + ".{}".format(conf[CONF_PLATFORM]), __name__ + ) + + remove = await platform.async_attach_trigger( + self.hass, conf, self.async_trigger, info + ) + + if not remove: + _LOGGER.error("Error setting up trigger %s", self._name) + continue + + _LOGGER.info("Initialized trigger %s", self._name) + removes.append(remove) + + if not removes: + return None + + @callback + def remove_triggers(): + """Remove attached triggers.""" + for remove in removes: + remove() + + return remove_triggers + @property def device_state_attributes(self): """Return automation attributes.""" @@ -441,22 +517,12 @@ async def _async_process_config(hass, config, component): if cond_func is None: continue else: + cond_func = None - def cond_func(variables): - """Condition will always pass.""" - return True - - async_attach_triggers = partial( - _async_process_trigger, - hass, - config, - config_block.get(CONF_TRIGGER, []), - name, - ) entity = AutomationEntity( automation_id, name, - async_attach_triggers, + config_block[CONF_TRIGGER], cond_func, action_script, hidden, @@ -471,7 +537,7 @@ async def _async_process_config(hass, config, component): async def _async_process_if(hass, config, p_config): """Process if checks.""" - if_configs = p_config.get(CONF_CONDITION) + if_configs = p_config[CONF_CONDITION] checks = [] for if_config in if_configs: @@ -485,35 +551,33 @@ async def _async_process_if(hass, config, p_config): """AND all conditions.""" return all(check(hass, variables) for check in checks) + if_action.config = if_configs + return if_action -async def _async_process_trigger(hass, config, trigger_configs, name, action): - """Set up the triggers. - - This method is a coroutine. - """ - removes = [] - info = {"name": name} - - for conf in trigger_configs: - platform = importlib.import_module(".{}".format(conf[CONF_PLATFORM]), __name__) - - remove = await platform.async_attach_trigger(hass, conf, action, info) - - if not remove: - _LOGGER.error("Error setting up trigger %s", name) - continue - - _LOGGER.info("Initialized trigger %s", name) - removes.append(remove) - - if not removes: +@callback +def _trigger_extract_device(trigger_conf: dict) -> Optional[str]: + """Extract devices from a trigger config.""" + if trigger_conf[CONF_PLATFORM] != "device": return None - def remove_triggers(): - """Remove attached triggers.""" - for remove in removes: - remove() + return trigger_conf[CONF_DEVICE_ID] - return remove_triggers + +@callback +def _trigger_extract_entities(trigger_conf: dict) -> List[str]: + """Extract entities from a trigger config.""" + if trigger_conf[CONF_PLATFORM] in ("state", "numeric_state"): + return trigger_conf[CONF_ENTITY_ID] + + if trigger_conf[CONF_PLATFORM] == "zone": + return trigger_conf[CONF_ENTITY_ID] + [trigger_conf[CONF_ZONE]] + + if trigger_conf[CONF_PLATFORM] == "geo_location": + return [trigger_conf[CONF_ZONE]] + + if trigger_conf[CONF_PLATFORM] == "sun": + return [sun.ENTITY_ID] + + return [] diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 391c9646dd4..c27a0262a4e 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -935,6 +935,11 @@ async def test_extraction_functions(hass): { "alias": "test1", "trigger": {"platform": "state", "entity_id": "sensor.trigger_1"}, + "condition": { + "condition": "state", + "entity_id": "light.condition_state", + "state": "on", + }, "action": [ { "service": "test.script", @@ -954,7 +959,20 @@ async def test_extraction_functions(hass): }, { "alias": "test2", - "trigger": {"platform": "state", "entity_id": "sensor.trigger_2"}, + "trigger": { + "platform": "device", + "domain": "light", + "type": "turned_on", + "entity_id": "light.trigger_2", + "device_id": "trigger-device-2", + }, + "condition": { + "condition": "device", + "device_id": "condition-device", + "domain": "light", + "type": "is_on", + "entity_id": "light.bla", + }, "action": [ { "service": "test.script", @@ -989,6 +1007,8 @@ async def test_extraction_functions(hass): "automation.test2", } assert set(automation.entities_in_automation(hass, "automation.test1")) == { + "sensor.trigger_1", + "light.condition_state", "light.in_both", "light.in_first", } @@ -997,6 +1017,8 @@ async def test_extraction_functions(hass): "automation.test2", } assert set(automation.devices_in_automation(hass, "automation.test2")) == { + "trigger-device-2", + "condition-device", "device-in-both", "device-in-last", } diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index 54a32bed229..a379b91f82a 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -163,7 +163,7 @@ async def test_search(hass): "automation": [ { "alias": "wled_entity", - "trigger": {"platform": "state", "entity_id": "sensor.trigger_1"}, + "trigger": {"platform": "template", "value_template": "true"}, "action": [ { "service": "test.script", @@ -173,7 +173,7 @@ async def test_search(hass): }, { "alias": "wled_device", - "trigger": {"platform": "state", "entity_id": "sensor.trigger_1"}, + "trigger": {"platform": "template", "value_template": "true"}, "action": [ { "domain": "light", From 97250d8225cde6d2ff3332380be4b3e96d95dfa7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 5 Feb 2020 11:04:17 +0100 Subject: [PATCH 51/54] Re-branding of Hass.io panel to Supervisor (#31480) --- homeassistant/components/hassio/__init__.py | 2 +- tests/components/hassio/test_init.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index f70e44cfa55..cc03f26085c 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -194,7 +194,7 @@ async def async_setup(hass, config): await hass.components.panel_custom.async_register_panel( frontend_url_path="hassio", webcomponent_name="hassio-main", - sidebar_title="Hass.io", + sidebar_title="Supervisor", sidebar_icon="hass:home-assistant", js_url="/api/hassio/app/entrypoint.js", embed_iframe=True, diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 1e227f943ed..2751062dedf 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -52,7 +52,7 @@ async def test_setup_api_panel(hass, aioclient_mock): assert panels.get("hassio").to_response() == { "component_name": "custom", "icon": "hass:home-assistant", - "title": "Hass.io", + "title": "Supervisor", "url_path": "hassio", "require_admin": True, "config": { From f1d5fcac75e74148f23cce3a0cccbb8612fcabb4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 5 Feb 2020 17:01:57 +0100 Subject: [PATCH 52/54] Bumped version to 0.105.0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index e321d3f8ba3..d374c85cada 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 105 -PATCH_VERSION = "0b7" +PATCH_VERSION = "0" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From 1ee1a43fb9e036512f4343d6e08338c443193eb7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 5 Feb 2020 09:00:20 -0800 Subject: [PATCH 53/54] Remove tests for deprecated key (#31491) --- .../components/google_assistant/test_http.py | 32 ------------------- 1 file changed, 32 deletions(-) diff --git a/tests/components/google_assistant/test_http.py b/tests/components/google_assistant/test_http.py index 112935f0160..f5e3e505a28 100644 --- a/tests/components/google_assistant/test_http.py +++ b/tests/components/google_assistant/test_http.py @@ -145,38 +145,6 @@ async def test_call_homegraph_api_retry(hass, aioclient_mock, hass_storage): assert call[3] == MOCK_HEADER -async def test_call_homegraph_api_key(hass, aioclient_mock, hass_storage): - """Test the function to call the homegraph api.""" - config = GoogleConfig( - hass, GOOGLE_ASSISTANT_SCHEMA({"project_id": "1234", "api_key": "dummy_key"}), - ) - await config.async_initialize() - - aioclient_mock.post(MOCK_URL, status=200, json={}) - - res = await config.async_call_homegraph_api_key(MOCK_URL, MOCK_JSON) - assert res == 200 - assert aioclient_mock.call_count == 1 - - call = aioclient_mock.mock_calls[0] - assert call[1].query == {"key": "dummy_key"} - assert call[2] == MOCK_JSON - - -async def test_call_homegraph_api_key_fail(hass, aioclient_mock, hass_storage): - """Test the function to call the homegraph api.""" - config = GoogleConfig( - hass, GOOGLE_ASSISTANT_SCHEMA({"project_id": "1234", "api_key": "dummy_key"}), - ) - await config.async_initialize() - - aioclient_mock.post(MOCK_URL, status=666, json={}) - - res = await config.async_call_homegraph_api_key(MOCK_URL, MOCK_JSON) - assert res == 666 - assert aioclient_mock.call_count == 1 - - async def test_report_state(hass, aioclient_mock, hass_storage): """Test the report state function.""" agent_user_id = "user" From 6a4d9d3a730d4fbb79e350145f5ca4140af3d4a6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 5 Feb 2020 09:50:00 -0800 Subject: [PATCH 54/54] Fix Google API key test (#31492) --- tests/components/google_assistant/test_init.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/components/google_assistant/test_init.py b/tests/components/google_assistant/test_init.py index 2773f3c3329..0df2b032b5a 100644 --- a/tests/components/google_assistant/test_init.py +++ b/tests/components/google_assistant/test_init.py @@ -3,17 +3,21 @@ from homeassistant.components import google_assistant as ga from homeassistant.core import Context from homeassistant.setup import async_setup_component -GA_API_KEY = "Agdgjsj399sdfkosd932ksd" +from .test_http import DUMMY_CONFIG async def test_request_sync_service(aioclient_mock, hass): """Test that it posts to the request_sync url.""" + aioclient_mock.post( + ga.const.HOMEGRAPH_TOKEN_URL, + status=200, + json={"access_token": "1234", "expires_in": 3600}, + ) + aioclient_mock.post(ga.const.REQUEST_SYNC_BASE_URL, status=200) await async_setup_component( - hass, - "google_assistant", - {"google_assistant": {"project_id": "test_project", "api_key": GA_API_KEY}}, + hass, "google_assistant", {"google_assistant": DUMMY_CONFIG}, ) assert aioclient_mock.call_count == 0 @@ -24,4 +28,4 @@ async def test_request_sync_service(aioclient_mock, hass): context=Context(user_id="123"), ) - assert aioclient_mock.call_count == 1 + assert aioclient_mock.call_count == 2 # token + request