diff --git a/homeassistant/components/unifiprotect/alarm_control_panel.py b/homeassistant/components/unifiprotect/alarm_control_panel.py index b9316cc2669f..472aa43a4c75 100644 --- a/homeassistant/components/unifiprotect/alarm_control_panel.py +++ b/homeassistant/components/unifiprotect/alarm_control_panel.py @@ -107,8 +107,9 @@ class ProtectNVRAlarmControlPanel(ProtectNVREntity, AlarmControlPanelEntity): self._attr_alarm_state = None return # arm_mode is delivered over the public devices websocket, so - # availability tracks the public WS health (like relay/siren), not the - # private connection the base class would otherwise apply for the NVR. + # availability tracks the public WS health, not the private connection + # the base class would otherwise apply for the NVR. The NVR carries no + # device state, so there is nothing else to gate on. self._attr_available = self.data.last_public_update_success # Fall back to DISARMED for unknown future status values rather than # rendering the entity as ``unknown``. diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 8dd233c2184e..c00388d0f2c3 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -570,7 +570,10 @@ class ProtectFobEntity(Entity): ``ProtectApiClient.public_bootstrap.fobs`` and is refreshed over the public devices websocket, so it does not use the private-device machinery in :class:`BaseProtectEntity`. Availability follows the public websocket health - and the fob's presence in the bootstrap, mirroring the relay switch. + and the fob's presence in the bootstrap. Unlike every other public device it + deliberately ignores ``state``: Protect models a fob's reachability as + ``away_state``, which the status sensor surfaces, so gating on ``state`` + would take that sensor away exactly when it has something to report. Subclasses fed by the events websocket set ``_ufp_requires_events_ws`` so they also go unavailable when that stream drops. """ diff --git a/homeassistant/components/unifiprotect/siren.py b/homeassistant/components/unifiprotect/siren.py index 5b6cabfa928b..7f22fe2be410 100644 --- a/homeassistant/components/unifiprotect/siren.py +++ b/homeassistant/components/unifiprotect/siren.py @@ -4,7 +4,7 @@ from datetime import datetime import logging from typing import Any, override -from uiprotect.data import PublicDeviceModel, Siren, SirenDuration +from uiprotect.data import DeviceState, PublicDeviceModel, Siren, SirenDuration from homeassistant.components.siren import ( ATTR_DURATION, @@ -102,7 +102,11 @@ class ProtectSiren(SirenEntity): @callback def _update_from_siren(self, siren: Siren) -> None: """Refresh cached attributes from the siren object.""" - self._attr_available = self.data.last_public_update_success + # A siren that dropped off the console stays in the bootstrap. + self._attr_available = ( + self.data.last_public_update_success + and siren.state is DeviceState.CONNECTED + ) self._attr_is_on = siren.is_active @callback diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index f35470a5d873..0fc189d473c9 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -7,6 +7,7 @@ from typing import Any, Literal, override from uiprotect.data import ( Camera, + DeviceState, ModelType, ProtectAdoptableDeviceModel, PublicDeviceModel, @@ -660,7 +661,11 @@ class ProtectRelayOutputSwitch(SwitchEntity): self._attr_available = False self._attr_is_on = None return - self._attr_available = self.data.last_public_update_success + # A relay that dropped off the console stays in the bootstrap. + self._attr_available = ( + self.data.last_public_update_success + and relay.state is DeviceState.CONNECTED + ) self._attr_is_on = ( _RELAY_STATE_MAP.get(output.state) if output.state is not None else None ) diff --git a/tests/components/unifiprotect/test_relay.py b/tests/components/unifiprotect/test_relay.py index b4107c90e607..4d6c42c1a9bf 100644 --- a/tests/components/unifiprotect/test_relay.py +++ b/tests/components/unifiprotect/test_relay.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, Mock import pytest from uiprotect.data import ( + DeviceState, ModelType, PublicRelayOutput, Relay, @@ -62,6 +63,7 @@ def _make_output( def _make_relay( *, outputs: list[Mock] | None = None, + state: DeviceState = DeviceState.CONNECTED, ) -> Mock: """Build a mock :class:`Relay` whose ``activate_output`` is awaitable.""" relay = Mock(spec=Relay) @@ -69,6 +71,7 @@ def _make_relay( relay.mac = RELAY_MAC relay.name = RELAY_NAME relay.model = ModelType.RELAY + relay.state = state relay.outputs = outputs if outputs is not None else [_make_output()] def get_output(output_id: int) -> Mock | None: @@ -389,6 +392,47 @@ async def test_relay_switch_becomes_unavailable_when_relay_removed( assert state.state == STATE_UNAVAILABLE +@pytest.mark.parametrize( + "state", + [DeviceState.DISCONNECTED, DeviceState.CONNECTING, DeviceState.UNKNOWN], +) +async def test_relay_switch_unavailable_when_not_connected_at_setup( + hass: HomeAssistant, + ufp: MockUFPFixture, + state: DeviceState, +) -> None: + """A relay that is not connected at setup starts out unavailable.""" + ufp.api.has_public_bootstrap = True + ufp.api.public_bootstrap = _make_public_bootstrap(_make_relay(state=state)) + + await init_entry(hass, ufp, []) + + assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_UNAVAILABLE + + +async def test_relay_switch_unavailable_when_disconnected( + hass: HomeAssistant, + ufp_with_relay: tuple[MockUFPFixture, Mock], +) -> None: + """A relay that drops off the console is unavailable, and recovers.""" + ufp, relay = ufp_with_relay + relay.outputs[0].state = RelayOutputState.ON + await init_entry(hass, ufp, []) + assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_ON + + relay.state = DeviceState.DISCONNECTED + ufp.devices_ws_subscription(public_device_ws_message(relay)) + await hass.async_block_till_done() + + assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_UNAVAILABLE + + relay.state = DeviceState.CONNECTED + ufp.devices_ws_subscription(public_device_ws_message(relay)) + await hass.async_block_till_done() + + assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_ON + + async def test_relay_switch_availability_follows_websocket_state( hass: HomeAssistant, ufp_with_relay: tuple[MockUFPFixture, Mock], diff --git a/tests/components/unifiprotect/test_siren.py b/tests/components/unifiprotect/test_siren.py index dc16ef132eef..55c8b9e5af0c 100644 --- a/tests/components/unifiprotect/test_siren.py +++ b/tests/components/unifiprotect/test_siren.py @@ -4,7 +4,14 @@ from datetime import timedelta from unittest.mock import AsyncMock, Mock import pytest -from uiprotect.data import ModelType, PublicSirenStatus, Siren, SirenDuration, WSAction +from uiprotect.data import ( + DeviceState, + ModelType, + PublicSirenStatus, + Siren, + SirenDuration, + WSAction, +) from uiprotect.exceptions import ClientError, NotAuthorized from uiprotect.websocket import WebsocketState @@ -44,7 +51,9 @@ SIREN_NAME = "Garage Siren" SIREN_ENTITY_ID = "siren.garage_siren" -def _make_siren(*, is_active: bool = False) -> Mock: +def _make_siren( + *, is_active: bool = False, state: DeviceState = DeviceState.CONNECTED +) -> Mock: """Build a mock :class:`Siren`.""" status = Mock(spec=PublicSirenStatus) status.is_active = is_active @@ -56,6 +65,7 @@ def _make_siren(*, is_active: bool = False) -> Mock: siren.mac = SIREN_MAC siren.name = SIREN_NAME siren.model = ModelType.SIREN + siren.state = state siren.volume = 50 siren.siren_status = status siren.is_active = is_active @@ -599,6 +609,46 @@ async def test_siren_auto_off_when_already_expired_at_update( assert state.state == STATE_OFF +@pytest.mark.parametrize( + "state", + [DeviceState.DISCONNECTED, DeviceState.CONNECTING, DeviceState.UNKNOWN], +) +async def test_siren_unavailable_when_not_connected_at_setup( + hass: HomeAssistant, + ufp: MockUFPFixture, + state: DeviceState, +) -> None: + """A siren that is not connected at setup starts out unavailable.""" + ufp.api.has_public_bootstrap = True + ufp.api.public_bootstrap = _make_public_bootstrap(_make_siren(state=state)) + + await init_entry(hass, ufp, []) + + assert hass.states.get(SIREN_ENTITY_ID).state == STATE_UNAVAILABLE + + +async def test_siren_unavailable_when_disconnected( + hass: HomeAssistant, + ufp_with_siren: MockUFPFixture, + siren: Mock, +) -> None: + """A siren that drops off the console is unavailable, and recovers.""" + await init_entry(hass, ufp_with_siren, []) + assert hass.states.get(SIREN_ENTITY_ID).state == STATE_OFF + + siren.state = DeviceState.DISCONNECTED + ufp_with_siren.devices_ws_subscription(_make_ws_msg(siren)) + await hass.async_block_till_done() + + assert hass.states.get(SIREN_ENTITY_ID).state == STATE_UNAVAILABLE + + siren.state = DeviceState.CONNECTED + ufp_with_siren.devices_ws_subscription(_make_ws_msg(siren)) + await hass.async_block_till_done() + + assert hass.states.get(SIREN_ENTITY_ID).state == STATE_OFF + + async def test_siren_unavailable_on_delete_event( hass: HomeAssistant, ufp_with_siren: MockUFPFixture,