diff --git a/homeassistant/components/myuplink/manifest.json b/homeassistant/components/myuplink/manifest.json index d3242115acb518..338bb07626dd3d 100644 --- a/homeassistant/components/myuplink/manifest.json +++ b/homeassistant/components/myuplink/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/myuplink", + "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "silver", "requirements": ["myuplink==0.7.0"] diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json index e907a5fd6dbd4a..a8d84c8d6a252c 100644 --- a/homeassistant/components/portainer/manifest.json +++ b/homeassistant/components/portainer/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/portainer", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["pyportainer==1.0.3"] + "requirements": ["pyportainer==1.0.4"] } diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index e36208dfee1104..192a52f925d4a0 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -418,14 +418,36 @@ async def refresh_coordinator_map(self) -> None: # If we don't have a cur map(shouldn't happen) just # return as we can't do anything. return - map_flags = sorted(self.maps, key=lambda data: data == cur_map, reverse=True) + if self.data.status.in_cleaning: + # If the vacuum is cleaning, we cannot change maps + # as it will interrupt the cleaning. + _LOGGER.info( + "Vacuum is cleaning, not switching to other maps to fetch rooms" + ) + # Since this is hitting the cloud api, we want to be careful and will just + # stop here rather than retrying in the future. + map_flags = [cur_map] + else: + map_flags = sorted( + self.maps, key=lambda data: data == cur_map, reverse=True + ) for map_flag in map_flags: if map_flag != cur_map: # Only change the map and sleep if we have multiple maps. - await self.cloud_api.load_multi_map(map_flag) - self.current_map = map_flag + try: + await self.cloud_api.load_multi_map(map_flag) + except RoborockException as ex: + _LOGGER.debug( + "Failed to change to map %s when refreshing maps: %s", + map_flag, + ex, + ) + continue + else: + self.current_map = map_flag # We cannot get the map until the roborock servers fully process the - # map change. + # map change. If the above command fails, we should still sleep, just + # in case it executes delayed. await asyncio.sleep(MAP_SLEEP) tasks = [self.set_current_map_rooms()] # The image is set within async_setup, so if it exists, we have it here. @@ -436,11 +458,18 @@ async def refresh_coordinator_map(self) -> None: # If either of these fail, we don't care, and we want to continue. await asyncio.gather(*tasks, return_exceptions=True) - if len(self.maps) > 1: + if len(self.maps) > 1 and not self.data.status.in_cleaning: # Set the map back to the map the user previously had selected so that it # does not change the end user's app. # Only needs to happen when we changed maps above. - await self.cloud_api.load_multi_map(cur_map) + try: + await self.cloud_api.load_multi_map(cur_map) + except RoborockException as ex: + _LOGGER.warning( + "Failed to change back to map %s when refreshing maps: %s", + cur_map, + ex, + ) self.current_map = cur_map diff --git a/homeassistant/components/thethingsnetwork/config_flow.py b/homeassistant/components/thethingsnetwork/config_flow.py index 412c5da4ef9f6c..3d1952068eab40 100644 --- a/homeassistant/components/thethingsnetwork/config_flow.py +++ b/homeassistant/components/thethingsnetwork/config_flow.py @@ -82,7 +82,14 @@ async def async_step_user( ), user_input, ) - return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + return self.async_show_form( + step_id="user", + data_schema=schema, + errors=errors, + description_placeholders={ + "instructions_url": "https://www.thethingsindustries.com/docs/integrations/adding-applications/" + }, + ) async def async_step_reauth( self, entry_data: Mapping[str, Any] diff --git a/homeassistant/components/thethingsnetwork/strings.json b/homeassistant/components/thethingsnetwork/strings.json index 8b3eb7b53c4c35..4caf4188165931 100644 --- a/homeassistant/components/thethingsnetwork/strings.json +++ b/homeassistant/components/thethingsnetwork/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Connect to The Things Network v3", - "description": "Enter the API hostname, application ID and API key to use with Home Assistant.\n\n[Read the instructions](https://www.thethingsindustries.com/docs/integrations/adding-applications/) on how to register your application and create an API key.", + "description": "Enter the API hostname, application ID and API key to use with Home Assistant.\n\n[Read the instructions]({instructions_url}) on how to register your application and create an API key.", "data": { "host": "[%key:common::config_flow::data::host%]", "app_id": "Application ID", diff --git a/requirements_all.txt b/requirements_all.txt index 55ceebaa41f27b..fcd51da3f48f54 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2302,7 +2302,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.3 +pyportainer==1.0.4 # homeassistant.components.probe_plus pyprobeplus==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b969066b7c7694..605a2745bd735b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1929,7 +1929,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.3 +pyportainer==1.0.4 # homeassistant.components.probe_plus pyprobeplus==1.1.1 diff --git a/tests/components/roborock/test_coordinator.py b/tests/components/roborock/test_coordinator.py index 77b5f9bfa29e30..cf2ead85cb2f25 100644 --- a/tests/components/roborock/test_coordinator.py +++ b/tests/components/roborock/test_coordinator.py @@ -1,5 +1,6 @@ """Test Roborock Coordinator specific logic.""" +import asyncio import copy from datetime import timedelta from unittest.mock import patch @@ -11,13 +12,15 @@ from homeassistant.components.roborock.const import ( CONF_SHOW_BACKGROUND, + DOMAIN, + GET_MAPS_SERVICE_NAME, V1_CLOUD_IN_CLEANING_INTERVAL, V1_CLOUD_NOT_CLEANING_INTERVAL, V1_LOCAL_IN_CLEANING_INTERVAL, V1_LOCAL_NOT_CLEANING_INTERVAL, ) from homeassistant.components.roborock.coordinator import RoborockDataUpdateCoordinator -from homeassistant.const import Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -29,7 +32,7 @@ @pytest.fixture def platforms() -> list[Platform]: """Fixture to set platforms used in the test.""" - return [Platform.SENSOR] + return [Platform.SENSOR, Platform.VACUUM] @pytest.mark.parametrize( @@ -166,3 +169,112 @@ async def test_no_maps( ): await hass.config_entries.async_setup(mock_roborock_entry.entry_id) assert load_map.call_count == 0 + + +async def test_two_maps_in_cleaning( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that we gracefully handle having two maps but we are in cleaning.""" + prop = copy.deepcopy(PROP) + prop.status.in_cleaning = True + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", + return_value=prop, + ), + patch( + "homeassistant.components.roborock.RoborockMqttClientV1.load_multi_map" + ) as load_map, + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + # We should not try to load any maps as we should just get the information for our + # current map and move on. + assert load_map.call_count == 0 + assert ( + "Vacuum is cleaning, not switching to other maps to fetch rooms" in caplog.text + ) + + +async def test_failed_load_multi_map( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that we gracefully handle one map failing to load.""" + with ( + patch( + "homeassistant.components.roborock.RoborockMqttClientV1.load_multi_map", + side_effect=[RoborockException(), None, None, None], + ) as load_map, + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert "Failed to change to map 1 when refreshing maps" in caplog.text + # We continue to try and load the next map so we we should have multiple load maps. + # 2 for both devices, even though one for one of the devices failed. + assert load_map.call_count == 4 + # Just to be safe since we load the maps asynchronously, lets make sure that only + # one map out of the four didn't get called. + responses = await asyncio.gather( + *( + hass.services.async_call( + DOMAIN, + GET_MAPS_SERVICE_NAME, + {ATTR_ENTITY_ID: dev}, + blocking=True, + return_response=True, + ) + for dev in ("vacuum.roborock_s7_maxv", "vacuum.roborock_s7_2") + ) + ) + num_no_rooms = sum( + 1 + for res in responses + for data in res.values() + for m in data["maps"] + if not m["rooms"] + ) + assert num_no_rooms == 1 + + +async def test_failed_reset_map( + hass: HomeAssistant, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that we gracefully handle not being able to revert back to the original map.""" + with ( + patch( + "homeassistant.components.roborock.RoborockMqttClientV1.load_multi_map", + side_effect=[None, None, None, RoborockException()], + ) as load_map, + ): + await hass.config_entries.async_setup(mock_roborock_entry.entry_id) + assert "Failed to change back to map 0 when refreshing maps" in caplog.text + # 2 for both devices, even though one for one of the devices failed. + assert load_map.call_count == 4 + responses = await asyncio.gather( + *( + hass.services.async_call( + DOMAIN, + GET_MAPS_SERVICE_NAME, + {ATTR_ENTITY_ID: dev}, + blocking=True, + return_response=True, + ) + for dev in ("vacuum.roborock_s7_maxv", "vacuum.roborock_s7_2") + ) + ) + num_no_rooms = sum( + 1 + for res in responses + for data in res.values() + for m in data["maps"] + if not m["rooms"] + ) + # No maps should be missing information, as we just couldn't go back to the original. + assert num_no_rooms == 0