diff --git a/README.md b/README.md index 63f55e9..61199ee 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 diff --git a/custom_components/unifi_access/__init__.py b/custom_components/unifi_access/__init__.py index 62906f1..cc20a98 100644 --- a/custom_components/unifi_access/__init__.py +++ b/custom_components/unifi_access/__init__.py @@ -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: diff --git a/custom_components/unifi_access/const.py b/custom_components/unifi_access/const.py index 1f659b5..df16dff 100644 --- a/custom_components/unifi_access/const.py +++ b/custom_components/unifi_access/const.py @@ -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" diff --git a/custom_components/unifi_access/door.py b/custom_components/unifi_access/door.py index db6a193..4188ecd 100644 --- a/custom_components/unifi_access/door.py +++ b/custom_components/unifi_access/door.py @@ -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 @@ -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 + ) diff --git a/custom_components/unifi_access/event.py b/custom_components/unifi_access/event.py new file mode 100644 index 0000000..3b77a32 --- /dev/null +++ b/custom_components/unifi_access/event.py @@ -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) diff --git a/custom_components/unifi_access/hub.py b/custom_components/unifi_access/hub.py index 44ed786..c9afa02 100644 --- a/custom_components/unifi_access/hub.py +++ b/custom_components/unifi_access/hub.py @@ -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, ) @@ -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): @@ -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 @@ -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"] @@ -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, @@ -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.""" diff --git a/custom_components/unifi_access/lock.py b/custom_components/unifi_access/lock.py index efa1c3f..02b7cf0 100644 --- a/custom_components/unifi_access/lock.py +++ b/custom_components/unifi_access/lock.py @@ -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 )