mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 09:23:17 -04:00
Mark disconnected UniFi Protect sirens and relays unavailable (#182635)
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user