From bb0284ac511e8c126ed0814c919eaece66186336 Mon Sep 17 00:00:00 2001 From: Andreas Hagen Date: Fri, 10 Jan 2025 08:15:03 +0100 Subject: [PATCH] Add minimal UniFi Access integration --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/ubiquiti.json | 8 +- .../components/unifi_access/__init__.py | 44 +++++ .../components/unifi_access/config_flow.py | 74 +++++++++ .../components/unifi_access/const.py | 5 + .../components/unifi_access/coordinator.py | 101 ++++++++++++ homeassistant/components/unifi_access/data.py | 15 ++ homeassistant/components/unifi_access/lock.py | 75 +++++++++ .../components/unifi_access/manifest.json | 13 ++ .../unifi_access/quality_scale.yaml | 60 +++++++ .../components/unifi_access/strings.json | 25 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 ++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/unifi_access/__init__.py | 1 + tests/components/unifi_access/common.py | 37 +++++ tests/components/unifi_access/conftest.py | 48 ++++++ .../unifi_access/test_config_flow.py | 151 ++++++++++++++++++ .../unifi_access/test_coordinator.py | 83 ++++++++++ 22 files changed, 765 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/unifi_access/__init__.py create mode 100644 homeassistant/components/unifi_access/config_flow.py create mode 100644 homeassistant/components/unifi_access/const.py create mode 100644 homeassistant/components/unifi_access/coordinator.py create mode 100644 homeassistant/components/unifi_access/data.py create mode 100644 homeassistant/components/unifi_access/lock.py create mode 100644 homeassistant/components/unifi_access/manifest.json create mode 100644 homeassistant/components/unifi_access/quality_scale.yaml create mode 100644 homeassistant/components/unifi_access/strings.json create mode 100644 tests/components/unifi_access/__init__.py create mode 100644 tests/components/unifi_access/common.py create mode 100644 tests/components/unifi_access/conftest.py create mode 100644 tests/components/unifi_access/test_config_flow.py create mode 100644 tests/components/unifi_access/test_coordinator.py diff --git a/.strict-typing b/.strict-typing index 98fbb16ff45e8c..9295bd9935b3fe 100644 --- a/.strict-typing +++ b/.strict-typing @@ -500,6 +500,7 @@ homeassistant.components.trend.* homeassistant.components.tts.* homeassistant.components.twentemilieu.* homeassistant.components.unifi.* +homeassistant.components.unifi_access.* homeassistant.components.unifiprotect.* homeassistant.components.upcloud.* homeassistant.components.update.* diff --git a/CODEOWNERS b/CODEOWNERS index 4ef40a79bd1840..21905626c10e02 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1594,6 +1594,8 @@ build.json @home-assistant/supervisor /homeassistant/components/unifi/ @Kane610 /tests/components/unifi/ @Kane610 /homeassistant/components/unifi_direct/ @tofuSCHNITZEL +/homeassistant/components/unifi_access/ @hagen93 +/tests/components/unifi_access/ @hagen93 /homeassistant/components/unifiled/ @florisvdk /homeassistant/components/unifiprotect/ @RaHehl /tests/components/unifiprotect/ @RaHehl diff --git a/homeassistant/brands/ubiquiti.json b/homeassistant/brands/ubiquiti.json index 8b64cffaa7e59e..45d273f3ef14e7 100644 --- a/homeassistant/brands/ubiquiti.json +++ b/homeassistant/brands/ubiquiti.json @@ -1,5 +1,11 @@ { "domain": "ubiquiti", "name": "Ubiquiti", - "integrations": ["unifi", "unifi_direct", "unifiled", "unifiprotect"] + "integrations": [ + "unifi", + "unifi_direct", + "unifiled", + "unifiprotect", + "unifi_access" + ] } diff --git a/homeassistant/components/unifi_access/__init__.py b/homeassistant/components/unifi_access/__init__.py new file mode 100644 index 00000000000000..8ba370c4914def --- /dev/null +++ b/homeassistant/components/unifi_access/__init__.py @@ -0,0 +1,44 @@ +"""The UniFi Access integration.""" + +from __future__ import annotations + +import uiaccessclient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL, Platform +from homeassistant.core import HomeAssistant + +from .coordinator import UniFiAccessDoorCoordinator +from .data import UniFiAccessData + +PLATFORMS: list[Platform] = [Platform.LOCK] + +type UniFiAccessConfigEntry = ConfigEntry[UniFiAccessData] # noqa: F821 + + +async def async_setup_entry(hass: HomeAssistant, entry: UniFiAccessConfigEntry) -> bool: + """Configure UniFi Access integration.""" + host = f"https://{entry.data.get(CONF_HOST)}/api/v1/developer" + configuration = uiaccessclient.Configuration( + host, access_token=entry.data.get(CONF_API_TOKEN) + ) + configuration.verify_ssl = entry.data.get(CONF_VERIFY_SSL) + api_client = uiaccessclient.ApiClient(configuration) + + door_coordinator = UniFiAccessDoorCoordinator(hass, api_client) + await door_coordinator.async_config_entry_first_refresh() + + entry.runtime_data = UniFiAccessData( + api_client=api_client, + door_coordinator=door_coordinator, + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: UniFiAccessConfigEntry +) -> bool: + """Unload UniFi Access integration.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/unifi_access/config_flow.py b/homeassistant/components/unifi_access/config_flow.py new file mode 100644 index 00000000000000..c3aa2ae30e22a0 --- /dev/null +++ b/homeassistant/components/unifi_access/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow for the UniFi Access integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import uiaccessclient +import urllib3.exceptions +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.core import HomeAssistant + +from .const import DEFAULT_HOST, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST, default=DEFAULT_HOST): str, + vol.Required(CONF_API_TOKEN): str, + vol.Required(CONF_VERIFY_SSL): bool, + } +) + + +class UniFiAccessConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for UniFi Access.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Process configuration form.""" + errors: dict[str, str] = {} + + if user_input is not None: + info = await _validate_input(self.hass, user_input, errors) + if not errors: + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +async def _validate_input( + hass: HomeAssistant, data: dict[str, Any], errors: dict[str, str] +) -> dict[str, Any]: + host = f"https://{data[CONF_HOST]}/api/v1/developer" + configuration = uiaccessclient.Configuration( + host, access_token=data[CONF_API_TOKEN] + ) + configuration.verify_ssl = data[CONF_VERIFY_SSL] + api_client = uiaccessclient.ApiClient(configuration) + space_api = uiaccessclient.SpaceApi(api_client) + + try: + await hass.async_add_executor_job(space_api.fetch_all_doors) + except ( + uiaccessclient.exceptions.UnauthorizedException, + uiaccessclient.exceptions.ForbiddenException, + ): + errors["base"] = "invalid_auth" + except urllib3.exceptions.HTTPError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return {"title": "UniFi Access"} diff --git a/homeassistant/components/unifi_access/const.py b/homeassistant/components/unifi_access/const.py new file mode 100644 index 00000000000000..f5869e147327d2 --- /dev/null +++ b/homeassistant/components/unifi_access/const.py @@ -0,0 +1,5 @@ +"""Constants for the UniFi Access integration.""" + +DOMAIN = "unifi_access" + +DEFAULT_HOST = "unifi.localdomain:12445" diff --git a/homeassistant/components/unifi_access/coordinator.py b/homeassistant/components/unifi_access/coordinator.py new file mode 100644 index 00000000000000..c8af1f8a64f76b --- /dev/null +++ b/homeassistant/components/unifi_access/coordinator.py @@ -0,0 +1,101 @@ +"""Coordinators for the UniFi Access integration.""" + +from asyncio import CancelledError, Task +from contextlib import suppress +import logging + +import aiohttp +import uiaccessclient + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + + +class UniFiAccessDoorCoordinator(DataUpdateCoordinator[dict[str, uiaccessclient.Door]]): + """Handles refreshing door data.""" + + def __init__( + self, hass: HomeAssistant, api_client: uiaccessclient.ApiClient + ) -> None: + """Initialize the door coordinator class.""" + super().__init__( + hass, + _LOGGER, + name="UniFi Access door", + always_update=False, + ) + + self.task: Task[None] | None = None + self.configuration = api_client.configuration + self.space_api = uiaccessclient.SpaceApi(api_client) + + async def _async_setup(self) -> None: + self.task = self.hass.async_create_task( + self.receive_updated_data(), eager_start=True + ) + + async def async_shutdown(self) -> None: + """Cancel any scheduled call, and ignore new runs.""" + await super().async_shutdown() + + if self.task is not None: + self.task.cancel() + with suppress(CancelledError): + await self.task + + async def _async_update_data(self) -> dict[str, uiaccessclient.Door]: + return await self.hass.async_add_executor_job(self._update_data) + + async def receive_updated_data(self) -> None: + """Start websocket receiver for updated data from UniFi Access.""" + _LOGGER.debug( + "Starting UniFi Access websocket with %s", self.configuration.host + ) + try: + async with ( + aiohttp.ClientSession( + base_url=self.configuration.host.rstrip("/") + "/", + headers={ + "Authorization": f"Bearer {self.configuration.access_token}" + }, + ) as session, + session.ws_connect( + "devices/notifications", verify_ssl=self.configuration.verify_ssl + ) as socket, + ): + # WebSocket API is poorly documented so we will just use the REST API whenever we get + # an update to fetch all the relevant data. + async for message in socket: + json = message.json() + if ( + type(json) is dict + and json.get("event") == "access.data.v2.device.update" + ): + _LOGGER.debug( + "Received update from UniFi Access: %s", json.get("event") + ) + self.async_set_updated_data( + await self.hass.async_add_executor_job(self._update_data) + ) + except Exception as exc: + _LOGGER.error("Error in UniFi Access websocket receiver: %s", exc) + raise + + _LOGGER.debug("UniFi Access websocket receiver has been cancelled") + + def _update_data(self) -> dict[str, uiaccessclient.Door]: + _LOGGER.debug("Refreshing UniFi Access door data") + try: + response = self.space_api.fetch_all_doors() + except ( + uiaccessclient.exceptions.UnauthorizedException, + uiaccessclient.exceptions.ForbiddenException, + ) as err: + raise ConfigEntryAuthFailed from err + except uiaccessclient.ApiException as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + return {door.id: door for door in response.data if door.is_bind_hub} diff --git a/homeassistant/components/unifi_access/data.py b/homeassistant/components/unifi_access/data.py new file mode 100644 index 00000000000000..f0f877f92a6886 --- /dev/null +++ b/homeassistant/components/unifi_access/data.py @@ -0,0 +1,15 @@ +"""Data structures for UniFi Access integration.""" + +from dataclasses import dataclass + +import uiaccessclient + +from . import UniFiAccessDoorCoordinator + + +@dataclass +class UniFiAccessData: + """Data structure for UniFi Access integration.""" + + api_client: uiaccessclient.ApiClient + door_coordinator: UniFiAccessDoorCoordinator diff --git a/homeassistant/components/unifi_access/lock.py b/homeassistant/components/unifi_access/lock.py new file mode 100644 index 00000000000000..062d92e2a53721 --- /dev/null +++ b/homeassistant/components/unifi_access/lock.py @@ -0,0 +1,75 @@ +"""Lock entities for the UniFi Access integration.""" + +from typing import Any + +import uiaccessclient + +from homeassistant.components.lock import LockEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import UniFiAccessConfigEntry, UniFiAccessDoorCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + entry: UniFiAccessConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Configure lock entities.""" + api_client = entry.runtime_data.api_client + door_coordinator = entry.runtime_data.door_coordinator + + async_add_entities( + UniFiAccessDoorLock(hass, api_client, door_coordinator, door_id) + for door_id in door_coordinator.data + ) + + +class UniFiAccessDoorLock(CoordinatorEntity, LockEntity): + """Represents a UniFi Access door lock.""" + + _attr_has_entity_name = True + + def __init__( + self, + hass: HomeAssistant, + api_client: uiaccessclient.ApiClient, + coordinator: UniFiAccessDoorCoordinator, + door_id: str, + ) -> None: + """Initialize the door lock.""" + super().__init__(coordinator, context=door_id) + + self.hass = hass + self.space_api = uiaccessclient.SpaceApi(api_client) + + self._attr_unique_id = door_id + self._update_attributes() + + @property + def translation_key(self) -> str: + """Return the translation key to translate the entity's states.""" + return "door_lock" + + @callback + def _handle_coordinator_update(self) -> None: + self._update_attributes() + super()._handle_coordinator_update() + + def _update_attributes(self) -> None: + assert isinstance(self.unique_id, str) + door = self.coordinator.data[self.unique_id] + self._attr_is_locked = door.door_lock_relay_status == "lock" + self._attr_is_open = door.door_position_status == "open" + + async def async_unlock(self, **kwargs: Any) -> None: + """Lock the door.""" + assert isinstance(self.unique_id, str) + await self.hass.async_add_executor_job( + self.space_api.remote_door_unlocking, self.unique_id + ) + + self._attr_is_locked = False + self.async_write_ha_state() diff --git a/homeassistant/components/unifi_access/manifest.json b/homeassistant/components/unifi_access/manifest.json new file mode 100644 index 00000000000000..0b339d55c28332 --- /dev/null +++ b/homeassistant/components/unifi_access/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "unifi_access", + "name": "UniFi Access", + "codeowners": ["@hagen93"], + "config_flow": true, + "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/unifi_access", + "homekit": {}, + "iot_class": "local_push", + "requirements": ["uiaccessclient==0.9.1"], + "ssdp": [], + "zeroconf": [] +} diff --git a/homeassistant/components/unifi_access/quality_scale.yaml b/homeassistant/components/unifi_access/quality_scale.yaml new file mode 100644 index 00000000000000..201a91652e5445 --- /dev/null +++ b/homeassistant/components/unifi_access/quality_scale.yaml @@ -0,0 +1,60 @@ +rules: + # Bronze + action-setup: todo + appropriate-polling: todo + brands: todo + common-modules: todo + config-flow-test-coverage: todo + config-flow: todo + dependency-transparency: todo + docs-actions: todo + docs-high-level-description: todo + docs-installation-instructions: todo + docs-removal-instructions: todo + entity-event-setup: todo + entity-unique-id: todo + has-entity-name: todo + runtime-data: todo + test-before-configure: todo + test-before-setup: todo + unique-config-entry: todo + + # Silver + action-exceptions: todo + config-entry-unloading: todo + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: todo + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: todo + 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: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/unifi_access/strings.json b/homeassistant/components/unifi_access/strings.json new file mode 100644 index 00000000000000..a785193165e5e6 --- /dev/null +++ b/homeassistant/components/unifi_access/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "api_token": "[%key:common::config_flow::data::api_token%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + } + } + }, + "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%]" + } + }, + "entity": { + "lock": { + "door_lock": { + "name": "Lock" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 14061d2e960240..5f1016e46db021 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -649,6 +649,7 @@ "ukraine_alarm", "unifi", "unifiprotect", + "unifi_access", "upb", "upcloud", "upnp", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 768443c36ee9c8..16831b9152bf8b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6698,6 +6698,12 @@ "config_flow": true, "iot_class": "local_push", "name": "UniFi Protect" + }, + "unifi_access": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "UniFi Access" } } }, diff --git a/mypy.ini b/mypy.ini index 55fd0b3cd650ad..edb6e8082f507a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4758,6 +4758,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.unifi_access.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.unifiprotect.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index ba7f5883a45cf8..8dd1d69e61a511 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2927,6 +2927,9 @@ typedmonarchmoney==0.3.1 # homeassistant.components.ukraine_alarm uasiren==0.0.1 +# homeassistant.components.unifi_access +uiaccessclient==0.9.1 + # homeassistant.components.unifiprotect uiprotect==7.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3457fd666a34d3..62d74237037c43 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2349,6 +2349,9 @@ typedmonarchmoney==0.3.1 # homeassistant.components.ukraine_alarm uasiren==0.0.1 +# homeassistant.components.unifi_access +uiaccessclient==0.9.1 + # homeassistant.components.unifiprotect uiprotect==7.4.1 diff --git a/tests/components/unifi_access/__init__.py b/tests/components/unifi_access/__init__.py new file mode 100644 index 00000000000000..f62aa5584e852d --- /dev/null +++ b/tests/components/unifi_access/__init__.py @@ -0,0 +1 @@ +"""Tests for the UniFi Access integration.""" diff --git a/tests/components/unifi_access/common.py b/tests/components/unifi_access/common.py new file mode 100644 index 00000000000000..a1457fc5fb0afd --- /dev/null +++ b/tests/components/unifi_access/common.py @@ -0,0 +1,37 @@ +"""Module for shared utilities and configurations.""" + +from asyncio import Future, Queue +from contextlib import asynccontextmanager + +from aiohttp import web + + +class MockWebsocketApplication(web.Application): + """Application for mocking websockets.""" + + def __init__(self) -> None: + """Initialize the application.""" + super().__init__() + + def add_mock_route(self, method, path): + """Add a mock route.""" + + @asynccontextmanager + async def manager(): + socket, done = await queue.get() + yield socket + await socket.close() + done.set_result(None) + + async def handler(request): + socket = web.WebSocketResponse() + await socket.prepare(request) + done = Future() + await queue.put((socket, done)) + await done + return socket + + self.router.add_route(method, path, handler) + + queue = Queue() + return manager() diff --git a/tests/components/unifi_access/conftest.py b/tests/components/unifi_access/conftest.py new file mode 100644 index 00000000000000..f95ca7e2f35f71 --- /dev/null +++ b/tests/components/unifi_access/conftest.py @@ -0,0 +1,48 @@ +"""Common fixtures for the UniFi Access tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from uiaccessclient import Door + +from homeassistant.components.unifi_access.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_async_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.unifi_access.async_setup_entry", return_value=True + ) as mock_async_setup_entry: + yield mock_async_setup_entry + + assert len(mock_async_setup_entry.mock_calls) == 1 + + +@pytest.fixture +async def config_entry( + hass: HomeAssistant, mock_async_setup_entry: AsyncMock +) -> MockConfigEntry: + """Create config entry for UniFi Access in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry + + +@pytest.fixture +def mock_doors() -> list[Door]: + """Return mocked doors.""" + return [ + Door(id="id-1", name="Door 1", is_bind_hub=True), + Door(id="id-2", name="Door 2", is_bind_hub=True), + Door(id="bogus", name="Bogus door", is_bind_hub=False), + ] 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 00000000000000..97d29614263192 --- /dev/null +++ b/tests/components/unifi_access/test_config_flow.py @@ -0,0 +1,151 @@ +"""Test the UniFi Access config flow.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries +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 homeassistant.data_entry_flow import FlowResultType + + +@pytest.fixture(autouse=True) +def lock_platform_only(): + """Skip setting up platform to speed up tests.""" + with patch("homeassistant.components.unifi_access.PLATFORMS", []): + yield + + +async def test_form(hass: HomeAssistant, mock_async_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.unifi_access.config_flow._validate_input", + return_value={"title": "Title"}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_API_TOKEN: "test-api-token", + CONF_VERIFY_SSL: True, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Title" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_API_TOKEN: "test-api-token", + CONF_VERIFY_SSL: True, + } + + +async def test_form_invalid_auth( + hass: HomeAssistant, mock_async_setup_entry: AsyncMock +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + def side_effect(_, __, errors): + errors["base"] = "invalid_auth" + return {} + + with patch( + "homeassistant.components.unifi_access.config_flow._validate_input", + side_effect=side_effect, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_API_TOKEN: "test-api-token", + CONF_VERIFY_SSL: True, + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + with patch( + "homeassistant.components.unifi_access.config_flow._validate_input", + return_value={"title": "Title"}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_API_TOKEN: "test-api-token", + CONF_VERIFY_SSL: True, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Title" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_API_TOKEN: "test-api-token", + CONF_VERIFY_SSL: True, + } + + +async def test_form_cannot_connect( + hass: HomeAssistant, mock_async_setup_entry: AsyncMock +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + def side_effect(_, __, errors): + errors["base"] = "cannot_connect" + return {} + + with patch( + "homeassistant.components.unifi_access.config_flow._validate_input", + side_effect=side_effect, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_API_TOKEN: "test-api-token", + CONF_VERIFY_SSL: True, + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + with patch( + "homeassistant.components.unifi_access.config_flow._validate_input", + return_value={"title": "Title"}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_API_TOKEN: "test-api-token", + CONF_VERIFY_SSL: True, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Title" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_API_TOKEN: "test-api-token", + CONF_VERIFY_SSL: True, + } diff --git a/tests/components/unifi_access/test_coordinator.py b/tests/components/unifi_access/test_coordinator.py new file mode 100644 index 00000000000000..446469a2f16a3a --- /dev/null +++ b/tests/components/unifi_access/test_coordinator.py @@ -0,0 +1,83 @@ +"""Unit tests for the UniFiAccessDoorCoordinator class.""" + +from asyncio import timeout +from contextlib import asynccontextmanager +from unittest.mock import patch + +import pytest +import uiaccessclient + +from homeassistant import config_entries +from homeassistant.components.unifi_access import UniFiAccessDoorCoordinator +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import HomeAssistant + +from .common import MockWebsocketApplication + +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +def lock_platform_only(): + """Skip setting up platform to speed up tests.""" + with patch("homeassistant.components.unifi_access.PLATFORMS", []): + yield + + +@pytest.mark.usefixtures("socket_enabled") +async def test_coordinator_lifecycle( + hass: HomeAssistant, + aiohttp_client: ClientSessionGenerator, + config_entry: ConfigEntry, + mock_doors: list[uiaccessclient.Door], +) -> None: + """Test the coordinator lifecycle.""" + config_entries.current_entry.set(config_entry) + coordinator = UniFiAccessDoorCoordinator( + hass, uiaccessclient.ApiClient.get_default() + ) + + app = MockWebsocketApplication() + sockets = app.add_mock_route("GET", "/devices/notifications") + session = await aiohttp_client(app) + + @asynccontextmanager + async def session_manager(): + yield session + + # Run first refresh + with ( + patch( + "homeassistant.config_entries.ConfigEntry.__setattr__", object.__setattr__ + ), + patch.object(config_entry, "state", ConfigEntryState.SETUP_IN_PROGRESS), + patch("aiohttp.ClientSession", return_value=session_manager()), + patch( + "uiaccessclient.SpaceApi.fetch_all_doors", + return_value=uiaccessclient.DoorsResponse(data=mock_doors), + ), + ): + await coordinator.async_config_entry_first_refresh() + + # Assert state after first refresh + assert coordinator.task is not None + assert not coordinator.task.cancelled() + + assert len(coordinator.data) == 2 + assert coordinator.data["id-1"] == mock_doors[0] + assert coordinator.data["id-2"] == mock_doors[1] + + # Trigger update by sending event on the coordinator's websocket + with patch( + "uiaccessclient.SpaceApi.fetch_all_doors", + return_value=uiaccessclient.DoorsResponse(data=mock_doors[1:]), + ): + async with timeout(1), sockets as socket: + await socket.send_json({"event": "access.data.v2.device.update"}) + + # Assert state after update triggered from websocket + assert len(coordinator.data) == 1 + assert coordinator.data["id-2"] == mock_doors[1] + + await coordinator.async_shutdown() + assert coordinator.task.done()