From 0ff38cdc7fa7f65c747d27fe009fcd892fce59ee Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:04:49 +0200 Subject: [PATCH] Fix/unifi access uah door and thumbnail (#168708) --- .../components/unifi_access/coordinator.py | 10 +- .../components/unifi_access/image.py | 16 +- .../unifi_access/snapshots/test_image.ambr | 52 ------- tests/components/unifi_access/test_event.py | 144 ++++++++++++++++++ tests/components/unifi_access/test_image.py | 46 ++++-- tests/components/unifi_access/test_init.py | 10 +- 6 files changed, 203 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/unifi_access/coordinator.py b/homeassistant/components/unifi_access/coordinator.py index c7eb64bf8cf..4a097d8a8bc 100644 --- a/homeassistant/components/unifi_access/coordinator.py +++ b/homeassistant/components/unifi_access/coordinator.py @@ -453,9 +453,15 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]): log = cast(LogAdd, msg) source = log.data.source device_target = source.device_config - if device_target is None or device_target.id not in self._device_to_door: + if device_target is None: + return + if device_target.id in self._device_to_door: + door_id = self._device_to_door[device_target.id] + elif msg.door_id: + # UAH-DOOR devices: door_id is enriched by the library via MAC→door map + door_id = msg.door_id + else: return - door_id = self._device_to_door[device_target.id] event_type = ( "access_granted" if source.event.result == "ACCESS" else "access_denied" ) diff --git a/homeassistant/components/unifi_access/image.py b/homeassistant/components/unifi_access/image.py index b2ca2b5d242..fd8d2f326ad 100644 --- a/homeassistant/components/unifi_access/image.py +++ b/homeassistant/components/unifi_access/image.py @@ -3,8 +3,9 @@ from __future__ import annotations from datetime import UTC, datetime +import logging -from unifi_access_api import Door +from unifi_access_api import Door, UnifiAccessError from homeassistant.components.image import ImageEntity from homeassistant.const import CONF_VERIFY_SSL @@ -14,6 +15,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator from .entity import UnifiAccessEntity +_LOGGER = logging.getLogger(__name__) + PARALLEL_UPDATES = 0 @@ -28,7 +31,7 @@ async def async_setup_entry( @callback def _async_add_new_doors() -> None: - new_door_ids = sorted(set(coordinator.data.doors) - added_doors) + new_door_ids = sorted(set(coordinator.data.door_thumbnails) - added_doors) if not new_door_ids: return async_add_entities( @@ -72,7 +75,14 @@ class UnifiAccessDoorImageEntity(UnifiAccessEntity, ImageEntity): async def async_image(self) -> bytes | None: """Return the door thumbnail image bytes.""" if thumbnail := self.coordinator.data.door_thumbnails.get(self._door_id): - return await self.coordinator.client.get_thumbnail(thumbnail.url) + try: + return await self.coordinator.client.get_thumbnail(thumbnail.url) + except UnifiAccessError as err: + _LOGGER.warning( + "Failed to fetch thumbnail for door %s: %s", + self._door_id, + err, + ) return None def _handle_coordinator_update(self) -> None: diff --git a/tests/components/unifi_access/snapshots/test_image.ambr b/tests/components/unifi_access/snapshots/test_image.ambr index dcd1981e357..dc29e8d7f45 100644 --- a/tests/components/unifi_access/snapshots/test_image.ambr +++ b/tests/components/unifi_access/snapshots/test_image.ambr @@ -1,56 +1,4 @@ # serializer version: 1 -# name: test_image_entities[image.back_door_thumbnail-entry] - EntityRegistryEntrySnapshot({ - 'aliases': list([ - None, - ]), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'image', - 'entity_category': None, - 'entity_id': 'image.back_door_thumbnail', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': 'Thumbnail', - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Thumbnail', - 'platform': 'unifi_access', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'door_thumbnail', - 'unique_id': 'door-002-thumbnail', - 'unit_of_measurement': None, - }) -# --- -# name: test_image_entities[image.back_door_thumbnail-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'access_token': '1', - 'entity_picture': '/api/image_proxy/image.back_door_thumbnail?token=1', - 'friendly_name': 'Back Door Thumbnail', - }), - 'context': , - 'entity_id': 'image.back_door_thumbnail', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_image_entities[image.front_door_thumbnail-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/unifi_access/test_event.py b/tests/components/unifi_access/test_event.py index 72e7957c282..0a1145b23a6 100644 --- a/tests/components/unifi_access/test_event.py +++ b/tests/components/unifi_access/test_event.py @@ -607,6 +607,7 @@ async def test_logs_add_no_device_config_target_ignored( log_msg = LogAdd( event="access.logs.add", + door_id="door-001", # enriched door_id must not bypass missing device_config data=LogAddData( source=LogSource( target=[ @@ -777,3 +778,146 @@ async def test_logs_add_device_mapping_pruned_on_refresh( # door-001 entity was removed when the door disappeared assert hass.states.get(FRONT_DOOR_ACCESS_ENTITY) is None + + +@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00") +async def test_logs_add_uah_door_via_enriched_door_id( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test UAH-DOOR access event resolved via library-enriched door_id.""" + handlers = _get_ws_handlers(mock_client) + + # UAH-DOOR: device MAC is not in coordinator's _device_to_door, + # but the library has enriched msg.door_id via its MAC→door map. + log_msg = LogAdd( + event="access.logs.add", + door_id="door-001", + data=LogAddData( + source=LogSource( + target=[ + LogTarget( + type="device_config", + id="uah-door-mac-aa:bb:cc:dd:ee:ff", + display_name="UAH Door Reader", + ), + ], + actor=LogActor(display_name="Jane Doe"), + event=LogEvent(result="ACCESS"), + authentication=LogAuthentication(credential_provider="NFC"), + ), + ), + ) + + await handlers["access.logs.add"](log_msg) + await hass.async_block_till_done() + + state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY) + assert state is not None + assert state.attributes["event_type"] == "access_granted" + assert state.attributes["actor"] == "Jane Doe" + assert state.attributes["authentication"] == "NFC" + assert state.attributes["result"] == "ACCESS" + assert state.state == "2025-01-01T00:00:00.000+00:00" + + +@pytest.mark.freeze_time("2025-01-01 00:00:00+00:00") +async def test_logs_add_uah_door_access_denied( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test UAH-DOOR access_denied event resolved via library-enriched door_id.""" + handlers = _get_ws_handlers(mock_client) + + log_msg = LogAdd( + event="access.logs.add", + door_id="door-001", + data=LogAddData( + source=LogSource( + target=[ + LogTarget( + type="device_config", + id="uah-door-mac-aa:bb:cc:dd:ee:ff", + display_name="UAH Door Reader", + ), + ], + event=LogEvent(result="BLOCKED"), + ), + ), + ) + + await handlers["access.logs.add"](log_msg) + await hass.async_block_till_done() + + state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY) + assert state is not None + assert state.attributes["event_type"] == "access_denied" + assert state.attributes["result"] == "BLOCKED" + assert state.state == "2025-01-01T00:00:00.000+00:00" + + +async def test_logs_add_uah_door_unknown_door_ignored( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test UAH-DOOR event is ignored when door_id is not a known door.""" + handlers = _get_ws_handlers(mock_client) + + log_msg = LogAdd( + event="access.logs.add", + door_id="door-unknown", + data=LogAddData( + source=LogSource( + target=[ + LogTarget( + type="device_config", + id="uah-door-mac-aa:bb:cc:dd:ee:ff", + display_name="UAH Door Reader", + ), + ], + event=LogEvent(result="ACCESS"), + ), + ), + ) + + await handlers["access.logs.add"](log_msg) + await hass.async_block_till_done() + + state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY) + assert state is not None + assert state.state == "unknown" + + +async def test_logs_add_no_device_and_no_enriched_door_id_ignored( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test logs.add event is ignored when neither device mapping nor door_id resolves.""" + handlers = _get_ws_handlers(mock_client) + + log_msg = LogAdd( + event="access.logs.add", + data=LogAddData( + source=LogSource( + target=[ + LogTarget( + type="device_config", + id="unknown-device", + display_name="Unknown", + ), + ], + event=LogEvent(result="ACCESS"), + ), + ), + ) + + await handlers["access.logs.add"](log_msg) + await hass.async_block_till_done() + + state = hass.states.get(FRONT_DOOR_ACCESS_ENTITY) + assert state is not None + assert state.state == "unknown" diff --git a/tests/components/unifi_access/test_image.py b/tests/components/unifi_access/test_image.py index 2ce0a0d8974..ce6d935cd5a 100644 --- a/tests/components/unifi_access/test_image.py +++ b/tests/components/unifi_access/test_image.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from unifi_access_api import ApiNotFoundError from unifi_access_api.models.websocket import ( LocationUpdateData, LocationUpdateV2, @@ -76,17 +77,13 @@ async def test_async_image_with_thumbnail( mock_client.get_thumbnail.assert_awaited_once_with("/preview/front_door.png") -async def test_async_image_without_thumbnail( +async def test_no_image_entity_for_door_without_thumbnail( hass: HomeAssistant, init_integration: MockConfigEntry, mock_client: MagicMock, - hass_client: ClientSessionGenerator, ) -> None: - """Test async_image returns empty response when no thumbnail exists.""" - client = await hass_client() - resp = await client.get(f"/api/image_proxy/{BACK_DOOR_IMAGE}") - - assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + """Test that no image entity is created for a door with no thumbnail data.""" + assert hass.states.get(BACK_DOOR_IMAGE) is None async def test_initial_thumbnail_sets_image_last_updated( @@ -99,10 +96,8 @@ async def test_initial_thumbnail_sets_image_last_updated( assert state is not None assert state.state != "unknown" - # Back door has no thumbnail, so it should be unknown - state_back = hass.states.get(BACK_DOOR_IMAGE) - assert state_back is not None - assert state_back.state == "unknown" + # Back door has no thumbnail, so no entity is created + assert hass.states.get(BACK_DOOR_IMAGE) is None async def test_handle_coordinator_update_sets_image_last_updated( @@ -110,11 +105,9 @@ async def test_handle_coordinator_update_sets_image_last_updated( init_integration: MockConfigEntry, mock_client: MagicMock, ) -> None: - """Test WS thumbnail update sets image_last_updated from thumbnail.""" - # Back door starts without thumbnail - state_before = hass.states.get(BACK_DOOR_IMAGE) - assert state_before is not None - assert state_before.state == "unknown" + """Test WS thumbnail update creates entity and sets image_last_updated.""" + # Back door starts without thumbnail — no entity exists yet + assert hass.states.get(BACK_DOOR_IMAGE) is None handlers = _get_ws_handlers(mock_client) await handlers["access.data.device.location_update_v2"]( @@ -157,3 +150,24 @@ async def test_handle_coordinator_update_sets_image_last_updated( state_updated = hass.states.get(BACK_DOOR_IMAGE) assert state_updated is not None assert state_updated.state != state_after.state + + +async def test_async_image_get_thumbnail_api_error_returns_none( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test async_image returns None (500) when get_thumbnail raises an API error.""" + mock_client.get_thumbnail.side_effect = ApiNotFoundError( + "Thumbnail fetch failed (404)" + ) + + client = await hass_client() + resp = await client.get(f"/api/image_proxy/{FRONT_DOOR_IMAGE}") + + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + mock_client.get_thumbnail.assert_awaited_once_with("/preview/front_door.png") + assert "Failed to fetch thumbnail for door" in caplog.text + assert "Thumbnail fetch failed (404)" in caplog.text diff --git a/tests/components/unifi_access/test_init.py b/tests/components/unifi_access/test_init.py index 0b34bf8c3ba..c0e17c60629 100644 --- a/tests/components/unifi_access/test_init.py +++ b/tests/components/unifi_access/test_init.py @@ -333,7 +333,8 @@ async def test_ws_location_update_with_thumbnail( mock_client: MagicMock, ) -> None: """Test location_update_v2 with thumbnail updates image entity.""" - assert hass.states.get(BACK_DOOR_IMAGE).state == "unknown" + # Back door starts without thumbnail — entity does not exist yet + assert hass.states.get(BACK_DOOR_IMAGE) is None handlers = _get_ws_handlers(mock_client) msg = LocationUpdateV2( @@ -416,7 +417,12 @@ async def test_new_door_entities_created_on_refresh( # Add a new door to the API response mock_client.get_doors.return_value = [ *mock_client.get_doors.return_value, - _make_door("door-003", "Garage Door"), + _make_door( + "door-003", + "Garage Door", + door_thumbnail="/preview/garage_door.png", + door_thumbnail_last_update=1700000000, + ), ] # Trigger natural refresh via WebSocket reconnect