diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index f22cfc24d7eb..8fd6793445f0 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -156,6 +156,9 @@ "api_timeout": { "message": "Timeout communicating with Ring API" }, + "chime_type_unknown": { + "message": "The in-home chime type changed and is no longer supported. Reload the Ring integration." + }, "no_subscription": { "message": "Ring Protect subscription required for snapshots" }, diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index a5b95fe4d4d6..35b276602360 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -11,10 +11,12 @@ from ring_doorbell.const import DOORBELL_EXISTING_TYPE from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import RingConfigEntry +from .const import DOMAIN from .coordinator import RingDataCoordinator from .entity import ( DeprecatedInfo, @@ -34,6 +36,40 @@ PARALLEL_UPDATES = 1 IN_HOME_CHIME_IS_PRESENT = {v for k, v in DOORBELL_EXISTING_TYPE.items() if k != 2} +def _in_home_chime_exists(device: RingDoorBell) -> bool: + """Return True if the doorbell has an in-home chime.""" + if device.family != "doorbots": + return False + try: + return device.existing_doorbell_type in IN_HOME_CHIME_IS_PRESENT + except KeyError as ex: + _LOGGER.debug( + "Unknown doorbell chime type %s; skipping in-home chime for %s", + ex, + device.device_api_id, + ) + return False + + +def _in_home_chime_is_on(device: RingDoorBell) -> bool | None: + """Return if the in-home chime is enabled; None when the chime type is unknown.""" + try: + return device.existing_doorbell_type_enabled or False + except KeyError: + return None + + +async def _async_set_in_home_chime_enabled(device: RingDoorBell, enabled: bool) -> None: + """Enable or disable the in-home chime.""" + try: + await device.async_set_existing_doorbell_type_enabled(enabled) + except KeyError as ex: + _LOGGER.debug("In-home chime type unknown for %s", device.device_api_id) + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="chime_type_unknown" + ) from ex + + @dataclass(frozen=True, kw_only=True) class RingSwitchEntityDescription( SwitchEntityDescription, @@ -46,7 +82,7 @@ class RingSwitchEntityDescription( unique_id_fn: Callable[[Self, RingDeviceT], str] = lambda self, device: ( f"{device.device_api_id}-{self.key}" ) - is_on_fn: Callable[[RingDeviceT], bool] + is_on_fn: Callable[[RingDeviceT], bool | None] turn_on_fn: Callable[[RingDeviceT], Coroutine[Any, Any, None]] turn_off_fn: Callable[[RingDeviceT], Coroutine[Any, Any, None]] @@ -66,15 +102,10 @@ SWITCHES: Sequence[RingSwitchEntityDescription[Any]] = ( RingSwitchEntityDescription[RingDoorBell]( key="in_home_chime", translation_key="in_home_chime", - exists_fn=lambda device: ( - device.family == "doorbots" - and device.existing_doorbell_type in IN_HOME_CHIME_IS_PRESENT - ), - is_on_fn=lambda device: device.existing_doorbell_type_enabled or False, - turn_on_fn=lambda device: device.async_set_existing_doorbell_type_enabled(True), - turn_off_fn=lambda device: device.async_set_existing_doorbell_type_enabled( - False - ), + exists_fn=_in_home_chime_exists, + is_on_fn=_in_home_chime_is_on, + turn_on_fn=lambda device: _async_set_in_home_chime_enabled(device, True), + turn_off_fn=lambda device: _async_set_in_home_chime_enabled(device, False), ), RingSwitchEntityDescription[RingDoorBell]( key="motion_detection", diff --git a/tests/components/ring/test_switch.py b/tests/components/ring/test_switch.py index 22b90253c237..71bbb45245f4 100644 --- a/tests/components/ring/test_switch.py +++ b/tests/components/ring/test_switch.py @@ -1,9 +1,11 @@ """The tests for the Ring switch platform.""" +from typing import Any from unittest.mock import Mock import pytest import ring_doorbell +from ring_doorbell import RingCapability from syrupy.assertion import SnapshotAssertion from homeassistant.components.ring.const import DOMAIN @@ -15,6 +17,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNKNOWN, Platform, ) from homeassistant.core import HomeAssistant @@ -26,6 +29,50 @@ from .common import MockConfigEntry, setup_platform from tests.common import snapshot_platform +class BadChimeDoorbell: + """Doorbell whose chime type is unknown to ring_doorbell.""" + + family = "doorbots" + id = 987654321 + device_api_id = 987654321 + device_id = "aa:bb:cc:dd:ee:ff" + name = "Bad Chime Doorbell" + + @property + def existing_doorbell_type(self) -> str: + """Mimic ring_doorbell raising for an unknown chime type.""" + raise KeyError(3) + + def has_capability(self, capability: RingCapability) -> bool: + """Return False for all capabilities.""" + return False + + +class ChimeCapabilityLostDoorbell: + """Doorbell whose chime enabled-state read raises after setup.""" + + family = "doorbots" + id = 987654322 + device_api_id = 987654322 + device_id = "aa:bb:cc:dd:ee:00" + name = "Chime Capability Lost Doorbell" + model = "doorbots" + existing_doorbell_type = "Mechanical" + + @property + def existing_doorbell_type_enabled(self) -> bool: + """Mimic ring_doorbell raising when the chime type becomes unknown.""" + raise KeyError(3) + + async def async_set_existing_doorbell_type_enabled(self, value: bool) -> None: + """Mimic ring_doorbell raising when the chime type becomes unknown.""" + raise KeyError(3) + + def has_capability(self, capability: RingCapability) -> bool: + """Return False for all capabilities.""" + return False + + @pytest.fixture def create_deprecated_siren_entity( hass: HomeAssistant, @@ -169,3 +216,56 @@ async def test_switch_errors_when_turned_on( ) == reauth_expected ) + + +@pytest.mark.usefixtures("mock_ring_client", "create_deprecated_siren_entity") +async def test_switch_setup_succeeds_with_unknown_chime_type( + hass: HomeAssistant, mock_ring_devices: Any +) -> None: + """Test that an unknown doorbell chime type does not abort switch setup.""" + mock_ring_devices.all_devices.append(BadChimeDoorbell()) + + await setup_platform(hass, Platform.SWITCH) + + assert hass.states.get("switch.front_siren") + assert hass.states.get("switch.front_motion_detection") + assert not hass.states.get("switch.bad_chime_doorbell_in_home_chime") + + +@pytest.mark.usefixtures("mock_ring_client", "create_deprecated_siren_entity") +async def test_in_home_chime_unknown_when_type_becomes_unreadable( + hass: HomeAssistant, mock_ring_devices: Any +) -> None: + """Test that an in-home chime switch reads as unknown when its type becomes unreadable.""" + mock_ring_devices.all_devices.append(ChimeCapabilityLostDoorbell()) + + await setup_platform(hass, Platform.SWITCH) + + state = hass.states.get("switch.chime_capability_lost_doorbell_in_home_chime") + assert state + assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("mock_ring_client", "create_deprecated_siren_entity") +async def test_in_home_chime_toggle_errors_when_type_unreadable( + hass: HomeAssistant, mock_ring_devices: Any +) -> None: + """Test that toggling raises a translated error when the chime type becomes unreadable.""" + mock_ring_devices.all_devices.append(ChimeCapabilityLostDoorbell()) + + await setup_platform(hass, Platform.SWITCH) + + state = hass.states.get("switch.chime_capability_lost_doorbell_in_home_chime") + assert state + assert state.state == STATE_UNKNOWN + + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {"entity_id": state.entity_id}, + blocking=True, + ) + assert err.value.translation_key == "chime_type_unknown" + assert err.value.translation_domain == DOMAIN + await hass.async_block_till_done()