From b902b590b15a1c1bc13a6e48bd68139784359a82 Mon Sep 17 00:00:00 2001 From: Anis Kadri Date: Mon, 16 Mar 2026 06:03:46 -0700 Subject: [PATCH] Add UniFi Access binary sensors (#165569) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/unifi_access/__init__.py | 7 +- .../components/unifi_access/binary_sensor.py | 50 +++++++ .../snapshots/test_binary_sensor.ambr | 101 ++++++++++++++ .../unifi_access/test_binary_sensor.py | 125 ++++++++++++++++++ 4 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/unifi_access/binary_sensor.py create mode 100644 tests/components/unifi_access/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/unifi_access/test_binary_sensor.py diff --git a/homeassistant/components/unifi_access/__init__.py b/homeassistant/components/unifi_access/__init__.py index ffa8aabd94f..0c5c0930edd 100644 --- a/homeassistant/components/unifi_access/__init__.py +++ b/homeassistant/components/unifi_access/__init__.py @@ -11,7 +11,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator -PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.EVENT, Platform.SWITCH] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.EVENT, + Platform.SWITCH, +] async def async_setup_entry(hass: HomeAssistant, entry: UnifiAccessConfigEntry) -> bool: diff --git a/homeassistant/components/unifi_access/binary_sensor.py b/homeassistant/components/unifi_access/binary_sensor.py new file mode 100644 index 00000000000..a59dc4d2b1c --- /dev/null +++ b/homeassistant/components/unifi_access/binary_sensor.py @@ -0,0 +1,50 @@ +"""Binary sensor platform for the UniFi Access integration.""" + +from __future__ import annotations + +from unifi_access_api import Door, DoorPositionStatus + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator +from .entity import UnifiAccessEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: UnifiAccessConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up UniFi Access binary sensor entities.""" + coordinator = entry.runtime_data + async_add_entities( + UnifiAccessDoorPositionBinarySensor(coordinator, door) + for door in coordinator.data.doors.values() + ) + + +class UnifiAccessDoorPositionBinarySensor(UnifiAccessEntity, BinarySensorEntity): + """Representation of a UniFi Access door position binary sensor.""" + + _attr_name = None + _attr_device_class = BinarySensorDeviceClass.DOOR + + def __init__( + self, + coordinator: UnifiAccessCoordinator, + door: Door, + ) -> None: + """Initialize the binary sensor entity.""" + super().__init__(coordinator, door, "access_door_dps") + + @property + def is_on(self) -> bool: + """Return whether the door is open.""" + return self._door.door_position_status == DoorPositionStatus.OPEN diff --git a/tests/components/unifi_access/snapshots/test_binary_sensor.ambr b/tests/components/unifi_access/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..bc5f79cac56 --- /dev/null +++ b/tests/components/unifi_access/snapshots/test_binary_sensor.ambr @@ -0,0 +1,101 @@ +# serializer version: 1 +# name: test_binary_sensor_entities[binary_sensor.back_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.back_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'unifi_access', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'door-002-access_door_dps', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_entities[binary_sensor.back_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Back Door', + }), + 'context': , + 'entity_id': 'binary_sensor.back_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_entities[binary_sensor.front_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.front_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'unifi_access', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'door-001-access_door_dps', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_entities[binary_sensor.front_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Front Door', + }), + 'context': , + 'entity_id': 'binary_sensor.front_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/unifi_access/test_binary_sensor.py b/tests/components/unifi_access/test_binary_sensor.py new file mode 100644 index 00000000000..b328d11069b --- /dev/null +++ b/tests/components/unifi_access/test_binary_sensor.py @@ -0,0 +1,125 @@ +"""Tests for the UniFi Access binary sensor platform.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from unittest.mock import MagicMock, patch + +from syrupy.assertion import SnapshotAssertion +from unifi_access_api import DoorPositionStatus +from unifi_access_api.models.websocket import ( + LocationUpdateData, + LocationUpdateState, + LocationUpdateV2, + WebsocketMessage, +) + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +FRONT_DOOR_ENTITY = "binary_sensor.front_door" +BACK_DOOR_ENTITY = "binary_sensor.back_door" + + +def _get_on_connect(mock_client: MagicMock) -> Callable[[], None]: + """Extract on_connect callback from mock client.""" + return mock_client.start_websocket.call_args[1]["on_connect"] + + +def _get_on_disconnect(mock_client: MagicMock) -> Callable[[], None]: + """Extract on_disconnect callback from mock client.""" + return mock_client.start_websocket.call_args[1]["on_disconnect"] + + +def _get_ws_handlers( + mock_client: MagicMock, +) -> dict[str, Callable[[WebsocketMessage], Awaitable[None]]]: + """Extract WebSocket handlers from mock client.""" + return mock_client.start_websocket.call_args[0][0] + + +async def test_binary_sensor_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test binary sensor entities are created with expected state.""" + with patch( + "homeassistant.components.unifi_access.PLATFORMS", [Platform.BINARY_SENSOR] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_binary_sensor_states( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test binary sensor states reflect initial door status.""" + assert hass.states.get(FRONT_DOOR_ENTITY).state == "off" + assert hass.states.get(BACK_DOOR_ENTITY).state == "on" + + +async def test_binary_sensor_state_updates( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test location updates change the binary sensor state.""" + handlers = _get_ws_handlers(mock_client) + + update_msg = LocationUpdateV2( + event="access.data.device.location_update_v2", + data=LocationUpdateData( + id="door-001", + location_type="DOOR", + state=LocationUpdateState(dps=DoorPositionStatus.OPEN), + ), + ) + + await handlers["access.data.device.location_update_v2"](update_msg) + await hass.async_block_till_done() + + assert hass.states.get(FRONT_DOOR_ENTITY).state == "on" + + +async def test_ws_disconnect_marks_binary_sensors_unavailable( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test WebSocket disconnect marks binary sensors unavailable.""" + on_disconnect = _get_on_disconnect(mock_client) + on_disconnect() + await hass.async_block_till_done() + + assert hass.states.get(FRONT_DOOR_ENTITY).state == "unavailable" + assert hass.states.get(BACK_DOOR_ENTITY).state == "unavailable" + + +async def test_ws_reconnect_restores_binary_sensor_states( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test WebSocket reconnect restores binary sensor availability.""" + on_disconnect = _get_on_disconnect(mock_client) + on_connect = _get_on_connect(mock_client) + + on_disconnect() + await hass.async_block_till_done() + assert hass.states.get(FRONT_DOOR_ENTITY).state == "unavailable" + + on_connect() + await hass.async_block_till_done() + + assert hass.states.get(FRONT_DOOR_ENTITY).state == "off" + assert hass.states.get(BACK_DOOR_ENTITY).state == "on"