Skip to content

Commit

Permalink
Add minimal UniFi Access integration
Browse files Browse the repository at this point in the history
  • Loading branch information
andreashagensjolvsagt committed Jan 10, 2025
1 parent 4086d09 commit bb0284a
Show file tree
Hide file tree
Showing 22 changed files with 765 additions and 1 deletion.
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion homeassistant/brands/ubiquiti.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
{
"domain": "ubiquiti",
"name": "Ubiquiti",
"integrations": ["unifi", "unifi_direct", "unifiled", "unifiprotect"]
"integrations": [
"unifi",
"unifi_direct",
"unifiled",
"unifiprotect",
"unifi_access"
]
}
44 changes: 44 additions & 0 deletions homeassistant/components/unifi_access/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
74 changes: 74 additions & 0 deletions homeassistant/components/unifi_access/config_flow.py
Original file line number Diff line number Diff line change
@@ -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"}
5 changes: 5 additions & 0 deletions homeassistant/components/unifi_access/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Constants for the UniFi Access integration."""

DOMAIN = "unifi_access"

DEFAULT_HOST = "unifi.localdomain:12445"
101 changes: 101 additions & 0 deletions homeassistant/components/unifi_access/coordinator.py
Original file line number Diff line number Diff line change
@@ -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}
15 changes: 15 additions & 0 deletions homeassistant/components/unifi_access/data.py
Original file line number Diff line number Diff line change
@@ -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
75 changes: 75 additions & 0 deletions homeassistant/components/unifi_access/lock.py
Original file line number Diff line number Diff line change
@@ -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()
13 changes: 13 additions & 0 deletions homeassistant/components/unifi_access/manifest.json
Original file line number Diff line number Diff line change
@@ -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": []
}
Loading

0 comments on commit bb0284a

Please sign in to comment.