Mark disconnected UniFi Protect sirens and relays unavailable (#182635)

This commit is contained in:
Raphael Hehl
2026-09-19 07:14:28 +02:00
committed by GitHub
parent 4fdcc2a3f5
commit 69a5efb37b
6 changed files with 115 additions and 8 deletions
@@ -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``.
@@ -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.
"""
@@ -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
@@ -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
)
@@ -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],
+52 -2
View File
@@ -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,