From 81a2fb9615421b1ac81b4328011897b8e55cba65 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 29 Apr 2020 16:27:45 +0200 Subject: [PATCH 01/67] Reload braviatv entry after options update (#34576) * Reload entry after options update * Undo update listener when unloading --- homeassistant/components/braviatv/__init__.py | 17 +++++++++++++++-- .../components/braviatv/config_flow.py | 3 ++- homeassistant/components/braviatv/const.py | 2 ++ .../components/braviatv/media_player.py | 3 ++- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/braviatv/__init__.py b/homeassistant/components/braviatv/__init__.py index 9c55ef01cee..46fd8675358 100644 --- a/homeassistant/components/braviatv/__init__.py +++ b/homeassistant/components/braviatv/__init__.py @@ -5,7 +5,7 @@ from bravia_tv import BraviaRC from homeassistant.const import CONF_HOST, CONF_MAC -from .const import DOMAIN +from .const import BRAVIARC, DOMAIN, UNDO_UPDATE_LISTENER PLATFORMS = ["media_player"] @@ -20,8 +20,13 @@ async def async_setup_entry(hass, config_entry): host = config_entry.data[CONF_HOST] mac = config_entry.data[CONF_MAC] + undo_listener = config_entry.add_update_listener(update_listener) + hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][config_entry.entry_id] = BraviaRC(host, mac) + hass.data[DOMAIN][config_entry.entry_id] = { + BRAVIARC: BraviaRC(host, mac), + UNDO_UPDATE_LISTENER: undo_listener, + } for component in PLATFORMS: hass.async_create_task( @@ -41,7 +46,15 @@ async def async_unload_entry(hass, config_entry): ] ) ) + + hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER]() + if unload_ok: hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok + + +async def update_listener(hass, config_entry): + """Handle options update.""" + await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/braviatv/config_flow.py b/homeassistant/components/braviatv/config_flow.py index be2a91c8429..660e2e83ea1 100644 --- a/homeassistant/components/braviatv/config_flow.py +++ b/homeassistant/components/braviatv/config_flow.py @@ -15,6 +15,7 @@ from .const import ( # pylint:disable=unused-import ATTR_CID, ATTR_MAC, ATTR_MODEL, + BRAVIARC, CLIENTID_PREFIX, CONF_IGNORED_SOURCES, DOMAIN, @@ -152,7 +153,7 @@ class BraviaTVOptionsFlowHandler(config_entries.OptionsFlow): async def async_step_init(self, user_input=None): """Manage the options.""" - self.braviarc = self.hass.data[DOMAIN][self.config_entry.entry_id] + self.braviarc = self.hass.data[DOMAIN][self.config_entry.entry_id][BRAVIARC] if not self.braviarc.is_connected(): await self.hass.async_add_executor_job( self.braviarc.connect, self.pin, CLIENTID_PREFIX, NICKNAME, diff --git a/homeassistant/components/braviatv/const.py b/homeassistant/components/braviatv/const.py index 1fa96e6a98d..a5d7a88d4c3 100644 --- a/homeassistant/components/braviatv/const.py +++ b/homeassistant/components/braviatv/const.py @@ -6,8 +6,10 @@ ATTR_MODEL = "model" CONF_IGNORED_SOURCES = "ignored_sources" +BRAVIARC = "braviarc" BRAVIA_CONFIG_FILE = "bravia.conf" CLIENTID_PREFIX = "HomeAssistant" DEFAULT_NAME = f"{ATTR_MANUFACTURER} Bravia TV" DOMAIN = "braviatv" NICKNAME = "Home Assistant" +UNDO_UPDATE_LISTENER = "undo_update_listener" diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py index 718f99d8357..e4b28c0c2ab 100644 --- a/homeassistant/components/braviatv/media_player.py +++ b/homeassistant/components/braviatv/media_player.py @@ -30,6 +30,7 @@ from homeassistant.util.json import load_json from .const import ( ATTR_MANUFACTURER, BRAVIA_CONFIG_FILE, + BRAVIARC, CLIENTID_PREFIX, CONF_IGNORED_SOURCES, DEFAULT_NAME, @@ -103,7 +104,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): "model": config_entry.title, } - braviarc = hass.data[DOMAIN][config_entry.entry_id] + braviarc = hass.data[DOMAIN][config_entry.entry_id][BRAVIARC] ignored_sources = config_entry.options.get(CONF_IGNORED_SOURCES, []) From 6d9aafd3b07ee93b7f88673b8eca73e25efc0a7b Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 29 Apr 2020 18:06:25 +0200 Subject: [PATCH 02/67] Fix CVE-2020-1967 (#34853) --- build.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/build.json b/build.json index 331999b5470..b2c3cedc378 100644 --- a/build.json +++ b/build.json @@ -1,11 +1,11 @@ { "image": "homeassistant/{arch}-homeassistant", "build_from": { - "aarch64": "homeassistant/aarch64-homeassistant-base:7.1.0", - "armhf": "homeassistant/armhf-homeassistant-base:7.1.0", - "armv7": "homeassistant/armv7-homeassistant-base:7.1.0", - "amd64": "homeassistant/amd64-homeassistant-base:7.1.0", - "i386": "homeassistant/i386-homeassistant-base:7.1.0" + "aarch64": "homeassistant/aarch64-homeassistant-base:7.2.0", + "armhf": "homeassistant/armhf-homeassistant-base:7.2.0", + "armv7": "homeassistant/armv7-homeassistant-base:7.2.0", + "amd64": "homeassistant/amd64-homeassistant-base:7.2.0", + "i386": "homeassistant/i386-homeassistant-base:7.2.0" }, "labels": { "io.hass.type": "core" From 3bf1cf4f853ea22b63ab171c87cb5760a91fe5c9 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Wed, 29 Apr 2020 16:05:20 -0500 Subject: [PATCH 03/67] SmartThings continue correct config flow after external auth (#34862) --- .../components/smartthings/__init__.py | 10 + .../components/smartthings/config_flow.py | 6 +- .../components/smartthings/smartapp.py | 59 +- .../smartthings/test_config_flow.py | 1028 +++++++++++------ tests/components/smartthings/test_smartapp.py | 62 - 5 files changed, 698 insertions(+), 467 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 97a7d32a9c1..e4d720c94e5 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -37,6 +37,7 @@ from .const import ( TOKEN_REFRESH_INTERVAL, ) from .smartapp import ( + format_unique_id, setup_smartapp, setup_smartapp_endpoint, smartapp_sync_subscriptions, @@ -76,6 +77,15 @@ async def async_migrate_entry(hass: HomeAssistantType, entry: ConfigEntry): async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): """Initialize config entry which represents an installed SmartApp.""" + # For backwards compat + if entry.unique_id is None: + hass.config_entries.async_update_entry( + entry, + unique_id=format_unique_id( + entry.data[CONF_APP_ID], entry.data[CONF_LOCATION_ID] + ), + ) + if not validate_webhook_requirements(hass): _LOGGER.warning( "The 'base_url' of the 'http' integration must be configured and start with 'https://'" diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index cb4623cea1c..c03ade4d8b1 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -7,7 +7,7 @@ from pysmartthings.installedapp import format_install_url import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_FORBIDDEN +from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_FORBIDDEN, HTTP_UNAUTHORIZED from homeassistant.helpers.aiohttp_client import async_get_clientsession # pylint: disable=unused-import @@ -26,6 +26,7 @@ from .const import ( from .smartapp import ( create_app, find_app, + format_unique_id, get_webhook_url, setup_smartapp, setup_smartapp_endpoint, @@ -138,7 +139,7 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) return self._show_step_pat(errors) except ClientResponseError as ex: - if ex.status == 401: + if ex.status == HTTP_UNAUTHORIZED: errors[CONF_ACCESS_TOKEN] = "token_unauthorized" _LOGGER.debug( "Unauthorized error received setting up SmartApp", exc_info=True @@ -183,6 +184,7 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) self.location_id = user_input[CONF_LOCATION_ID] + await self.async_set_unique_id(format_unique_id(self.app_id, self.location_id)) return await self.async_step_authorize() async def async_step_authorize(self, user_input=None): diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py index 0b86a430d89..7d02a04d2ff 100644 --- a/homeassistant/components/smartthings/smartapp.py +++ b/homeassistant/components/smartthings/smartapp.py @@ -39,7 +39,6 @@ from .const import ( CONF_CLOUDHOOK_URL, CONF_INSTALLED_APP_ID, CONF_INSTANCE_ID, - CONF_LOCATION_ID, CONF_REFRESH_TOKEN, DATA_BROKERS, DATA_MANAGER, @@ -53,6 +52,11 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +def format_unique_id(app_id: str, location_id: str) -> str: + """Format the unique id for a config entry.""" + return f"{app_id}_{location_id}" + + async def find_app(hass: HomeAssistantType, api): """Find an existing SmartApp for this installation of hass.""" apps = await api.apps() @@ -366,13 +370,20 @@ async def smartapp_sync_subscriptions( _LOGGER.debug("Subscriptions for app '%s' are up-to-date", installed_app_id) -async def smartapp_install(hass: HomeAssistantType, req, resp, app): - """Handle a SmartApp installation and continue the config flow.""" +async def _continue_flow( + hass: HomeAssistantType, + app_id: str, + location_id: str, + installed_app_id: str, + refresh_token: str, +): + """Continue a config flow if one is in progress for the specific installed app.""" + unique_id = format_unique_id(app_id, location_id) flow = next( ( flow for flow in hass.config_entries.flow.async_progress() - if flow["handler"] == DOMAIN + if flow["handler"] == DOMAIN and flow["context"]["unique_id"] == unique_id ), None, ) @@ -380,18 +391,23 @@ async def smartapp_install(hass: HomeAssistantType, req, resp, app): await hass.config_entries.flow.async_configure( flow["flow_id"], { - CONF_INSTALLED_APP_ID: req.installed_app_id, - CONF_LOCATION_ID: req.location_id, - CONF_REFRESH_TOKEN: req.refresh_token, + CONF_INSTALLED_APP_ID: installed_app_id, + CONF_REFRESH_TOKEN: refresh_token, }, ) _LOGGER.debug( "Continued config flow '%s' for SmartApp '%s' under parent app '%s'", flow["flow_id"], - req.installed_app_id, - app.app_id, + installed_app_id, + app_id, ) + +async def smartapp_install(hass: HomeAssistantType, req, resp, app): + """Handle a SmartApp installation and continue the config flow.""" + await _continue_flow( + hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token + ) _LOGGER.debug( "Installed SmartApp '%s' under parent app '%s'", req.installed_app_id, @@ -420,30 +436,9 @@ async def smartapp_update(hass: HomeAssistantType, req, resp, app): app.app_id, ) - flow = next( - ( - flow - for flow in hass.config_entries.flow.async_progress() - if flow["handler"] == DOMAIN - ), - None, + await _continue_flow( + hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token ) - if flow is not None: - await hass.config_entries.flow.async_configure( - flow["flow_id"], - { - CONF_INSTALLED_APP_ID: req.installed_app_id, - CONF_LOCATION_ID: req.location_id, - CONF_REFRESH_TOKEN: req.refresh_token, - }, - ) - _LOGGER.debug( - "Continued config flow '%s' for SmartApp '%s' under parent app '%s'", - flow["flow_id"], - req.installed_app_id, - app.app_id, - ) - _LOGGER.debug( "Updated SmartApp '%s' under parent app '%s'", req.installed_app_id, app.app_id ) diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index dc046f718a8..81dbab917a3 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -8,28 +8,30 @@ from pysmartthings.installedapp import format_install_url from homeassistant import data_entry_flow from homeassistant.components.smartthings import smartapp -from homeassistant.components.smartthings.config_flow import SmartThingsFlowHandler from homeassistant.components.smartthings.const import ( CONF_APP_ID, CONF_INSTALLED_APP_ID, CONF_LOCATION_ID, CONF_OAUTH_CLIENT_ID, CONF_OAUTH_CLIENT_SECRET, - CONF_REFRESH_TOKEN, DOMAIN, ) -from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_FORBIDDEN, HTTP_NOT_FOUND +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + HTTP_FORBIDDEN, + HTTP_NOT_FOUND, + HTTP_UNAUTHORIZED, +) from tests.common import MockConfigEntry, mock_coro -async def test_step_import(hass): - """Test import returns user.""" - flow = SmartThingsFlowHandler() - flow.hass = hass - - result = await flow.async_step_import() - +async def test_import_shows_user_step(hass): + """Test import source shows the user form.""" + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "import"} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["description_placeholders"][ @@ -37,237 +39,316 @@ async def test_step_import(hass): ] == smartapp.get_webhook_url(hass) -async def test_step_user(hass): - """Test the webhook confirmation is shown.""" - flow = SmartThingsFlowHandler() - flow.hass = hass - - result = await flow.async_step_user() - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - - -async def test_step_user_aborts_invalid_webhook(hass): - """Test flow aborts if webhook is invalid.""" - hass.config.api.base_url = "http://0.0.0.0" - flow = SmartThingsFlowHandler() - flow.hass = hass - - result = await flow.async_step_user() - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "invalid_webhook_url" - assert result["description_placeholders"][ - "webhook_url" - ] == smartapp.get_webhook_url(hass) - assert "component_url" in result["description_placeholders"] - - -async def test_step_user_advances_to_pat(hass): - """Test user step advances to the pat step.""" - flow = SmartThingsFlowHandler() - flow.hass = hass - - result = await flow.async_step_user({}) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "pat" - - -async def test_step_pat(hass): - """Test pat step shows the input form.""" - flow = SmartThingsFlowHandler() - flow.hass = hass - - result = await flow.async_step_pat() - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "pat" - assert result["errors"] == {} - assert result["data_schema"]({CONF_ACCESS_TOKEN: ""}) == {CONF_ACCESS_TOKEN: ""} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_step_pat_defaults_token(hass): - """Test pat form defaults the token from another entry.""" +async def test_entry_created(hass, app, app_oauth_client, location, smartthings_mock): + """Test local webhook, new app, install event creates entry.""" token = str(uuid4()) - entry = MockConfigEntry(domain=DOMAIN, data={CONF_ACCESS_TOKEN: token}) - entry.add_to_hass(hass) - flow = SmartThingsFlowHandler() - flow.hass = hass - - result = await flow.async_step_pat() - - assert flow.access_token == token - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "pat" - assert result["errors"] == {} - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_step_pat_invalid_token(hass): - """Test an error is shown for invalid token formats.""" - flow = SmartThingsFlowHandler() - flow.hass = hass - token = "123456789" - - result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "pat" - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - assert result["errors"] == {"access_token": "token_invalid_format"} - assert "token_url" in result["description_placeholders"] - assert "component_url" in result["description_placeholders"] - - -async def test_step_pat_unauthorized(hass, smartthings_mock): - """Test an error is shown when the token is not authorized.""" - flow = SmartThingsFlowHandler() - flow.hass = hass - request_info = Mock(real_url="http://example.com") - smartthings_mock.apps.side_effect = ClientResponseError( - request_info=request_info, history=None, status=401 - ) - token = str(uuid4()) - - result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "pat" - assert result["errors"] == {CONF_ACCESS_TOKEN: "token_unauthorized"} - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - - -async def test_step_pat_forbidden(hass, smartthings_mock): - """Test an error is shown when the token is forbidden.""" - flow = SmartThingsFlowHandler() - flow.hass = hass - request_info = Mock(real_url="http://example.com") - smartthings_mock.apps.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTP_FORBIDDEN - ) - token = str(uuid4()) - - result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "pat" - assert result["errors"] == {CONF_ACCESS_TOKEN: "token_forbidden"} - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - - -async def test_step_pat_webhook_error(hass, smartthings_mock): - """Test an error is shown when there's an problem with the webhook endpoint.""" - flow = SmartThingsFlowHandler() - flow.hass = hass - data = {"error": {}} - request_info = Mock(real_url="http://example.com") - error = APIResponseError( - request_info=request_info, history=None, data=data, status=422 - ) - error.is_target_error = Mock(return_value=True) - smartthings_mock.apps.side_effect = error - token = str(uuid4()) - - result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "pat" - assert result["errors"] == {"base": "webhook_error"} - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - - -async def test_step_pat_api_error(hass, smartthings_mock): - """Test an error is shown when other API errors occur.""" - flow = SmartThingsFlowHandler() - flow.hass = hass - data = {"error": {}} - request_info = Mock(real_url="http://example.com") - error = APIResponseError( - request_info=request_info, history=None, data=data, status=400 - ) - smartthings_mock.apps.side_effect = error - token = str(uuid4()) - - result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "pat" - assert result["errors"] == {"base": "app_setup_error"} - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - - -async def test_step_pat_unknown_api_error(hass, smartthings_mock): - """Test an error is shown when there is an unknown API error.""" - flow = SmartThingsFlowHandler() - flow.hass = hass - request_info = Mock(real_url="http://example.com") - smartthings_mock.apps.side_effect = ClientResponseError( - request_info=request_info, history=None, status=HTTP_NOT_FOUND - ) - token = str(uuid4()) - - result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "pat" - assert result["errors"] == {"base": "app_setup_error"} - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - - -async def test_step_pat_unknown_error(hass, smartthings_mock): - """Test an error is shown when there is an unknown API error.""" - flow = SmartThingsFlowHandler() - flow.hass = hass - smartthings_mock.apps.side_effect = Exception("Unknown error") - token = str(uuid4()) - - result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "pat" - assert result["errors"] == {"base": "app_setup_error"} - assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} - - -async def test_step_pat_app_created_webhook( - hass, app, app_oauth_client, location, smartthings_mock -): - """Test SmartApp is created when one does not exist and shows location form.""" - flow = SmartThingsFlowHandler() - flow.hass = hass - + installed_app_id = str(uuid4()) + refresh_token = str(uuid4()) smartthings_mock.apps.return_value = [] smartthings_mock.create_app.return_value = (app, app_oauth_client) smartthings_mock.locations.return_value = [location] - token = str(uuid4()) + request = Mock() + request.installed_app_id = installed_app_id + request.auth_token = token + request.location_id = location.location_id + request.refresh_token = refresh_token - result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["description_placeholders"][ + "webhook_url" + ] == smartapp.get_webhook_url(hass) - assert flow.access_token == token - assert flow.app_id == app.app_id - assert flow.oauth_client_secret == app_oauth_client.client_secret - assert flow.oauth_client_id == app_oauth_client.client_id + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + # Enter token and advance to location screen + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "select_location" + # Select location and advance to external auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_LOCATION_ID: location.location_id} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert result["step_id"] == "authorize" + assert result["url"] == format_install_url(app.app_id, location.location_id) -async def test_step_pat_app_created_cloudhook( + # Complete external auth and advance to install + await smartapp.smartapp_install(hass, request, None, app) + + # Finish + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"]["app_id"] == app.app_id + assert result["data"]["installed_app_id"] == installed_app_id + assert result["data"]["location_id"] == location.location_id + assert result["data"]["access_token"] == token + assert result["data"]["refresh_token"] == request.refresh_token + assert result["data"]["client_secret"] == app_oauth_client.client_secret + assert result["data"]["client_id"] == app_oauth_client.client_id + assert result["title"] == location.name + entry = next((entry for entry in hass.config_entries.async_entries(DOMAIN)), None,) + assert entry.unique_id == smartapp.format_unique_id( + app.app_id, location.location_id + ) + + +async def test_entry_created_from_update_event( hass, app, app_oauth_client, location, smartthings_mock ): - """Test SmartApp is created with a cloudhook and shows location form.""" - hass.config.components.add("cloud") + """Test local webhook, new app, update event creates entry.""" + token = str(uuid4()) + installed_app_id = str(uuid4()) + refresh_token = str(uuid4()) + smartthings_mock.apps.return_value = [] + smartthings_mock.create_app.return_value = (app, app_oauth_client) + smartthings_mock.locations.return_value = [location] + request = Mock() + request.installed_app_id = installed_app_id + request.auth_token = token + request.location_id = location.location_id + request.refresh_token = refresh_token + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["description_placeholders"][ + "webhook_url" + ] == smartapp.get_webhook_url(hass) + + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + # Enter token and advance to location screen + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "select_location" + + # Select location and advance to external auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_LOCATION_ID: location.location_id} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert result["step_id"] == "authorize" + assert result["url"] == format_install_url(app.app_id, location.location_id) + + # Complete external auth and advance to install + await smartapp.smartapp_update(hass, request, None, app) + + # Finish + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"]["app_id"] == app.app_id + assert result["data"]["installed_app_id"] == installed_app_id + assert result["data"]["location_id"] == location.location_id + assert result["data"]["access_token"] == token + assert result["data"]["refresh_token"] == request.refresh_token + assert result["data"]["client_secret"] == app_oauth_client.client_secret + assert result["data"]["client_id"] == app_oauth_client.client_id + assert result["title"] == location.name + entry = next((entry for entry in hass.config_entries.async_entries(DOMAIN)), None,) + assert entry.unique_id == smartapp.format_unique_id( + app.app_id, location.location_id + ) + + +async def test_entry_created_existing_app_new_oauth_client( + hass, app, app_oauth_client, location, smartthings_mock +): + """Test entry is created with an existing app and generation of a new oauth client.""" + token = str(uuid4()) + installed_app_id = str(uuid4()) + refresh_token = str(uuid4()) + smartthings_mock.apps.return_value = [app] + smartthings_mock.generate_app_oauth.return_value = app_oauth_client + smartthings_mock.locations.return_value = [location] + request = Mock() + request.installed_app_id = installed_app_id + request.auth_token = token + request.location_id = location.location_id + request.refresh_token = refresh_token + + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["description_placeholders"][ + "webhook_url" + ] == smartapp.get_webhook_url(hass) + + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + # Enter token and advance to location screen + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "select_location" + + # Select location and advance to external auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_LOCATION_ID: location.location_id} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert result["step_id"] == "authorize" + assert result["url"] == format_install_url(app.app_id, location.location_id) + + # Complete external auth and advance to install + await smartapp.smartapp_install(hass, request, None, app) + + # Finish + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"]["app_id"] == app.app_id + assert result["data"]["installed_app_id"] == installed_app_id + assert result["data"]["location_id"] == location.location_id + assert result["data"]["access_token"] == token + assert result["data"]["refresh_token"] == request.refresh_token + assert result["data"]["client_secret"] == app_oauth_client.client_secret + assert result["data"]["client_id"] == app_oauth_client.client_id + assert result["title"] == location.name + entry = next((entry for entry in hass.config_entries.async_entries(DOMAIN)), None,) + assert entry.unique_id == smartapp.format_unique_id( + app.app_id, location.location_id + ) + + +async def test_entry_created_existing_app_copies_oauth_client( + hass, app, location, smartthings_mock +): + """Test entry is created with an existing app and copies the oauth client from another entry.""" + token = str(uuid4()) + installed_app_id = str(uuid4()) + refresh_token = str(uuid4()) + oauth_client_id = str(uuid4()) + oauth_client_secret = str(uuid4()) + smartthings_mock.apps.return_value = [app] + smartthings_mock.locations.return_value = [location] + request = Mock() + request.installed_app_id = installed_app_id + request.auth_token = token + request.location_id = location.location_id + request.refresh_token = refresh_token + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_APP_ID: app.app_id, + CONF_OAUTH_CLIENT_ID: oauth_client_id, + CONF_OAUTH_CLIENT_SECRET: oauth_client_secret, + CONF_LOCATION_ID: str(uuid4()), + CONF_INSTALLED_APP_ID: str(uuid4()), + CONF_ACCESS_TOKEN: token, + }, + ) + entry.add_to_hass(hass) + + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["description_placeholders"][ + "webhook_url" + ] == smartapp.get_webhook_url(hass) + + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + # Assert access token is defaulted to an existing entry for convenience. + assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} + + # Enter token and advance to location screen + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "select_location" + + # Select location and advance to external auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_LOCATION_ID: location.location_id} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert result["step_id"] == "authorize" + assert result["url"] == format_install_url(app.app_id, location.location_id) + + # Complete external auth and advance to install + await smartapp.smartapp_install(hass, request, None, app) + + # Finish + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"]["app_id"] == app.app_id + assert result["data"]["installed_app_id"] == installed_app_id + assert result["data"]["location_id"] == location.location_id + assert result["data"]["access_token"] == token + assert result["data"]["refresh_token"] == request.refresh_token + assert result["data"]["client_secret"] == oauth_client_secret + assert result["data"]["client_id"] == oauth_client_id + assert result["title"] == location.name + entry = next( + ( + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.data[CONF_INSTALLED_APP_ID] == installed_app_id + ), + None, + ) + assert entry.unique_id == smartapp.format_unique_id( + app.app_id, location.location_id + ) + + +async def test_entry_created_with_cloudhook( + hass, app, app_oauth_client, location, smartthings_mock +): + """Test cloud, new app, install event creates entry.""" + hass.config.components.add("cloud") # Unload the endpoint so we can reload it under the cloud. await smartapp.unload_smartapp_endpoint(hass) + token = str(uuid4()) + installed_app_id = str(uuid4()) + refresh_token = str(uuid4()) + smartthings_mock.apps.return_value = [] + smartthings_mock.create_app.return_value = (app, app_oauth_client) + smartthings_mock.locations.return_value = [location] + request = Mock() + request.installed_app_id = installed_app_id + request.auth_token = token + request.location_id = location.location_id + request.refresh_token = refresh_token with patch.object( hass.components.cloud, "async_active_subscription", return_value=True @@ -279,163 +360,368 @@ async def test_step_pat_app_created_cloudhook( await smartapp.setup_smartapp_endpoint(hass) - flow = SmartThingsFlowHandler() - flow.hass = hass - smartthings_mock.apps.return_value = [] - smartthings_mock.create_app.return_value = (app, app_oauth_client) - smartthings_mock.locations.return_value = [location] - token = str(uuid4()) - - result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) - - assert flow.access_token == token - assert flow.app_id == app.app_id - assert flow.oauth_client_secret == app_oauth_client.client_secret - assert flow.oauth_client_id == app_oauth_client.client_id + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "select_location" + assert result["step_id"] == "user" + assert result["description_placeholders"][ + "webhook_url" + ] == smartapp.get_webhook_url(hass) assert mock_create_cloudhook.call_count == 1 + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] -async def test_step_pat_app_updated_webhook( + # Enter token and advance to location screen + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "select_location" + + # Select location and advance to external auth + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_LOCATION_ID: location.location_id} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert result["step_id"] == "authorize" + assert result["url"] == format_install_url(app.app_id, location.location_id) + + # Complete external auth and advance to install + await smartapp.smartapp_install(hass, request, None, app) + + # Finish + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"]["app_id"] == app.app_id + assert result["data"]["installed_app_id"] == installed_app_id + assert result["data"]["location_id"] == location.location_id + assert result["data"]["access_token"] == token + assert result["data"]["refresh_token"] == request.refresh_token + assert result["data"]["client_secret"] == app_oauth_client.client_secret + assert result["data"]["client_id"] == app_oauth_client.client_id + assert result["title"] == location.name + entry = next( + (entry for entry in hass.config_entries.async_entries(DOMAIN)), None, + ) + assert entry.unique_id == smartapp.format_unique_id( + app.app_id, location.location_id + ) + + +async def test_invalid_webhook_aborts(hass): + """Test flow aborts if webhook is invalid.""" + hass.config.api.base_url = "http://0.0.0.0" + + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "invalid_webhook_url" + assert result["description_placeholders"][ + "webhook_url" + ] == smartapp.get_webhook_url(hass) + assert "component_url" in result["description_placeholders"] + + +async def test_invalid_token_shows_error(hass): + """Test an error is shown for invalid token formats.""" + token = "123456789" + + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["description_placeholders"][ + "webhook_url" + ] == smartapp.get_webhook_url(hass) + + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + # Enter token + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} + assert result["errors"] == {CONF_ACCESS_TOKEN: "token_invalid_format"} + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + +async def test_unauthorized_token_shows_error(hass, smartthings_mock): + """Test an error is shown for unauthorized token formats.""" + token = str(uuid4()) + request_info = Mock(real_url="http://example.com") + smartthings_mock.apps.side_effect = ClientResponseError( + request_info=request_info, history=None, status=HTTP_UNAUTHORIZED + ) + + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["description_placeholders"][ + "webhook_url" + ] == smartapp.get_webhook_url(hass) + + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + # Enter token + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} + assert result["errors"] == {CONF_ACCESS_TOKEN: "token_unauthorized"} + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + +async def test_forbidden_token_shows_error(hass, smartthings_mock): + """Test an error is shown for forbidden token formats.""" + token = str(uuid4()) + request_info = Mock(real_url="http://example.com") + smartthings_mock.apps.side_effect = ClientResponseError( + request_info=request_info, history=None, status=HTTP_FORBIDDEN + ) + + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["description_placeholders"][ + "webhook_url" + ] == smartapp.get_webhook_url(hass) + + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + # Enter token + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} + assert result["errors"] == {CONF_ACCESS_TOKEN: "token_forbidden"} + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + +async def test_webhook_problem_shows_error(hass, smartthings_mock): + """Test an error is shown when there's an problem with the webhook endpoint.""" + token = str(uuid4()) + data = {"error": {}} + request_info = Mock(real_url="http://example.com") + error = APIResponseError( + request_info=request_info, history=None, data=data, status=422 + ) + error.is_target_error = Mock(return_value=True) + smartthings_mock.apps.side_effect = error + + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["description_placeholders"][ + "webhook_url" + ] == smartapp.get_webhook_url(hass) + + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + # Enter token + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} + assert result["errors"] == {"base": "webhook_error"} + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + +async def test_api_error_shows_error(hass, smartthings_mock): + """Test an error is shown when other API errors occur.""" + token = str(uuid4()) + data = {"error": {}} + request_info = Mock(real_url="http://example.com") + error = APIResponseError( + request_info=request_info, history=None, data=data, status=400 + ) + smartthings_mock.apps.side_effect = error + + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["description_placeholders"][ + "webhook_url" + ] == smartapp.get_webhook_url(hass) + + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + # Enter token + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} + assert result["errors"] == {"base": "app_setup_error"} + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + +async def test_unknown_response_error_shows_error(hass, smartthings_mock): + """Test an error is shown when there is an unknown API error.""" + token = str(uuid4()) + request_info = Mock(real_url="http://example.com") + error = ClientResponseError( + request_info=request_info, history=None, status=HTTP_NOT_FOUND + ) + smartthings_mock.apps.side_effect = error + + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["description_placeholders"][ + "webhook_url" + ] == smartapp.get_webhook_url(hass) + + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + # Enter token + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} + assert result["errors"] == {"base": "app_setup_error"} + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + +async def test_unknown_error_shows_error(hass, smartthings_mock): + """Test an error is shown when there is an unknown API error.""" + token = str(uuid4()) + smartthings_mock.apps.side_effect = Exception("Unknown error") + + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["description_placeholders"][ + "webhook_url" + ] == smartapp.get_webhook_url(hass) + + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + # Enter token + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert result["data_schema"]({}) == {CONF_ACCESS_TOKEN: token} + assert result["errors"] == {"base": "app_setup_error"} + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + +async def test_no_available_locations_aborts( hass, app, app_oauth_client, location, smartthings_mock ): - """Test SmartApp is updated then show location form.""" - flow = SmartThingsFlowHandler() - flow.hass = hass - - smartthings_mock.apps.return_value = [app] - smartthings_mock.generate_app_oauth.return_value = app_oauth_client - smartthings_mock.locations.return_value = [location] - token = str(uuid4()) - - result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) - - assert flow.access_token == token - assert flow.app_id == app.app_id - assert flow.oauth_client_secret == app_oauth_client.client_secret - assert flow.oauth_client_id == app_oauth_client.client_id - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "select_location" - - -async def test_step_pat_app_updated_webhook_from_existing_oauth_client( - hass, app, location, smartthings_mock -): - """Test SmartApp is updated from existing then show location form.""" - oauth_client_id = str(uuid4()) - oauth_client_secret = str(uuid4()) - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_APP_ID: app.app_id, - CONF_OAUTH_CLIENT_ID: oauth_client_id, - CONF_OAUTH_CLIENT_SECRET: oauth_client_secret, - CONF_LOCATION_ID: str(uuid4()), - }, - ) - entry.add_to_hass(hass) - flow = SmartThingsFlowHandler() - flow.hass = hass - smartthings_mock.apps.return_value = [app] - smartthings_mock.locations.return_value = [location] - token = str(uuid4()) - - result = await flow.async_step_pat({CONF_ACCESS_TOKEN: token}) - - assert flow.access_token == token - assert flow.app_id == app.app_id - assert flow.oauth_client_secret == oauth_client_secret - assert flow.oauth_client_id == oauth_client_id - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "select_location" - - -async def test_step_select_location(hass, location, smartthings_mock): - """Test select location shows form with available locations.""" - smartthings_mock.locations.return_value = [location] - flow = SmartThingsFlowHandler() - flow.hass = hass - flow.api = smartthings_mock - - result = await flow.async_step_select_location() - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "select_location" - assert result["data_schema"]({CONF_LOCATION_ID: location.location_id}) == { - CONF_LOCATION_ID: location.location_id - } - - -async def test_step_select_location_aborts(hass, location, smartthings_mock): """Test select location aborts if no available locations.""" + token = str(uuid4()) + smartthings_mock.apps.return_value = [] + smartthings_mock.create_app.return_value = (app, app_oauth_client) smartthings_mock.locations.return_value = [location] entry = MockConfigEntry( domain=DOMAIN, data={CONF_LOCATION_ID: location.location_id} ) entry.add_to_hass(hass) - flow = SmartThingsFlowHandler() - flow.hass = hass - flow.api = smartthings_mock - result = await flow.async_step_select_location() + # Webhook confirmation shown + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["description_placeholders"][ + "webhook_url" + ] == smartapp.get_webhook_url(hass) + + # Advance to PAT screen + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pat" + assert "token_url" in result["description_placeholders"] + assert "component_url" in result["description_placeholders"] + + # Enter token and advance to location screen + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: token} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "no_available_locations" - - -async def test_step_select_location_advances(hass): - """Test select location aborts if no available locations.""" - location_id = str(uuid4()) - app_id = str(uuid4()) - flow = SmartThingsFlowHandler() - flow.hass = hass - flow.app_id = app_id - - result = await flow.async_step_select_location({CONF_LOCATION_ID: location_id}) - - assert flow.location_id == location_id - assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP - assert result["step_id"] == "authorize" - assert result["url"] == format_install_url(app_id, location_id) - - -async def test_step_authorize_advances(hass): - """Test authorize step advances when completed.""" - installed_app_id = str(uuid4()) - refresh_token = str(uuid4()) - flow = SmartThingsFlowHandler() - flow.hass = hass - - result = await flow.async_step_authorize( - {CONF_INSTALLED_APP_ID: installed_app_id, CONF_REFRESH_TOKEN: refresh_token} - ) - - assert flow.installed_app_id == installed_app_id - assert flow.refresh_token == refresh_token - assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP_DONE - assert result["step_id"] == "install" - - -async def test_step_install_creates_entry(hass, location, smartthings_mock): - """Test a config entry is created once the app is installed.""" - flow = SmartThingsFlowHandler() - flow.hass = hass - flow.api = smartthings_mock - flow.access_token = str(uuid4()) - flow.app_id = str(uuid4()) - flow.installed_app_id = str(uuid4()) - flow.location_id = location.location_id - flow.oauth_client_id = str(uuid4()) - flow.oauth_client_secret = str(uuid4()) - flow.refresh_token = str(uuid4()) - - result = await flow.async_step_install() - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["data"]["app_id"] == flow.app_id - assert result["data"]["installed_app_id"] == flow.installed_app_id - assert result["data"]["location_id"] == flow.location_id - assert result["data"]["access_token"] == flow.access_token - assert result["data"]["refresh_token"] == flow.refresh_token - assert result["data"]["client_secret"] == flow.oauth_client_secret - assert result["data"]["client_id"] == flow.oauth_client_id - assert result["title"] == location.name diff --git a/tests/components/smartthings/test_smartapp.py b/tests/components/smartthings/test_smartapp.py index 4d7280a6a9e..efc4844cef2 100644 --- a/tests/components/smartthings/test_smartapp.py +++ b/tests/components/smartthings/test_smartapp.py @@ -6,8 +6,6 @@ from pysmartthings import AppEntity, Capability from homeassistant.components.smartthings import smartapp from homeassistant.components.smartthings.const import ( - CONF_INSTALLED_APP_ID, - CONF_LOCATION_ID, CONF_REFRESH_TOKEN, DATA_MANAGER, DOMAIN, @@ -39,36 +37,6 @@ async def test_update_app_updated_needed(hass, app): assert mock_app.classifications == app.classifications -async def test_smartapp_install_configures_flow(hass): - """Test install event continues an existing flow.""" - # Arrange - flow_id = str(uuid4()) - flows = [{"flow_id": flow_id, "handler": DOMAIN}] - app = Mock() - app.app_id = uuid4() - request = Mock() - request.installed_app_id = str(uuid4()) - request.auth_token = str(uuid4()) - request.location_id = str(uuid4()) - request.refresh_token = str(uuid4()) - - # Act - with patch.object( - hass.config_entries.flow, "async_progress", return_value=flows - ), patch.object(hass.config_entries.flow, "async_configure") as configure_mock: - - await smartapp.smartapp_install(hass, request, None, app) - - configure_mock.assert_called_once_with( - flow_id, - { - CONF_INSTALLED_APP_ID: request.installed_app_id, - CONF_LOCATION_ID: request.location_id, - CONF_REFRESH_TOKEN: request.refresh_token, - }, - ) - - async def test_smartapp_update_saves_token( hass, smartthings_mock, location, device_factory ): @@ -92,36 +60,6 @@ async def test_smartapp_update_saves_token( assert entry.data[CONF_REFRESH_TOKEN] == request.refresh_token -async def test_smartapp_update_configures_flow(hass): - """Test update event continues an existing flow.""" - # Arrange - flow_id = str(uuid4()) - flows = [{"flow_id": flow_id, "handler": DOMAIN}] - app = Mock() - app.app_id = uuid4() - request = Mock() - request.installed_app_id = str(uuid4()) - request.auth_token = str(uuid4()) - request.location_id = str(uuid4()) - request.refresh_token = str(uuid4()) - - # Act - with patch.object( - hass.config_entries.flow, "async_progress", return_value=flows - ), patch.object(hass.config_entries.flow, "async_configure") as configure_mock: - - await smartapp.smartapp_update(hass, request, None, app) - - configure_mock.assert_called_once_with( - flow_id, - { - CONF_INSTALLED_APP_ID: request.installed_app_id, - CONF_LOCATION_ID: request.location_id, - CONF_REFRESH_TOKEN: request.refresh_token, - }, - ) - - async def test_smartapp_uninstall(hass, config_entry): """Test the config entry is unloaded when the app is uninstalled.""" config_entry.add_to_hass(hass) From 5f7711e7a6bbf65645635029ec87b2a77f3801e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2020 16:02:59 -0500 Subject: [PATCH 04/67] Abort nexia import if the username is already configured (#34863) --- homeassistant/components/nexia/config_flow.py | 3 ++ homeassistant/components/nexia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/nexia/test_config_flow.py | 41 +++++++++++++++++++ 5 files changed, 47 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nexia/config_flow.py b/homeassistant/components/nexia/config_flow.py index c26e42a22d2..d71a5470c98 100644 --- a/homeassistant/components/nexia/config_flow.py +++ b/homeassistant/components/nexia/config_flow.py @@ -88,6 +88,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_import(self, user_input): """Handle import.""" + for entry in self._async_current_entries(): + if entry.data[CONF_USERNAME] == user_input[CONF_USERNAME]: + return self.async_abort(reason="already_configured") return await self.async_step_user(user_input) diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index f09d4d1a4d1..e86d2072db8 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -1,7 +1,7 @@ { "domain": "nexia", "name": "Nexia", - "requirements": ["nexia==0.9.2"], + "requirements": ["nexia==0.9.3"], "codeowners": ["@ryannazaretian", "@bdraco"], "documentation": "https://www.home-assistant.io/integrations/nexia", "config_flow": true diff --git a/requirements_all.txt b/requirements_all.txt index b252313922d..066f6de6fd0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -931,7 +931,7 @@ netdisco==2.6.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==0.9.2 +nexia==0.9.3 # homeassistant.components.nextcloud nextcloudmonitor==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 033c6580820..fb622f5640b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -366,7 +366,7 @@ nessclient==0.9.15 netdisco==2.6.0 # homeassistant.components.nexia -nexia==0.9.2 +nexia==0.9.3 # homeassistant.components.nsw_fuel_station nsw-fuel-api-client==1.0.10 diff --git a/tests/components/nexia/test_config_flow.py b/tests/components/nexia/test_config_flow.py index 3cb57d77f12..ff6f8590287 100644 --- a/tests/components/nexia/test_config_flow.py +++ b/tests/components/nexia/test_config_flow.py @@ -74,3 +74,44 @@ async def test_form_cannot_connect(hass): assert result2["type"] == "form" assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_import(hass): + """Test we get the form with import source.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.nexia.config_flow.NexiaHome.get_name", + return_value="myhouse", + ), patch( + "homeassistant.components.nexia.config_flow.NexiaHome.login", + side_effect=MagicMock(), + ), patch( + "homeassistant.components.nexia.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.nexia.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == "myhouse" + assert result["data"] == { + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "already_configured" From d391b87227b82239d770a3ba4a38450d82b69529 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Apr 2020 16:00:31 -0500 Subject: [PATCH 05/67] Prevent homekit fans from going to 100% than speed when turning on (#34875) --- homeassistant/components/homekit/type_fans.py | 14 +++++++------- tests/components/homekit/test_type_fans.py | 15 +++++++-------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index 9167c8fcf5d..b7208b1746c 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -94,13 +94,13 @@ class Fan(HomeAccessory): _LOGGER.debug("Fan _set_chars: %s", char_values) if CHAR_ACTIVE in char_values: if char_values[CHAR_ACTIVE]: - is_on = False - state = self.hass.states.get(self.entity_id) - if state and state.state == STATE_ON: - is_on = True - # Only set the state to active if we - # did not get a rotation speed or its off - if not is_on or CHAR_ROTATION_SPEED not in char_values: + # If the device supports set speed we + # do not want to turn on as it will take + # the fan to 100% than to the desired speed. + # + # Setting the speed will take care of turning + # on the fan if SUPPORT_SET_SPEED is set. + if not self.char_speed or CHAR_ROTATION_SPEED not in char_values: self.set_state(1) else: # Its off, nothing more to do as setting the diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py index 915e7c59d7c..ca6e03217f3 100644 --- a/tests/components/homekit/test_type_fans.py +++ b/tests/components/homekit/test_type_fans.py @@ -419,8 +419,7 @@ async def test_fan_set_all_one_shot(hass, hk_driver, cls, events): ) await hass.async_block_till_done() acc.speed_mapping.speed_to_states.assert_called_with(42) - assert call_turn_on - assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert not call_turn_on assert call_set_speed[0] assert call_set_speed[0].data[ATTR_ENTITY_ID] == entity_id assert call_set_speed[0].data[ATTR_SPEED] == "ludicrous" @@ -430,11 +429,11 @@ async def test_fan_set_all_one_shot(hass, hk_driver, cls, events): assert call_set_direction[0] assert call_set_direction[0].data[ATTR_ENTITY_ID] == entity_id assert call_set_direction[0].data[ATTR_DIRECTION] == DIRECTION_REVERSE - assert len(events) == 4 + assert len(events) == 3 - assert events[1].data[ATTR_VALUE] is True - assert events[2].data[ATTR_VALUE] == DIRECTION_REVERSE - assert events[3].data[ATTR_VALUE] == "ludicrous" + assert events[0].data[ATTR_VALUE] is True + assert events[1].data[ATTR_VALUE] == DIRECTION_REVERSE + assert events[2].data[ATTR_VALUE] == "ludicrous" hass.states.async_set( entity_id, @@ -482,7 +481,7 @@ async def test_fan_set_all_one_shot(hass, hk_driver, cls, events): # and we set a fan speed await hass.async_block_till_done() acc.speed_mapping.speed_to_states.assert_called_with(42) - assert len(events) == 7 + assert len(events) == 6 assert call_set_speed[1] assert call_set_speed[1].data[ATTR_ENTITY_ID] == entity_id assert call_set_speed[1].data[ATTR_SPEED] == "ludicrous" @@ -526,7 +525,7 @@ async def test_fan_set_all_one_shot(hass, hk_driver, cls, events): ) await hass.async_block_till_done() - assert len(events) == 8 + assert len(events) == 7 assert call_turn_off assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id assert len(call_set_speed) == 2 From 98bff965f54ac14db4174e5ddb68c43cf3bbe8f7 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 29 Apr 2020 13:29:44 -0600 Subject: [PATCH 06/67] Fix Flu Near You exception re: stale coroutines (#34880) --- .../components/flunearyou/__init__.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/flunearyou/__init__.py b/homeassistant/components/flunearyou/__init__.py index 1e8e2e20b2d..6e1c8ddb3d2 100644 --- a/homeassistant/components/flunearyou/__init__.py +++ b/homeassistant/components/flunearyou/__init__.py @@ -131,15 +131,6 @@ class FluNearYouData: self.latitude = latitude self.longitude = longitude - self._api_coros = { - CATEGORY_CDC_REPORT: self._client.cdc_reports.status_by_coordinates( - latitude, longitude - ), - CATEGORY_USER_REPORT: self._client.user_reports.status_by_coordinates( - latitude, longitude - ), - } - self._api_category_count = { CATEGORY_CDC_REPORT: 0, CATEGORY_USER_REPORT: 0, @@ -155,8 +146,17 @@ class FluNearYouData: if self._api_category_count[api_category] == 0: return + if api_category == CATEGORY_CDC_REPORT: + api_coro = self._client.cdc_reports.status_by_coordinates( + self.latitude, self.longitude + ) + else: + api_coro = self._client.user_reports.status_by_coordinates( + self.latitude, self.longitude + ) + try: - self.data[api_category] = await self._api_coros[api_category] + self.data[api_category] = await api_coro except FluNearYouError as err: LOGGER.error("Unable to get %s data: %s", api_category, err) self.data[api_category] = None @@ -200,7 +200,7 @@ class FluNearYouData: """Update Flu Near You data.""" tasks = [ self._async_get_data_from_api(api_category) - for api_category in self._api_coros + for api_category in self._api_category_count ] await asyncio.gather(*tasks) From d891810e95b32334e478be049f7307a8bdac7351 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 29 Apr 2020 15:34:24 -0700 Subject: [PATCH 07/67] Fix Garmin Connect doing I/O in event loop (#34895) --- homeassistant/components/garmin_connect/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/garmin_connect/__init__.py b/homeassistant/components/garmin_connect/__init__.py index 8abdbbbbae9..85e8132bf02 100644 --- a/homeassistant/components/garmin_connect/__init__.py +++ b/homeassistant/components/garmin_connect/__init__.py @@ -86,6 +86,7 @@ class GarminConnectData: def __init__(self, hass, client): """Initialize.""" + self.hass = hass self.client = client self.data = None @@ -95,7 +96,9 @@ class GarminConnectData: today = date.today() try: - self.data = self.client.get_stats_and_body(today.isoformat()) + self.data = await self.hass.async_add_executor_job( + self.client.get_stats_and_body, today.isoformat() + ) except ( GarminConnectAuthenticationError, GarminConnectTooManyRequestsError, From e2475e67c60fc9abcffab223fad9b505cda645cf Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 29 Apr 2020 15:34:55 -0700 Subject: [PATCH 08/67] Fix Toon doing I/O in event loop (#34896) --- homeassistant/components/toon/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/toon/__init__.py b/homeassistant/components/toon/__init__.py index b078dab898d..595d3cc1ede 100644 --- a/homeassistant/components/toon/__init__.py +++ b/homeassistant/components/toon/__init__.py @@ -80,7 +80,7 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigType) -> bool: ) hass.data.setdefault(DATA_TOON_CLIENT, {})[entry.entry_id] = toon - toon_data = ToonData(hass, entry, toon) + toon_data = await hass.async_add_executor_job(ToonData, hass, entry, toon) hass.data.setdefault(DATA_TOON, {})[entry.entry_id] = toon_data async_track_time_interval(hass, toon_data.update, conf[CONF_SCAN_INTERVAL]) From 88c755518fa291d429f0568214459814406d6dcf Mon Sep 17 00:00:00 2001 From: jjlawren Date: Thu, 30 Apr 2020 02:10:02 -0500 Subject: [PATCH 09/67] Reduce log level for WebOS connection error (#34904) --- homeassistant/components/webostv/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/webostv/__init__.py b/homeassistant/components/webostv/__init__.py index f0a059fc5b8..a441a70888b 100644 --- a/homeassistant/components/webostv/__init__.py +++ b/homeassistant/components/webostv/__init__.py @@ -145,7 +145,7 @@ async def async_setup_tv_finalize(hass, config, conf, client): if client.connection is None: async_call_later(hass, 60, async_load_platforms) - _LOGGER.warning( + _LOGGER.debug( "No connection could be made with host %s, retrying in 60 seconds", conf.get(CONF_HOST), ) From 6b2a006feab25e5daa5749a87cb2c416c4066eb2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2020 02:09:33 -0500 Subject: [PATCH 10/67] Fix handling homekit thermostat states (#34905) --- .../components/homekit/type_thermostats.py | 146 +++++++---- .../homekit/test_type_thermostats.py | 231 +++++++++++++++++- 2 files changed, 319 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 0a488917381..0da92ef3dba 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -117,12 +117,15 @@ class Thermostat(HomeAccessory): """Initialize a Thermostat accessory object.""" super().__init__(*args, category=CATEGORY_THERMOSTAT) self._unit = self.hass.config.units.temperature_unit + self._state_updates = 0 + self.hc_homekit_to_hass = None + self.hc_hass_to_homekit = None min_temp, max_temp = self.get_temperature_range() # Homekit only supports 10-38, overwriting - # the max to appears to work, but less than 10 causes + # the max to appears to work, but less than 0 causes # a crash on the home app - hc_min_temp = max(min_temp, HC_MIN_TEMP) + hc_min_temp = max(min_temp, 0) hc_max_temp = max_temp min_humidity = self.hass.states.get(self.entity_id).attributes.get( @@ -149,48 +152,17 @@ class Thermostat(HomeAccessory): CHAR_CURRENT_HEATING_COOLING, value=0 ) - # Target mode characteristics - hc_modes = state.attributes.get(ATTR_HVAC_MODES) - if hc_modes is None: - _LOGGER.error( - "%s: HVAC modes not yet available. Please disable auto start for homekit.", - self.entity_id, - ) - hc_modes = ( - HVAC_MODE_HEAT, - HVAC_MODE_COOL, - HVAC_MODE_HEAT_COOL, - HVAC_MODE_OFF, - ) - - # Determine available modes for this entity, - # Prefer HEAT_COOL over AUTO and COOL over FAN_ONLY, DRY - # - # HEAT_COOL is preferred over auto because HomeKit Accessory Protocol describes - # heating or cooling comes on to maintain a target temp which is closest to - # the Home Assistant spec - # - # HVAC_MODE_HEAT_COOL: The device supports heating/cooling to a range - self.hc_homekit_to_hass = { - c: s - for s, c in HC_HASS_TO_HOMEKIT.items() - if ( - s in hc_modes - and not ( - (s == HVAC_MODE_AUTO and HVAC_MODE_HEAT_COOL in hc_modes) - or ( - s in (HVAC_MODE_DRY, HVAC_MODE_FAN_ONLY) - and HVAC_MODE_COOL in hc_modes - ) - ) - ) - } - hc_valid_values = {k: v for v, k in self.hc_homekit_to_hass.items()} - + self._configure_hvac_modes(state) + # Must set the value first as setting + # valid_values happens before setting + # the value and if 0 is not a valid + # value this will throw self.char_target_heat_cool = serv_thermostat.configure_char( - CHAR_TARGET_HEATING_COOLING, valid_values=hc_valid_values, + CHAR_TARGET_HEATING_COOLING, value=list(self.hc_homekit_to_hass)[0] + ) + self.char_target_heat_cool.override_properties( + valid_values=self.hc_hass_to_homekit ) - # Current and target temperature characteristics self.char_current_temp = serv_thermostat.configure_char( @@ -249,7 +221,7 @@ class Thermostat(HomeAccessory): CHAR_CURRENT_HUMIDITY, value=50 ) - self.update_state(state) + self._update_state(state) serv_thermostat.setter_callback = self._set_chars @@ -356,6 +328,46 @@ class Thermostat(HomeAccessory): if CHAR_TARGET_HUMIDITY in char_values: self.set_target_humidity(char_values[CHAR_TARGET_HUMIDITY]) + def _configure_hvac_modes(self, state): + """Configure target mode characteristics.""" + hc_modes = state.attributes.get(ATTR_HVAC_MODES) + if not hc_modes: + # This cannot be none OR an empty list + _LOGGER.error( + "%s: HVAC modes not yet available. Please disable auto start for homekit.", + self.entity_id, + ) + hc_modes = ( + HVAC_MODE_HEAT, + HVAC_MODE_COOL, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + ) + + # Determine available modes for this entity, + # Prefer HEAT_COOL over AUTO and COOL over FAN_ONLY, DRY + # + # HEAT_COOL is preferred over auto because HomeKit Accessory Protocol describes + # heating or cooling comes on to maintain a target temp which is closest to + # the Home Assistant spec + # + # HVAC_MODE_HEAT_COOL: The device supports heating/cooling to a range + self.hc_homekit_to_hass = { + c: s + for s, c in HC_HASS_TO_HOMEKIT.items() + if ( + s in hc_modes + and not ( + (s == HVAC_MODE_AUTO and HVAC_MODE_HEAT_COOL in hc_modes) + or ( + s in (HVAC_MODE_DRY, HVAC_MODE_FAN_ONLY) + and HVAC_MODE_COOL in hc_modes + ) + ) + ) + } + self.hc_hass_to_homekit = {k: v for v, k in self.hc_homekit_to_hass.items()} + def get_temperature_range(self): """Return min and max temperature range.""" max_temp = self.hass.states.get(self.entity_id).attributes.get(ATTR_MAX_TEMP) @@ -382,14 +394,46 @@ class Thermostat(HomeAccessory): def update_state(self, new_state): """Update thermostat state after state changed.""" + if self._state_updates < 3: + # When we get the first state updates + # we recheck valid hvac modes as the entity + # may not have been fully setup when we saw it the + # first time + original_hc_hass_to_homekit = self.hc_hass_to_homekit + self._configure_hvac_modes(new_state) + if self.hc_hass_to_homekit != original_hc_hass_to_homekit: + if self.char_target_heat_cool.value not in self.hc_homekit_to_hass: + # We must make sure the char value is + # in the new valid values before + # setting the new valid values or + # changing them with throw + self.char_target_heat_cool.set_value( + list(self.hc_homekit_to_hass)[0], should_notify=False + ) + self.char_target_heat_cool.override_properties( + valid_values=self.hc_hass_to_homekit + ) + self._state_updates += 1 + + self._update_state(new_state) + + def _update_state(self, new_state): + """Update state without rechecking the device features.""" features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) # Update target operation mode FIRST hvac_mode = new_state.state if hvac_mode and hvac_mode in HC_HASS_TO_HOMEKIT: homekit_hvac_mode = HC_HASS_TO_HOMEKIT[hvac_mode] - if self.char_target_heat_cool.value != homekit_hvac_mode: - self.char_target_heat_cool.set_value(homekit_hvac_mode) + if homekit_hvac_mode in self.hc_homekit_to_hass: + if self.char_target_heat_cool.value != homekit_hvac_mode: + self.char_target_heat_cool.set_value(homekit_hvac_mode) + else: + _LOGGER.error( + "Cannot map hvac target mode: %s to homekit as only %s modes are supported", + hvac_mode, + self.hc_homekit_to_hass, + ) # Set current operation mode for supported thermostats hvac_action = new_state.attributes.get(ATTR_HVAC_ACTION) @@ -444,13 +488,13 @@ class Thermostat(HomeAccessory): # even if the device does not support it hc_hvac_mode = self.char_target_heat_cool.value if hc_hvac_mode == HC_HEAT_COOL_HEAT: - target_temp = self._temperature_to_homekit( - new_state.attributes.get(ATTR_TARGET_TEMP_LOW) - ) + temp_low = new_state.attributes.get(ATTR_TARGET_TEMP_LOW) + if isinstance(temp_low, (int, float)): + target_temp = self._temperature_to_homekit(temp_low) elif hc_hvac_mode == HC_HEAT_COOL_COOL: - target_temp = self._temperature_to_homekit( - new_state.attributes.get(ATTR_TARGET_TEMP_HIGH) - ) + temp_high = new_state.attributes.get(ATTR_TARGET_TEMP_HIGH) + if isinstance(temp_high, (int, float)): + target_temp = self._temperature_to_homekit(temp_high) if target_temp and self.char_target_temp.value != target_temp: self.char_target_temp.set_value(target_temp) diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index 8ee533521e8..82abed32c0e 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -43,7 +43,6 @@ from homeassistant.components.homekit.const import ( PROP_MIN_STEP, PROP_MIN_VALUE, ) -from homeassistant.components.homekit.type_thermostats import HC_MIN_TEMP from homeassistant.components.water_heater import DOMAIN as DOMAIN_WATER_HEATER from homeassistant.const import ( ATTR_ENTITY_ID, @@ -116,7 +115,7 @@ async def test_thermostat(hass, hk_driver, cls, events): assert acc.char_current_humidity is None assert acc.char_target_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP - assert acc.char_target_temp.properties[PROP_MIN_VALUE] == HC_MIN_TEMP + assert acc.char_target_temp.properties[PROP_MIN_VALUE] == 7.0 assert acc.char_target_temp.properties[PROP_MIN_STEP] == 0.1 hass.states.async_set( @@ -126,6 +125,14 @@ async def test_thermostat(hass, hk_driver, cls, events): ATTR_TEMPERATURE: 22.2, ATTR_CURRENT_TEMPERATURE: 17.8, ATTR_HVAC_ACTION: CURRENT_HVAC_HEAT, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_COOL, + HVAC_MODE_OFF, + HVAC_MODE_AUTO, + ], }, ) await hass.async_block_till_done() @@ -142,6 +149,14 @@ async def test_thermostat(hass, hk_driver, cls, events): ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 23.0, ATTR_HVAC_ACTION: CURRENT_HVAC_IDLE, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_COOL, + HVAC_MODE_OFF, + HVAC_MODE_AUTO, + ], }, ) await hass.async_block_till_done() @@ -158,6 +173,14 @@ async def test_thermostat(hass, hk_driver, cls, events): ATTR_TEMPERATURE: 20.0, ATTR_CURRENT_TEMPERATURE: 25.0, ATTR_HVAC_ACTION: CURRENT_HVAC_COOL, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_COOL, + HVAC_MODE_OFF, + HVAC_MODE_AUTO, + ], }, ) await hass.async_block_till_done() @@ -202,6 +225,14 @@ async def test_thermostat(hass, hk_driver, cls, events): ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 18.0, ATTR_HVAC_ACTION: CURRENT_HVAC_HEAT, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_COOL, + HVAC_MODE_OFF, + HVAC_MODE_AUTO, + ], }, ) await hass.async_block_till_done() @@ -218,6 +249,14 @@ async def test_thermostat(hass, hk_driver, cls, events): ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 25.0, ATTR_HVAC_ACTION: CURRENT_HVAC_COOL, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_COOL, + HVAC_MODE_OFF, + HVAC_MODE_AUTO, + ], }, ) await hass.async_block_till_done() @@ -234,6 +273,14 @@ async def test_thermostat(hass, hk_driver, cls, events): ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 22.0, ATTR_HVAC_ACTION: CURRENT_HVAC_IDLE, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_COOL, + HVAC_MODE_OFF, + HVAC_MODE_AUTO, + ], }, ) await hass.async_block_till_done() @@ -250,6 +297,14 @@ async def test_thermostat(hass, hk_driver, cls, events): ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 22.0, ATTR_HVAC_ACTION: CURRENT_HVAC_FAN, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_COOL, + HVAC_MODE_OFF, + HVAC_MODE_AUTO, + ], }, ) await hass.async_block_till_done() @@ -369,7 +424,15 @@ async def test_thermostat_auto(hass, hk_driver, cls, events): HVAC_MODE_OFF, { ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE - | SUPPORT_TARGET_TEMPERATURE_RANGE + | SUPPORT_TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_COOL, + HVAC_MODE_OFF, + HVAC_MODE_AUTO, + ], }, ) await hass.async_block_till_done() @@ -383,10 +446,10 @@ async def test_thermostat_auto(hass, hk_driver, cls, events): assert acc.char_heating_thresh_temp.value == 19.0 assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP - assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == HC_MIN_TEMP + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 7.0 assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1 assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP - assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == HC_MIN_TEMP + assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 7.0 assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1 hass.states.async_set( @@ -397,6 +460,14 @@ async def test_thermostat_auto(hass, hk_driver, cls, events): ATTR_TARGET_TEMP_LOW: 20.0, ATTR_CURRENT_TEMPERATURE: 18.0, ATTR_HVAC_ACTION: CURRENT_HVAC_HEAT, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_COOL, + HVAC_MODE_OFF, + HVAC_MODE_AUTO, + ], }, ) await hass.async_block_till_done() @@ -415,6 +486,14 @@ async def test_thermostat_auto(hass, hk_driver, cls, events): ATTR_TARGET_TEMP_LOW: 19.0, ATTR_CURRENT_TEMPERATURE: 24.0, ATTR_HVAC_ACTION: CURRENT_HVAC_COOL, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_COOL, + HVAC_MODE_OFF, + HVAC_MODE_AUTO, + ], }, ) await hass.async_block_till_done() @@ -433,6 +512,14 @@ async def test_thermostat_auto(hass, hk_driver, cls, events): ATTR_TARGET_TEMP_LOW: 19.0, ATTR_CURRENT_TEMPERATURE: 21.0, ATTR_HVAC_ACTION: CURRENT_HVAC_IDLE, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_COOL, + HVAC_MODE_OFF, + HVAC_MODE_AUTO, + ], }, ) await hass.async_block_till_done() @@ -1094,10 +1181,10 @@ async def test_thermostat_without_target_temp_only_range(hass, hk_driver, cls, e assert acc.char_heating_thresh_temp.value == 19.0 assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP - assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == HC_MIN_TEMP + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 7.0 assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1 assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP - assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == HC_MIN_TEMP + assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 7.0 assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1 hass.states.async_set( @@ -1109,6 +1196,14 @@ async def test_thermostat_without_target_temp_only_range(hass, hk_driver, cls, e ATTR_CURRENT_TEMPERATURE: 18.0, ATTR_HVAC_ACTION: CURRENT_HVAC_HEAT, ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_COOL, + HVAC_MODE_OFF, + HVAC_MODE_AUTO, + ], }, ) await hass.async_block_till_done() @@ -1128,6 +1223,14 @@ async def test_thermostat_without_target_temp_only_range(hass, hk_driver, cls, e ATTR_CURRENT_TEMPERATURE: 24.0, ATTR_HVAC_ACTION: CURRENT_HVAC_COOL, ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_COOL, + HVAC_MODE_OFF, + HVAC_MODE_AUTO, + ], }, ) await hass.async_block_till_done() @@ -1147,6 +1250,14 @@ async def test_thermostat_without_target_temp_only_range(hass, hk_driver, cls, e ATTR_CURRENT_TEMPERATURE: 21.0, ATTR_HVAC_ACTION: CURRENT_HVAC_IDLE, ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [ + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_COOL, + HVAC_MODE_OFF, + HVAC_MODE_AUTO, + ], }, ) await hass.async_block_till_done() @@ -1400,3 +1511,109 @@ async def test_water_heater_restore(hass, hk_driver, cls, events): "Heat", "Off", } + + +async def test_thermostat_with_no_modes_when_we_first_see(hass, hk_driver, cls, events): + """Test if a thermostat that is not ready when we first see it.""" + entity_id = "climate.test" + + # support_auto = True + hass.states.async_set( + entity_id, + HVAC_MODE_OFF, + { + ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE + | SUPPORT_TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [], + }, + ) + await hass.async_block_till_done() + acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + await acc.run_handler() + await hass.async_block_till_done() + + assert acc.char_cooling_thresh_temp.value == 23.0 + assert acc.char_heating_thresh_temp.value == 19.0 + + assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 7.0 + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1 + assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP + assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 7.0 + assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1 + + assert acc.char_target_heat_cool.value == 0 + + hass.states.async_set( + entity_id, + HVAC_MODE_HEAT_COOL, + { + ATTR_TARGET_TEMP_HIGH: 22.0, + ATTR_TARGET_TEMP_LOW: 20.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_HVAC_ACTION: CURRENT_HVAC_HEAT, + ATTR_HVAC_MODES: [HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, HVAC_MODE_AUTO], + }, + ) + await hass.async_block_till_done() + assert acc.char_heating_thresh_temp.value == 20.0 + assert acc.char_cooling_thresh_temp.value == 22.0 + assert acc.char_current_heat_cool.value == 1 + assert acc.char_target_heat_cool.value == 3 + assert acc.char_current_temp.value == 18.0 + assert acc.char_display_units.value == 0 + + +async def test_thermostat_with_no_off_after_recheck(hass, hk_driver, cls, events): + """Test if a thermostat that is not ready when we first see it that actually does not have off.""" + entity_id = "climate.test" + + # support_auto = True + hass.states.async_set( + entity_id, + HVAC_MODE_COOL, + { + ATTR_SUPPORTED_FEATURES: SUPPORT_TARGET_TEMPERATURE + | SUPPORT_TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [], + }, + ) + await hass.async_block_till_done() + acc = cls.thermostat(hass, hk_driver, "Climate", entity_id, 1, None) + hk_driver.add_accessory(acc) + + await acc.run_handler() + await hass.async_block_till_done() + + assert acc.char_cooling_thresh_temp.value == 23.0 + assert acc.char_heating_thresh_temp.value == 19.0 + + assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 7.0 + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1 + assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP + assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 7.0 + assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1 + + assert acc.char_target_heat_cool.value == 2 + + hass.states.async_set( + entity_id, + HVAC_MODE_HEAT_COOL, + { + ATTR_TARGET_TEMP_HIGH: 22.0, + ATTR_TARGET_TEMP_LOW: 20.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_HVAC_ACTION: CURRENT_HVAC_HEAT, + ATTR_HVAC_MODES: [HVAC_MODE_HEAT_COOL, HVAC_MODE_AUTO], + }, + ) + await hass.async_block_till_done() + assert acc.char_heating_thresh_temp.value == 20.0 + assert acc.char_cooling_thresh_temp.value == 22.0 + assert acc.char_current_heat_cool.value == 1 + assert acc.char_target_heat_cool.value == 3 + assert acc.char_current_temp.value == 18.0 + assert acc.char_display_units.value == 0 From 6f20a4a1811ae2dae723d5b38a5a0687629b5400 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2020 02:08:56 -0500 Subject: [PATCH 11/67] Avoid error when battery appears after homekit has started (#34906) --- .../components/homekit/accessories.py | 10 ++++++- tests/components/homekit/test_accessories.py | 27 ++++++++++++++++--- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index a18f42e76b6..4f0a840770c 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -99,6 +99,9 @@ class HomeAccessory(Accessory): self.entity_id = entity_id self.hass = hass self.debounce = {} + self._char_battery = None + self._char_charging = None + self._char_low_battery = None self.linked_battery_sensor = self.config.get(CONF_LINKED_BATTERY_SENSOR) self.linked_battery_charging_sensor = self.config.get( CONF_LINKED_BATTERY_CHARGING_SENSOR @@ -247,6 +250,10 @@ class HomeAccessory(Accessory): Only call this function if self._support_battery_level is True. """ + if not self._char_battery: + # Battery appeared after homekit was started + return + battery_level = convert_to_float(battery_level) if battery_level is not None: if self._char_battery.value != battery_level: @@ -258,7 +265,8 @@ class HomeAccessory(Accessory): "%s: Updated battery level to %d", self.entity_id, battery_level ) - if battery_charging is None: + # Charging state can appear after homekit was started + if battery_charging is None or not self._char_charging: return hk_charging = HK_CHARGING if battery_charging else HK_NOT_CHARGING diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index becbcb2d6d4..c4b61f68833 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -363,9 +363,30 @@ async def test_missing_linked_battery_sensor(hass, hk_driver, caplog): await hass.async_block_till_done() assert not acc.linked_battery_sensor - assert not hasattr(acc, "_char_battery") - assert not hasattr(acc, "_char_low_battery") - assert not hasattr(acc, "_char_charging") + assert acc._char_battery is None + assert acc._char_low_battery is None + assert acc._char_charging is None + + +async def test_battery_appears_after_startup(hass, hk_driver, caplog): + """Test battery level appears after homekit is started.""" + entity_id = "homekit.accessory" + hass.states.async_set(entity_id, None, {}) + await hass.async_block_till_done() + + acc = HomeAccessory( + hass, hk_driver, "Accessory without battery", entity_id, 2, None + ) + acc.update_state = lambda x: None + assert acc._char_battery is None + + await acc.run_handler() + await hass.async_block_till_done() + assert acc._char_battery is None + + hass.states.async_set(entity_id, None, {ATTR_BATTERY_LEVEL: 15}) + await hass.async_block_till_done() + assert acc._char_battery is None async def test_call_service(hass, hk_driver, events): From d974e64a8b1eceb23cdfaf372a4343c3ae00eb02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2020 02:08:40 -0500 Subject: [PATCH 12/67] Make sqlalchemy engine connect listener recorder specific (#34908) --- homeassistant/components/recorder/__init__.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index cb5d1f4499f..fcccaa2fb9f 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -9,9 +9,7 @@ import threading import time from typing import Any, Dict, Optional -from sqlalchemy import create_engine, exc, select -from sqlalchemy.engine import Engine -from sqlalchemy.event import listens_for +from sqlalchemy import create_engine, event as sqlalchemy_event, exc, select from sqlalchemy.orm import scoped_session, sessionmaker from sqlalchemy.pool import StaticPool import voluptuous as vol @@ -488,15 +486,13 @@ class Recorder(threading.Thread): """Ensure database is ready to fly.""" kwargs = {} - # pylint: disable=unused-variable - @listens_for(Engine, "connect") - def setup_connection(dbapi_connection, connection_record): + def setup_recorder_connection(dbapi_connection, connection_record): """Dbapi specific connection settings.""" # We do not import sqlite3 here so mysql/other # users do not have to pay for it to be loaded in # memory - if self.db_url == "sqlite://" or ":memory:" in self.db_url: + if self.db_url.startswith("sqlite://"): old_isolation = dbapi_connection.isolation_level dbapi_connection.isolation_level = None cursor = dbapi_connection.cursor() @@ -519,6 +515,9 @@ class Recorder(threading.Thread): self.engine.dispose() self.engine = create_engine(self.db_url, **kwargs) + + sqlalchemy_event.listen(self.engine, "connect", setup_recorder_connection) + Base.metadata.create_all(self.engine) self.get_session = scoped_session(sessionmaker(bind=self.engine)) From 38f955934890da7938860b8aed9e0caa778848b5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Apr 2020 00:11:31 -0700 Subject: [PATCH 13/67] Bumped version to 0.109.1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index a30c0f0bdc5..2d0212f2a31 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 109 -PATCH_VERSION = "0" +PATCH_VERSION = "1" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From ade4e36da72be42d70ba505790085dcafdb06242 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Thu, 30 Apr 2020 02:21:53 -0500 Subject: [PATCH 14/67] Use entry ID when IPP printer offers no identifier (#34316) --- homeassistant/components/ipp/__init__.py | 8 ++--- homeassistant/components/ipp/sensor.py | 39 ++++++++++++++++++------ tests/components/ipp/__init__.py | 10 ++++-- tests/components/ipp/test_sensor.py | 12 ++++++++ 4 files changed, 50 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/ipp/__init__.py b/homeassistant/components/ipp/__init__.py index 5979caa37db..1258e1031b4 100644 --- a/homeassistant/components/ipp/__init__.py +++ b/homeassistant/components/ipp/__init__.py @@ -128,24 +128,20 @@ class IPPEntity(Entity): self, *, entry_id: str, + device_id: str, coordinator: IPPDataUpdateCoordinator, name: str, icon: str, enabled_default: bool = True, ) -> None: """Initialize the IPP entity.""" - self._device_id = None + self._device_id = device_id self._enabled_default = enabled_default self._entry_id = entry_id self._icon = icon self._name = name self.coordinator = coordinator - if coordinator.data.info.uuid is not None: - self._device_id = coordinator.data.info.uuid - elif coordinator.data.info.serial is not None: - self._device_id = coordinator.data.info.serial - @property def name(self) -> str: """Return the name of the entity.""" diff --git a/homeassistant/components/ipp/sensor.py b/homeassistant/components/ipp/sensor.py index 5c29be09d94..bbb051d3158 100644 --- a/homeassistant/components/ipp/sensor.py +++ b/homeassistant/components/ipp/sensor.py @@ -32,13 +32,21 @@ async def async_setup_entry( """Set up IPP sensor based on a config entry.""" coordinator: IPPDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + # config flow sets this to either UUID, serial number or None + unique_id = entry.unique_id + + if unique_id is None: + unique_id = entry.entry_id + sensors = [] - sensors.append(IPPPrinterSensor(entry.entry_id, coordinator)) - sensors.append(IPPUptimeSensor(entry.entry_id, coordinator)) + sensors.append(IPPPrinterSensor(entry.entry_id, unique_id, coordinator)) + sensors.append(IPPUptimeSensor(entry.entry_id, unique_id, coordinator)) for marker_index in range(len(coordinator.data.markers)): - sensors.append(IPPMarkerSensor(entry.entry_id, coordinator, marker_index)) + sensors.append( + IPPMarkerSensor(entry.entry_id, unique_id, coordinator, marker_index) + ) async_add_entities(sensors, True) @@ -52,6 +60,7 @@ class IPPSensor(IPPEntity): coordinator: IPPDataUpdateCoordinator, enabled_default: bool = True, entry_id: str, + unique_id: str, icon: str, key: str, name: str, @@ -62,13 +71,12 @@ class IPPSensor(IPPEntity): self._key = key self._unique_id = None - if coordinator.data.info.uuid is not None: - self._unique_id = f"{coordinator.data.info.uuid}_{key}" - elif coordinator.data.info.serial is not None: - self._unique_id = f"{coordinator.data.info.serial}_{key}" + if unique_id is not None: + self._unique_id = f"{unique_id}_{key}" super().__init__( entry_id=entry_id, + device_id=unique_id, coordinator=coordinator, name=name, icon=icon, @@ -90,7 +98,11 @@ class IPPMarkerSensor(IPPSensor): """Defines an IPP marker sensor.""" def __init__( - self, entry_id: str, coordinator: IPPDataUpdateCoordinator, marker_index: int + self, + entry_id: str, + unique_id: str, + coordinator: IPPDataUpdateCoordinator, + marker_index: int, ) -> None: """Initialize IPP marker sensor.""" self.marker_index = marker_index @@ -98,6 +110,7 @@ class IPPMarkerSensor(IPPSensor): super().__init__( coordinator=coordinator, entry_id=entry_id, + unique_id=unique_id, icon="mdi:water", key=f"marker_{marker_index}", name=f"{coordinator.data.info.name} {coordinator.data.markers[marker_index].name}", @@ -133,11 +146,14 @@ class IPPMarkerSensor(IPPSensor): class IPPPrinterSensor(IPPSensor): """Defines an IPP printer sensor.""" - def __init__(self, entry_id: str, coordinator: IPPDataUpdateCoordinator) -> None: + def __init__( + self, entry_id: str, unique_id: str, coordinator: IPPDataUpdateCoordinator + ) -> None: """Initialize IPP printer sensor.""" super().__init__( coordinator=coordinator, entry_id=entry_id, + unique_id=unique_id, icon="mdi:printer", key="printer", name=coordinator.data.info.name, @@ -166,12 +182,15 @@ class IPPPrinterSensor(IPPSensor): class IPPUptimeSensor(IPPSensor): """Defines a IPP uptime sensor.""" - def __init__(self, entry_id: str, coordinator: IPPDataUpdateCoordinator) -> None: + def __init__( + self, entry_id: str, unique_id: str, coordinator: IPPDataUpdateCoordinator + ) -> None: """Initialize IPP uptime sensor.""" super().__init__( coordinator=coordinator, enabled_default=False, entry_id=entry_id, + unique_id=unique_id, icon="mdi:clock-outline", key="uptime", name=f"{coordinator.data.info.name} Uptime", diff --git a/tests/components/ipp/__init__.py b/tests/components/ipp/__init__.py index a8c79324494..f0dc45417e1 100644 --- a/tests/components/ipp/__init__.py +++ b/tests/components/ipp/__init__.py @@ -62,7 +62,11 @@ def load_fixture_binary(filename): async def init_integration( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, skip_setup: bool = False, + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + skip_setup: bool = False, + uuid: str = "cfe92100-67c4-11d4-a45f-f8d027761251", + unique_id: str = "cfe92100-67c4-11d4-a45f-f8d027761251", ) -> MockConfigEntry: """Set up the IPP integration in Home Assistant.""" fixture = "ipp/get-printer-attributes.bin" @@ -74,14 +78,14 @@ async def init_integration( entry = MockConfigEntry( domain=DOMAIN, - unique_id="cfe92100-67c4-11d4-a45f-f8d027761251", + unique_id=unique_id, data={ CONF_HOST: "192.168.1.31", CONF_PORT: 631, CONF_SSL: False, CONF_VERIFY_SSL: True, CONF_BASE_PATH: "/ipp/print", - CONF_UUID: "cfe92100-67c4-11d4-a45f-f8d027761251", + CONF_UUID: uuid, }, ) diff --git a/tests/components/ipp/test_sensor.py b/tests/components/ipp/test_sensor.py index b7db606d870..e6830f559c6 100644 --- a/tests/components/ipp/test_sensor.py +++ b/tests/components/ipp/test_sensor.py @@ -94,3 +94,15 @@ async def test_disabled_by_default_sensors( assert entry assert entry.disabled assert entry.disabled_by == "integration" + + +async def test_missing_entry_unique_id( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the unique_id of IPP sensor when printer is missing identifiers.""" + entry = await init_integration(hass, aioclient_mock, uuid=None, unique_id=None) + registry = await hass.helpers.entity_registry.async_get_registry() + + entity = registry.async_get("sensor.epson_xp_6000_series") + assert entity + assert entity.unique_id == f"{entry.entry_id}_printer" From 4d49fe6b931facea60a13cc65a2e020f42f83674 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 1 May 2020 00:05:45 -0400 Subject: [PATCH 15/67] Don't attempt to set Vizio is_volume_muted property if Vizio API doesn't provide muted state (#34782) --- .../components/vizio/media_player.py | 15 +++++++----- tests/components/vizio/test_media_player.py | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index bb7ae3f75b0..022a5ea35f1 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -132,7 +132,7 @@ class VizioDevice(MediaPlayerDevice): self._state = None self._volume_level = None self._volume_step = config_entry.options[CONF_VOLUME_STEP] - self._is_muted = None + self._is_volume_muted = None self._current_input = None self._current_app = None self._current_app_config = None @@ -190,7 +190,7 @@ class VizioDevice(MediaPlayerDevice): if not is_on: self._state = STATE_OFF self._volume_level = None - self._is_muted = None + self._is_volume_muted = None self._current_input = None self._available_inputs = None self._current_app = None @@ -207,7 +207,10 @@ class VizioDevice(MediaPlayerDevice): ) if audio_settings is not None: self._volume_level = float(audio_settings["volume"]) / self._max_volume - self._is_muted = audio_settings["mute"].lower() == "on" + if "mute" in audio_settings: + self._is_volume_muted = audio_settings["mute"].lower() == "on" + else: + self._is_volume_muted = None if VIZIO_SOUND_MODE in audio_settings: self._supported_commands |= SUPPORT_SELECT_SOUND_MODE @@ -324,7 +327,7 @@ class VizioDevice(MediaPlayerDevice): @property def is_volume_muted(self): """Boolean if volume is currently muted.""" - return self._is_muted + return self._is_volume_muted @property def source(self) -> str: @@ -428,10 +431,10 @@ class VizioDevice(MediaPlayerDevice): """Mute the volume.""" if mute: await self._device.mute_on() - self._is_muted = True + self._is_volume_muted = True else: await self._device.mute_off() - self._is_muted = False + self._is_volume_muted = False async def async_media_previous_track(self) -> None: """Send previous channel command.""" diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index 1f6abf10563..b823241ef59 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -621,3 +621,26 @@ async def test_setup_with_no_running_app( assert attr["source"] == "CAST" assert "app_id" not in attr assert "app_name" not in attr + + +async def test_setup_tv_without_mute( + hass: HomeAssistantType, + vizio_connect: pytest.fixture, + vizio_update: pytest.fixture, +) -> None: + """Test Vizio TV entity setup when mute property isn't returned by Vizio API.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=vol.Schema(VIZIO_SCHEMA)(MOCK_USER_VALID_TV_CONFIG), + unique_id=UNIQUE_ID, + ) + + async with _cm_for_test_setup_without_apps( + {"volume": int(MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] / 2)}, STATE_ON, + ): + await _add_config_entry_to_hass(hass, config_entry) + + attr = _get_attr_and_assert_base_attr(hass, DEVICE_CLASS_TV, STATE_ON) + _assert_sources_and_volume(attr, VIZIO_DEVICE_CLASS_TV) + assert "sound_mode" not in attr + assert "is_volume_muted" not in attr From 9ccd56c01920d5a3cd477dafdbd37e4d268d8904 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Apr 2020 15:43:02 +0200 Subject: [PATCH 16/67] Bump brother to 0.1.14 (#34930) --- homeassistant/components/brother/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index 48df788c93a..7f59aaa9c2c 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -3,7 +3,7 @@ "name": "Brother Printer", "documentation": "https://www.home-assistant.io/integrations/brother", "codeowners": ["@bieniu"], - "requirements": ["brother==0.1.13"], + "requirements": ["brother==0.1.14"], "zeroconf": ["_printer._tcp.local."], "config_flow": true, "quality_scale": "platinum" diff --git a/requirements_all.txt b/requirements_all.txt index 066f6de6fd0..9c6eddf3196 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -365,7 +365,7 @@ bravia-tv==1.0.2 broadlink==0.13.2 # homeassistant.components.brother -brother==0.1.13 +brother==0.1.14 # homeassistant.components.brottsplatskartan brottsplatskartan==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fb622f5640b..215fafcd467 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -147,7 +147,7 @@ bravia-tv==1.0.2 broadlink==0.13.2 # homeassistant.components.brother -brother==0.1.13 +brother==0.1.14 # homeassistant.components.buienradar buienradar==1.0.4 From b68228c7e888f2508d1bafd62bbfbaa7669f6a78 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 1 May 2020 07:34:44 +0200 Subject: [PATCH 17/67] Fix MQTT debug info for same topic (#34952) --- homeassistant/components/mqtt/debug_info.py | 25 ++++-- tests/components/mqtt/test_init.py | 93 ++++++++++++++++++++- 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/mqtt/debug_info.py b/homeassistant/components/mqtt/debug_info.py index 2a216366bb1..86850c61638 100644 --- a/homeassistant/components/mqtt/debug_info.py +++ b/homeassistant/components/mqtt/debug_info.py @@ -23,7 +23,7 @@ def log_messages(hass: HomeAssistantType, entity_id: str) -> MessageCallbackType debug_info = hass.data[DATA_MQTT_DEBUG_INFO] messages = debug_info["entities"][entity_id]["subscriptions"][ msg.subscribed_topic - ] + ]["messages"] if msg not in messages: messages.append(msg) @@ -50,16 +50,27 @@ def add_subscription(hass, message_callback, subscription): entity_info = debug_info["entities"].setdefault( entity_id, {"subscriptions": {}, "discovery_data": {}} ) - entity_info["subscriptions"][subscription] = deque([], STORED_MESSAGES) + if subscription not in entity_info["subscriptions"]: + entity_info["subscriptions"][subscription] = { + "count": 0, + "messages": deque([], STORED_MESSAGES), + } + entity_info["subscriptions"][subscription]["count"] += 1 def remove_subscription(hass, message_callback, subscription): - """Remove debug data for subscription.""" + """Remove debug data for subscription if it exists.""" entity_id = getattr(message_callback, "__entity_id", None) if entity_id and entity_id in hass.data[DATA_MQTT_DEBUG_INFO]["entities"]: - hass.data[DATA_MQTT_DEBUG_INFO]["entities"][entity_id]["subscriptions"].pop( + hass.data[DATA_MQTT_DEBUG_INFO]["entities"][entity_id]["subscriptions"][ subscription - ) + ]["count"] -= 1 + if not hass.data[DATA_MQTT_DEBUG_INFO]["entities"][entity_id]["subscriptions"][ + subscription + ]["count"]: + hass.data[DATA_MQTT_DEBUG_INFO]["entities"][entity_id]["subscriptions"].pop( + subscription + ) def add_entity_discovery_data(hass, discovery_data, entity_id): @@ -127,10 +138,10 @@ async def info_for_device(hass, device_id): "topic": topic, "messages": [ {"payload": msg.payload, "time": msg.timestamp, "topic": msg.topic} - for msg in list(messages) + for msg in list(subscription["messages"]) ], } - for topic, messages in entity_info["subscriptions"].items() + for topic, subscription in entity_info["subscriptions"].items() ] discovery_data = { "topic": entity_info["discovery_data"].get(ATTR_DISCOVERY_TOPIC, ""), diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 290b70953af..24b51414944 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -960,6 +960,42 @@ async def test_mqtt_ws_remove_discovered_device_twice( assert response["error"]["code"] == websocket_api.const.ERR_NOT_FOUND +async def test_mqtt_ws_remove_discovered_device_same_topic( + hass, device_reg, hass_ws_client, mqtt_mock +): + """Test MQTT websocket device removal.""" + config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, config_entry) + + data = ( + '{ "device":{"identifiers":["0AFFD2"]},' + ' "state_topic": "foobar/sensor",' + ' "availability_topic": "foobar/sensor",' + ' "unique_id": "unique" }' + ) + + async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) + await hass.async_block_till_done() + + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + assert device_entry is not None + + client = await hass_ws_client(hass) + await client.send_json( + {"id": 5, "type": "mqtt/device/remove", "device_id": device_entry.id} + ) + response = await client.receive_json() + assert response["success"] + + await client.send_json( + {"id": 6, "type": "mqtt/device/remove", "device_id": device_entry.id} + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == websocket_api.const.ERR_NOT_FOUND + + async def test_mqtt_ws_remove_non_mqtt_device( hass, device_reg, hass_ws_client, mqtt_mock ): @@ -1306,7 +1342,60 @@ async def test_debug_info_filter_same(hass, mqtt_mock): assert { "topic": "sensor/#", "messages": [ - {"topic": "sensor/abc", "payload": "123", "time": dt1}, - {"topic": "sensor/abc", "payload": "123", "time": dt2}, + {"payload": "123", "time": dt1, "topic": "sensor/abc"}, + {"payload": "123", "time": dt2, "topic": "sensor/abc"}, ], } == debug_info_data["entities"][0]["subscriptions"][0] + + +async def test_debug_info_same_topic(hass, mqtt_mock): + """Test debug info.""" + config = { + "device": {"identifiers": ["helloworld"]}, + "platform": "mqtt", + "name": "test", + "state_topic": "sensor/status", + "availability_topic": "sensor/status", + "unique_id": "veryunique", + } + + entry = MockConfigEntry(domain=mqtt.DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + data = json.dumps(config) + async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) + await hass.async_block_till_done() + + device = registry.async_get_device({("mqtt", "helloworld")}, set()) + assert device is not None + + debug_info_data = await debug_info.info_for_device(hass, device.id) + assert len(debug_info_data["entities"][0]["subscriptions"]) >= 1 + assert {"topic": "sensor/status", "messages": []} in debug_info_data["entities"][0][ + "subscriptions" + ] + + start_dt = datetime(2019, 1, 1, 0, 0, 0) + with patch("homeassistant.util.dt.utcnow") as dt_utcnow: + dt_utcnow.return_value = start_dt + async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) + + debug_info_data = await debug_info.info_for_device(hass, device.id) + assert len(debug_info_data["entities"][0]["subscriptions"]) == 1 + assert { + "payload": "123", + "time": start_dt, + "topic": "sensor/status", + } in debug_info_data["entities"][0]["subscriptions"][0]["messages"] + + config["availability_topic"] = "sensor/availability" + data = json.dumps(config) + async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) + await hass.async_block_till_done() + + start_dt = datetime(2019, 1, 1, 0, 0, 0) + with patch("homeassistant.util.dt.utcnow") as dt_utcnow: + dt_utcnow.return_value = start_dt + async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) From de18207ca392950e190811acdee7d50dfb461aea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2020 13:34:25 -0500 Subject: [PATCH 18/67] Fix preservation of homekit fan speed on toggle (#34971) --- homeassistant/components/homekit/type_fans.py | 4 ++- homeassistant/components/homekit/util.py | 2 +- tests/components/homekit/test_type_fans.py | 26 +++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index b7208b1746c..291b3ffed90 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -165,7 +165,9 @@ class Fan(HomeAccessory): self.char_direction.set_value(hk_direction) # Handle Speed - if self.char_speed is not None: + if self.char_speed is not None and state != STATE_OFF: + # We do not change the homekit speed when turning off + # as it will clear the restore state speed = new_state.attributes.get(ATTR_SPEED) hk_speed_value = self.speed_mapping.speed_to_homekit(speed) if hk_speed_value is not None and self.char_speed.value != hk_speed_value: diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 0295440df49..b8d98ad2304 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -200,7 +200,7 @@ class HomeKitSpeedMapping: if speed is None: return None speed_range = self.speed_ranges[speed] - return speed_range.target + return round(speed_range.target) def speed_to_states(self, speed): """Map HomeKit speed to Home Assistant speed state.""" diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py index ca6e03217f3..4d2ace24eab 100644 --- a/tests/components/homekit/test_type_fans.py +++ b/tests/components/homekit/test_type_fans.py @@ -304,6 +304,7 @@ async def test_fan_speed(hass, hk_driver, cls, events): call_set_speed = async_mock_service(hass, DOMAIN, "set_speed") char_speed_iid = acc.char_speed.to_HAP()[HAP_REPR_IID] + char_active_iid = acc.char_active.to_HAP()[HAP_REPR_IID] hk_driver.set_characteristics( { @@ -320,12 +321,37 @@ async def test_fan_speed(hass, hk_driver, cls, events): await hass.async_add_executor_job(acc.char_speed.client_update_value, 42) await hass.async_block_till_done() acc.speed_mapping.speed_to_states.assert_called_with(42) + assert acc.char_speed.value == 42 + assert acc.char_active.value == 1 + assert call_set_speed[0] assert call_set_speed[0].data[ATTR_ENTITY_ID] == entity_id assert call_set_speed[0].data[ATTR_SPEED] == "ludicrous" assert len(events) == 1 assert events[-1].data[ATTR_VALUE] == "ludicrous" + # Verify speed is preserved from off to on + hass.states.async_set(entity_id, STATE_OFF, {ATTR_SPEED: SPEED_OFF}) + await hass.async_block_till_done() + assert acc.char_speed.value == 42 + assert acc.char_active.value == 0 + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_active_iid, + HAP_REPR_VALUE: 1, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + assert acc.char_speed.value == 42 + assert acc.char_active.value == 1 + async def test_fan_set_all_one_shot(hass, hk_driver, cls, events): """Test fan with speed.""" From 42dd3ba748c0d525ec1202b4e64ab50ef82435e9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 May 2020 00:36:01 -0500 Subject: [PATCH 19/67] Fix restoring isy994 brightness with no previous state (#34972) --- CODEOWNERS | 1 + homeassistant/components/isy994/light.py | 29 +++++++++++++++++-- homeassistant/components/isy994/manifest.json | 2 +- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 815f1b6b85a..5f1bb3a7773 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -195,6 +195,7 @@ homeassistant/components/ipp/* @ctalkington homeassistant/components/iqvia/* @bachya homeassistant/components/irish_rail_transport/* @ttroy50 homeassistant/components/islamic_prayer_times/* @engrbm87 +homeassistant/components/isy994/* @bdraco homeassistant/components/izone/* @Swamp-Ig homeassistant/components/jewish_calendar/* @tsvi homeassistant/components/juicenet/* @jesserockz diff --git a/homeassistant/components/isy994/light.py b/homeassistant/components/isy994/light.py index a8c30220637..39392ae062a 100644 --- a/homeassistant/components/isy994/light.py +++ b/homeassistant/components/isy994/light.py @@ -3,12 +3,15 @@ import logging from typing import Callable from homeassistant.components.light import DOMAIN, SUPPORT_BRIGHTNESS, Light +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from . import ISY994_NODES, ISYDevice _LOGGER = logging.getLogger(__name__) +ATTR_LAST_BRIGHTNESS = "last_brightness" + def setup_platform( hass, config: ConfigType, add_entities: Callable[[list], None], discovery_info=None @@ -21,13 +24,13 @@ def setup_platform( add_entities(devices) -class ISYLightDevice(ISYDevice, Light): +class ISYLightDevice(ISYDevice, Light, RestoreEntity): """Representation of an ISY994 light device.""" def __init__(self, node) -> None: """Initialize the ISY994 light device.""" super().__init__(node) - self._last_brightness = self.brightness + self._last_brightness = None @property def is_on(self) -> bool: @@ -56,7 +59,7 @@ class ISYLightDevice(ISYDevice, Light): # pylint: disable=arguments-differ def turn_on(self, brightness=None, **kwargs) -> None: """Send the turn on command to the ISY994 light device.""" - if brightness is None and self._last_brightness is not None: + if brightness is None and self._last_brightness: brightness = self._last_brightness if not self._node.on(val=brightness): _LOGGER.debug("Unable to turn on light") @@ -65,3 +68,23 @@ class ISYLightDevice(ISYDevice, Light): def supported_features(self): """Flag supported features.""" return SUPPORT_BRIGHTNESS + + @property + def device_state_attributes(self): + """Return the light attributes.""" + return {ATTR_LAST_BRIGHTNESS: self._last_brightness} + + async def async_added_to_hass(self) -> None: + """Restore last_brightness on restart.""" + await super().async_added_to_hass() + + self._last_brightness = self.brightness or 255 + last_state = await self.async_get_last_state() + if not last_state: + return + + if ( + ATTR_LAST_BRIGHTNESS in last_state.attributes + and last_state.attributes[ATTR_LAST_BRIGHTNESS] + ): + self._last_brightness = last_state.attributes[ATTR_LAST_BRIGHTNESS] diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index 0b48528335d..083f25808fb 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -3,5 +3,5 @@ "name": "Universal Devices ISY994", "documentation": "https://www.home-assistant.io/integrations/isy994", "requirements": ["PyISY==1.1.2"], - "codeowners": [] + "codeowners": ["@bdraco"] } From 3a4b8625bdbf9cd3542c54074622063ebad3c9bb Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Thu, 30 Apr 2020 23:08:32 -0500 Subject: [PATCH 20/67] Support num_repeats for roku remote (#34981) --- homeassistant/components/roku/remote.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/roku/remote.py b/homeassistant/components/roku/remote.py index 999747c9a27..9a61ec8d5d8 100644 --- a/homeassistant/components/roku/remote.py +++ b/homeassistant/components/roku/remote.py @@ -7,7 +7,7 @@ from requests.exceptions import ( ) from roku import RokuException -from homeassistant.components.remote import RemoteDevice +from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteDevice from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType @@ -84,8 +84,11 @@ class RokuRemote(RemoteDevice): def send_command(self, command, **kwargs): """Send a command to one device.""" - for single_command in command: - if not hasattr(self.roku, single_command): - continue + num_repeats = kwargs[ATTR_NUM_REPEATS] - getattr(self.roku, single_command)() + for _ in range(num_repeats): + for single_command in command: + if not hasattr(self.roku, single_command): + continue + + getattr(self.roku, single_command)() From 12119f89e5bd30a83b2e08745c4a94006d410fd6 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Thu, 30 Apr 2020 23:08:15 -0500 Subject: [PATCH 21/67] Support num_repeats for directv remote (#34982) --- homeassistant/components/directv/remote.py | 23 ++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/directv/remote.py b/homeassistant/components/directv/remote.py index 8bc7c220833..e8137ace711 100644 --- a/homeassistant/components/directv/remote.py +++ b/homeassistant/components/directv/remote.py @@ -5,7 +5,7 @@ from typing import Any, Callable, Iterable, List from directv import DIRECTV, DIRECTVError -from homeassistant.components.remote import RemoteDevice +from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteDevice from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType @@ -95,12 +95,15 @@ class DIRECTVRemote(DIRECTVEntity, RemoteDevice): blue, chanup, chandown, prev, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, dash, enter """ - for single_command in command: - try: - await self.dtv.remote(single_command, self._address) - except DIRECTVError: - _LOGGER.exception( - "Sending command %s to device %s failed", - single_command, - self._device_id, - ) + num_repeats = kwargs[ATTR_NUM_REPEATS] + + for _ in range(num_repeats): + for single_command in command: + try: + await self.dtv.remote(single_command, self._address) + except DIRECTVError: + _LOGGER.exception( + "Sending command %s to device %s failed", + single_command, + self._device_id, + ) From 77d89d13b670cc72d9bce730bb8e52483a102f6d Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 1 May 2020 06:06:16 +0200 Subject: [PATCH 22/67] UniFi - Disconnected clients wrongfully marked as wired not created (#34986) --- homeassistant/components/unifi/device_tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 2ac516fac55..b56fe117ef7 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -101,7 +101,7 @@ def add_entities(controller, async_add_entities): if tracker_class is UniFiClientTracker: - if item.is_wired: + if mac not in controller.wireless_clients: if not controller.option_track_wired_clients: continue else: From b8070567bf516a8dd92969b332129aadb59558b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Apr 2020 19:41:24 -0500 Subject: [PATCH 23/67] Log the rachio webhook url (#34992) --- homeassistant/components/rachio/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/rachio/__init__.py b/homeassistant/components/rachio/__init__.py index a5c9f5ab0a9..b84ccb8fa5d 100644 --- a/homeassistant/components/rachio/__init__.py +++ b/homeassistant/components/rachio/__init__.py @@ -123,7 +123,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): if not person.controllers: _LOGGER.error("No Rachio devices found in account %s", person.username) return False - _LOGGER.info("%d Rachio device(s) found", len(person.controllers)) + _LOGGER.info( + "%d Rachio device(s) found; The url %s must be accessible from the internet in order to receive updates", + len(person.controllers), + webhook_url, + ) # Enable component hass.data[DOMAIN][entry.entry_id] = person From b01131ab9deb0e9a262b89ec5082d55b0a5da90d Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Thu, 30 Apr 2020 18:35:32 -0700 Subject: [PATCH 24/67] Add allow extra to totalconnect config schema (#34993) --- homeassistant/components/totalconnect/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/totalconnect/__init__.py b/homeassistant/components/totalconnect/__init__.py index fce67f71b24..25b1141bd9b 100644 --- a/homeassistant/components/totalconnect/__init__.py +++ b/homeassistant/components/totalconnect/__init__.py @@ -24,7 +24,8 @@ CONFIG_SCHEMA = vol.Schema( vol.Required(CONF_PASSWORD): cv.string, } ) - } + }, + extra=vol.ALLOW_EXTRA, ) From bc9125666f84398cc5b950220b6e2859088cab43 Mon Sep 17 00:00:00 2001 From: Xiaonan Shen Date: Thu, 30 Apr 2020 21:13:45 -0700 Subject: [PATCH 25/67] Fix roomba not reporting error (#34996) --- homeassistant/components/roomba/irobot_base.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py index a62ad4fdd96..1f55ab48d06 100644 --- a/homeassistant/components/roomba/irobot_base.py +++ b/homeassistant/components/roomba/irobot_base.py @@ -3,6 +3,7 @@ import asyncio import logging from homeassistant.components.vacuum import ( + ATTR_STATUS, STATE_CLEANING, STATE_DOCKED, STATE_ERROR, @@ -16,6 +17,7 @@ from homeassistant.components.vacuum import ( SUPPORT_SEND_COMMAND, SUPPORT_START, SUPPORT_STATE, + SUPPORT_STATUS, SUPPORT_STOP, StateVacuumDevice, ) @@ -40,6 +42,7 @@ SUPPORT_IROBOT = ( | SUPPORT_SEND_COMMAND | SUPPORT_START | SUPPORT_STATE + | SUPPORT_STATUS | SUPPORT_STOP | SUPPORT_LOCATE ) @@ -143,7 +146,7 @@ class IRobotVacuum(IRobotEntity, StateVacuumDevice): state = STATE_MAP[phase] except KeyError: return STATE_ERROR - if cycle != "none" and state != STATE_CLEANING and state != STATE_RETURNING: + if cycle != "none" and (state == STATE_IDLE or state == STATE_DOCKED): state = STATE_PAUSED return state @@ -173,6 +176,9 @@ class IRobotVacuum(IRobotEntity, StateVacuumDevice): # Set properties that are to appear in the GUI state_attrs = {ATTR_SOFTWARE_VERSION: software_version} + # Set legacy status to avoid break changes + state_attrs[ATTR_STATUS] = self.vacuum.current_state + # Only add cleaning time and cleaned area attrs when the vacuum is # currently on if self.state == STATE_CLEANING: From af5cdc1f65c04d2fe1c0008dd59dcd974d20d3ca Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Apr 2020 22:27:34 -0700 Subject: [PATCH 26/67] Lint roomba (#35000) --- homeassistant/components/roomba/irobot_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py index 1f55ab48d06..4ebe580e7b2 100644 --- a/homeassistant/components/roomba/irobot_base.py +++ b/homeassistant/components/roomba/irobot_base.py @@ -146,7 +146,7 @@ class IRobotVacuum(IRobotEntity, StateVacuumDevice): state = STATE_MAP[phase] except KeyError: return STATE_ERROR - if cycle != "none" and (state == STATE_IDLE or state == STATE_DOCKED): + if cycle != "none" and state in (STATE_IDLE, STATE_DOCKED): state = STATE_PAUSED return state From 837b1384af9e74a5f8c4d7fed81ec3898ba75fe5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Apr 2020 22:41:44 -0700 Subject: [PATCH 27/67] Bumped version to 0.109.2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 2d0212f2a31..9e3be432dfa 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 109 -PATCH_VERSION = "1" +PATCH_VERSION = "2" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From 7e47481676fe013b7dbcc29bc4c5cdda1cf3aa56 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Apr 2020 22:55:59 -0700 Subject: [PATCH 28/67] Fix MQTT test --- tests/components/mqtt/test_init.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 24b51414944..fa7d49482c8 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -1378,7 +1378,7 @@ async def test_debug_info_same_topic(hass, mqtt_mock): ] start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: + with mock.patch("homeassistant.util.dt.utcnow") as dt_utcnow: dt_utcnow.return_value = start_dt async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) @@ -1396,6 +1396,6 @@ async def test_debug_info_same_topic(hass, mqtt_mock): await hass.async_block_till_done() start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: + with mock.patch("homeassistant.util.dt.utcnow") as dt_utcnow: dt_utcnow.return_value = start_dt async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) From 421ebb087b0beca0ace258ab7bfab580a56c35ab Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Apr 2020 23:50:16 -0700 Subject: [PATCH 29/67] Fix pylint CI --- azure-pipelines-ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index af323ecde1a..5322360f1c3 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -190,6 +190,9 @@ stages: pip install -U pip setuptools wheel pip install -r requirements_all.txt -c homeassistant/package_constraints.txt pip install -r requirements_test.txt -c homeassistant/package_constraints.txt + # This is a TEMP. Eventually we should make sure our 4 dependencies drop typing. + # Find offending deps with `pipdeptree -r -p typing` + pip uninstall -y typing - script: | . venv/bin/activate pip install -e . From 0c5b24a1f53043104222a46633702096ca82b001 Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Fri, 1 May 2020 14:33:46 +0300 Subject: [PATCH 30/67] Fix MELCloud temperature unit (#35003) The MELCLoud API produces and consumes only Celsius. --- homeassistant/components/melcloud/climate.py | 18 ++++++------------ homeassistant/components/melcloud/const.py | 9 --------- homeassistant/components/melcloud/sensor.py | 16 ++++++++-------- .../components/melcloud/water_heater.py | 4 ++-- 4 files changed, 16 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index e2d1fdd984d..9630d1bb855 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -31,7 +31,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import TEMP_CELSIUS from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.typing import HomeAssistantType -from homeassistant.util.temperature import convert as convert_temperature from . import MelCloudDevice from .const import ( @@ -44,7 +43,6 @@ from .const import ( DOMAIN, SERVICE_SET_VANE_HORIZONTAL, SERVICE_SET_VANE_VERTICAL, - TEMP_UNIT_LOOKUP, ) SCAN_INTERVAL = timedelta(seconds=60) @@ -169,7 +167,7 @@ class AtaDeviceClimate(MelCloudClimate): @property def temperature_unit(self) -> str: """Return the unit of measurement used by the platform.""" - return TEMP_UNIT_LOOKUP.get(self._device.temp_unit, TEMP_CELSIUS) + return TEMP_CELSIUS @property def hvac_mode(self) -> str: @@ -281,9 +279,7 @@ class AtaDeviceClimate(MelCloudClimate): if min_value is not None: return min_value - return convert_temperature( - DEFAULT_MIN_TEMP, TEMP_CELSIUS, self.temperature_unit - ) + return DEFAULT_MIN_TEMP @property def max_temp(self) -> float: @@ -292,9 +288,7 @@ class AtaDeviceClimate(MelCloudClimate): if max_value is not None: return max_value - return convert_temperature( - DEFAULT_MAX_TEMP, TEMP_CELSIUS, self.temperature_unit - ) + return DEFAULT_MAX_TEMP class AtwDeviceZoneClimate(MelCloudClimate): @@ -331,7 +325,7 @@ class AtwDeviceZoneClimate(MelCloudClimate): @property def temperature_unit(self) -> str: """Return the unit of measurement used by the platform.""" - return TEMP_UNIT_LOOKUP.get(self._device.temp_unit, TEMP_CELSIUS) + return TEMP_CELSIUS @property def hvac_mode(self) -> str: @@ -391,7 +385,7 @@ class AtwDeviceZoneClimate(MelCloudClimate): MELCloud API does not expose radiator zone temperature limits. """ - return convert_temperature(10, TEMP_CELSIUS, self.temperature_unit) + return 10 @property def max_temp(self) -> float: @@ -399,4 +393,4 @@ class AtwDeviceZoneClimate(MelCloudClimate): MELCloud API does not expose radiator zone temperature limits. """ - return convert_temperature(30, TEMP_CELSIUS, self.temperature_unit) + return 30 diff --git a/homeassistant/components/melcloud/const.py b/homeassistant/components/melcloud/const.py index d58f483d441..27cffb75223 100644 --- a/homeassistant/components/melcloud/const.py +++ b/homeassistant/components/melcloud/const.py @@ -1,7 +1,4 @@ """Constants for the MELCloud Climate integration.""" -from pymelcloud.const import UNIT_TEMP_CELSIUS, UNIT_TEMP_FAHRENHEIT - -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT DOMAIN = "melcloud" @@ -15,9 +12,3 @@ ATTR_VANE_VERTICAL_POSITIONS = "vane_vertical_positions" SERVICE_SET_VANE_HORIZONTAL = "set_vane_horizontal" SERVICE_SET_VANE_VERTICAL = "set_vane_vertical" - -TEMP_UNIT_LOOKUP = { - UNIT_TEMP_CELSIUS: TEMP_CELSIUS, - UNIT_TEMP_FAHRENHEIT: TEMP_FAHRENHEIT, -} -TEMP_UNIT_REVERSE_LOOKUP = {v: k for k, v in TEMP_UNIT_LOOKUP.items()} diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index 9dee01c2fba..13cc5a94a18 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -12,11 +12,11 @@ from homeassistant.const import ( from homeassistant.helpers.entity import Entity from . import MelCloudDevice -from .const import DOMAIN, TEMP_UNIT_LOOKUP +from .const import DOMAIN ATTR_MEASUREMENT_NAME = "measurement_name" ATTR_ICON = "icon" -ATTR_UNIT_FN = "unit_fn" +ATTR_UNIT = "unit" ATTR_DEVICE_CLASS = "device_class" ATTR_VALUE_FN = "value_fn" ATTR_ENABLED_FN = "enabled" @@ -25,7 +25,7 @@ ATA_SENSORS = { "room_temperature": { ATTR_MEASUREMENT_NAME: "Room Temperature", ATTR_ICON: "mdi:thermometer", - ATTR_UNIT_FN: lambda x: TEMP_UNIT_LOOKUP.get(x.device.temp_unit, TEMP_CELSIUS), + ATTR_UNIT: TEMP_CELSIUS, ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, ATTR_VALUE_FN: lambda x: x.device.room_temperature, ATTR_ENABLED_FN: lambda x: True, @@ -33,7 +33,7 @@ ATA_SENSORS = { "energy": { ATTR_MEASUREMENT_NAME: "Energy", ATTR_ICON: "mdi:factory", - ATTR_UNIT_FN: lambda x: ENERGY_KILO_WATT_HOUR, + ATTR_UNIT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: None, ATTR_VALUE_FN: lambda x: x.device.total_energy_consumed, ATTR_ENABLED_FN: lambda x: x.device.has_energy_consumed_meter, @@ -43,7 +43,7 @@ ATW_SENSORS = { "outside_temperature": { ATTR_MEASUREMENT_NAME: "Outside Temperature", ATTR_ICON: "mdi:thermometer", - ATTR_UNIT_FN: lambda x: TEMP_UNIT_LOOKUP.get(x.device.temp_unit, TEMP_CELSIUS), + ATTR_UNIT: TEMP_CELSIUS, ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, ATTR_VALUE_FN: lambda x: x.device.outside_temperature, ATTR_ENABLED_FN: lambda x: True, @@ -51,7 +51,7 @@ ATW_SENSORS = { "tank_temperature": { ATTR_MEASUREMENT_NAME: "Tank Temperature", ATTR_ICON: "mdi:thermometer", - ATTR_UNIT_FN: lambda x: TEMP_UNIT_LOOKUP.get(x.device.temp_unit, TEMP_CELSIUS), + ATTR_UNIT: TEMP_CELSIUS, ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, ATTR_VALUE_FN: lambda x: x.device.tank_temperature, ATTR_ENABLED_FN: lambda x: True, @@ -61,7 +61,7 @@ ATW_ZONE_SENSORS = { "room_temperature": { ATTR_MEASUREMENT_NAME: "Room Temperature", ATTR_ICON: "mdi:thermometer", - ATTR_UNIT_FN: lambda x: TEMP_UNIT_LOOKUP.get(x.device.temp_unit, TEMP_CELSIUS), + ATTR_UNIT: TEMP_CELSIUS, ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, ATTR_VALUE_FN: lambda zone: zone.room_temperature, ATTR_ENABLED_FN: lambda x: True, @@ -131,7 +131,7 @@ class MelDeviceSensor(Entity): @property def unit_of_measurement(self): """Return the unit of measurement.""" - return self._def[ATTR_UNIT_FN](self._api) + return self._def[ATTR_UNIT] @property def device_class(self): diff --git a/homeassistant/components/melcloud/water_heater.py b/homeassistant/components/melcloud/water_heater.py index fa7aff2b640..ce1b1ae15cc 100644 --- a/homeassistant/components/melcloud/water_heater.py +++ b/homeassistant/components/melcloud/water_heater.py @@ -18,7 +18,7 @@ from homeassistant.const import TEMP_CELSIUS from homeassistant.helpers.typing import HomeAssistantType from . import DOMAIN, MelCloudDevice -from .const import ATTR_STATUS, TEMP_UNIT_LOOKUP +from .const import ATTR_STATUS async def async_setup_entry( @@ -80,7 +80,7 @@ class AtwWaterHeater(WaterHeaterDevice): @property def temperature_unit(self) -> str: """Return the unit of measurement used by the platform.""" - return TEMP_UNIT_LOOKUP.get(self._device.temp_unit, TEMP_CELSIUS) + return TEMP_CELSIUS @property def current_operation(self) -> Optional[str]: From ed4441a1c2d186875caee02a44990423e247cb1e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 1 May 2020 11:34:09 -0700 Subject: [PATCH 31/67] Fix translation merging for custom components without translations (#35032) --- homeassistant/helpers/translation.py | 9 ++++++--- tests/helpers/test_translation.py | 8 ++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index abf39972186..d0fac953ac1 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -210,6 +210,9 @@ async def async_get_component_strings( else: files_to_load[loaded] = path + if not files_to_load: + return translations + # Load files load_translations_job = hass.async_add_executor_job( load_translations_files, files_to_load @@ -218,12 +221,12 @@ async def async_get_component_strings( loaded_translations = await load_translations_job # Translations that miss "title" will get integration put in. - for loaded, translations in loaded_translations.items(): + for loaded, loaded_translation in loaded_translations.items(): if "." in loaded: continue - if "title" not in translations: - translations["title"] = integrations[loaded].name + if "title" not in loaded_translation: + loaded_translation["title"] = integrations[loaded].name translations.update(loaded_translations) diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index 3957d2c19ee..94e3829afee 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -265,3 +265,11 @@ async def test_caching(hass): await translation.async_get_translations(hass, "en", "state") assert len(mock_merge.mock_calls) == 2 + + +async def test_custom_component_translations(hass): + """Test getting translation from custom components.""" + hass.config.components.add("test_standalone") + hass.config.components.add("test_embedded") + hass.config.components.add("test_package") + assert await translation.async_get_translations(hass, "en", "state") == {} From 6a5f9d74f5cfc6cb66dc7d621859302f32a2a981 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 1 May 2020 22:01:29 -0600 Subject: [PATCH 32/67] Fix Canary doing I/O in the event loop (#35039) --- homeassistant/components/canary/camera.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py index 1631038f81a..870256ffcff 100644 --- a/homeassistant/components/canary/camera.py +++ b/homeassistant/components/canary/camera.py @@ -81,7 +81,7 @@ class CanaryCamera(Camera): async def async_camera_image(self): """Return a still image response from the camera.""" - self.renew_live_stream_session() + await self.hass.async_add_executor_job(self.renew_live_stream_session) ffmpeg = ImageFrame(self._ffmpeg.binary, loop=self.hass.loop) image = await asyncio.shield( From 93631341a513f3534f25edb71e9cc91b815fd86c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 1 May 2020 21:00:45 -0700 Subject: [PATCH 33/67] Bump hass-nabucasa to 0.34.2 (#35046) --- homeassistant/components/cloud/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/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 9b2541eedd0..de5496cfd99 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -2,7 +2,7 @@ "domain": "cloud", "name": "Home Assistant Cloud", "documentation": "https://www.home-assistant.io/integrations/cloud", - "requirements": ["hass-nabucasa==0.34.1"], + "requirements": ["hass-nabucasa==0.34.2"], "dependencies": ["http", "webhook", "alexa"], "after_dependencies": ["google_assistant"], "codeowners": ["@home-assistant/cloud"] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 938427eadd4..c888f44c173 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,7 +11,7 @@ ciso8601==2.1.3 cryptography==2.9 defusedxml==0.6.0 distro==1.5.0 -hass-nabucasa==0.34.1 +hass-nabucasa==0.34.2 home-assistant-frontend==20200427.1 importlib-metadata==1.6.0 jinja2>=2.11.1 diff --git a/requirements_all.txt b/requirements_all.txt index 9c6eddf3196..fc416149e32 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -683,7 +683,7 @@ habitipy==0.2.0 hangups==0.4.9 # homeassistant.components.cloud -hass-nabucasa==0.34.1 +hass-nabucasa==0.34.2 # homeassistant.components.mqtt hbmqtt==0.9.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 215fafcd467..1689d8c717d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -273,7 +273,7 @@ ha-ffmpeg==2.0 hangups==0.4.9 # homeassistant.components.cloud -hass-nabucasa==0.34.1 +hass-nabucasa==0.34.2 # homeassistant.components.mqtt hbmqtt==0.9.5 From 002a8f49004d0fca5002a3b541fc0f72b8434ceb Mon Sep 17 00:00:00 2001 From: Quentame Date: Sat, 2 May 2020 01:31:39 +0200 Subject: [PATCH 34/67] Bump python-synology to 0.7.4 (#35052) --- homeassistant/components/synology_dsm/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index bb3c5ce3aec..4a538606ecb 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -2,7 +2,7 @@ "domain": "synology_dsm", "name": "Synology DSM", "documentation": "https://www.home-assistant.io/integrations/synology_dsm", - "requirements": ["python-synology==0.7.3"], + "requirements": ["python-synology==0.7.4"], "codeowners": ["@ProtoThis", "@Quentame"], "config_flow": true, "ssdp": [ diff --git a/requirements_all.txt b/requirements_all.txt index fc416149e32..24b9a039f05 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1680,7 +1680,7 @@ python-sochain-api==0.0.2 python-songpal==0.11.2 # homeassistant.components.synology_dsm -python-synology==0.7.3 +python-synology==0.7.4 # homeassistant.components.tado python-tado==0.8.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1689d8c717d..662f92c03dc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -650,7 +650,7 @@ python-miio==0.5.0.1 python-nest==4.1.0 # homeassistant.components.synology_dsm -python-synology==0.7.3 +python-synology==0.7.4 # homeassistant.components.tado python-tado==0.8.1 From f445a81a4d1d870808c4c2868129b8e7555381a0 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 2 May 2020 23:16:45 +0200 Subject: [PATCH 35/67] UniFi - Catch controllers running on UniFi OS that don't have a local user configured (#35060) --- homeassistant/components/unifi/config_flow.py | 12 ++++- homeassistant/components/unifi/errors.py | 4 ++ homeassistant/components/unifi/strings.json | 1 + .../components/unifi/translations/en.json | 1 + tests/components/unifi/test_config_flow.py | 47 +++++++++++++++++++ 5 files changed, 64 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index e91001e51be..d62df809c2e 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -32,7 +32,12 @@ from .const import ( LOGGER, ) from .controller import get_controller -from .errors import AlreadyConfigured, AuthenticationRequired, CannotConnect +from .errors import ( + AlreadyConfigured, + AuthenticationRequired, + CannotConnect, + NoLocalUser, +) DEFAULT_PORT = 8443 DEFAULT_SITE_ID = "default" @@ -135,6 +140,8 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): for site in self.sites.values(): if desc == site["desc"]: + if "role" not in site: + raise NoLocalUser self.config[CONF_SITE_ID] = site["name"] break @@ -153,6 +160,9 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except AlreadyConfigured: return self.async_abort(reason="already_configured") + except NoLocalUser: + return self.async_abort(reason="no_local_user") + if len(self.sites) == 1: self.desc = next(iter(self.sites.values()))["desc"] return await self.async_step_site(user_input={}) diff --git a/homeassistant/components/unifi/errors.py b/homeassistant/components/unifi/errors.py index c90c4956312..e0da64f245c 100644 --- a/homeassistant/components/unifi/errors.py +++ b/homeassistant/components/unifi/errors.py @@ -22,5 +22,9 @@ class LoginRequired(UnifiException): """Component got logged out.""" +class NoLocalUser(UnifiException): + """No local user.""" + + class UserLevel(UnifiException): """User level too low.""" diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index 6c142d371c9..df7fc267a43 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -20,6 +20,7 @@ }, "abort": { "already_configured": "Controller site is already configured", + "no_local_user": "No local user found, configure a local account on controller and try again", "user_privilege": "User needs to be administrator" } }, diff --git a/homeassistant/components/unifi/translations/en.json b/homeassistant/components/unifi/translations/en.json index 618c393b7aa..d61dba3b5ef 100644 --- a/homeassistant/components/unifi/translations/en.json +++ b/homeassistant/components/unifi/translations/en.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Controller site is already configured", + "no_local_user": "No local user found, configure a local account on controller and try again", "user_privilege": "User needs to be administrator" }, "error": { diff --git a/tests/components/unifi/test_config_flow.py b/tests/components/unifi/test_config_flow.py index c6b4f27e7f4..6c675bce53c 100644 --- a/tests/components/unifi/test_config_flow.py +++ b/tests/components/unifi/test_config_flow.py @@ -183,6 +183,53 @@ async def test_flow_fails_site_already_configured(hass, aioclient_mock): ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_fails_site_has_no_local_user(hass, aioclient_mock): + """Test config flow.""" + entry = MockConfigEntry( + domain=UNIFI_DOMAIN, data={"controller": {"host": "1.2.3.4", "site": "site_id"}} + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + UNIFI_DOMAIN, context={"source": "user"} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + aioclient_mock.get("https://1.2.3.4:1234", status=302) + + aioclient_mock.post( + "https://1.2.3.4:1234/api/login", + json={"data": "login successful", "meta": {"rc": "ok"}}, + headers={"content-type": "application/json"}, + ) + + aioclient_mock.get( + "https://1.2.3.4:1234/api/self/sites", + json={ + "data": [{"desc": "Site name", "name": "site_id"}], + "meta": {"rc": "ok"}, + }, + headers={"content-type": "application/json"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "1.2.3.4", + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_PORT: 1234, + CONF_VERIFY_SSL: True, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "no_local_user" async def test_flow_fails_user_credentials_faulty(hass, aioclient_mock): From 2f374abc586dedc95ecfb585aa502b8021d2224d Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 2 May 2020 23:16:18 +0200 Subject: [PATCH 36/67] UniFi - Add support for 2.4/5 GHz separated SSIDs (#35062) --- homeassistant/components/unifi/config_flow.py | 7 ++++++- homeassistant/components/unifi/controller.py | 2 +- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/unifi/test_config_flow.py | 9 ++++++--- 6 files changed, 16 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index d62df809c2e..38d37560952 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -199,7 +199,12 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): self.options.update(user_input) return await self.async_step_client_control() - ssid_filter = {wlan: wlan for wlan in self.controller.api.wlans} + ssids = list(self.controller.api.wlans) + [ + f"{wlan.name}{wlan.name_combine_suffix}" + for wlan in self.controller.api.wlans.values() + if not wlan.name_combine_enabled + ] + ssid_filter = {ssid: ssid for ssid in sorted(ssids)} return self.async_show_form( step_id="device_tracker", diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 33e7fc3836b..2500d6b4106 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -188,7 +188,7 @@ class UniFiController: elif signal == SIGNAL_DATA and data: if DATA_EVENT in data: - if data[DATA_EVENT].event in ( + if next(iter(data[DATA_EVENT])).event in ( WIRELESS_CLIENT_CONNECTED, WIRELESS_GUEST_CONNECTED, ): diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 0a5ba84cdb3..8c05d195316 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -3,7 +3,7 @@ "name": "Ubiquiti UniFi", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifi", - "requirements": ["aiounifi==18"], + "requirements": ["aiounifi==20"], "codeowners": ["@kane610"], "quality_scale": "platinum" } diff --git a/requirements_all.txt b/requirements_all.txt index 24b9a039f05..7396382957a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -212,7 +212,7 @@ aiopylgtv==0.3.3 aioswitcher==1.1.0 # homeassistant.components.unifi -aiounifi==18 +aiounifi==20 # homeassistant.components.wwlln aiowwlln==2.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 662f92c03dc..25ab458ca9d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -95,7 +95,7 @@ aiopylgtv==0.3.3 aioswitcher==1.1.0 # homeassistant.components.unifi -aiounifi==18 +aiounifi==20 # homeassistant.components.wwlln aiowwlln==2.0.2 diff --git a/tests/components/unifi/test_config_flow.py b/tests/components/unifi/test_config_flow.py index 6c675bce53c..4f29bd0a3d1 100644 --- a/tests/components/unifi/test_config_flow.py +++ b/tests/components/unifi/test_config_flow.py @@ -32,7 +32,10 @@ from tests.common import MockConfigEntry CLIENTS = [{"mac": "00:00:00:00:00:01"}] -WLANS = [{"name": "SSID 1"}, {"name": "SSID 2"}] +WLANS = [ + {"name": "SSID 1"}, + {"name": "SSID 2", "name_combine_enabled": False, "name_combine_suffix": "_IOT"}, +] async def test_flow_works(hass, aioclient_mock, mock_discovery): @@ -331,7 +334,7 @@ async def test_option_flow(hass): CONF_TRACK_CLIENTS: False, CONF_TRACK_WIRED_CLIENTS: False, CONF_TRACK_DEVICES: False, - CONF_SSID_FILTER: ["SSID 1"], + CONF_SSID_FILTER: ["SSID 1", "SSID 2_IOT"], CONF_DETECTION_TIME: 100, }, ) @@ -356,7 +359,7 @@ async def test_option_flow(hass): CONF_TRACK_CLIENTS: False, CONF_TRACK_WIRED_CLIENTS: False, CONF_TRACK_DEVICES: False, - CONF_SSID_FILTER: ["SSID 1"], + CONF_SSID_FILTER: ["SSID 1", "SSID 2_IOT"], CONF_DETECTION_TIME: 100, CONF_IGNORE_WIRED_BUG: False, CONF_POE_CLIENTS: False, From 69b8032a5ae2c077166a8fb4e2380285028db79f Mon Sep 17 00:00:00 2001 From: Xiaonan Shen Date: Sat, 2 May 2020 09:19:39 -0700 Subject: [PATCH 37/67] Bump roombapy to 1.5.2 (#35067) --- homeassistant/components/roomba/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index a164509bc99..b9e521ffc12 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -3,7 +3,7 @@ "name": "iRobot Roomba", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/roomba", - "requirements": ["roombapy==1.5.1"], + "requirements": ["roombapy==1.5.2"], "dependencies": [], "codeowners": ["@pschmitt", "@cyr-ius", "@shenxn"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7396382957a..2b09189aefa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1825,7 +1825,7 @@ rocketchat-API==0.6.1 roku==4.1.0 # homeassistant.components.roomba -roombapy==1.5.1 +roombapy==1.5.2 # homeassistant.components.rova rova==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 25ab458ca9d..1bee293a79f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -701,7 +701,7 @@ ring_doorbell==0.6.0 roku==4.1.0 # homeassistant.components.roomba -roombapy==1.5.1 +roombapy==1.5.2 # homeassistant.components.yamaha rxv==0.6.0 From b0dc1fdfad49c80833069e9d55d5172057b5436c Mon Sep 17 00:00:00 2001 From: Quentame Date: Sat, 2 May 2020 20:31:15 +0200 Subject: [PATCH 38/67] Bump python-synology to 0.8.0 + Fix disk space incorrect sensor type (#35068) * Fix Synology disk space incorrect sensor type * Review 1 --- .../components/synology_dsm/const.py | 5 +- .../components/synology_dsm/manifest.json | 2 +- .../components/synology_dsm/sensor.py | 58 ++++++++++--------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 38 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index 8c17de5e997..b3c9f66c8da 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -2,6 +2,7 @@ from homeassistant.const import ( DATA_MEGABYTES, DATA_RATE_KILOBYTES_PER_SECOND, + DATA_TERABYTES, UNIT_PERCENTAGE, ) @@ -34,8 +35,8 @@ UTILISATION_SENSORS = { STORAGE_VOL_SENSORS = { "volume_status": ["Status", None, "mdi:checkbox-marked-circle-outline"], "volume_device_type": ["Type", None, "mdi:harddisk"], - "volume_size_total": ["Total Size", None, "mdi:chart-pie"], - "volume_size_used": ["Used Space", None, "mdi:chart-pie"], + "volume_size_total": ["Total Size", DATA_TERABYTES, "mdi:chart-pie"], + "volume_size_used": ["Used Space", DATA_TERABYTES, "mdi:chart-pie"], "volume_percentage_used": ["Volume Used", UNIT_PERCENTAGE, "mdi:chart-pie"], "volume_disk_temp_avg": ["Average Disk Temp", None, "mdi:thermometer"], "volume_disk_temp_max": ["Maximum Disk Temp", None, "mdi:thermometer"], diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index 4a538606ecb..f57f1843f45 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -2,7 +2,7 @@ "domain": "synology_dsm", "name": "Synology DSM", "documentation": "https://www.home-assistant.io/integrations/synology_dsm", - "requirements": ["python-synology==0.7.4"], + "requirements": ["python-synology==0.8.0"], "codeowners": ["@ProtoThis", "@Quentame"], "config_flow": true, "ssdp": [ diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 6e5a486ab89..b6a88fe5a5a 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -7,11 +7,13 @@ from homeassistant.const import ( CONF_DISKS, DATA_MEGABYTES, DATA_RATE_KILOBYTES_PER_SECOND, + DATA_TERABYTES, TEMP_CELSIUS, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util.temperature import celsius_to_fahrenheit from . import SynoApi from .const import ( @@ -63,7 +65,7 @@ async def async_setup_entry( class SynoNasSensor(Entity): - """Representation of a Synology NAS Sensor.""" + """Representation of a Synology NAS sensor.""" def __init__( self, @@ -142,47 +144,51 @@ class SynoNasSensor(Entity): class SynoNasUtilSensor(SynoNasSensor): - """Representation a Synology Utilisation Sensor.""" + """Representation a Synology Utilisation sensor.""" @property def state(self): """Return the state.""" - if self._unit == DATA_RATE_KILOBYTES_PER_SECOND or self._unit == DATA_MEGABYTES: - attr = getattr(self._api.utilisation, self.sensor_type)(False) + attr = getattr(self._api.utilisation, self.sensor_type) + if callable(attr): + attr = attr() + if not attr: + return None - if attr is None: - return None + # Data (RAM) + if self._unit == DATA_MEGABYTES: + return round(attr / 1024.0 ** 2, 1) - if self._unit == DATA_RATE_KILOBYTES_PER_SECOND: - return round(attr / 1024.0, 1) - if self._unit == DATA_MEGABYTES: - return round(attr / 1024.0 / 1024.0, 1) - else: - return getattr(self._api.utilisation, self.sensor_type) + # Network + if self._unit == DATA_RATE_KILOBYTES_PER_SECOND: + return round(attr / 1024.0, 1) + + return attr class SynoNasStorageSensor(SynoNasSensor): - """Representation a Synology Storage Sensor.""" + """Representation a Synology Storage sensor.""" @property def state(self): """Return the state.""" - if self.monitored_device: - if self.sensor_type in TEMP_SENSORS_KEYS: - attr = getattr(self._api.storage, self.sensor_type)( - self.monitored_device - ) + attr = getattr(self._api.storage, self.sensor_type)(self.monitored_device) + if not attr: + return None - if attr is None: - return None + # Data (disk space) + if self._unit == DATA_TERABYTES: + return round(attr / 1024.0 ** 4, 2) - if self._api.temp_unit == TEMP_CELSIUS: - return attr + # Temperature + if self._api.temp_unit == TEMP_CELSIUS: + # Celsius + return attr + if self.sensor_type in TEMP_SENSORS_KEYS: + # Fahrenheit + return celsius_to_fahrenheit(attr) - return round(attr * 1.8 + 32.0, 1) - - return getattr(self._api.storage, self.sensor_type)(self.monitored_device) - return None + return attr @property def device_info(self) -> Dict[str, any]: diff --git a/requirements_all.txt b/requirements_all.txt index 2b09189aefa..694be2927f2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1680,7 +1680,7 @@ python-sochain-api==0.0.2 python-songpal==0.11.2 # homeassistant.components.synology_dsm -python-synology==0.7.4 +python-synology==0.8.0 # homeassistant.components.tado python-tado==0.8.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1bee293a79f..4f173af030d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -650,7 +650,7 @@ python-miio==0.5.0.1 python-nest==4.1.0 # homeassistant.components.synology_dsm -python-synology==0.7.4 +python-synology==0.8.0 # homeassistant.components.tado python-tado==0.8.1 From 4c65c51ede95f4b4ff6a94c11c7a8d89623d1391 Mon Sep 17 00:00:00 2001 From: David Nielsen Date: Sat, 2 May 2020 15:21:57 -0400 Subject: [PATCH 39/67] Update bravia-tv to 1.0.3 (#35077) --- homeassistant/components/braviatv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/braviatv/manifest.json b/homeassistant/components/braviatv/manifest.json index cde236b4ca4..98c1dca08d2 100644 --- a/homeassistant/components/braviatv/manifest.json +++ b/homeassistant/components/braviatv/manifest.json @@ -2,7 +2,7 @@ "domain": "braviatv", "name": "Sony Bravia TV", "documentation": "https://www.home-assistant.io/integrations/braviatv", - "requirements": ["bravia-tv==1.0.2"], + "requirements": ["bravia-tv==1.0.3"], "codeowners": ["@robbiet480", "@bieniu"], "config_flow": true } diff --git a/requirements_all.txt b/requirements_all.txt index 694be2927f2..91e8de4ec51 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -359,7 +359,7 @@ bomradarloop==0.1.4 boto3==1.9.252 # homeassistant.components.braviatv -bravia-tv==1.0.2 +bravia-tv==1.0.3 # homeassistant.components.broadlink broadlink==0.13.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f173af030d..c225d56988d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -141,7 +141,7 @@ bellows-homeassistant==0.15.2 bomradarloop==0.1.4 # homeassistant.components.braviatv -bravia-tv==1.0.2 +bravia-tv==1.0.3 # homeassistant.components.broadlink broadlink==0.13.2 From a083eb334e32dcb2eca612407c289ec77a4c2103 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Sat, 2 May 2020 21:55:11 +0200 Subject: [PATCH 40/67] Updated frontend to 20200427.2 (#35079) --- 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 9759c38af7d..0ed1697bf42 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20200427.1"], + "requirements": ["home-assistant-frontend==20200427.2"], "dependencies": [ "api", "auth", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c888f44c173..b7e70914714 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ cryptography==2.9 defusedxml==0.6.0 distro==1.5.0 hass-nabucasa==0.34.2 -home-assistant-frontend==20200427.1 +home-assistant-frontend==20200427.2 importlib-metadata==1.6.0 jinja2>=2.11.1 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 91e8de4ec51..461f4d27d6d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -713,7 +713,7 @@ hole==0.5.1 holidays==0.10.2 # homeassistant.components.frontend -home-assistant-frontend==20200427.1 +home-assistant-frontend==20200427.2 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c225d56988d..56a3ac96310 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -291,7 +291,7 @@ hole==0.5.1 holidays==0.10.2 # homeassistant.components.frontend -home-assistant-frontend==20200427.1 +home-assistant-frontend==20200427.2 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From bd882fa4e4dcb88339fcfeaa8db6f14af7afeb15 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 May 2020 16:15:44 -0500 Subject: [PATCH 41/67] Improve stability of homekit media players (#35080) --- homeassistant/components/homekit/__init__.py | 7 +- .../components/homekit/type_media_players.py | 53 +++++++-------- homeassistant/components/homekit/util.py | 66 ++++++++++++++++++- .../homekit/test_type_media_players.py | 20 ++++++ tests/components/homekit/test_util.py | 14 ++++ 5 files changed, 125 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 35620e9e11d..b2aeea85c94 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -275,13 +275,12 @@ def get_accessory(hass, driver, state, aid, config): elif state.domain == "media_player": device_class = state.attributes.get(ATTR_DEVICE_CLASS) - feature_list = config.get(CONF_FEATURE_LIST) + feature_list = config.get(CONF_FEATURE_LIST, []) if device_class == DEVICE_CLASS_TV: a_type = "TelevisionMediaPlayer" - else: - if feature_list and validate_media_player_features(state, feature_list): - a_type = "MediaPlayer" + elif validate_media_player_features(state, feature_list): + a_type = "MediaPlayer" elif state.domain == "sensor": device_class = state.attributes.get(ATTR_DEVICE_CLASS) diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index 78c11fc41f9..76f4532b868 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -65,6 +65,7 @@ from .const import ( SERV_TELEVISION, SERV_TELEVISION_SPEAKER, ) +from .util import get_media_player_features _LOGGER = logging.getLogger(__name__) @@ -84,10 +85,12 @@ MEDIA_PLAYER_KEYS = { # 15: "Information", } +# Names may not contain special characters +# or emjoi (/ is a special character for Apple) MODE_FRIENDLY_NAME = { FEATURE_ON_OFF: "Power", - FEATURE_PLAY_PAUSE: "Play/Pause", - FEATURE_PLAY_STOP: "Play/Stop", + FEATURE_PLAY_PAUSE: "Play-Pause", + FEATURE_PLAY_STOP: "Play-Stop", FEATURE_TOGGLE_MUTE: "Mute", } @@ -106,7 +109,9 @@ class MediaPlayer(HomeAccessory): FEATURE_PLAY_STOP: None, FEATURE_TOGGLE_MUTE: None, } - feature_list = self.config[CONF_FEATURE_LIST] + feature_list = self.config.get( + CONF_FEATURE_LIST, get_media_player_features(state) + ) if FEATURE_ON_OFF in feature_list: name = self.generate_service_name(FEATURE_ON_OFF) @@ -214,7 +219,7 @@ class MediaPlayer(HomeAccessory): self.chars[FEATURE_PLAY_STOP].set_value(hk_state) if self.chars[FEATURE_TOGGLE_MUTE]: - current_state = new_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) + current_state = bool(new_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED)) _LOGGER.debug( '%s: Set current state for "toggle_mute" to %s', self.entity_id, @@ -240,9 +245,7 @@ class TelevisionMediaPlayer(HomeAccessory): # Add additional characteristics if volume or input selection supported self.chars_tv = [] self.chars_speaker = [] - features = self.hass.states.get(self.entity_id).attributes.get( - ATTR_SUPPORTED_FEATURES, 0 - ) + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if features & (SUPPORT_PLAY | SUPPORT_PAUSE): self.chars_tv.append(CHAR_REMOTE_KEY) @@ -253,7 +256,8 @@ class TelevisionMediaPlayer(HomeAccessory): if features & SUPPORT_VOLUME_SET: self.chars_speaker.append(CHAR_VOLUME) - if features & SUPPORT_SELECT_SOURCE: + source_list = state.attributes.get(ATTR_INPUT_SOURCE_LIST, []) + if source_list and features & SUPPORT_SELECT_SOURCE: self.support_select_source = True serv_tv = self.add_preload_service(SERV_TELEVISION, self.chars_tv) @@ -298,9 +302,7 @@ class TelevisionMediaPlayer(HomeAccessory): ) if self.support_select_source: - self.sources = self.hass.states.get(self.entity_id).attributes.get( - ATTR_INPUT_SOURCE_LIST, [] - ) + self.sources = source_list self.char_input_source = serv_tv.configure_char( CHAR_ACTIVE_IDENTIFIER, setter_callback=self.set_input_source ) @@ -380,14 +382,13 @@ class TelevisionMediaPlayer(HomeAccessory): hk_state = 0 if current_state not in ("None", STATE_OFF, STATE_UNKNOWN): hk_state = 1 - _LOGGER.debug("%s: Set current active state to %s", self.entity_id, hk_state) if self.char_active.value != hk_state: self.char_active.set_value(hk_state) # Set mute state if CHAR_VOLUME_SELECTOR in self.chars_speaker: - current_mute_state = new_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) + current_mute_state = bool(new_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED)) _LOGGER.debug( "%s: Set current mute state to %s", self.entity_id, current_mute_state, ) @@ -395,20 +396,16 @@ class TelevisionMediaPlayer(HomeAccessory): self.char_mute.set_value(current_mute_state) # Set active input - if self.support_select_source: + if self.support_select_source and self.sources: source_name = new_state.attributes.get(ATTR_INPUT_SOURCE) - if self.sources: - _LOGGER.debug( - "%s: Set current input to %s", self.entity_id, source_name + _LOGGER.debug("%s: Set current input to %s", self.entity_id, source_name) + if source_name in self.sources: + index = self.sources.index(source_name) + if self.char_input_source.value != index: + self.char_input_source.set_value(index) + else: + _LOGGER.warning( + "%s: Sources out of sync. Restart Home Assistant", self.entity_id, ) - if source_name in self.sources: - index = self.sources.index(source_name) - if self.char_input_source.value != index: - self.char_input_source.set_value(index) - else: - _LOGGER.warning( - "%s: Sources out of sync. Restart Home Assistant", - self.entity_id, - ) - if self.char_input_source.value != 0: - self.char_input_source.set_value(0) + if self.char_input_source.value != 0: + self.char_input_source.set_value(0) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index b8d98ad2304..9c8e7e8c053 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -96,6 +96,40 @@ SWITCH_TYPE_SCHEMA = BASIC_INFO_SCHEMA.extend( ) +HOMEKIT_CHAR_TRANSLATIONS = { + 0: " ", # nul + 10: " ", # nl + 13: " ", # cr + 33: "-", # ! + 34: " ", # " + 36: "-", # $ + 37: "-", # % + 40: "-", # ( + 41: "-", # ) + 42: "-", # * + 43: "-", # + + 47: "-", # / + 58: "-", # : + 59: "-", # ; + 60: "-", # < + 61: "-", # = + 62: "-", # > + 63: "-", # ? + 64: "-", # @ + 91: "-", # [ + 92: "-", # \ + 93: "-", # ] + 94: "-", # ^ + 95: " ", # _ + 96: "-", # ` + 123: "-", # { + 124: "-", # | + 125: "-", # } + 126: "-", # ~ + 127: "-", # del +} + + def validate_entity_config(values): """Validate config entry for CONF_ENTITY.""" if not isinstance(values, dict): @@ -133,8 +167,8 @@ def validate_entity_config(values): return entities -def validate_media_player_features(state, feature_list): - """Validate features for media players.""" +def get_media_player_features(state): + """Determine features for media players.""" features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) supported_modes = [] @@ -148,6 +182,20 @@ def validate_media_player_features(state, feature_list): supported_modes.append(FEATURE_PLAY_STOP) if features & media_player.const.SUPPORT_VOLUME_MUTE: supported_modes.append(FEATURE_TOGGLE_MUTE) + return supported_modes + + +def validate_media_player_features(state, feature_list): + """Validate features for media players.""" + supported_modes = get_media_player_features(state) + + if not supported_modes: + _LOGGER.error("%s does not support any media_player features", state.entity_id) + return False + + if not feature_list: + # Auto detected + return True error_list = [] for feature in feature_list: @@ -155,7 +203,9 @@ def validate_media_player_features(state, feature_list): error_list.append(feature) if error_list: - _LOGGER.error("%s does not support features: %s", state.entity_id, error_list) + _LOGGER.error( + "%s does not support media_player features: %s", state.entity_id, error_list + ) return False return True @@ -247,6 +297,16 @@ def convert_to_float(state): return None +def cleanup_name_for_homekit(name): + """Ensure the name of the device will not crash homekit.""" + # + # This is not a security measure. + # + # UNICODE_EMOJI is also not allowed but that + # likely isn't a problem + return name.translate(HOMEKIT_CHAR_TRANSLATIONS) + + def temperature_to_homekit(temperature, unit): """Convert temperature to Celsius for HomeKit.""" return round(temp_util.convert(temperature, unit, TEMP_CELSIUS), 1) diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index 5fe8c438ca1..50385bdd880 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -368,6 +368,26 @@ async def test_media_player_television_basic(hass, hk_driver, events, caplog): assert not caplog.messages or "Error" not in caplog.messages[-1] +async def test_media_player_television_supports_source_select_no_sources( + hass, hk_driver, events, caplog +): + """Test if basic tv that supports source select but is missing a source list.""" + entity_id = "media_player.television" + + # Supports turn_on', 'turn_off' + hass.states.async_set( + entity_id, + None, + {ATTR_DEVICE_CLASS: DEVICE_CLASS_TV, ATTR_SUPPORTED_FEATURES: 3469}, + ) + await hass.async_block_till_done() + acc = TelevisionMediaPlayer(hass, hk_driver, "MediaPlayer", entity_id, 2, None) + await acc.run_handler() + await hass.async_block_till_done() + + assert acc.support_select_source is False + + async def test_tv_restore(hass, hk_driver, events): """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 41b134c10a5..45ad042238f 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -22,6 +22,7 @@ from homeassistant.components.homekit.const import ( from homeassistant.components.homekit.util import ( HomeKitSpeedMapping, SpeedRange, + cleanup_name_for_homekit, convert_to_float, density_to_air_quality, dismiss_setup_message, @@ -172,6 +173,19 @@ def test_convert_to_float(): assert convert_to_float(None) is None +def test_cleanup_name_for_homekit(): + """Ensure name sanitize works as expected.""" + + assert cleanup_name_for_homekit("abc") == "abc" + assert cleanup_name_for_homekit("a b c") == "a b c" + assert cleanup_name_for_homekit("ab_c") == "ab c" + assert ( + cleanup_name_for_homekit('ab!@#$%^&*()-=":.,> Date: Sat, 2 May 2020 15:00:00 -0700 Subject: [PATCH 42/67] Bumped version to 0.109.3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 9e3be432dfa..91d6fe39921 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 109 -PATCH_VERSION = "2" +PATCH_VERSION = "3" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From cf2e71a530fec102ba33577eea26340ec66bffa5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 2 May 2020 15:38:24 -0700 Subject: [PATCH 43/67] Fix import --- tests/components/unifi/test_config_flow.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/unifi/test_config_flow.py b/tests/components/unifi/test_config_flow.py index 4f29bd0a3d1..920f22a8054 100644 --- a/tests/components/unifi/test_config_flow.py +++ b/tests/components/unifi/test_config_flow.py @@ -17,6 +17,7 @@ from homeassistant.components.unifi.const import ( CONF_TRACK_CLIENTS, CONF_TRACK_DEVICES, CONF_TRACK_WIRED_CLIENTS, + DOMAIN as UNIFI_DOMAIN, ) from homeassistant.const import ( CONF_HOST, From d3b6255ffe2d785ee41a221d79bb214d2611d38b Mon Sep 17 00:00:00 2001 From: Quentame Date: Sun, 3 May 2020 07:46:55 +0200 Subject: [PATCH 44/67] Fix Synology NAS discovered multiple times (#35094) --- .../components/synology_dsm/__init__.py | 20 +++++++--- .../components/synology_dsm/config_flow.py | 34 ++++++++--------- .../synology_dsm/test_config_flow.py | 37 ++++++++++++++----- 3 files changed, 58 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 3fbed6955d9..9431cb7b1c9 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -11,6 +11,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_DISKS, CONF_HOST, + CONF_MAC, CONF_PASSWORD, CONF_PORT, CONF_SSL, @@ -77,6 +78,13 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.unique_id] = api + # For SSDP compat + if not entry.data.get(CONF_MAC): + network = await hass.async_add_executor_job(getattr, api.dsm, "network") + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_MAC: network.macs} + ) + hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, "sensor") ) @@ -115,7 +123,7 @@ class SynoApi: self._device_token = device_token self.temp_unit = temp_unit - self._dsm: SynologyDSM = None + self.dsm: SynologyDSM = None self.information: SynoDSMInformation = None self.utilisation: SynoCoreUtilization = None self.storage: SynoStorage = None @@ -129,7 +137,7 @@ class SynoApi: async def async_setup(self): """Start interacting with the NAS.""" - self._dsm = SynologyDSM( + self.dsm = SynologyDSM( self._host, self._port, self._username, @@ -147,9 +155,9 @@ class SynoApi: def _fetch_device_configuration(self): """Fetch initial device config.""" - self.information = self._dsm.information - self.utilisation = self._dsm.utilisation - self.storage = self._dsm.storage + self.information = self.dsm.information + self.utilisation = self.dsm.utilisation + self.storage = self.dsm.storage async def async_unload(self): """Stop interacting with the NAS and prepare for removal from hass.""" @@ -157,5 +165,5 @@ class SynoApi: async def update(self, now=None): """Update function for updating API information.""" - await self._hass.async_add_executor_job(self._dsm.update) + await self._hass.async_add_executor_job(self.dsm.update) async_dispatcher_send(self._hass, self.signal_sensor_update) diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 4b09e516451..c3d15aff2fd 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -17,6 +17,7 @@ from homeassistant.components import ssdp from homeassistant.const import ( CONF_DISKS, CONF_HOST, + CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_PORT, @@ -145,6 +146,7 @@ class SynologyDSMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): CONF_SSL: use_ssl, CONF_USERNAME: username, CONF_PASSWORD: password, + CONF_MAC: api.network.macs, } if otp_code: config_data["device_token"] = api.device_token @@ -162,16 +164,11 @@ class SynologyDSMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME].split("(", 1)[0].strip() ) - if self._host_already_configured(parsed_url.hostname): + # Synology NAS can broadcast on multiple IP addresses, since they can be connected to multiple ethernets. + # The serial of the NAS is actually its MAC address. + if self._mac_already_configured(discovery_info[ssdp.ATTR_UPNP_SERIAL].upper()): return self.async_abort(reason="already_configured") - if ssdp.ATTR_UPNP_SERIAL in discovery_info: - # Synology can broadcast on multiple IP addresses - await self.async_set_unique_id( - discovery_info[ssdp.ATTR_UPNP_SERIAL].upper() - ) - self._abort_if_unique_id_configured() - self.discovered_conf = { CONF_NAME: friendly_name, CONF_HOST: parsed_url.hostname, @@ -205,12 +202,14 @@ class SynologyDSMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_user(user_input) - def _host_already_configured(self, hostname): - """See if we already have a host matching user input configured.""" - existing_hosts = { - entry.data[CONF_HOST] for entry in self._async_current_entries() - } - return hostname in existing_hosts + def _mac_already_configured(self, mac): + """See if we already have configured a NAS with this MAC address.""" + existing_macs = [ + mac.replace("-", "") + for entry in self._async_current_entries() + for mac in entry.data.get(CONF_MAC, []) + ] + return mac in existing_macs def _login_and_fetch_syno_info(api, otp_code): @@ -221,10 +220,11 @@ def _login_and_fetch_syno_info(api, otp_code): storage = api.storage if ( - api.information.serial is None + not api.information.serial or utilisation.cpu_user_load is None - or storage.disks_ids is None - or storage.volumes_ids is None + or not storage.disks_ids + or not storage.volumes_ids + or not api.network.macs ): raise InvalidData diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index 66f752ffaf4..795348d900c 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -25,6 +25,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_SSDP, SOURCE_USER from homeassistant.const import ( CONF_DISKS, CONF_HOST, + CONF_MAC, CONF_PASSWORD, CONF_PORT, CONF_SSL, @@ -47,6 +48,8 @@ USERNAME = "Home_Assistant" PASSWORD = "password" DEVICE_TOKEN = "Dév!cè_T0k€ñ" +MACS = ["00-11-32-XX-XX-59", "00-11-32-XX-XX-5A"] + @pytest.fixture(name="service") def mock_controller_service(): @@ -56,8 +59,9 @@ def mock_controller_service(): ) as service_mock: service_mock.return_value.information.serial = SERIAL service_mock.return_value.utilisation.cpu_user_load = 1 - service_mock.return_value.storage.disks_ids = [] - service_mock.return_value.storage.volumes_ids = [] + service_mock.return_value.storage.disks_ids = ["sda", "sdb", "sdc"] + service_mock.return_value.storage.volumes_ids = ["volume_1"] + service_mock.return_value.network.macs = MACS yield service_mock @@ -72,8 +76,9 @@ def mock_controller_service_2sa(): ) service_mock.return_value.information.serial = SERIAL service_mock.return_value.utilisation.cpu_user_load = 1 - service_mock.return_value.storage.disks_ids = [] - service_mock.return_value.storage.volumes_ids = [] + service_mock.return_value.storage.disks_ids = ["sda", "sdb", "sdc"] + service_mock.return_value.storage.volumes_ids = ["volume_1"] + service_mock.return_value.network.macs = MACS yield service_mock @@ -85,8 +90,9 @@ def mock_controller_service_failed(): ) as service_mock: service_mock.return_value.information.serial = None service_mock.return_value.utilisation.cpu_user_load = None - service_mock.return_value.storage.disks_ids = None - service_mock.return_value.storage.volumes_ids = None + service_mock.return_value.storage.disks_ids = [] + service_mock.return_value.storage.volumes_ids = [] + service_mock.return_value.network.macs = [] yield service_mock @@ -118,6 +124,7 @@ async def test_user(hass: HomeAssistantType, service: MagicMock): assert result["data"][CONF_SSL] == SSL assert result["data"][CONF_USERNAME] == USERNAME assert result["data"][CONF_PASSWORD] == PASSWORD + assert result["data"][CONF_MAC] == MACS assert result["data"].get("device_token") is None assert result["data"].get(CONF_DISKS) is None assert result["data"].get(CONF_VOLUMES) is None @@ -142,6 +149,7 @@ async def test_user(hass: HomeAssistantType, service: MagicMock): assert not result["data"][CONF_SSL] assert result["data"][CONF_USERNAME] == USERNAME assert result["data"][CONF_PASSWORD] == PASSWORD + assert result["data"][CONF_MAC] == MACS assert result["data"].get("device_token") is None assert result["data"].get(CONF_DISKS) is None assert result["data"].get(CONF_VOLUMES) is None @@ -183,6 +191,7 @@ async def test_user_2sa(hass: HomeAssistantType, service_2sa: MagicMock): assert result["data"][CONF_SSL] == DEFAULT_SSL assert result["data"][CONF_USERNAME] == USERNAME assert result["data"][CONF_PASSWORD] == PASSWORD + assert result["data"][CONF_MAC] == MACS assert result["data"].get("device_token") == DEVICE_TOKEN assert result["data"].get(CONF_DISKS) is None assert result["data"].get(CONF_VOLUMES) is None @@ -204,6 +213,7 @@ async def test_import(hass: HomeAssistantType, service: MagicMock): assert result["data"][CONF_SSL] == DEFAULT_SSL assert result["data"][CONF_USERNAME] == USERNAME assert result["data"][CONF_PASSWORD] == PASSWORD + assert result["data"][CONF_MAC] == MACS assert result["data"].get("device_token") is None assert result["data"].get(CONF_DISKS) is None assert result["data"].get(CONF_VOLUMES) is None @@ -231,6 +241,7 @@ async def test_import(hass: HomeAssistantType, service: MagicMock): assert result["data"][CONF_SSL] == SSL assert result["data"][CONF_USERNAME] == USERNAME assert result["data"][CONF_PASSWORD] == PASSWORD + assert result["data"][CONF_MAC] == MACS assert result["data"].get("device_token") is None assert result["data"][CONF_DISKS] == ["sda", "sdb", "sdc"] assert result["data"][CONF_VOLUMES] == ["volume_1"] @@ -329,8 +340,13 @@ async def test_form_ssdp_already_configured( MockConfigEntry( domain=DOMAIN, - data={CONF_HOST: HOST, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - unique_id=SERIAL.upper(), + data={ + CONF_HOST: HOST, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS, + }, + unique_id=SERIAL, ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -339,7 +355,7 @@ async def test_form_ssdp_already_configured( data={ ssdp.ATTR_SSDP_LOCATION: "http://192.168.1.5:5000", ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", - ssdp.ATTR_UPNP_SERIAL: SERIAL, + ssdp.ATTR_UPNP_SERIAL: "001132XXXX59", # Existing in MACS[0], but SSDP does not have `-` }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -355,7 +371,7 @@ async def test_form_ssdp(hass: HomeAssistantType, service: MagicMock): data={ ssdp.ATTR_SSDP_LOCATION: "http://192.168.1.5:5000", ssdp.ATTR_UPNP_FRIENDLY_NAME: "mydsm", - ssdp.ATTR_UPNP_SERIAL: SERIAL, + ssdp.ATTR_UPNP_SERIAL: "001132XXXX99", # MAC address, but SSDP does not have `-` }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -373,6 +389,7 @@ async def test_form_ssdp(hass: HomeAssistantType, service: MagicMock): assert result["data"][CONF_SSL] == DEFAULT_SSL assert result["data"][CONF_USERNAME] == USERNAME assert result["data"][CONF_PASSWORD] == PASSWORD + assert result["data"][CONF_MAC] == MACS assert result["data"].get("device_token") is None assert result["data"].get(CONF_DISKS) is None assert result["data"].get(CONF_VOLUMES) is None From ab2c3a21300bd19206960a2761149d190ae7b50c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sun, 3 May 2020 11:36:06 +0200 Subject: [PATCH 45/67] Correct typo Asssitant -> Assistant (#35117) --- homeassistant/components/websocket_api/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/websocket_api/manifest.json b/homeassistant/components/websocket_api/manifest.json index 76e2742b996..66dd76af769 100644 --- a/homeassistant/components/websocket_api/manifest.json +++ b/homeassistant/components/websocket_api/manifest.json @@ -1,6 +1,6 @@ { "domain": "websocket_api", - "name": "Home Asssitant WebSocket API", + "name": "Home Assistant WebSocket API", "documentation": "https://www.home-assistant.io/integrations/websocket_api", "dependencies": ["http"], "codeowners": ["@home-assistant/core"], From 0d8390b9df6b6b784297ad7e1b88f9b65f7b439c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 3 May 2020 16:21:10 -0700 Subject: [PATCH 46/67] Hue: Guard for when there is no brightness (#35151) --- homeassistant/components/hue/light.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index b808dd0594d..6157a7fde23 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -259,6 +259,9 @@ class HueLight(Light): else: bri = self.light.state.get("bri") + if bri is None: + return bri + return hue_brightness_to_hass(bri) @property From 89cbbfc2e53edcd775e35c29dca183cc9c1d908b Mon Sep 17 00:00:00 2001 From: Quentame Date: Mon, 4 May 2020 01:22:12 +0200 Subject: [PATCH 47/67] Bump pyiCloud to 0.9.7 + do not warn when pending devices (#35156) --- homeassistant/components/icloud/account.py | 2 +- homeassistant/components/icloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py index e0d9a608605..d039b270bb8 100644 --- a/homeassistant/components/icloud/account.py +++ b/homeassistant/components/icloud/account.py @@ -186,7 +186,7 @@ class IcloudAccount: DEVICE_STATUS_CODES.get(list(api_devices)[0][DEVICE_STATUS]) == "pending" and not self._retried_fetch ): - _LOGGER.warning("Pending devices, trying again in 15s") + _LOGGER.debug("Pending devices, trying again in 15s") self._fetch_interval = 0.25 self._retried_fetch = True else: diff --git a/homeassistant/components/icloud/manifest.json b/homeassistant/components/icloud/manifest.json index 2b8bc2fccae..40b58cbf2d0 100644 --- a/homeassistant/components/icloud/manifest.json +++ b/homeassistant/components/icloud/manifest.json @@ -3,6 +3,6 @@ "name": "Apple iCloud", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/icloud", - "requirements": ["pyicloud==0.9.6.1"], + "requirements": ["pyicloud==0.9.7"], "codeowners": ["@Quentame"] } diff --git a/requirements_all.txt b/requirements_all.txt index 461f4d27d6d..ffe6f72c21f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1342,7 +1342,7 @@ pyhomeworks==0.0.6 pyialarm==0.3 # homeassistant.components.icloud -pyicloud==0.9.6.1 +pyicloud==0.9.7 # homeassistant.components.intesishome pyintesishome==1.7.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 56a3ac96310..593ee4ac55f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -537,7 +537,7 @@ pyheos==0.6.0 pyhomematic==0.1.66 # homeassistant.components.icloud -pyicloud==0.9.6.1 +pyicloud==0.9.7 # homeassistant.components.ipma pyipma==2.0.5 From 45d934e64db570aa5bb47efe2f6b26e63de9b580 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 4 May 2020 10:35:40 +0200 Subject: [PATCH 48/67] Fix Canary KeyError: 'ffmpeg_arguments' (#35158) --- homeassistant/components/canary/alarm_control_panel.py | 5 +---- homeassistant/components/canary/camera.py | 3 +++ 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/canary/alarm_control_panel.py b/homeassistant/components/canary/alarm_control_panel.py index 35fff8accbd..43290e9a345 100644 --- a/homeassistant/components/canary/alarm_control_panel.py +++ b/homeassistant/components/canary/alarm_control_panel.py @@ -24,10 +24,7 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Canary alarms.""" data = hass.data[DATA_CANARY] - devices = [] - - for location in data.locations: - devices.append(CanaryAlarm(data, location.location_id)) + devices = [CanaryAlarm(data, location.location_id) for location in data.locations] add_entities(devices, True) diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py index 870256ffcff..3ba7f094da1 100644 --- a/homeassistant/components/canary/camera.py +++ b/homeassistant/components/canary/camera.py @@ -29,6 +29,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Canary sensors.""" + if discovery_info is not None: + return + data = hass.data[DATA_CANARY] devices = [] From c03e3ce9616d5464b4e99127f072e136f6e8c807 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 5 May 2020 03:02:09 +0200 Subject: [PATCH 49/67] Fix UVC doing I/O inside the event loop (#35169) --- homeassistant/components/uvc/camera.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/uvc/camera.py b/homeassistant/components/uvc/camera.py index 05937cc3ee9..5878023bf2e 100644 --- a/homeassistant/components/uvc/camera.py +++ b/homeassistant/components/uvc/camera.py @@ -192,11 +192,7 @@ class UnifiVideoCamera(Camera): def set_motion_detection(self, mode): """Set motion detection on or off.""" - - if mode is True: - set_mode = "motion" - else: - set_mode = "none" + set_mode = "motion" if mode is True else "none" try: self._nvr.set_recordmode(self._uuid, set_mode) @@ -215,9 +211,7 @@ class UnifiVideoCamera(Camera): async def stream_source(self): """Return the source of the stream.""" - caminfo = self._nvr.get_camera(self._uuid) - channels = caminfo["channels"] - for channel in channels: + for channel in self._caminfo["channels"]: if channel["isRtspEnabled"]: return channel["rtspUris"][0] From 96576a97755d5b66f9c80864610a3c769d6704d2 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 5 May 2020 01:44:00 +0100 Subject: [PATCH 50/67] Fix utility_meter calibration with float values (#35186) --- homeassistant/components/utility_meter/sensor.py | 4 ++-- tests/components/utility_meter/test_sensor.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index c891c698cf6..c5b6e9a292c 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -95,7 +95,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= platform.async_register_entity_service( SERVICE_CALIBRATE_METER, - {vol.Required(ATTR_VALUE): vol.Coerce(float)}, + {vol.Required(ATTR_VALUE): vol.Coerce(Decimal)}, "async_calibrate", ) @@ -222,7 +222,7 @@ class UtilityMeterSensor(RestoreEntity): async def async_calibrate(self, value): """Calibrate the Utility Meter with a given value.""" _LOGGER.debug("Calibrate %s = %s", self._name, value) - self._state = Decimal(value) + self._state = value self.async_write_ha_state() async def async_added_to_hass(self): diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 6118d74d0dd..b4ee618b54f 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -119,6 +119,17 @@ async def test_state(hass): assert state is not None assert state.state == "100" + await hass.services.async_call( + DOMAIN, + SERVICE_CALIBRATE_METER, + {ATTR_ENTITY_ID: "sensor.energy_bill_midpeak", ATTR_VALUE: "0.123"}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.energy_bill_midpeak") + assert state is not None + assert state.state == "0.123" + async def test_net_consumption(hass): """Test utility sensor state.""" From 222a8bb7bed35a17c26800776d1cb8ae7930ee31 Mon Sep 17 00:00:00 2001 From: Quentame Date: Mon, 4 May 2020 23:19:08 +0200 Subject: [PATCH 51/67] Fix Synology DSM sensor to be False or 0 (#35208) --- homeassistant/components/synology_dsm/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index b6a88fe5a5a..8b5da35177e 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -152,7 +152,7 @@ class SynoNasUtilSensor(SynoNasSensor): attr = getattr(self._api.utilisation, self.sensor_type) if callable(attr): attr = attr() - if not attr: + if attr is None: return None # Data (RAM) @@ -173,7 +173,7 @@ class SynoNasStorageSensor(SynoNasSensor): def state(self): """Return the state.""" attr = getattr(self._api.storage, self.sensor_type)(self.monitored_device) - if not attr: + if attr is None: return None # Data (disk space) From d4200b5eb9c1c30d7568ea1cc83e6522bf9cea74 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 4 May 2020 18:03:16 -0700 Subject: [PATCH 52/67] Bumped version to 0.109.4 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 91d6fe39921..b4b4041cdda 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 109 -PATCH_VERSION = "3" +PATCH_VERSION = "4" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From 3f449e1d274c8e3876e237c4af71aa4051954f6d Mon Sep 17 00:00:00 2001 From: Xiaonan Shen Date: Mon, 4 May 2020 05:28:32 -0700 Subject: [PATCH 53/67] Remove `certificate` configuration variable from roomba (#35162) * Remove certificate option from roomba * Fix roomba test * Add back certificate with deprecated flag * Remove invalidation_version --- homeassistant/components/roomba/__init__.py | 22 ++++++++++--------- .../components/roomba/config_flow.py | 4 ---- homeassistant/components/roomba/manifest.json | 2 +- homeassistant/components/roomba/strings.json | 1 - requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/roomba/test_config_flow.py | 4 ---- 7 files changed, 15 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/roomba/__init__.py b/homeassistant/components/roomba/__init__.py index 28092f96477..a9efa1f24ab 100644 --- a/homeassistant/components/roomba/__init__.py +++ b/homeassistant/components/roomba/__init__.py @@ -40,15 +40,18 @@ def _has_all_unique_bilds(value): return value -DEVICE_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Required(CONF_BLID): str, - vol.Required(CONF_PASSWORD): str, - vol.Optional(CONF_CERT, default=DEFAULT_CERT): str, - vol.Optional(CONF_CONTINUOUS, default=DEFAULT_CONTINUOUS): bool, - vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): int, - }, +DEVICE_SCHEMA = vol.All( + cv.deprecated(CONF_CERT), + vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_BLID): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_CERT, default=DEFAULT_CERT): str, + vol.Optional(CONF_CONTINUOUS, default=DEFAULT_CONTINUOUS): bool, + vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): int, + }, + ), ) @@ -92,7 +95,6 @@ async def async_setup_entry(hass, config_entry): address=config_entry.data[CONF_HOST], blid=config_entry.data[CONF_BLID], password=config_entry.data[CONF_PASSWORD], - cert_name=config_entry.data[CONF_CERT], continuous=config_entry.options[CONF_CONTINUOUS], delay=config_entry.options[CONF_DELAY], ) diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index 3668984a41f..72ad6855d79 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -11,11 +11,9 @@ from homeassistant.core import callback from . import CannotConnect, async_connect_or_timeout, async_disconnect_or_timeout from .const import ( CONF_BLID, - CONF_CERT, CONF_CONTINUOUS, CONF_DELAY, CONF_NAME, - DEFAULT_CERT, DEFAULT_CONTINUOUS, DEFAULT_DELAY, ROOMBA_SESSION, @@ -27,7 +25,6 @@ DATA_SCHEMA = vol.Schema( vol.Required(CONF_HOST): str, vol.Required(CONF_BLID): str, vol.Required(CONF_PASSWORD): str, - vol.Optional(CONF_CERT, default=DEFAULT_CERT): str, vol.Optional(CONF_CONTINUOUS, default=DEFAULT_CONTINUOUS): bool, vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): int, } @@ -45,7 +42,6 @@ async def validate_input(hass: core.HomeAssistant, data): address=data[CONF_HOST], blid=data[CONF_BLID], password=data[CONF_PASSWORD], - cert_name=data[CONF_CERT], continuous=data[CONF_CONTINUOUS], delay=data[CONF_DELAY], ) diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index b9e521ffc12..45fe9133bca 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -3,7 +3,7 @@ "name": "iRobot Roomba", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/roomba", - "requirements": ["roombapy==1.5.2"], + "requirements": ["roombapy==1.5.3"], "dependencies": [], "codeowners": ["@pschmitt", "@cyr-ius", "@shenxn"] } diff --git a/homeassistant/components/roomba/strings.json b/homeassistant/components/roomba/strings.json index a679b2fdbb5..b813f35883b 100644 --- a/homeassistant/components/roomba/strings.json +++ b/homeassistant/components/roomba/strings.json @@ -8,7 +8,6 @@ "host": "Hostname or IP Address", "blid": "BLID", "password": "Password", - "certificate": "Certificate", "continuous": "Continuous", "delay": "Delay" } diff --git a/requirements_all.txt b/requirements_all.txt index ffe6f72c21f..34442550e13 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1825,7 +1825,7 @@ rocketchat-API==0.6.1 roku==4.1.0 # homeassistant.components.roomba -roombapy==1.5.2 +roombapy==1.5.3 # homeassistant.components.rova rova==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 593ee4ac55f..5770d0562df 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -701,7 +701,7 @@ ring_doorbell==0.6.0 roku==4.1.0 # homeassistant.components.roomba -roombapy==1.5.2 +roombapy==1.5.3 # homeassistant.components.yamaha rxv==0.6.0 diff --git a/tests/components/roomba/test_config_flow.py b/tests/components/roomba/test_config_flow.py index c9a0d8fde17..529b59dd8a6 100644 --- a/tests/components/roomba/test_config_flow.py +++ b/tests/components/roomba/test_config_flow.py @@ -5,7 +5,6 @@ from roomba import RoombaConnectionError from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components.roomba.const import ( CONF_BLID, - CONF_CERT, CONF_CONTINUOUS, CONF_DELAY, DOMAIN, @@ -20,7 +19,6 @@ VALID_YAML_CONFIG = { CONF_HOST: "1.2.3.4", CONF_BLID: "blid", CONF_PASSWORD: "password", - CONF_CERT: "/etc/ssl/certs/ca-certificates.crt", CONF_CONTINUOUS: True, CONF_DELAY: 1, } @@ -69,7 +67,6 @@ async def test_form(hass): assert result2["result"].unique_id == "blid" assert result2["data"] == { CONF_BLID: "blid", - CONF_CERT: "/etc/ssl/certs/ca-certificates.crt", CONF_CONTINUOUS: True, CONF_DELAY: 1, CONF_HOST: "1.2.3.4", @@ -131,7 +128,6 @@ async def test_form_import(hass): assert result["title"] == "imported_roomba" assert result["data"] == { CONF_BLID: "blid", - CONF_CERT: "/etc/ssl/certs/ca-certificates.crt", CONF_CONTINUOUS: True, CONF_DELAY: 1, CONF_HOST: "1.2.3.4", From f5282bfcf3d484f31d8a26255c19489aa01497f4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 4 May 2020 18:18:15 -0700 Subject: [PATCH 54/67] remove deprecation --- homeassistant/components/roomba/__init__.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/roomba/__init__.py b/homeassistant/components/roomba/__init__.py index a9efa1f24ab..9fb93211576 100644 --- a/homeassistant/components/roomba/__init__.py +++ b/homeassistant/components/roomba/__init__.py @@ -40,18 +40,15 @@ def _has_all_unique_bilds(value): return value -DEVICE_SCHEMA = vol.All( - cv.deprecated(CONF_CERT), - vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Required(CONF_BLID): str, - vol.Required(CONF_PASSWORD): str, - vol.Optional(CONF_CERT, default=DEFAULT_CERT): str, - vol.Optional(CONF_CONTINUOUS, default=DEFAULT_CONTINUOUS): bool, - vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): int, - }, - ), +DEVICE_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_BLID): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_CERT, default=DEFAULT_CERT): str, + vol.Optional(CONF_CONTINUOUS, default=DEFAULT_CONTINUOUS): bool, + vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): int, + }, ) From 36c3c624e7079ee698ea5ae97b2402d641b84345 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 6 May 2020 14:08:43 +0200 Subject: [PATCH 55/67] Add retry on empty modbus messages for serial protocol (#34678) --- homeassistant/components/modbus/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 34a396758cb..0a7ea08543a 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -188,6 +188,7 @@ class ModbusHub: bytesize=self._config_bytesize, parity=self._config_parity, timeout=self._config_timeout, + retry_on_empty=True, ) elif self._config_type == "rtuovertcp": self._client = ModbusTcpClient( From 4bf45cd2ea2b090c6247801af5ae5bbf193062b8 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 6 May 2020 14:07:09 +0200 Subject: [PATCH 56/67] Change Modbus switch to use verify_register when defined (#34679) --- homeassistant/components/modbus/switch.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py index 97a5d00a30f..0d5a32c45e0 100644 --- a/homeassistant/components/modbus/switch.py +++ b/homeassistant/components/modbus/switch.py @@ -273,10 +273,12 @@ class ModbusRegisterSwitch(ModbusCoilSwitch): def _read_register(self) -> Optional[int]: try: if self._register_type == CALL_TYPE_REGISTER_INPUT: - result = self._hub.read_input_registers(self._slave, self._register, 1) + result = self._hub.read_input_registers( + self._slave, self._verify_register, 1 + ) else: result = self._hub.read_holding_registers( - self._slave, self._register, 1 + self._slave, self._verify_register, 1 ) except ConnectionException: self._available = False From b42590c5a9e737f53e76d5f794108dbc048af5f7 Mon Sep 17 00:00:00 2001 From: escoand Date: Tue, 5 May 2020 02:50:59 +0200 Subject: [PATCH 57/67] Catch samsungtv timeout exception (#35205) --- homeassistant/components/samsungtv/bridge.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index a0f16e91cf5..472ce894e1a 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -259,4 +259,6 @@ class SamsungTVWSBridge(SamsungTVBridge): except ConnectionFailure: self._notify_callback() raise + except WebSocketException: + self._remote = None return self._remote From 1a52632b6844e4e07e4ca4de748b016e3dd00632 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 5 May 2020 13:56:32 -0600 Subject: [PATCH 58/67] Broader Notion exception handling (#35265) --- homeassistant/components/notion/__init__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index 5e8450ffdea..77ab68d8e70 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -196,7 +196,14 @@ class Notion: results = await asyncio.gather(*tasks.values(), return_exceptions=True) for attr, result in zip(tasks, results): if isinstance(result, NotionError): - _LOGGER.error("There was an error while updating %s: %s", attr, result) + _LOGGER.error( + "There was a Notion error while updating %s: %s", attr, result + ) + continue + if isinstance(result, Exception): + _LOGGER.error( + "There was an unknown error while updating %s: %s", attr, result + ) continue holding_pen = getattr(self, attr) From 651824947030fc9419f504b623d4709e2ee1c11c Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Thu, 7 May 2020 00:35:04 +0200 Subject: [PATCH 59/67] UniFi - Support SSID filter of SSIDs from access points with extra configuration (#35295) --- homeassistant/components/unifi/config_flow.py | 20 +++++++++---- tests/components/unifi/test_config_flow.py | 29 +++++++++++++++++-- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 38d37560952..f584a41ae54 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -199,12 +199,20 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): self.options.update(user_input) return await self.async_step_client_control() - ssids = list(self.controller.api.wlans) + [ - f"{wlan.name}{wlan.name_combine_suffix}" - for wlan in self.controller.api.wlans.values() - if not wlan.name_combine_enabled - ] - ssid_filter = {ssid: ssid for ssid in sorted(ssids)} + ssids = ( + set(self.controller.api.wlans) + | { + f"{wlan.name}{wlan.name_combine_suffix}" + for wlan in self.controller.api.wlans.values() + if not wlan.name_combine_enabled + } + | { + wlan["name"] + for ap in self.controller.api.devices.values() + for wlan in ap.raw.get("wlan_overrides", []) + } + ) + ssid_filter = {ssid: ssid for ssid in sorted(list(ssids))} return self.async_show_form( step_id="device_tracker", diff --git a/tests/components/unifi/test_config_flow.py b/tests/components/unifi/test_config_flow.py index 920f22a8054..1cebb605b5a 100644 --- a/tests/components/unifi/test_config_flow.py +++ b/tests/components/unifi/test_config_flow.py @@ -33,6 +33,29 @@ from tests.common import MockConfigEntry CLIENTS = [{"mac": "00:00:00:00:00:01"}] +DEVICES = [ + { + "board_rev": 21, + "device_id": "mock-id", + "ip": "10.0.1.1", + "last_seen": 0, + "mac": "00:00:00:00:01:01", + "model": "U7PG2", + "name": "access_point", + "state": 1, + "type": "uap", + "version": "4.0.80.10875", + "wlan_overrides": [ + { + "name": "SSID 3", + "radio": "na", + "radio_name": "wifi1", + "wlan_id": "012345678910111213141516", + }, + ], + } +] + WLANS = [ {"name": "SSID 1"}, {"name": "SSID 2", "name_combine_enabled": False, "name_combine_suffix": "_IOT"}, @@ -319,7 +342,7 @@ async def test_flow_fails_unknown_problem(hass, aioclient_mock): async def test_option_flow(hass): """Test config flow options.""" controller = await setup_unifi_integration( - hass, clients_response=CLIENTS, wlans_response=WLANS + hass, clients_response=CLIENTS, devices_response=DEVICES, wlans_response=WLANS ) result = await hass.config_entries.options.async_init( @@ -335,7 +358,7 @@ async def test_option_flow(hass): CONF_TRACK_CLIENTS: False, CONF_TRACK_WIRED_CLIENTS: False, CONF_TRACK_DEVICES: False, - CONF_SSID_FILTER: ["SSID 1", "SSID 2_IOT"], + CONF_SSID_FILTER: ["SSID 1", "SSID 2_IOT", "SSID 3"], CONF_DETECTION_TIME: 100, }, ) @@ -360,7 +383,7 @@ async def test_option_flow(hass): CONF_TRACK_CLIENTS: False, CONF_TRACK_WIRED_CLIENTS: False, CONF_TRACK_DEVICES: False, - CONF_SSID_FILTER: ["SSID 1", "SSID 2_IOT"], + CONF_SSID_FILTER: ["SSID 1", "SSID 2_IOT", "SSID 3"], CONF_DETECTION_TIME: 100, CONF_IGNORE_WIRED_BUG: False, CONF_POE_CLIENTS: False, From 7f1793e6a94f2955e44826be69b19cc320331519 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 6 May 2020 18:42:47 -0700 Subject: [PATCH 60/67] Fix SMS doing I/O in event loop (#35313) --- homeassistant/components/sms/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sms/__init__.py b/homeassistant/components/sms/__init__.py index 4897ef2844b..b8d46a4aec5 100644 --- a/homeassistant/components/sms/__init__.py +++ b/homeassistant/components/sms/__init__.py @@ -17,7 +17,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass, config): +def setup(hass, config): """Configure Gammu state machine.""" conf = config[DOMAIN] device = conf.get(CONF_DEVICE) From 00501019b480af03386880717730b58c33e270de Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 6 May 2020 18:44:00 -0700 Subject: [PATCH 61/67] Bumped version to 0.109.5 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index b4b4041cdda..6b33e58fe02 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 109 -PATCH_VERSION = "4" +PATCH_VERSION = "5" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From 39fefa49cc37749574042e342aebdbdf11975749 Mon Sep 17 00:00:00 2001 From: Xiaonan Shen Date: Thu, 7 May 2020 10:04:13 -0700 Subject: [PATCH 62/67] Fix roomba 980 position report (#35316) --- homeassistant/components/roomba/__init__.py | 1 - homeassistant/components/roomba/binary_sensor.py | 4 ++++ homeassistant/components/roomba/irobot_base.py | 13 ++++++++++++- homeassistant/components/roomba/sensor.py | 4 ++++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roomba/__init__.py b/homeassistant/components/roomba/__init__.py index 9fb93211576..90c7c06b387 100644 --- a/homeassistant/components/roomba/__init__.py +++ b/homeassistant/components/roomba/__init__.py @@ -95,7 +95,6 @@ async def async_setup_entry(hass, config_entry): continuous=config_entry.options[CONF_CONTINUOUS], delay=config_entry.options[CONF_DELAY], ) - roomba.exclude = "wifistat" # ignore wifistat to avoid unnecessary updates try: if not await async_connect_or_timeout(hass, roomba): diff --git a/homeassistant/components/roomba/binary_sensor.py b/homeassistant/components/roomba/binary_sensor.py index 47212563b5b..49e3da1d8de 100644 --- a/homeassistant/components/roomba/binary_sensor.py +++ b/homeassistant/components/roomba/binary_sensor.py @@ -45,3 +45,7 @@ class RoombaBinStatus(IRobotEntity, BinarySensorDevice): def state(self): """Return the state of the sensor.""" return roomba_reported_state(self.vacuum).get("bin", {}).get("full", False) + + def new_state_filter(self, new_state): + """Filter the new state.""" + return "bin" in new_state diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py index 4ebe580e7b2..2df5bf2abbf 100644 --- a/homeassistant/components/roomba/irobot_base.py +++ b/homeassistant/components/roomba/irobot_base.py @@ -102,9 +102,15 @@ class IRobotEntity(Entity): """Register callback function.""" self.vacuum.register_on_message_callback(self.on_message) + def new_state_filter(self, new_state): + """Filter the new state.""" + raise NotImplementedError + def on_message(self, json_data): """Update state on message change.""" - self.schedule_update_ha_state() + state = json_data.get("state", {}).get("reported", {}) + if self.new_state_filter(state): + self.schedule_update_ha_state() class IRobotVacuum(IRobotEntity, StateVacuumDevice): @@ -212,6 +218,11 @@ class IRobotVacuum(IRobotEntity, StateVacuumDevice): def on_message(self, json_data): """Update state on message change.""" + new_state = json_data.get("state", {}).get("reported", {}) + if ( + len(new_state) == 1 and "signal" in new_state + ): # filter out wifi stat messages + return _LOGGER.debug("Got new state from the vacuum: %s", json_data) self.vacuum_state = roomba_reported_state(self.vacuum) self.schedule_update_ha_state() diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index 1a10c429321..40dbd52e158 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -46,3 +46,7 @@ class RoombaBattery(IRobotEntity): def state(self): """Return the state of the sensor.""" return roomba_reported_state(self.vacuum).get("batPct") + + def new_state_filter(self, new_state): + """Filter the new state.""" + return "batPct" in new_state From efb04256025a5da59f4d0e0e6f2b849c222c7934 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 May 2020 12:32:29 -0500 Subject: [PATCH 63/67] Update pymyq to 2.0.2 (#35330) --- homeassistant/components/myq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/myq/manifest.json b/homeassistant/components/myq/manifest.json index 940c59c9979..953f7a31097 100644 --- a/homeassistant/components/myq/manifest.json +++ b/homeassistant/components/myq/manifest.json @@ -2,7 +2,7 @@ "domain": "myq", "name": "MyQ", "documentation": "https://www.home-assistant.io/integrations/myq", - "requirements": ["pymyq==2.0.1"], + "requirements": ["pymyq==2.0.2"], "codeowners": ["@bdraco"], "config_flow": true, "homekit": { diff --git a/requirements_all.txt b/requirements_all.txt index 34442550e13..804299d2e37 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1429,7 +1429,7 @@ pymsteams==0.1.12 pymusiccast==0.1.6 # homeassistant.components.myq -pymyq==2.0.1 +pymyq==2.0.2 # homeassistant.components.mysensors pymysensors==0.18.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5770d0562df..35f0535d77e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -579,7 +579,7 @@ pymodbus==2.3.0 pymonoprice==0.3 # homeassistant.components.myq -pymyq==2.0.1 +pymyq==2.0.2 # homeassistant.components.nut pynut2==2.1.2 From fc7851a98cb0cbf4e0b713360f2bea705d0d4d97 Mon Sep 17 00:00:00 2001 From: Quentame Date: Thu, 7 May 2020 19:31:35 +0200 Subject: [PATCH 64/67] Fix Synology DSM discovery can't be ignored (#35331) --- homeassistant/components/synology_dsm/config_flow.py | 6 +++++- tests/components/synology_dsm/test_config_flow.py | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index c3d15aff2fd..5d8e43ab175 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -164,11 +164,15 @@ class SynologyDSMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME].split("(", 1)[0].strip() ) + mac = discovery_info[ssdp.ATTR_UPNP_SERIAL].upper() # Synology NAS can broadcast on multiple IP addresses, since they can be connected to multiple ethernets. # The serial of the NAS is actually its MAC address. - if self._mac_already_configured(discovery_info[ssdp.ATTR_UPNP_SERIAL].upper()): + if self._mac_already_configured(mac): return self.async_abort(reason="already_configured") + await self.async_set_unique_id(mac) + self._abort_if_unique_id_configured() + self.discovered_conf = { CONF_NAME: friendly_name, CONF_HOST: parsed_url.hostname, diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index 795348d900c..51ee92b1865 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -383,6 +383,7 @@ async def test_form_ssdp(hass: HomeAssistantType, service: MagicMock): ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["result"].unique_id == SERIAL assert result["title"] == "192.168.1.5" assert result["data"][CONF_HOST] == "192.168.1.5" assert result["data"][CONF_PORT] == 5001 From f72690870727704a5bec3f579ba0214e9ffdfa6e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 7 May 2020 10:33:11 -0700 Subject: [PATCH 65/67] Bumped version to 0.109.6 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 6b33e58fe02..00b574f730e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 109 -PATCH_VERSION = "5" +PATCH_VERSION = "6" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From 00282dab7283571d40b05ade58c4e82b9f86b9d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Gabriel?= Date: Thu, 30 Apr 2020 17:04:53 -0300 Subject: [PATCH 66/67] Remove panasonic_viera from legacy discovery (#34909) --- homeassistant/components/discovery/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index b9b3f51f60d..227995db971 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -66,7 +66,6 @@ SERVICE_HANDLERS = { SERVICE_OCTOPRINT: ("octoprint", None), SERVICE_FREEBOX: ("freebox", None), SERVICE_YEELIGHT: ("yeelight", None), - "panasonic_viera": ("media_player", "panasonic_viera"), "yamaha": ("media_player", "yamaha"), "logitech_mediaserver": ("media_player", "squeezebox"), "denonavr": ("media_player", "denonavr"), From 97d66c4eb2f14cd3fb0dabe70f60ecfcd681a63d Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Wed, 6 May 2020 16:17:40 +0300 Subject: [PATCH 67/67] Fix Islamic prayer sensor timestamp format (#35243) --- homeassistant/components/islamic_prayer_times/__init__.py | 4 ++-- homeassistant/components/islamic_prayer_times/sensor.py | 7 ++++++- tests/components/islamic_prayer_times/__init__.py | 2 +- tests/components/islamic_prayer_times/test_sensor.py | 3 ++- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index fa676221ea3..90a31890d16 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -125,11 +125,11 @@ class IslamicPrayerClient: """ _LOGGER.debug("Scheduling next update for Islamic prayer times") - now = dt_util.as_local(dt_util.now()) + now = dt_util.utcnow() midnight_dt = self.prayer_times_info["Midnight"] - if now > dt_util.as_local(midnight_dt): + if now > dt_util.as_utc(midnight_dt): next_update_at = midnight_dt + timedelta(days=1, minutes=1) _LOGGER.debug( "Midnight is after day the changes so schedule update for after Midnight the next day" diff --git a/homeassistant/components/islamic_prayer_times/sensor.py b/homeassistant/components/islamic_prayer_times/sensor.py index 92a0a491d8d..e0c2cf16f68 100644 --- a/homeassistant/components/islamic_prayer_times/sensor.py +++ b/homeassistant/components/islamic_prayer_times/sensor.py @@ -4,6 +4,7 @@ import logging from homeassistant.const import DEVICE_CLASS_TIMESTAMP from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity +import homeassistant.util.dt as dt_util from .const import DATA_UPDATED, DOMAIN, PRAYER_TIMES_ICON, SENSOR_TYPES @@ -48,7 +49,11 @@ class IslamicPrayerTimeSensor(Entity): @property def state(self): """Return the state of the sensor.""" - return self.client.prayer_times_info.get(self.sensor_type).isoformat() + return ( + self.client.prayer_times_info.get(self.sensor_type) + .astimezone(dt_util.UTC) + .isoformat() + ) @property def should_poll(self): diff --git a/tests/components/islamic_prayer_times/__init__.py b/tests/components/islamic_prayer_times/__init__.py index db25428d17a..a8b68b98715 100644 --- a/tests/components/islamic_prayer_times/__init__.py +++ b/tests/components/islamic_prayer_times/__init__.py @@ -42,4 +42,4 @@ NEW_PRAYER_TIMES_TIMESTAMPS = { "Midnight": datetime(2020, 1, 1, 00, 43, 0), } -NOW = datetime(2020, 1, 1, 00, 00, 0) +NOW = datetime(2020, 1, 1, 00, 00, 0).astimezone() diff --git a/tests/components/islamic_prayer_times/test_sensor.py b/tests/components/islamic_prayer_times/test_sensor.py index 0579664ae7b..5250732ad94 100644 --- a/tests/components/islamic_prayer_times/test_sensor.py +++ b/tests/components/islamic_prayer_times/test_sensor.py @@ -2,6 +2,7 @@ from unittest.mock import patch from homeassistant.components import islamic_prayer_times +import homeassistant.util.dt as dt_util from . import NOW, PRAYER_TIMES, PRAYER_TIMES_TIMESTAMPS @@ -26,5 +27,5 @@ async def test_islamic_prayer_times_sensors(hass): hass.states.get( f"sensor.{prayer}_{islamic_prayer_times.const.SENSOR_TYPES[prayer]}" ).state - == PRAYER_TIMES_TIMESTAMPS[prayer].isoformat() + == PRAYER_TIMES_TIMESTAMPS[prayer].astimezone(dt_util.UTC).isoformat() )