mirror of
https://github.com/home-assistant/core.git
synced 2026-08-03 20:24:55 +02:00
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:
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user