Improve totalconnect error handling (#68716)

This commit is contained in:
Austin Mroczek 2022-04-03 06:12:33 -07:00 committed by GitHub
parent 5b874ce6e8
commit e5fe18bdb1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 208 additions and 77 deletions

View File

@ -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

View File

@ -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()

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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