From 9a93ebda101c9eeefd4baca46d0ecdb0734064a9 Mon Sep 17 00:00:00 2001 From: Mattias Arrelid Date: Wed, 29 Jul 2026 11:42:41 +0200 Subject: [PATCH] Add image platform for DoorBird last motion and last ring (#173120) Co-authored-by: Joost Lekkerkerker Co-authored-by: Claude Opus 4.8 (1M context) --- homeassistant/components/doorbird/const.py | 2 +- homeassistant/components/doorbird/image.py | 130 ++++++++++++++++++ .../components/doorbird/strings.json | 8 ++ tests/components/doorbird/conftest.py | 13 +- tests/components/doorbird/test_image.py | 104 ++++++++++++++ 5 files changed, 252 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/doorbird/image.py create mode 100644 tests/components/doorbird/test_image.py diff --git a/homeassistant/components/doorbird/const.py b/homeassistant/components/doorbird/const.py index b4b9d6f32230..da677ea54e3f 100644 --- a/homeassistant/components/doorbird/const.py +++ b/homeassistant/components/doorbird/const.py @@ -3,7 +3,7 @@ from homeassistant.const import Platform DOMAIN = "doorbird" -PLATFORMS = [Platform.BUTTON, Platform.CAMERA, Platform.EVENT] +PLATFORMS = [Platform.BUTTON, Platform.CAMERA, Platform.EVENT, Platform.IMAGE] CONF_EVENTS = "events" MANUFACTURER = "Bird Home Automation Group" diff --git a/homeassistant/components/doorbird/image.py b/homeassistant/components/doorbird/image.py new file mode 100644 index 000000000000..61cde11e09ba --- /dev/null +++ b/homeassistant/components/doorbird/image.py @@ -0,0 +1,130 @@ +"""Last motion and last ring image entities for a DoorBird device.""" + +# These replace the same-named camera entities, which exposed stills through the +# camera UI even though no live video is involved. The legacy camera entities are +# kept to avoid breaking existing dashboards and automations; a follow-up should +# deprecate them via a repair issue once users have had time to migrate. + +from dataclasses import dataclass +from typing import override + +import aiohttp + +from homeassistant.components.image import ( + Image, + ImageEntity, + ImageEntityDescription, + infer_image_type, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .entity import DoorBirdEntity +from .models import DoorBirdConfigEntry, DoorBirdData + + +@dataclass(frozen=True, kw_only=True) +class DoorBirdImageEntityDescription(ImageEntityDescription): + """Describes a DoorBird image entity.""" + + doorbird_event_type: str + + +IMAGE_DESCRIPTIONS: tuple[DoorBirdImageEntityDescription, ...] = ( + DoorBirdImageEntityDescription( + key="last_motion", + translation_key="last_motion", + doorbird_event_type="motion", + ), + DoorBirdImageEntityDescription( + key="last_ring", + translation_key="last_ring", + doorbird_event_type="doorbell", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: DoorBirdConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the DoorBird image platform.""" + door_bird_data = config_entry.runtime_data + async_add_entities( + DoorBirdLastEventImage(hass, door_bird_data, description) + for description in IMAGE_DESCRIPTIONS + ) + + +class DoorBirdLastEventImage(ImageEntity, DoorBirdEntity): + """An image of the last motion or last ring on a DoorBird device.""" + + entity_description: DoorBirdImageEntityDescription + + def __init__( + self, + hass: HomeAssistant, + door_bird_data: DoorBirdData, + description: DoorBirdImageEntityDescription, + ) -> None: + """Initialize the image entity.""" + ImageEntity.__init__(self, hass) + DoorBirdEntity.__init__(self, door_bird_data) + self.entity_description = description + self._attr_unique_id = f"{self._mac_addr}_{description.key}" + history_type = ( + "doorbell" + if description.doorbird_event_type == "doorbell" + else "motionsensor" + ) + self._image_url = self._door_station.device.history_image_url(1, history_type) + self._matching_event_names = [ + event.event + for event in self._door_station.event_descriptions + if event.event_type == description.doorbird_event_type + ] + + @override + async def async_image(self) -> bytes | None: + """Return bytes of the last event image.""" + if self._cached_image: + return self._cached_image.content + try: + # No explicit timeout here — the image framework wraps async_image() in its + # own asyncio.timeout(IMAGE_TIMEOUT) and raises HTTP 500 on expiry. + image_bytes = await self._door_station.device.get_image(self._image_url) + except aiohttp.ClientError as error: + raise HomeAssistantError( + f"Error getting image from DoorBird: {error}" + ) from error + content_type = infer_image_type(image_bytes) + if content_type is None: + raise HomeAssistantError("DoorBird returned an unrecognized image") + self._cached_image = Image(content_type=content_type, content=image_bytes) + self._attr_content_type = content_type + return image_bytes + + @override + async def async_added_to_hass(self) -> None: + """Subscribe to the underlying DoorBird events.""" + await super().async_added_to_hass() + for event_name in self._matching_event_names: + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{event_name}", + self._async_handle_event, + ) + ) + + @callback + def _async_handle_event(self) -> None: + """Bust the cache and bump the last-updated timestamp on a new event.""" + self._cached_image = None + self._attr_image_last_updated = dt_util.utcnow() + self.async_write_ha_state() diff --git a/homeassistant/components/doorbird/strings.json b/homeassistant/components/doorbird/strings.json index 482c625a0599..e40fb3f420ab 100644 --- a/homeassistant/components/doorbird/strings.json +++ b/homeassistant/components/doorbird/strings.json @@ -74,6 +74,14 @@ } } } + }, + "image": { + "last_motion": { + "name": "[%key:component::doorbird::entity::camera::last_motion::name%]" + }, + "last_ring": { + "name": "[%key:component::doorbird::entity::camera::last_ring::name%]" + } } }, "issues": { diff --git a/tests/components/doorbird/conftest.py b/tests/components/doorbird/conftest.py index bcdcb49b7284..897f4ac529ce 100644 --- a/tests/components/doorbird/conftest.py +++ b/tests/components/doorbird/conftest.py @@ -38,17 +38,22 @@ def doorbird_info() -> dict[str, Any]: return load_json_value_fixture("info.json", "doorbird")["BHA"]["VERSION"][0] -@pytest.fixture(scope="package") +@pytest.fixture def doorbird_schedule() -> list[DoorBirdScheduleEntry]: - """Return a loaded DoorBird schedule fixture.""" + """Return a freshly parsed DoorBird schedule fixture. + + Function-scoped because the integration mutates schedule entries in place + via `_configure_unconfigured_favorites` — sharing one instance across tests + would let earlier tests poison later ones. + """ return DoorBirdScheduleEntry.parse_all( load_json_value_fixture("schedule.json", "doorbird") ) -@pytest.fixture(scope="package") +@pytest.fixture def doorbird_schedule_wrong_param() -> list[DoorBirdScheduleEntry]: - """Return a loaded DoorBird schedule fixture with an incorrect param.""" + """Return a freshly parsed DoorBird schedule fixture with an incorrect param.""" return DoorBirdScheduleEntry.parse_all( load_json_value_fixture("schedule_wrong_param.json", "doorbird") ) diff --git a/tests/components/doorbird/test_image.py b/tests/components/doorbird/test_image.py new file mode 100644 index 000000000000..74014672c45b --- /dev/null +++ b/tests/components/doorbird/test_image.py @@ -0,0 +1,104 @@ +"""Test DoorBird image entities.""" + +from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import mock_webhook_call +from .conftest import DoorbirdMockerType + +from tests.typing import ClientSessionGenerator + +# A body whose first 4 bytes are a recognized JPEG magic number, so +# infer_image_type accepts it. The trailing bytes are arbitrary padding. +VALID_JPEG = b"\xff\xd8\xff\xe0junk" + + +async def test_image_entities_registered( + hass: HomeAssistant, + doorbird_mocker: DoorbirdMockerType, + entity_registry: er.EntityRegistry, +) -> None: + """Both last_motion and last_ring image entities are registered.""" + await doorbird_mocker() + last_motion = hass.states.get("image.mydoorbird_last_motion") + last_ring = hass.states.get("image.mydoorbird_last_ring") + assert last_motion is not None + assert last_ring is not None + # No event has fired yet, so image_last_updated is None → state is unknown. + assert last_motion.state == STATE_UNKNOWN + assert last_ring.state == STATE_UNKNOWN + assert ( + entity_registry.async_get("image.mydoorbird_last_motion").unique_id + == "1234ABCD_last_motion" + ) + assert ( + entity_registry.async_get("image.mydoorbird_last_ring").unique_id + == "1234ABCD_last_ring" + ) + + +async def test_image_updates_on_event( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + doorbird_mocker: DoorbirdMockerType, +) -> None: + """Receiving a doorbird event bumps image_last_updated on the matching image.""" + doorbird_entry = await doorbird_mocker() + client = await hass_client() + + assert hass.states.get("image.mydoorbird_last_ring").state == STATE_UNKNOWN + assert hass.states.get("image.mydoorbird_last_motion").state == STATE_UNKNOWN + + await mock_webhook_call(doorbird_entry.entry, client, "mydoorbird_doorbell") + await hass.async_block_till_done() + + # Ring event only updates the ring image. + ring_state = hass.states.get("image.mydoorbird_last_ring").state + motion_state = hass.states.get("image.mydoorbird_last_motion").state + assert ring_state != STATE_UNKNOWN + assert motion_state == STATE_UNKNOWN + + await mock_webhook_call(doorbird_entry.entry, client, "mydoorbird_motion") + await hass.async_block_till_done() + + assert hass.states.get("image.mydoorbird_last_motion").state != STATE_UNKNOWN + + +async def test_image_entity_fetches_bytes( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + doorbird_mocker: DoorbirdMockerType, +) -> None: + """The image proxy returns bytes fetched from the device.""" + doorbird_entry = await doorbird_mocker() + doorbird_entry.api.get_image.return_value = VALID_JPEG + client = await hass_client() + + state = hass.states.get("image.mydoorbird_last_ring") + access_token = state.attributes["access_token"] + resp = await client.get( + f"/api/{IMAGE_DOMAIN}_proxy/image.mydoorbird_last_ring?token={access_token}" + ) + assert resp.status == 200 + assert await resp.read() == VALID_JPEG + assert doorbird_entry.api.get_image.called + + +async def test_image_rejects_non_image_body( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + doorbird_mocker: DoorbirdMockerType, +) -> None: + """A body that is not a recognized image is rejected instead of cached.""" + doorbird_entry = await doorbird_mocker() + doorbird_entry.api.get_image.return_value = b"error" + client = await hass_client() + + state = hass.states.get("image.mydoorbird_last_ring") + access_token = state.attributes["access_token"] + resp = await client.get( + f"/api/{IMAGE_DOMAIN}_proxy/image.mydoorbird_last_ring?token={access_token}" + ) + assert resp.status == 500