diff --git a/homeassistant/components/totalconnect/__init__.py b/homeassistant/components/totalconnect/__init__.py index 641c0c4477a..8a4aee0debb 100644 --- a/homeassistant/components/totalconnect/__init__.py +++ b/homeassistant/components/totalconnect/__init__.py @@ -4,7 +4,11 @@ from datetime import timedelta import logging from total_connect_client.client import TotalConnectClient -from total_connect_client.exceptions import AuthenticationError, TotalConnectError +from total_connect_client.exceptions import ( + AuthenticationError, + ServiceUnavailable, + TotalConnectError, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform @@ -40,7 +44,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: TotalConnectClient, username, password, usercodes ) except AuthenticationError as exception: - raise ConfigEntryAuthFailed("TotalConnect authentication failed") from exception + raise ConfigEntryAuthFailed( + "TotalConnect authentication failed during setup" + ) from exception coordinator = TotalConnectDataUpdateCoordinator(hass, client) await coordinator.async_config_entry_first_refresh() @@ -83,7 +89,12 @@ class TotalConnectDataUpdateCoordinator(DataUpdateCoordinator): except AuthenticationError as exception: # should only encounter if password changes during operation raise ConfigEntryAuthFailed( - "TotalConnect authentication failed" + "TotalConnect authentication failed during operation." + ) from exception + except ServiceUnavailable as exception: + raise UpdateFailed( + "Error connecting to TotalConnect or the service is unavailable. " + "Check https://status.resideo.com/ for outages." ) from exception except TotalConnectError as exception: raise UpdateFailed(exception) from exception diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index d64aab5317b..7582f3076c2 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -1,8 +1,6 @@ """Interfaces with TotalConnect alarm control panels.""" -import logging - from total_connect_client import ArmingHelper -from total_connect_client.exceptions import BadResultCodeError +from total_connect_client.exceptions import BadResultCodeError, UsercodeInvalid import homeassistant.components.alarm_control_panel as alarm from homeassistant.components.alarm_control_panel.const import ( @@ -29,8 +27,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - SERVICE_ALARM_ARM_AWAY_INSTANT = "arm_away_instant" SERVICE_ALARM_ARM_HOME_INSTANT = "arm_home_instant" @@ -172,84 +168,114 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): async def async_alarm_disarm(self, code=None): """Send disarm command.""" - await self.hass.async_add_executor_job(self._disarm) - await self.coordinator.async_request_refresh() - - def _disarm(self, code=None): - """Disarm synchronous.""" try: - ArmingHelper(self._partition).disarm() + await self.hass.async_add_executor_job(self._disarm) + except UsercodeInvalid as error: + self.coordinator.config_entry.async_start_reauth(self.hass) + raise HomeAssistantError( + "TotalConnect usercode is invalid. Did not disarm" + ) from error except BadResultCodeError as error: raise HomeAssistantError( f"TotalConnect failed to disarm {self._name}." ) from error + await self.coordinator.async_request_refresh() + + def _disarm(self, code=None): + """Disarm synchronous.""" + ArmingHelper(self._partition).disarm() async def async_alarm_arm_home(self, code=None): """Send arm home command.""" - await self.hass.async_add_executor_job(self._arm_home) - await self.coordinator.async_request_refresh() - - def _arm_home(self): - """Arm home synchronous.""" try: - ArmingHelper(self._partition).arm_stay() + await self.hass.async_add_executor_job(self._arm_home) + except UsercodeInvalid as error: + self.coordinator.config_entry.async_start_reauth(self.hass) + raise HomeAssistantError( + "TotalConnect usercode is invalid. Did not arm home" + ) from error except BadResultCodeError as error: raise HomeAssistantError( f"TotalConnect failed to arm home {self._name}." ) from error + await self.coordinator.async_request_refresh() + + def _arm_home(self): + """Arm home synchronous.""" + ArmingHelper(self._partition).arm_stay() async def async_alarm_arm_away(self, code=None): """Send arm away command.""" - await self.hass.async_add_executor_job(self._arm_away) - await self.coordinator.async_request_refresh() - - def _arm_away(self, code=None): - """Arm away synchronous.""" try: - ArmingHelper(self._partition).arm_away() + await self.hass.async_add_executor_job(self._arm_away) + except UsercodeInvalid as error: + self.coordinator.config_entry.async_start_reauth(self.hass) + raise HomeAssistantError( + "TotalConnect usercode is invalid. Did not arm away" + ) from error except BadResultCodeError as error: raise HomeAssistantError( f"TotalConnect failed to arm away {self._name}." ) from error + await self.coordinator.async_request_refresh() + + def _arm_away(self, code=None): + """Arm away synchronous.""" + ArmingHelper(self._partition).arm_away() async def async_alarm_arm_night(self, code=None): """Send arm night command.""" - await self.hass.async_add_executor_job(self._arm_night) - await self.coordinator.async_request_refresh() - - def _arm_night(self, code=None): - """Arm night synchronous.""" try: - ArmingHelper(self._partition).arm_stay_night() + await self.hass.async_add_executor_job(self._arm_night) + except UsercodeInvalid as error: + self.coordinator.config_entry.async_start_reauth(self.hass) + raise HomeAssistantError( + "TotalConnect usercode is invalid. Did not arm night" + ) from error except BadResultCodeError as error: raise HomeAssistantError( f"TotalConnect failed to arm night {self._name}." ) from error + await self.coordinator.async_request_refresh() + + def _arm_night(self, code=None): + """Arm night synchronous.""" + ArmingHelper(self._partition).arm_stay_night() async def async_alarm_arm_home_instant(self, code=None): """Send arm home instant command.""" - await self.hass.async_add_executor_job(self._arm_home_instant) - await self.coordinator.async_request_refresh() - - def _arm_home_instant(self): - """Arm home instant synchronous.""" try: - ArmingHelper(self._partition).arm_stay_instant() + await self.hass.async_add_executor_job(self._arm_home_instant) + except UsercodeInvalid as error: + self.coordinator.config_entry.async_start_reauth(self.hass) + raise HomeAssistantError( + "TotalConnect usercode is invalid. Did not arm home instant" + ) from error except BadResultCodeError as error: raise HomeAssistantError( f"TotalConnect failed to arm home instant {self._name}." ) from error + await self.coordinator.async_request_refresh() + + def _arm_home_instant(self): + """Arm home instant synchronous.""" + ArmingHelper(self._partition).arm_stay_instant() async def async_alarm_arm_away_instant(self, code=None): """Send arm away instant command.""" - await self.hass.async_add_executor_job(self._arm_away_instant) - await self.coordinator.async_request_refresh() - - def _arm_away_instant(self, code=None): - """Arm away instant synchronous.""" try: - ArmingHelper(self._partition).arm_away_instant() + await self.hass.async_add_executor_job(self._arm_away_instant) + except UsercodeInvalid as error: + self.coordinator.config_entry.async_start_reauth(self.hass) + raise HomeAssistantError( + "TotalConnect usercode is invalid. Did not arm away instant" + ) from error except BadResultCodeError as error: raise HomeAssistantError( f"TotalConnect failed to arm away instant {self._name}." ) from error + await self.coordinator.async_request_refresh() + + def _arm_away_instant(self, code=None): + """Arm away instant synchronous.""" + ArmingHelper(self._partition).arm_away_instant() diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index d2a77080672..bc62a5b17af 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -2,7 +2,7 @@ "domain": "totalconnect", "name": "Total Connect", "documentation": "https://www.home-assistant.io/integrations/totalconnect", - "requirements": ["total_connect_client==2022.2.1"], + "requirements": ["total_connect_client==2022.3"], "dependencies": [], "codeowners": ["@austinmroczek"], "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index 1638c2efba4..d4dfdb62adc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2306,7 +2306,7 @@ tololib==0.1.0b3 toonapi==0.2.1 # homeassistant.components.totalconnect -total_connect_client==2022.2.1 +total_connect_client==2022.3 # homeassistant.components.tplink_lte tp-connected==0.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7575061292a..68b08ccdc75 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1482,7 +1482,7 @@ tololib==0.1.0b3 toonapi==0.2.1 # homeassistant.components.totalconnect -total_connect_client==2022.2.1 +total_connect_client==2022.3 # homeassistant.components.transmission transmissionrpc==0.11 diff --git a/tests/components/totalconnect/test_alarm_control_panel.py b/tests/components/totalconnect/test_alarm_control_panel.py index c623b826459..3066dfff172 100644 --- a/tests/components/totalconnect/test_alarm_control_panel.py +++ b/tests/components/totalconnect/test_alarm_control_panel.py @@ -3,9 +3,10 @@ from datetime import timedelta from unittest.mock import patch import pytest +from total_connect_client.exceptions import ServiceUnavailable, TotalConnectError from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_DOMAIN -from homeassistant.components.totalconnect import DOMAIN +from homeassistant.components.totalconnect import DOMAIN, SCAN_INTERVAL from homeassistant.components.totalconnect.alarm_control_panel import ( SERVICE_ALARM_ARM_AWAY_INSTANT, SERVICE_ALARM_ARM_HOME_INSTANT, @@ -110,7 +111,7 @@ async def test_arm_home_success(hass: HomeAssistant) -> None: async def test_arm_home_failure(hass: HomeAssistant) -> None: """Test arm home method failure.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_FAILURE] + responses = [RESPONSE_DISARMED, RESPONSE_ARM_FAILURE, RESPONSE_USER_CODE_INVALID] with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: await setup_platform(hass, ALARM_DOMAIN) assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED @@ -125,6 +126,18 @@ async def test_arm_home_failure(hass: HomeAssistant) -> None: assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED assert mock_request.call_count == 2 + # usercode is invalid + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + ALARM_DOMAIN, SERVICE_ALARM_ARM_HOME, DATA, blocking=True + ) + await hass.async_block_till_done() + assert f"{err.value}" == "TotalConnect usercode is invalid. Did not arm home" + assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED + # should have started a re-auth flow + assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 + assert mock_request.call_count == 3 + async def test_arm_home_instant_success(hass: HomeAssistant) -> None: """Test arm home instant method success.""" @@ -148,7 +161,7 @@ async def test_arm_home_instant_success(hass: HomeAssistant) -> None: async def test_arm_home_instant_failure(hass: HomeAssistant) -> None: """Test arm home instant method failure.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_FAILURE] + responses = [RESPONSE_DISARMED, RESPONSE_ARM_FAILURE, RESPONSE_USER_CODE_INVALID] with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: await setup_platform(hass, ALARM_DOMAIN) assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED @@ -163,6 +176,21 @@ async def test_arm_home_instant_failure(hass: HomeAssistant) -> None: assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED assert mock_request.call_count == 2 + # usercode is invalid + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + DOMAIN, SERVICE_ALARM_ARM_HOME_INSTANT, DATA, blocking=True + ) + await hass.async_block_till_done() + assert ( + f"{err.value}" + == "TotalConnect usercode is invalid. Did not arm home instant" + ) + assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED + # should have started a re-auth flow + assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 + assert mock_request.call_count == 3 + async def test_arm_away_instant_success(hass: HomeAssistant) -> None: """Test arm home instant method success.""" @@ -186,7 +214,7 @@ async def test_arm_away_instant_success(hass: HomeAssistant) -> None: async def test_arm_away_instant_failure(hass: HomeAssistant) -> None: """Test arm home instant method failure.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_FAILURE] + responses = [RESPONSE_DISARMED, RESPONSE_ARM_FAILURE, RESPONSE_USER_CODE_INVALID] with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: await setup_platform(hass, ALARM_DOMAIN) assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED @@ -201,23 +229,20 @@ async def test_arm_away_instant_failure(hass: HomeAssistant) -> None: assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED assert mock_request.call_count == 2 - -async def test_arm_home_invalid_usercode(hass: HomeAssistant) -> None: - """Test arm home method with invalid usercode.""" - responses = [RESPONSE_DISARMED, RESPONSE_USER_CODE_INVALID] - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await setup_platform(hass, ALARM_DOMAIN) - assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED - assert mock_request.call_count == 1 - + # usercode is invalid with pytest.raises(HomeAssistantError) as err: await hass.services.async_call( - ALARM_DOMAIN, SERVICE_ALARM_ARM_HOME, DATA, blocking=True + DOMAIN, SERVICE_ALARM_ARM_AWAY_INSTANT, DATA, blocking=True ) await hass.async_block_till_done() - assert f"{err.value}" == "TotalConnect failed to arm home test." + assert ( + f"{err.value}" + == "TotalConnect usercode is invalid. Did not arm away instant" + ) assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED - assert mock_request.call_count == 2 + # should have started a re-auth flow + assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 + assert mock_request.call_count == 3 async def test_arm_away_success(hass: HomeAssistant) -> None: @@ -241,7 +266,7 @@ async def test_arm_away_success(hass: HomeAssistant) -> None: async def test_arm_away_failure(hass: HomeAssistant) -> None: """Test arm away method failure.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_FAILURE] + responses = [RESPONSE_DISARMED, RESPONSE_ARM_FAILURE, RESPONSE_USER_CODE_INVALID] with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: await setup_platform(hass, ALARM_DOMAIN) assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED @@ -256,6 +281,18 @@ async def test_arm_away_failure(hass: HomeAssistant) -> None: assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED assert mock_request.call_count == 2 + # usercode is invalid + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + ALARM_DOMAIN, SERVICE_ALARM_ARM_AWAY, DATA, blocking=True + ) + await hass.async_block_till_done() + assert f"{err.value}" == "TotalConnect usercode is invalid. Did not arm away" + assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED + # should have started a re-auth flow + assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 + assert mock_request.call_count == 3 + async def test_disarm_success(hass: HomeAssistant) -> None: """Test disarm method success.""" @@ -278,7 +315,11 @@ async def test_disarm_success(hass: HomeAssistant) -> None: async def test_disarm_failure(hass: HomeAssistant) -> None: """Test disarm method failure.""" - responses = [RESPONSE_ARMED_AWAY, RESPONSE_DISARM_FAILURE] + responses = [ + RESPONSE_ARMED_AWAY, + RESPONSE_DISARM_FAILURE, + RESPONSE_USER_CODE_INVALID, + ] with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: await setup_platform(hass, ALARM_DOMAIN) assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_AWAY @@ -293,23 +334,17 @@ async def test_disarm_failure(hass: HomeAssistant) -> None: assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_AWAY assert mock_request.call_count == 2 - -async def test_disarm_invalid_usercode(hass: HomeAssistant) -> None: - """Test disarm method failure.""" - responses = [RESPONSE_ARMED_AWAY, RESPONSE_USER_CODE_INVALID] - with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: - await setup_platform(hass, ALARM_DOMAIN) - assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_AWAY - assert mock_request.call_count == 1 - + # usercode is invalid with pytest.raises(HomeAssistantError) as err: await hass.services.async_call( ALARM_DOMAIN, SERVICE_ALARM_DISARM, DATA, blocking=True ) await hass.async_block_till_done() - assert f"{err.value}" == "TotalConnect failed to disarm test." + assert f"{err.value}" == "TotalConnect usercode is invalid. Did not disarm" assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_AWAY - assert mock_request.call_count == 2 + # should have started a re-auth flow + assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 + assert mock_request.call_count == 3 async def test_arm_night_success(hass: HomeAssistant) -> None: @@ -333,7 +368,7 @@ async def test_arm_night_success(hass: HomeAssistant) -> None: async def test_arm_night_failure(hass: HomeAssistant) -> None: """Test arm night method failure.""" - responses = [RESPONSE_DISARMED, RESPONSE_ARM_FAILURE] + responses = [RESPONSE_DISARMED, RESPONSE_ARM_FAILURE, RESPONSE_USER_CODE_INVALID] with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: await setup_platform(hass, ALARM_DOMAIN) assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED @@ -348,6 +383,18 @@ async def test_arm_night_failure(hass: HomeAssistant) -> None: assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED assert mock_request.call_count == 2 + # usercode is invalid + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + ALARM_DOMAIN, SERVICE_ALARM_ARM_NIGHT, DATA, blocking=True + ) + await hass.async_block_till_done() + assert f"{err.value}" == "TotalConnect usercode is invalid. Did not arm night" + assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED + # should have started a re-auth flow + assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 + assert mock_request.call_count == 3 + async def test_arming(hass: HomeAssistant) -> None: """Test arming.""" @@ -436,3 +483,50 @@ async def test_unknown(hass: HomeAssistant) -> None: await setup_platform(hass, ALARM_DOMAIN) assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE assert mock_request.call_count == 1 + + +async def test_other_update_failures(hass: HomeAssistant) -> None: + """Test other failures seen during updates.""" + responses = [ + RESPONSE_DISARMED, + ServiceUnavailable, + RESPONSE_DISARMED, + TotalConnectError, + RESPONSE_DISARMED, + ValueError, + ] + with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: + # first things work as planned + await setup_platform(hass, ALARM_DOMAIN) + assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED + assert mock_request.call_count == 1 + + # then an error: ServiceUnavailable --> UpdateFailed + async_fire_time_changed(hass, dt.utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE + assert mock_request.call_count == 2 + + # works again + async_fire_time_changed(hass, dt.utcnow() + SCAN_INTERVAL * 2) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED + assert mock_request.call_count == 3 + + # then an error: TotalConnectError --> UpdateFailed + async_fire_time_changed(hass, dt.utcnow() + SCAN_INTERVAL * 3) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE + assert mock_request.call_count == 4 + + # works again + async_fire_time_changed(hass, dt.utcnow() + SCAN_INTERVAL * 4) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED + assert mock_request.call_count == 5 + + # unknown TotalConnect status via ValueError + async_fire_time_changed(hass, dt.utcnow() + SCAN_INTERVAL * 5) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE + assert mock_request.call_count == 6