diff --git a/CODEOWNERS b/CODEOWNERS index 45e7f0957b3..4cc77b3079e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1786,6 +1786,8 @@ build.json @home-assistant/supervisor /tests/components/ukraine_alarm/ @PaulAnnekov /homeassistant/components/unifi/ @Kane610 /tests/components/unifi/ @Kane610 +/homeassistant/components/unifi_access/ @imhotep @RaHehl +/tests/components/unifi_access/ @imhotep @RaHehl /homeassistant/components/unifi_direct/ @tofuSCHNITZEL /homeassistant/components/unifiled/ @florisvdk /homeassistant/components/unifiprotect/ @RaHehl diff --git a/homeassistant/components/unifi_access/__init__.py b/homeassistant/components/unifi_access/__init__.py new file mode 100644 index 00000000000..b3637055a3e --- /dev/null +++ b/homeassistant/components/unifi_access/__init__.py @@ -0,0 +1,54 @@ +"""The UniFi Access integration.""" + +from __future__ import annotations + +from unifi_access_api import ApiAuthError, ApiConnectionError, UnifiAccessApiClient + +from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator + +PLATFORMS: list[Platform] = [Platform.BUTTON] + + +async def async_setup_entry(hass: HomeAssistant, entry: UnifiAccessConfigEntry) -> bool: + """Set up UniFi Access from a config entry.""" + session = async_get_clientsession(hass, verify_ssl=entry.data[CONF_VERIFY_SSL]) + + client = UnifiAccessApiClient( + host=entry.data[CONF_HOST], + api_token=entry.data[CONF_API_TOKEN], + session=session, + verify_ssl=entry.data[CONF_VERIFY_SSL], + ) + + try: + await client.authenticate() + except ApiAuthError as err: + raise ConfigEntryNotReady( + f"Authentication failed for UniFi Access at {entry.data[CONF_HOST]}" + ) from err + except ApiConnectionError as err: + raise ConfigEntryNotReady( + f"Unable to connect to UniFi Access at {entry.data[CONF_HOST]}" + ) from err + + coordinator = UnifiAccessCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + entry.async_on_unload(client.close) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: UnifiAccessConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/unifi_access/button.py b/homeassistant/components/unifi_access/button.py new file mode 100644 index 00000000000..ff467fdeb04 --- /dev/null +++ b/homeassistant/components/unifi_access/button.py @@ -0,0 +1,52 @@ +"""Button platform for the UniFi Access integration.""" + +from __future__ import annotations + +from unifi_access_api import ApiError, Door + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator +from .entity import UnifiAccessEntity + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: UnifiAccessConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up UniFi Access button entities.""" + coordinator = entry.runtime_data + async_add_entities( + UnifiAccessUnlockButton(coordinator, door) for door in coordinator.data.values() + ) + + +class UnifiAccessUnlockButton(UnifiAccessEntity, ButtonEntity): + """Representation of a UniFi Access door unlock button.""" + + _attr_translation_key = "unlock" + + def __init__( + self, + coordinator: UnifiAccessCoordinator, + door: Door, + ) -> None: + """Initialize the button entity.""" + super().__init__(coordinator, door, "unlock") + + async def async_press(self) -> None: + """Unlock the door.""" + try: + await self.coordinator.client.unlock_door(self._door_id) + except ApiError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unlock_failed", + ) from err diff --git a/homeassistant/components/unifi_access/config_flow.py b/homeassistant/components/unifi_access/config_flow.py new file mode 100644 index 00000000000..08cb9e9d358 --- /dev/null +++ b/homeassistant/components/unifi_access/config_flow.py @@ -0,0 +1,68 @@ +"""Config flow for UniFi Access integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from unifi_access_api import ApiAuthError, ApiConnectionError, UnifiAccessApiClient +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class UnifiAccessConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for UniFi Access.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + session = async_get_clientsession( + self.hass, verify_ssl=user_input[CONF_VERIFY_SSL] + ) + client = UnifiAccessApiClient( + host=user_input[CONF_HOST], + api_token=user_input[CONF_API_TOKEN], + session=session, + verify_ssl=user_input[CONF_VERIFY_SSL], + ) + try: + await client.authenticate() + except ApiAuthError: + errors["base"] = "invalid_auth" + except ApiConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + return self.async_create_entry( + title="UniFi Access", + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_API_TOKEN): str, + vol.Required(CONF_VERIFY_SSL, default=False): bool, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/unifi_access/const.py b/homeassistant/components/unifi_access/const.py new file mode 100644 index 00000000000..36ac8fee8f9 --- /dev/null +++ b/homeassistant/components/unifi_access/const.py @@ -0,0 +1,3 @@ +"""Constants for the UniFi Access integration.""" + +DOMAIN = "unifi_access" diff --git a/homeassistant/components/unifi_access/coordinator.py b/homeassistant/components/unifi_access/coordinator.py new file mode 100644 index 00000000000..d031d5c487d --- /dev/null +++ b/homeassistant/components/unifi_access/coordinator.py @@ -0,0 +1,128 @@ +"""Data update coordinator for the UniFi Access integration.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import cast + +from unifi_access_api import ( + ApiAuthError, + ApiConnectionError, + ApiError, + Door, + UnifiAccessApiClient, + WsMessageHandler, +) +from unifi_access_api.models.websocket import ( + LocationUpdateState, + LocationUpdateV2, + V2LocationState, + V2LocationUpdate, + WebsocketMessage, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type UnifiAccessConfigEntry = ConfigEntry[UnifiAccessCoordinator] + + +class UnifiAccessCoordinator(DataUpdateCoordinator[dict[str, Door]]): + """Coordinator for fetching UniFi Access door data.""" + + config_entry: UnifiAccessConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: UnifiAccessConfigEntry, + client: UnifiAccessApiClient, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=None, + ) + self.client = client + + async def _async_setup(self) -> None: + """Set up the WebSocket connection for push updates.""" + handlers: dict[str, WsMessageHandler] = { + "access.data.device.location_update_v2": self._handle_location_update, + "access.data.v2.location.update": self._handle_v2_location_update, + } + self.client.start_websocket( + handlers, + on_connect=self._on_ws_connect, + on_disconnect=self._on_ws_disconnect, + ) + + async def _async_update_data(self) -> dict[str, Door]: + """Fetch all doors from the API.""" + try: + async with asyncio.timeout(10): + doors = await self.client.get_doors() + except ApiAuthError as err: + raise UpdateFailed(f"Authentication failed: {err}") from err + except ApiConnectionError as err: + raise UpdateFailed(f"Error connecting to API: {err}") from err + except ApiError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + return {door.id: door for door in doors} + + def _on_ws_connect(self) -> None: + """Handle WebSocket connection established.""" + _LOGGER.debug("WebSocket connected to UniFi Access") + if not self.last_update_success: + self.config_entry.async_create_background_task( + self.hass, + self.async_request_refresh(), + "unifi_access_reconnect_refresh", + ) + + def _on_ws_disconnect(self) -> None: + """Handle WebSocket disconnection.""" + _LOGGER.debug("WebSocket disconnected from UniFi Access") + self.async_set_update_error( + UpdateFailed("WebSocket disconnected from UniFi Access") + ) + + async def _handle_location_update(self, msg: WebsocketMessage) -> None: + """Handle location_update_v2 messages.""" + update = cast(LocationUpdateV2, msg) + self._process_door_update(update.data.id, update.data.state) + + async def _handle_v2_location_update(self, msg: WebsocketMessage) -> None: + """Handle V2 location update messages.""" + update = cast(V2LocationUpdate, msg) + self._process_door_update(update.data.id, update.data.state) + + def _process_door_update( + self, door_id: str, ws_state: LocationUpdateState | V2LocationState | None + ) -> None: + """Process a door state update from WebSocket.""" + if self.data is None or door_id not in self.data: + return + + if ws_state is None: + return + + current_door = self.data[door_id] + updates: dict[str, object] = {} + if ws_state.dps is not None: + updates["door_position_status"] = ws_state.dps + if ws_state.lock == "locked": + updates["door_lock_relay_status"] = "lock" + elif ws_state.lock == "unlocked": + updates["door_lock_relay_status"] = "unlock" + updated_door = current_door.with_updates(**updates) + self.async_set_updated_data({**self.data, door_id: updated_door}) diff --git a/homeassistant/components/unifi_access/entity.py b/homeassistant/components/unifi_access/entity.py new file mode 100644 index 00000000000..3502a2c4df5 --- /dev/null +++ b/homeassistant/components/unifi_access/entity.py @@ -0,0 +1,43 @@ +"""Base entity for the UniFi Access integration.""" + +from __future__ import annotations + +from unifi_access_api import Door + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import UnifiAccessCoordinator + + +class UnifiAccessEntity(CoordinatorEntity[UnifiAccessCoordinator]): + """Base entity for UniFi Access doors.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: UnifiAccessCoordinator, + door: Door, + key: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._door_id = door.id + self._attr_unique_id = f"{door.id}-{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, door.id)}, + name=door.name, + manufacturer="Ubiquiti", + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self._door_id in self.coordinator.data + + @property + def _door(self) -> Door: + """Return the current door state from coordinator data.""" + return self.coordinator.data[self._door_id] diff --git a/homeassistant/components/unifi_access/icons.json b/homeassistant/components/unifi_access/icons.json new file mode 100644 index 00000000000..4482c48bde1 --- /dev/null +++ b/homeassistant/components/unifi_access/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "button": { + "unlock": { + "default": "mdi:lock-open" + } + } + } +} diff --git a/homeassistant/components/unifi_access/manifest.json b/homeassistant/components/unifi_access/manifest.json new file mode 100644 index 00000000000..9374459e111 --- /dev/null +++ b/homeassistant/components/unifi_access/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "unifi_access", + "name": "UniFi Access", + "codeowners": ["@imhotep", "@RaHehl"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/unifi_access", + "integration_type": "hub", + "iot_class": "local_push", + "loggers": ["unifi_access_api"], + "quality_scale": "bronze", + "requirements": ["py-unifi-access==1.0.0"] +} diff --git a/homeassistant/components/unifi_access/quality_scale.yaml b/homeassistant/components/unifi_access/quality_scale.yaml new file mode 100644 index 00000000000..dce4e816e92 --- /dev/null +++ b/homeassistant/components/unifi_access/quality_scale.yaml @@ -0,0 +1,66 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: + status: exempt + comment: Integration uses WebSocket push updates, no polling. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: done + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/unifi_access/strings.json b/homeassistant/components/unifi_access/strings.json new file mode 100644 index 00000000000..4bd0c01256f --- /dev/null +++ b/homeassistant/components/unifi_access/strings.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]", + "host": "[%key:common::config_flow::data::host%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "api_token": "API token generated in the UniFi Access settings.", + "host": "Hostname or IP address of the UniFi Access controller.", + "verify_ssl": "Verify the SSL certificate of the controller." + } + } + } + }, + "entity": { + "button": { + "unlock": { + "name": "Unlock" + } + } + }, + "exceptions": { + "unlock_failed": { + "message": "Failed to unlock the door." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 5f5dd72f8cf..6ae80eb80df 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -751,6 +751,7 @@ FLOWS = { "uhoo", "ukraine_alarm", "unifi", + "unifi_access", "unifiprotect", "upb", "upcloud", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 2cf1cc20338..f4333f78e3e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7379,6 +7379,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "unifi_access": { + "name": "UniFi Access", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "universal": { "name": "Universal media player", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 58782b72050..cd182196a87 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1882,6 +1882,9 @@ py-sucks==0.9.11 # homeassistant.components.synology_dsm py-synologydsm-api==2.7.3 +# homeassistant.components.unifi_access +py-unifi-access==1.0.0 + # homeassistant.components.atome pyAtome==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 88d3703b6c9..75409169ebe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1631,6 +1631,9 @@ py-sucks==0.9.11 # homeassistant.components.synology_dsm py-synologydsm-api==2.7.3 +# homeassistant.components.unifi_access +py-unifi-access==1.0.0 + # homeassistant.components.hdmi_cec pyCEC==0.5.2 diff --git a/tests/components/unifi_access/__init__.py b/tests/components/unifi_access/__init__.py new file mode 100644 index 00000000000..63f1036ca7c --- /dev/null +++ b/tests/components/unifi_access/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the UniFi Access integration.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the UniFi Access integration for testing.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/unifi_access/conftest.py b/tests/components/unifi_access/conftest.py new file mode 100644 index 00000000000..30a5613289f --- /dev/null +++ b/tests/components/unifi_access/conftest.py @@ -0,0 +1,104 @@ +"""Fixtures for UniFi Access integration tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from unifi_access_api import Door, DoorLockRelayStatus, DoorPositionStatus + +from homeassistant.components.unifi_access.const import DOMAIN +from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + +MOCK_HOST = "192.168.1.1" +MOCK_API_TOKEN = "test-api-token-12345" + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="UniFi Access", + data={ + CONF_HOST: MOCK_HOST, + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_VERIFY_SSL: False, + }, + version=1, + minor_version=1, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.unifi_access.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +def _make_door( + door_id: str = "door-001", + name: str = "Front Door", + lock_status: DoorLockRelayStatus = DoorLockRelayStatus.LOCK, + position_status: DoorPositionStatus = DoorPositionStatus.CLOSE, +) -> Door: + """Create a mock Door object.""" + return Door( + id=door_id, + name=name, + door_lock_relay_status=lock_status, + door_position_status=position_status, + ) + + +MOCK_DOORS = [ + _make_door("door-001", "Front Door"), + _make_door( + "door-002", + "Back Door", + lock_status=DoorLockRelayStatus.UNLOCK, + position_status=DoorPositionStatus.OPEN, + ), +] + + +@pytest.fixture +def mock_client() -> Generator[MagicMock]: + """Return a mocked UniFi Access API client.""" + with ( + patch( + "homeassistant.components.unifi_access.UnifiAccessApiClient", + autospec=True, + ) as client_mock, + patch( + "homeassistant.components.unifi_access.config_flow.UnifiAccessApiClient", + new=client_mock, + ), + ): + client = client_mock.return_value + client.authenticate = AsyncMock() + client.get_doors = AsyncMock(return_value=MOCK_DOORS) + client.unlock_door = AsyncMock() + client.close = AsyncMock() + client.start_websocket = MagicMock() + yield client + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> MockConfigEntry: + """Set up the UniFi Access integration for testing.""" + await setup_integration(hass, mock_config_entry) + return mock_config_entry diff --git a/tests/components/unifi_access/snapshots/test_button.ambr b/tests/components/unifi_access/snapshots/test_button.ambr new file mode 100644 index 00000000000..4fa3f15c88b --- /dev/null +++ b/tests/components/unifi_access/snapshots/test_button.ambr @@ -0,0 +1,99 @@ +# serializer version: 1 +# name: test_button_entities[button.back_door_unlock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.back_door_unlock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Unlock', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Unlock', + 'platform': 'unifi_access', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'unlock', + 'unique_id': 'door-002-unlock', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_entities[button.back_door_unlock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Back Door Unlock', + }), + 'context': , + 'entity_id': 'button.back_door_unlock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_entities[button.front_door_unlock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.front_door_unlock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Unlock', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Unlock', + 'platform': 'unifi_access', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'unlock', + 'unique_id': 'door-001-unlock', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_entities[button.front_door_unlock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Front Door Unlock', + }), + 'context': , + 'entity_id': 'button.front_door_unlock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/unifi_access/test_button.py b/tests/components/unifi_access/test_button.py new file mode 100644 index 00000000000..783352bd2f7 --- /dev/null +++ b/tests/components/unifi_access/test_button.py @@ -0,0 +1,130 @@ +"""Tests for the UniFi Access button platform.""" + +from __future__ import annotations + +from collections.abc import Callable +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from unifi_access_api import ApiError + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +FRONT_DOOR_ENTITY = "button.front_door_unlock" +BACK_DOOR_ENTITY = "button.back_door_unlock" + + +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"] + + +async def test_button_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test button entities are created with expected state.""" + with patch("homeassistant.components.unifi_access.PLATFORMS", [Platform.BUTTON]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_unlock_door( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test pressing the unlock button.""" + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.front_door_unlock"}, + blocking=True, + ) + + mock_client.unlock_door.assert_awaited_once_with("door-001") + + +async def test_unlock_door_api_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test pressing the unlock button raises on API error.""" + mock_client.unlock_door.side_effect = ApiError("unlock failed") + + with pytest.raises(HomeAssistantError, match="Failed to unlock the door"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.front_door_unlock"}, + blocking=True, + ) + + +async def test_ws_disconnect_marks_entities_unavailable( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test WebSocket disconnect marks entities as unavailable.""" + assert hass.states.get(FRONT_DOOR_ENTITY).state == "unknown" + + 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_entities( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test WebSocket reconnect restores entity 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 == "unknown" + assert hass.states.get(BACK_DOOR_ENTITY).state == "unknown" + + +async def test_ws_connect_no_refresh_when_healthy( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test WebSocket connect does not trigger redundant refresh when healthy.""" + on_connect = _get_on_connect(mock_client) + + on_connect() + await hass.async_block_till_done() + + assert mock_client.get_doors.call_count == 1 diff --git a/tests/components/unifi_access/test_config_flow.py b/tests/components/unifi_access/test_config_flow.py new file mode 100644 index 00000000000..545094223da --- /dev/null +++ b/tests/components/unifi_access/test_config_flow.py @@ -0,0 +1,149 @@ +"""Tests for the UniFi Access config flow.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from unifi_access_api import ApiAuthError, ApiConnectionError + +from homeassistant.components.unifi_access.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import MOCK_API_TOKEN, MOCK_HOST + +from tests.common import MockConfigEntry + + +async def test_user_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: MagicMock, +) -> None: + """Test successful user config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: MOCK_HOST, + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_VERIFY_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "UniFi Access" + assert result["data"] == { + CONF_HOST: MOCK_HOST, + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_VERIFY_SSL: False, + } + mock_client.authenticate.assert_awaited_once() + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (ApiConnectionError("Connection failed"), "cannot_connect"), + (ApiAuthError(), "invalid_auth"), + (RuntimeError("boom"), "unknown"), + ], +) +async def test_user_flow_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: MagicMock, + exception: Exception, + error: str, +) -> None: + """Test user config flow errors and recovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_client.authenticate.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: MOCK_HOST, + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_VERIFY_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_client.authenticate.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: MOCK_HOST, + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_VERIFY_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_flow_already_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test user config flow aborts when already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: MOCK_HOST, + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_VERIFY_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_user_flow_different_host( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test user config flow allows different host.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "10.0.0.1", + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_VERIFY_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/unifi_access/test_init.py b/tests/components/unifi_access/test_init.py new file mode 100644 index 00000000000..3cbd0e6ae3d --- /dev/null +++ b/tests/components/unifi_access/test_init.py @@ -0,0 +1,92 @@ +"""Tests for the UniFi Access integration setup.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from unifi_access_api import ApiAuthError, ApiConnectionError, ApiError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test successful setup of a config entry.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_client.authenticate.assert_awaited_once() + mock_client.get_doors.assert_awaited_once() + + +@pytest.mark.parametrize( + ("exception", "expected_state"), + [ + (ApiAuthError(), ConfigEntryState.SETUP_RETRY), + (ApiConnectionError("Connection failed"), ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_entry_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + exception: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test setup handles errors correctly.""" + mock_client.authenticate.side_effect = exception + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is expected_state + + +@pytest.mark.parametrize( + "exception", + [ + ApiAuthError(), + ApiConnectionError("Connection failed"), + ApiError("API error"), + ], +) +async def test_coordinator_update_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + exception: Exception, +) -> None: + """Test coordinator handles update errors from get_doors.""" + mock_client.get_doors.side_effect = exception + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test unloading a config entry.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED