Add image platform for DoorBird last motion and last ring (#173120)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattias Arrelid
2026-07-29 11:42:41 +02:00
committed by GitHub
parent 95ac9dbeb1
commit 9a93ebda10
5 changed files with 252 additions and 5 deletions
+1 -1
View File
@@ -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"
+130
View File
@@ -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()
@@ -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": {
+9 -4
View File
@@ -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")
)
+104
View File
@@ -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"<html>error</html>"
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