1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-02 08:26:41 +01:00

Add UniFi Access integration (#165404)

Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
This commit is contained in:
Raphael Hehl
2026-03-14 00:00:18 +01:00
committed by GitHub
parent 7276403ab9
commit a47faa3ced
21 changed files with 1076 additions and 0 deletions

2
CODEOWNERS generated
View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
)

View File

@@ -0,0 +1,3 @@
"""Constants for the UniFi Access integration."""
DOMAIN = "unifi_access"

View File

@@ -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})

View File

@@ -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]

View File

@@ -0,0 +1,9 @@
{
"entity": {
"button": {
"unlock": {
"default": "mdi:lock-open"
}
}
}
}

View File

@@ -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"]
}

View File

@@ -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

View File

@@ -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."
}
}
}

View File

@@ -751,6 +751,7 @@ FLOWS = {
"uhoo",
"ukraine_alarm",
"unifi",
"unifi_access",
"unifiprotect",
"upb",
"upcloud",

View File

@@ -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",

3
requirements_all.txt generated
View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.back_door_unlock',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.back_door_unlock',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_button_entities[button.front_door_unlock-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.front_door_unlock',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.front_door_unlock',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@@ -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

View File

@@ -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

View File

@@ -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