1
0
mirror of https://github.com/home-assistant/core.git synced 2026-07-10 16:19:29 +01:00

Add UniFi Protect relay output switches via public API (#169201)

Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
This commit is contained in:
Raphael Hehl
2026-04-28 18:34:58 +02:00
committed by GitHub
parent de4e1c444e
commit 30f310fc24
6 changed files with 642 additions and 6 deletions
@@ -52,9 +52,9 @@ DEVICES_THAT_ADOPT = {
DEVICES_WITH_ENTITIES = DEVICES_THAT_ADOPT | {ModelType.NVR}
DEVICES_FOR_SUBSCRIBE = DEVICES_WITH_ENTITIES | {ModelType.EVENT}
# Public API devices WebSocket: NVR (for arm_mode updates) and Siren
# (for siren active-state updates).
DEVICES_WS_SUBSCRIBED_MODELS = {ModelType.NVR, ModelType.SIREN}
# Public API devices WebSocket: NVR (for arm_mode updates), Relay
# (for relay output state updates), and Siren (for siren active-state updates).
DEVICES_WS_SUBSCRIBED_MODELS = {ModelType.NVR, ModelType.RELAY, ModelType.SIREN}
MIN_REQUIRED_PROTECT_V = Version("6.0.0")
OUTDATED_LOG_MESSAGE = (
+37 -2
View File
@@ -19,6 +19,7 @@ from uiprotect.data import (
ModelType,
ProtectAdoptableDeviceModel,
PTZPatrol,
Relay,
Siren,
WSSubscriptionMessage,
)
@@ -84,6 +85,9 @@ class ProtectData:
self._subscriptions: defaultdict[
str, set[Callable[[ProtectDeviceType], None]]
] = defaultdict(set)
self._relay_subscriptions: defaultdict[str, set[Callable[[Relay], None]]] = (
defaultdict(set)
)
self._siren_subscriptions: defaultdict[str, set[Callable[[Siren], None]]] = (
defaultdict(set)
)
@@ -184,8 +188,10 @@ class ProtectData:
The API client pre-filters messages to the model types listed in
DEVICES_WS_SUBSCRIBED_MODELS. NVR messages signal the private NVR so
alarm entities pick up the new arm state. Siren messages dispatch
the merged Siren object by mac so siren entities can refresh.
alarm entities pick up the new arm state. Relay messages dispatch
the merged Relay object by mac so relay-output entities can refresh.
Siren messages dispatch the merged Siren object by mac so siren entities
can refresh.
"""
new_obj = message.new_obj
if new_obj is None:
@@ -197,6 +203,14 @@ class ProtectData:
if new_obj.model is ModelType.NVR:
self._async_signal_device_update(self.api.bootstrap.nvr)
return
if new_obj.model is ModelType.RELAY:
relay = cast(Relay, new_obj)
mac = relay.mac
if subscriptions := self._relay_subscriptions.get(mac):
_LOGGER.debug("Updating relay: %s (%s)", relay.name, mac)
for update_callback in subscriptions:
update_callback(relay)
return
if new_obj.model is ModelType.SIREN:
self._async_signal_siren_update(cast(Siren, new_obj))
@@ -372,6 +386,10 @@ class ProtectData:
for device in self.get_by_types(DEVICES_THAT_ADOPT):
self._async_signal_device_update(device)
if self.api.has_public_bootstrap:
for relay in self.api.public_bootstrap.relays.values():
if subscriptions := self._relay_subscriptions.get(relay.mac):
for subscription_callback in subscriptions:
subscription_callback(relay)
for siren in self.api.public_bootstrap.sirens.values():
self._async_signal_siren_update(siren)
@@ -402,6 +420,23 @@ class ProtectData:
if not self._subscriptions[mac]:
del self._subscriptions[mac]
@callback
def async_subscribe_relay(
self, mac: str, update_callback: Callable[[Relay], None]
) -> CALLBACK_TYPE:
"""Add a callback subscriber for relay updates."""
self._relay_subscriptions[mac].add(update_callback)
return partial(self._async_unsubscribe_relay, mac, update_callback)
@callback
def _async_unsubscribe_relay(
self, mac: str, update_callback: Callable[[Relay], None]
) -> None:
"""Remove a relay callback subscriber."""
self._relay_subscriptions[mac].remove(update_callback)
if not self._relay_subscriptions[mac]:
del self._relay_subscriptions[mac]
@callback
def async_subscribe_siren(
self, mac: str, update_callback: Callable[[Siren], None]
@@ -641,6 +641,9 @@
"privacy_mode": {
"name": "Privacy mode"
},
"relay_output": {
"name": "Output {output_name}"
},
"ssh_enabled": {
"name": "[%key:component::unifiprotect::entity::binary_sensor::ssh_enabled::name%]"
},
@@ -697,6 +700,9 @@
"ptz_preset_not_found": {
"message": "Could not find PTZ preset with name {preset_name} on camera {camera_name}"
},
"relay_not_available": {
"message": "Relay is no longer available"
},
"service_error": {
"message": "Error calling UniFi Protect service, check the logs for more details"
},
+130 -1
View File
@@ -5,22 +5,29 @@ from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from functools import partial
from typing import Any
from typing import Any, Literal
from uiprotect.data import (
Camera,
ModelType,
ProtectAdoptableDeviceModel,
PublicRelayOutput,
RecordingMode,
Relay,
RelayOutputState,
VideoMode,
)
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN
from .data import ProtectData, ProtectDeviceType, UFPConfigEntry
from .entity import (
BaseProtectEntity,
@@ -421,6 +428,12 @@ NVR_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
),
)
_RELAY_STATE_MAP: dict[RelayOutputState, bool] = {
RelayOutputState.ON: True,
RelayOutputState.OFF: False,
RelayOutputState.OFF_OTP: False,
}
_MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = {
ModelType.CAMERA: CAMERA_SWITCHES,
ModelType.LIGHT: LIGHT_SWITCHES,
@@ -562,3 +575,119 @@ async def async_setup_entry(
for switch in NVR_SWITCHES
)
async_add_entities(entities)
# Public API: relay output switches. Only available when the public
# bootstrap has been primed (requires API key + supported NVR firmware).
api = data.api
if api.has_public_bootstrap:
relay_entities: list[ProtectRelayOutputSwitch] = [
ProtectRelayOutputSwitch(data, relay, output)
for relay in api.public_bootstrap.relays.values()
for output in relay.outputs
]
if relay_entities:
async_add_entities(relay_entities)
class ProtectRelayOutputSwitch(SwitchEntity):
"""Switch entity for a single relay output channel (Public API).
The relay device and its outputs are exposed through UniFi Protect's
public integration API and cached in :attr:`ProtectApiClient.public_bootstrap`.
Each output channel is represented as its own switch entity; turning it
on/off goes through :meth:`Relay.activate_output`.
"""
_attr_has_entity_name = True
_attr_attribution = DEFAULT_ATTRIBUTION
_attr_should_poll = False
_attr_translation_key = "relay_output"
def __init__(
self,
data: ProtectData,
relay: Relay,
output: PublicRelayOutput,
) -> None:
"""Initialize the relay output switch."""
self.data = data
self._relay_id = relay.id
self._relay_mac = relay.mac
self._output_id = output.id
self._attr_unique_id = f"{relay.mac}_relay_output_{output.id}"
self._attr_translation_placeholders = {
"output_name": output.name or str(output.id),
}
nvr = data.api.bootstrap.nvr
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, relay.mac)},
identifiers={(DOMAIN, relay.mac)},
manufacturer=DEFAULT_BRAND,
name=relay.name,
model="Relay",
via_device=(DOMAIN, nvr.mac),
)
self._update_from_relay(relay)
@property
def _relay(self) -> Relay | None:
api = self.data.api
if not api.has_public_bootstrap:
return None
return api.public_bootstrap.relays.get(self._relay_id)
@callback
def _update_from_relay(self, relay: Relay) -> None:
"""Refresh ``_attr_is_on`` and availability from the cached relay."""
output = relay.get_output(self._output_id)
if output is None:
self._attr_available = False
self._attr_is_on = None
return
self._attr_available = self.data.last_update_success
self._attr_is_on = (
_RELAY_STATE_MAP.get(output.state) if output.state is not None else None
)
@callback
def _async_updated(self, relay: Relay) -> None:
"""Handle a public relay WS update for this relay."""
prev_state = (self._attr_available, self._attr_is_on)
self._update_from_relay(relay)
# If the relay was removed from the bootstrap while the WS update
# was in flight, mark unavailable so commands cannot succeed.
if self._relay is None:
self._attr_available = False
if (self._attr_available, self._attr_is_on) != prev_state:
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Subscribe to public relay WS updates dispatched by ProtectData."""
await super().async_added_to_hass()
self.async_on_remove(
self.data.async_subscribe_relay(self._relay_mac, self._async_updated)
)
async def _activate_output(self, state: Literal["on", "off"]) -> None:
"""Send activate_output to the relay, raising if unavailable."""
if (relay := self._relay) is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="relay_not_available",
)
if relay.get_output(self._output_id) is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="relay_not_available",
)
await relay.activate_output(self._output_id, state=state)
@async_ufp_instance_command
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the relay output on."""
await self._activate_output("on")
@async_ufp_instance_command
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the relay output off."""
await self._activate_output("off")
@@ -43,6 +43,7 @@ def _make_public_bootstrap(arm_mode: Mock | None) -> Mock:
pb = Mock(spec=PublicBootstrap)
pb.arm_mode = arm_mode
pb.arm_profiles = {}
pb.relays = {}
pb.sirens = {}
return pb
+465
View File
@@ -0,0 +1,465 @@
"""Tests for the UniFi Protect relay (Public API) switch entities."""
from __future__ import annotations
from unittest.mock import AsyncMock, Mock
import pytest
from uiprotect.data import (
ModelType,
PublicBootstrap,
PublicRelayOutput,
Relay,
RelayOutputState,
)
from uiprotect.exceptions import ClientError, NotAuthorized
from uiprotect.websocket import WebsocketState
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from .utils import MockUFPFixture, init_entry
RELAY_ID = "relay-id-1"
RELAY_MAC = "AA:BB:CC:DD:EE:01"
RELAY_NAME = "Garage Relay"
OUTPUT_ID = 1
OUTPUT_NAME = "output1"
SWITCH_ENTITY_ID = "switch.garage_relay_output_output1"
def _make_output(
output_id: int = OUTPUT_ID,
name: str | None = OUTPUT_NAME,
state: RelayOutputState | None = RelayOutputState.OFF,
) -> Mock:
"""Build a mock :class:`PublicRelayOutput`."""
output = Mock(spec=PublicRelayOutput)
output.id = output_id
output.name = name
output.state = state
return output
def _make_relay(
*,
outputs: list[Mock] | None = None,
) -> Mock:
"""Build a mock :class:`Relay` whose ``activate_output`` is awaitable."""
relay = Mock(spec=Relay)
relay.id = RELAY_ID
relay.mac = RELAY_MAC
relay.name = RELAY_NAME
relay.model = ModelType.RELAY
relay.outputs = outputs if outputs is not None else [_make_output()]
def get_output(output_id: int) -> Mock | None:
return next((o for o in relay.outputs if o.id == output_id), None)
relay.get_output = get_output
relay.activate_output = AsyncMock()
return relay
def _make_public_bootstrap(relay: Mock | None) -> Mock:
"""Build a public bootstrap mock holding the given relay."""
pb = Mock(spec=PublicBootstrap)
pb.relays = {relay.id: relay} if relay is not None else {}
pb.arm_mode = None
pb.arm_profiles = {}
pb.sirens = {}
return pb
@pytest.fixture(name="ufp_with_relay")
def _ufp_with_relay(ufp: MockUFPFixture) -> tuple[MockUFPFixture, Mock]:
"""Configure ufp fixture with a single relay accessible via public API."""
relay = _make_relay()
ufp.api.has_public_bootstrap = True
ufp.api.public_bootstrap = _make_public_bootstrap(relay)
return ufp, relay
# ---------------------------------------------------------------------------
# Switch
# ---------------------------------------------------------------------------
async def test_relay_switch_not_created_without_public_bootstrap(
hass: HomeAssistant, ufp: MockUFPFixture
) -> None:
"""No relay output switch is created when public bootstrap is unavailable."""
ufp.api.has_public_bootstrap = False
await init_entry(hass, ufp, [])
assert hass.states.get(SWITCH_ENTITY_ID) is None
async def test_relay_switch_created_with_state(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
ufp_with_relay: tuple[MockUFPFixture, Mock],
) -> None:
"""Relay output switch is created and reflects the cached state."""
ufp, relay = ufp_with_relay
relay.outputs[0].state = RelayOutputState.ON
await init_entry(hass, ufp, [])
entry = entity_registry.async_get(SWITCH_ENTITY_ID)
assert entry is not None
assert entry.unique_id == f"{RELAY_MAC}_relay_output_{OUTPUT_ID}"
state = hass.states.get(SWITCH_ENTITY_ID)
assert state is not None
assert state.state == STATE_ON
async def test_relay_switch_off_otp_is_off(
hass: HomeAssistant,
ufp_with_relay: tuple[MockUFPFixture, Mock],
) -> None:
"""OFF_OTP (over-temperature protection) is treated as ``off``."""
ufp, relay = ufp_with_relay
relay.outputs[0].state = RelayOutputState.OFF_OTP
await init_entry(hass, ufp, [])
state = hass.states.get(SWITCH_ENTITY_ID)
assert state is not None
assert state.state == STATE_OFF
async def test_relay_switch_unknown_state_is_unknown(
hass: HomeAssistant,
ufp_with_relay: tuple[MockUFPFixture, Mock],
) -> None:
"""Unknown relay state should leave the switch state as ``unknown``."""
ufp, relay = ufp_with_relay
relay.outputs[0].state = RelayOutputState.UNKNOWN
await init_entry(hass, ufp, [])
state = hass.states.get(SWITCH_ENTITY_ID)
assert state is not None
# ``is_on`` is None while ``available`` is True → state is "unknown".
# "unavailable" would mean the device is unreachable; UNKNOWN output state
# means state data was received but cannot be interpreted.
assert state.state == STATE_UNKNOWN
async def test_relay_switch_turn_on_off(
hass: HomeAssistant,
ufp_with_relay: tuple[MockUFPFixture, Mock],
) -> None:
"""Calling ``turn_on``/``turn_off`` invokes the public-API helper."""
ufp, relay = ufp_with_relay
await init_entry(hass, ufp, [])
await hass.services.async_call(
Platform.SWITCH,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: SWITCH_ENTITY_ID},
blocking=True,
)
relay.activate_output.assert_awaited_once_with(OUTPUT_ID, state="on")
relay.activate_output.reset_mock()
await hass.services.async_call(
Platform.SWITCH,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: SWITCH_ENTITY_ID},
blocking=True,
)
relay.activate_output.assert_awaited_once_with(OUTPUT_ID, state="off")
async def test_relay_switch_state_updates_from_public_ws(
hass: HomeAssistant,
ufp_with_relay: tuple[MockUFPFixture, Mock],
) -> None:
"""A public devices WS update for the relay refreshes the switch state."""
ufp, relay = ufp_with_relay
relay.outputs[0].state = RelayOutputState.OFF
await init_entry(hass, ufp, [])
state = hass.states.get(SWITCH_ENTITY_ID)
assert state is not None
assert state.state == STATE_OFF
relay.outputs[0].state = RelayOutputState.ON
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.old_obj = relay
mock_msg.new_obj = relay
assert ufp.devices_ws_subscription is not None
ufp.devices_ws_subscription(mock_msg)
await hass.async_block_till_done()
state = hass.states.get(SWITCH_ENTITY_ID)
assert state is not None
assert state.state == STATE_ON
async def test_relay_switch_creates_one_entity_per_output(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
ufp: MockUFPFixture,
) -> None:
"""Multiple outputs on a single relay yield multiple switch entities."""
relay = _make_relay(
outputs=[
_make_output(output_id=1, name="output1"),
_make_output(output_id=2, name="output2"),
],
)
ufp.api.has_public_bootstrap = True
ufp.api.public_bootstrap = _make_public_bootstrap(relay)
await init_entry(hass, ufp, [])
assert entity_registry.async_get("switch.garage_relay_output_output1") is not None
assert entity_registry.async_get("switch.garage_relay_output_output2") is not None
async def test_relay_switch_command_error_raises(
hass: HomeAssistant,
ufp_with_relay: tuple[MockUFPFixture, Mock],
) -> None:
"""``activate_output`` errors are surfaced as :class:`HomeAssistantError`."""
ufp, relay = ufp_with_relay
await init_entry(hass, ufp, [])
relay.activate_output.side_effect = NotAuthorized("denied")
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
Platform.SWITCH,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: SWITCH_ENTITY_ID},
blocking=True,
)
async def test_relay_switch_client_error_raises(
hass: HomeAssistant,
ufp_with_relay: tuple[MockUFPFixture, Mock],
) -> None:
"""``ClientError`` from ``activate_output`` is wrapped as HomeAssistantError."""
ufp, relay = ufp_with_relay
await init_entry(hass, ufp, [])
relay.activate_output.side_effect = ClientError("timeout")
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
Platform.SWITCH,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: SWITCH_ENTITY_ID},
blocking=True,
)
async def test_relay_switch_command_when_relay_gone(
hass: HomeAssistant,
ufp_with_relay: tuple[MockUFPFixture, Mock],
) -> None:
"""Command raises HomeAssistantError when the relay is no longer in bootstrap."""
ufp, _relay = ufp_with_relay
await init_entry(hass, ufp, [])
# Remove relay from bootstrap after setup.
ufp.api.public_bootstrap.relays = {}
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
Platform.SWITCH,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: SWITCH_ENTITY_ID},
blocking=True,
)
async def test_relay_switch_command_when_bootstrap_unavailable(
hass: HomeAssistant,
ufp_with_relay: tuple[MockUFPFixture, Mock],
) -> None:
"""Command raises HomeAssistantError when has_public_bootstrap is False."""
ufp, _relay = ufp_with_relay
await init_entry(hass, ufp, [])
ufp.api.has_public_bootstrap = False
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
Platform.SWITCH,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: SWITCH_ENTITY_ID},
blocking=True,
)
async def test_relay_switch_ws_update_no_state_change(
hass: HomeAssistant,
ufp_with_relay: tuple[MockUFPFixture, Mock],
) -> None:
"""WS update with the same state does not trigger an unnecessary state write."""
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 # type: ignore[union-attr]
# Fire update with identical state — entity state must not change.
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.old_obj = relay
mock_msg.new_obj = relay
assert ufp.devices_ws_subscription is not None
ufp.devices_ws_subscription(mock_msg)
await hass.async_block_till_done()
assert hass.states.get(SWITCH_ENTITY_ID).state == STATE_ON # type: ignore[union-attr]
async def test_relay_switch_becomes_unavailable_when_relay_removed(
hass: HomeAssistant,
ufp_with_relay: tuple[MockUFPFixture, Mock],
) -> None:
"""Entity becomes unavailable when the relay disappears from the bootstrap."""
ufp, relay = ufp_with_relay
relay.outputs[0].state = RelayOutputState.OFF
await init_entry(hass, ufp, [])
# Drop the relay from the public bootstrap.
ufp.api.public_bootstrap.relays = {}
# Send a WS update whose output list is still valid; the entity must still
# become unavailable because _relay now resolves to None.
relay2 = _make_relay()
relay2.id = relay.id
relay2.mac = relay.mac
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.old_obj = relay2
mock_msg.new_obj = relay2
assert ufp.devices_ws_subscription is not None
ufp.devices_ws_subscription(mock_msg)
await hass.async_block_till_done()
state = hass.states.get(SWITCH_ENTITY_ID)
assert state is not None
assert state.state == STATE_UNAVAILABLE
async def test_relay_switch_availability_follows_websocket_state(
hass: HomeAssistant,
ufp_with_relay: tuple[MockUFPFixture, Mock],
) -> None:
"""Relay switch becomes unavailable on WS disconnect and recovers on reconnect."""
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 # type: ignore[union-attr]
assert ufp.ws_state_subscription is not None
ufp.ws_state_subscription(WebsocketState.DISCONNECTED)
await hass.async_block_till_done()
state = hass.states.get(SWITCH_ENTITY_ID)
assert state is not None
assert state.state == STATE_UNAVAILABLE
ufp.ws_state_subscription(WebsocketState.CONNECTED)
await hass.async_block_till_done()
state = hass.states.get(SWITCH_ENTITY_ID)
assert state is not None
assert state.state == STATE_ON
async def test_relay_public_ws_message_with_none_new_obj(
hass: HomeAssistant,
ufp_with_relay: tuple[MockUFPFixture, Mock],
) -> None:
"""Public WS message with new_obj=None is silently ignored."""
ufp, _ = ufp_with_relay
await init_entry(hass, ufp, [])
state_before = hass.states.get(SWITCH_ENTITY_ID)
assert state_before is not None
mock_msg = Mock()
mock_msg.new_obj = None
assert ufp.devices_ws_subscription is not None
ufp.devices_ws_subscription(mock_msg)
await hass.async_block_till_done()
# Entity state must be unchanged.
assert hass.states.get(SWITCH_ENTITY_ID) == state_before
async def test_relay_switch_output_removed_from_relay_update(
hass: HomeAssistant,
ufp_with_relay: tuple[MockUFPFixture, Mock],
) -> None:
"""WS update where the output is no longer present marks the entity unavailable."""
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 # type: ignore[union-attr]
# Build a relay WS update that no longer contains any outputs.
relay_no_outputs = _make_relay(outputs=[])
relay_no_outputs.id = relay.id
relay_no_outputs.mac = relay.mac
mock_msg = Mock()
mock_msg.changed_data = {}
mock_msg.old_obj = relay_no_outputs
mock_msg.new_obj = relay_no_outputs
assert ufp.devices_ws_subscription is not None
ufp.devices_ws_subscription(mock_msg)
await hass.async_block_till_done()
state = hass.states.get(SWITCH_ENTITY_ID)
assert state is not None
assert state.state == STATE_UNAVAILABLE
async def test_relay_switch_command_when_output_gone(
hass: HomeAssistant,
ufp_with_relay: tuple[MockUFPFixture, Mock],
) -> None:
"""Command raises HomeAssistantError when the relay output channel is no longer present."""
ufp, relay = ufp_with_relay
await init_entry(hass, ufp, [])
# Remove all outputs from the relay so get_output returns None.
relay.outputs = []
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
Platform.SWITCH,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: SWITCH_ENTITY_ID},
blocking=True,
)