Skip to content

Commit

Permalink
Merge pull request #23 from imhotep/events
Browse files Browse the repository at this point in the history
adding events and fixing potential thread issue
  • Loading branch information
imhotep authored Mar 1, 2024
2 parents 7915add + 9076c7e commit 9387804
Show file tree
Hide file tree
Showing 7 changed files with 255 additions and 9 deletions.
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ This is a basic integration of [Unifi Access](https://ui.com/door-access) in [Ho
- Enter your API Token that you generated in Unifi Access
- Select `Verify SSL certificate` only if you have a valid SSL certificate. For example: If your Unifi Access API server is behind a reverse proxy. Selecting this will fail otherwise.
- Select `Use polling` if your Unifi Access version is < 1.90. Default is to use websockets for instantaneous updates and more features.
- It should find all of your doors and add two or three entities for each one
- It should find all of your doors and add the following entities for each one
- Door Position Sensor (binary_sensor). If you don't have one connected, it will always be **off** (closed).
- Doorbell Pressed (binary_sensor). Requires **Unifi Access Reader Pro G1/G2** otherwise always **off**. Only appears when **Use polling** is not selected!
- Door Lock (lock). This will not show up immediately under the device but it should show up after a while. You can unlock (but not lock) a door
- Event entities: Access and Doorbell Press


# Installation (manual)
Expand All @@ -29,10 +30,31 @@ This is a basic integration of [Unifi Access](https://ui.com/door-access) in [Ho
- Enter your API Token that you generated in Unifi Access
- Select `Verify SSL certificate` only if you have a valid SSL certificate. For example: If your Unifi Access API server is behind a reverse proxy. Selecting this will fail otherwise.
- Select `Use polling` if your Unifi Access version is < 1.90. Default is to use websockets for instantaneous updates and more features.
- It should find all of your doors and add two or three entities for each one
- It should find all of your doors and add the following entities for each one
- Door Position Sensor (binary_sensor). If you don't have one connected, it will always be **off** (closed).
- Doorbell Pressed (binary_sensor). Requires **Unifi Access Reader Pro G1/G2** otherwise always **off**. Only appears when **Use polling** is not selected!
- Door Lock (lock). This will not show up immediately under the device but it should show up after a while. You can unlock (but not lock) a door
- Event entities: Access and Doorbell Press

# Events
This integration currently supports two type of events

## Doorbell Press Event
An entity will get created for each door. Every time the doorbell is pressed there will be a `unifi_access_doorbell_start` event that will be received by this entity with some metadata. The same event will also be fired on the Home Assistant Event Bus. You can listen to it via the Developer Tools. When the doorbell is either answered or canceled there will be a `unifi_access_doorbell_stop` event.

### Event metadata
- door_name
- door_id
- type # `unifi_access_doorbell_start` or `unifi_access_doorbell_stop`

## Access
An entity will get created for each door. Every time a door is accessed (entry, exit, app, api) there will be a `unifi_access_entry` or `unifi_access_exit` event that will be received by this entity with some metadata. The same event will also be fired on the Home Assistant Event Bus. You can listen to it via the Developer Tools.

### Event metadata
- door_name
- door_id
- actor # this is the name of the user that accessed the door. If set to N/A that means UNAUTHORIZED ACCESS!
- type # `unifi_access_entry` or `unifi_access_exit`

# Example automation

Expand Down
2 changes: 1 addition & 1 deletion custom_components/unifi_access/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from .const import DOMAIN
from .hub import UnifiAccessHub

PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.LOCK]
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.EVENT]


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
Expand Down
7 changes: 7 additions & 0 deletions custom_components/unifi_access/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,10 @@
DOORS_URL = "/api/v1/developer/doors"
DOOR_UNLOCK_URL = "/api/v1/developer/doors/{door_id}/unlock"
DEVICE_NOTIFICATIONS_URL = "/api/v1/developer/devices/notifications"

DOORBELL_EVENT = "doorbell_press"
DOORBELL_START_EVENT = "unifi_access_doorbell_start"
DOORBELL_STOP_EVENT = "unifi_access_doorbell_stop"
ACCESS_EVENT = "unifi_access_{type}"
ACCESS_ENTRY_EVENT = "unifi_access_entry"
ACCESS_EXIT_EVENT = "unifi_access_exit"
35 changes: 34 additions & 1 deletion custom_components/unifi_access/door.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ def __init__(
) -> None:
"""Initialize door."""
self._callbacks: set[Callable] = set()
self._event_listeners: dict[str, set] = {
"access": set(),
"doorbell_press": set(),
}
self._is_locking = False
self._is_unlocking = False
self._hub = hub
Expand Down Expand Up @@ -75,7 +79,36 @@ def remove_callback(self, callback: Callable[[], None]) -> None:
"""Remove previously registered callback."""
self._callbacks.discard(callback)

def publish_updates(self) -> None:
async def publish_updates(self) -> None:
"""Schedule call all registered callbacks."""
for callback in self._callbacks:
callback()

def add_event_listener(
self, event: str, callback: Callable[[str, dict[str, str]], None]
) -> None:
"""Add event listener."""
if self._event_listeners.get(event) is not None:
self._event_listeners[event].add(callback)
_LOGGER.info("Registered event %s for door %s", event, self.name)

def remove_event_listener(
self, event: str, callback: Callable[[str, dict[str, str]], None]
) -> None:
"""Remove event listener."""
_LOGGER.info("Unregistered event %s for door %s", event, self.name)
self._event_listeners[event].discard(callback)

async def trigger_event(self, event: str, data: dict[str, str]):
"""Trigger event."""
_LOGGER.info(
"Triggering event %s for door %s with data %s",
event,
self.name,
data,
)
for callback in self._event_listeners[event]:
callback(data["type"], data)
_LOGGER.info(
"Event %s type %s for door %s fired", event, data["type"], self.name
)
122 changes: 122 additions & 0 deletions custom_components/unifi_access/event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Platform for sensor integration."""
from __future__ import annotations

import logging

from homeassistant.components.event import EventDeviceClass, EventEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback

from .const import (
ACCESS_ENTRY_EVENT,
ACCESS_EXIT_EVENT,
DOMAIN,
DOORBELL_START_EVENT,
DOORBELL_STOP_EVENT,
)
from .door import UnifiAccessDoor
from .hub import UnifiAccessCoordinator, UnifiAccessHub

_LOGGER = logging.getLogger(__name__)


async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Add Binary Sensor for passed config entry."""
hub: UnifiAccessHub = hass.data[DOMAIN][config_entry.entry_id]

coordinator: UnifiAccessCoordinator = UnifiAccessCoordinator(hass, hub)

await coordinator.async_config_entry_first_refresh()

async_add_entities(
(AccessEventEntity(hass, door) for door in coordinator.data.values()),
)
async_add_entities(
(DoorbellPressedEventEntity(hass, door) for door in coordinator.data.values()),
)


class AccessEventEntity(EventEntity):
"""Authorized User Event Entity."""

_attr_event_types = [ACCESS_ENTRY_EVENT, ACCESS_EXIT_EVENT]

def __init__(self, hass: HomeAssistant, door) -> None:
"""Initialize Unifi Access Door Lock."""
self.hass = hass
self.door: UnifiAccessDoor = door
self._attr_unique_id = f"{self.door.id}_access"
self._attr_name = f"{self.door.name} Access"

@property
def device_info(self) -> DeviceInfo:
"""Get device information."""
return DeviceInfo(
identifiers={(DOMAIN, self.door.id)},
name=self.door.name,
model="UAH",
manufacturer="Unifi",
)

def _async_handle_event(self, event: str, event_attributes: dict[str, str]) -> None:
"""Handle access events."""
_LOGGER.info("Triggering event %s with attributes %s", event, event_attributes)
self._trigger_event(event, event_attributes)
self.async_write_ha_state()
self.hass.bus.fire(event, event_attributes)

async def async_added_to_hass(self) -> None:
"""Register event listener with hub."""
self.door.add_event_listener("access", self._async_handle_event)

async def async_will_remove_from_hass(self) -> None:
"""Handle updates in case of push and removal."""
await super().async_will_remove_from_hass()
self.door.remove_event_listener("access", self._async_handle_event)


class DoorbellPressedEventEntity(EventEntity):
"""Doorbell Press Event Entity."""

_attr_device_class = EventDeviceClass.DOORBELL
_attr_event_types = [DOORBELL_START_EVENT, DOORBELL_STOP_EVENT]

def __init__(self, hass: HomeAssistant, door) -> None:
"""Initialize Unifi Access Doorbell Event."""
self.hass = hass
self.id = door.id
self.door: UnifiAccessDoor = door
self._attr_unique_id = f"{self.door.id}_doorbell_press"
self._attr_name = f"{self.door.name} Doorbell Press"

@property
def device_info(self) -> DeviceInfo:
"""Get device information."""
return DeviceInfo(
identifiers={(DOMAIN, self.door.id)},
name=self.door.name,
model="UAH",
manufacturer="Unifi",
)

def _async_handle_event(self, event: str, event_attributes: dict[str, str]) -> None:
"""Handle doorbell events."""
_LOGGER.info("Received event %s with attributes %s", event, event_attributes)
self._trigger_event(event, event_attributes)
self.async_write_ha_state()
self.hass.bus.fire(event, event_attributes)

async def async_added_to_hass(self) -> None:
"""Register event listener with door."""
self.door.add_event_listener("doorbell_press", self._async_handle_event)

async def async_will_remove_from_hass(self) -> None:
"""Handle updates in case of push and removal."""
await super().async_will_remove_from_hass()
self.door.remove_event_listener("doorbell_press", self._async_handle_event)
70 changes: 66 additions & 4 deletions custom_components/unifi_access/hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .const import (
ACCESS_EVENT,
DEVICE_NOTIFICATIONS_URL,
DOOR_UNLOCK_URL,
DOORBELL_START_EVENT,
DOORBELL_STOP_EVENT,
DOORS_URL,
UNIFI_ACCESS_API_PORT,
)
Expand Down Expand Up @@ -76,6 +79,7 @@ def __init__(
}
self._doors: dict[str, UnifiAccessDoor] = {}
self.update_t = None
self.loop = asyncio.get_event_loop()

@property
def doors(self):
Expand Down Expand Up @@ -189,7 +193,8 @@ def on_message(self, ws: websocket.WebSocketApp, message):
Doorbell presses are relying on door names so if those are not unique, it may cause some issues
"""
# _LOGGER.info(f"Got update {message}")
event = None
event_attributes = None
if "Hello" not in message:
update = json.loads(message)
existing_door = None
Expand All @@ -201,9 +206,10 @@ def on_message(self, ws: websocket.WebSocketApp, message):
existing_door = self.doors[door_id]
existing_door.door_position_status = update["data"]["status"]
_LOGGER.info(
"DPS Change of door %s with ID %s Updated",
"DPS Change for existing door %s with ID %s status: %s",
existing_door.name,
door_id,
update["data"]["status"],
)
case "access.data.device.remote_unlock":
door_id = update["data"]["unique_id"]
Expand Down Expand Up @@ -240,6 +246,12 @@ def on_message(self, ws: websocket.WebSocketApp, message):
)
if existing_door is not None:
existing_door.doorbell_request_id = update["data"]["request_id"]
event = "doorbell_press"
event_attributes = {
"door_name": existing_door.name,
"door_id": existing_door.id,
"type": DOORBELL_START_EVENT,
}
_LOGGER.info(
"Doorbell press on %s Request ID %s",
door_name,
Expand All @@ -260,14 +272,64 @@ def on_message(self, ws: websocket.WebSocketApp, message):
)
if existing_door is not None:
existing_door.doorbell_request_id = None
event = "doorbell_press"
event_attributes = {
"door_name": existing_door.name,
"door_id": existing_door.id,
"type": DOORBELL_STOP_EVENT,
}
_LOGGER.info(
"Doorbell press stopped on %s Request ID %s",
existing_door.name,
doorbell_request_id,
)

case "access.logs.add":
door = next(
(
target
for target in update["data"]["_source"]["target"]
if target["type"] == "door"
),
None,
)
if door is not None:
door_id = door["id"]
_LOGGER.info("Access log added via websocket %s", door_id)
if door_id in self.doors:
existing_door = self.doors[door_id]
actor = update["data"]["_source"]["actor"]["display_name"]
device_config = next(
(
target
for target in update["data"]["_source"]["target"]
if target["type"] == "device_config"
),
None,
)
if device_config is not None:
access_type = device_config["display_name"]
event = "access"
event_attributes = {
"door_name": existing_door.name,
"door_id": door_id,
"actor": actor,
"type": ACCESS_EVENT.format(type=access_type),
}
_LOGGER.info(
"Door name %s with ID %s accessed by %s. Access type: %s",
existing_door.name,
door_id,
actor,
access_type,
)
if existing_door is not None:
existing_door.publish_updates()
asyncio.run_coroutine_threadsafe(
existing_door.publish_updates(), self.loop
)
if event is not None and event_attributes is not None:
asyncio.run_coroutine_threadsafe(
existing_door.trigger_event(event, event_attributes), self.loop
)

def on_error(self, ws: websocket.WebSocketApp, error):
"""Handle errors in the websocket client."""
Expand Down
2 changes: 1 addition & 1 deletion custom_components/unifi_access/lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ async def async_setup_entry(
await coordinator.async_config_entry_first_refresh()

async_add_entities(
UnifiDoorLockEntity(coordinator, key) for key, value in coordinator.data.items()
UnifiDoorLockEntity(coordinator, key) for key in coordinator.data
)


Expand Down

0 comments on commit 9387804

Please sign in to comment.