diff --git a/custom_components/birdbuddy/__init__.py b/custom_components/birdbuddy/__init__.py index 09cbf85..4c7c4e8 100644 --- a/custom_components/birdbuddy/__init__.py +++ b/custom_components/birdbuddy/__init__.py @@ -1,4 +1,5 @@ """The Bird Buddy integration.""" + from __future__ import annotations from birdbuddy.client import BirdBuddy @@ -21,6 +22,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.IMAGE, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, diff --git a/custom_components/birdbuddy/image.py b/custom_components/birdbuddy/image.py new file mode 100644 index 0000000..3323557 --- /dev/null +++ b/custom_components/birdbuddy/image.py @@ -0,0 +1,123 @@ +"""The Bird Buddy image entity.""" + +from birdbuddy.feed import FeedNodeType +from birdbuddy.media import Media, is_media_expired +from birdbuddy.sightings import PostcardSighting + +from homeassistant.components.image import ImageEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, EVENT_NEW_POSTCARD_SIGHTING +from .coordinator import BirdBuddyDataUpdateCoordinator +from .device import BirdBuddyDevice +from .entity import BirdBuddyMixin +from .util import _find_media_with_species + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize config entry.""" + coordinator: BirdBuddyDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + feeders = coordinator.feeders.values() + async_add_entities( + BirdBuddyRecentVisitorImageEntity(hass, f, coordinator) for f in feeders + ) + + +class BirdBuddyRecentVisitorImageEntity(BirdBuddyMixin, ImageEntity): + """The latest visitor image entity.""" + + _attr_has_entity_name = True + _attr_name = "Recent Visitor Image" + + _latest_media: Media | None = None + + def __init__( + self, + hass: HomeAssistant, + feeder: BirdBuddyDevice, + coordinator: BirdBuddyDataUpdateCoordinator, + ) -> None: + """Initialize the entity.""" + ImageEntity.__init__(self, hass) + BirdBuddyMixin.__init__(self, feeder, coordinator) + self._latest_media = None + self._attr_unique_id = f"{self.feeder.id}-recent-image" + + def image(self) -> bytes | None: + """Return the image bytes.""" + # See async_image() + return None + + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + + @callback + def filter_my_postcards(event: Event) -> bool: + # FIXME: This signature changed in 2024.4 + data = event if callable(getattr(event, "get", None)) else event.data + return self.feeder.id == ( + data.get("sighting", {}).get("feeder", {}).get("id") + ) + + self.async_on_remove( + self.hass.bus.async_listen( + EVENT_NEW_POSTCARD_SIGHTING, + self._on_new_postcard, + event_filter=filter_my_postcards, + ) + ) + + await self._update_latest_visitor() + + async def _on_new_postcard(self, event: Event | None = None) -> None: + """ """ + postcard = PostcardSighting(event.data["sighting"]) + + assert postcard.report.sightings + assert postcard.medias + + # media has created_at + # but sightings[] does not. + media = next(iter(postcard.medias), None) + self._update_url(media) + + async def _update_latest_visitor(self) -> None: + feed = await self.coordinator.client.feed() + + items = feed.filter( + of_type=[ + FeedNodeType.SpeciesSighting, + FeedNodeType.SpeciesUnlocked, + FeedNodeType.NewPostcard, + FeedNodeType.CollectedPostcard, + ], + ) + + my_items = _find_media_with_species(self.feeder.id, items) + + if latest := max(my_items, default=None, key=lambda x: x.created_at): + self._latest_media = Media(latest["media"]) + self._update_url(self._latest_media) + self.async_write_ha_state() + + def _update_url(self, media: Media) -> None: + if ( + media + and (url := media.content_url or media.thumbnail_url) + and (created_at := media.created_at) + and not is_media_expired(url) + ): + self._attr_image_url = url + self._attr_image_last_updated = created_at + self._attr_entity_picture = url + elif is_media_expired(self._attr_image_url): + # Clear it + self._attr_image_url = None + self._attr_image_last_updated = None + self._attr_entity_picture = None diff --git a/custom_components/birdbuddy/sensor.py b/custom_components/birdbuddy/sensor.py index 68b2118..dbb9878 100644 --- a/custom_components/birdbuddy/sensor.py +++ b/custom_components/birdbuddy/sensor.py @@ -30,6 +30,7 @@ from .coordinator import BirdBuddyDataUpdateCoordinator from .entity import BirdBuddyMixin from .device import BirdBuddyDevice +from .util import _find_media_with_species async def async_setup_entry( @@ -161,7 +162,6 @@ async def _on_new_postcard(self, event: Event | None = None) -> None: if media: self._latest_media = media self._attr_entity_picture = media.content_url - # self._attr_extra_state_attributes["last_visit"] = media.created_at if unlocked := [ s for s in postcard.report.sightings if s.sighting_type.is_unlocked @@ -208,20 +208,7 @@ async def _update_latest_visitor(self) -> None: ], ) - my_items = [ - item | {"media": next(iter(medias), None)} - for item in items - if item - and ( - medias := [ - m - for m in item.get("medias", []) - if m.get("__typename") == "MediaImage" - and self.feeder.id in m.get("thumbnailUrl", "") - ] - ) - and item.get("species", None) - ] + my_items = _find_media_with_species(self.feeder.id, items) if latest := max(my_items, default=None, key=lambda x: x.created_at): self._latest_media = Media(latest["media"]) diff --git a/custom_components/birdbuddy/util.py b/custom_components/birdbuddy/util.py index 4e72f34..6d7a8ea 100644 --- a/custom_components/birdbuddy/util.py +++ b/custom_components/birdbuddy/util.py @@ -1,5 +1,7 @@ """Bird Buddy utilities""" +from birdbuddy.feed import FeedNode + from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import ( @@ -58,3 +60,20 @@ def _find_coordinator_by_device( ) coordinator: BirdBuddyDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] return coordinator + + +def _find_media_with_species(feeder_id: str, items: list[FeedNode]) -> list[FeedNode]: + return [ + item | {"media": next(iter(medias), None)} + for item in items + if item + and ( + medias := [ + m + for m in item.get("medias", []) + if m.get("__typename") == "MediaImage" + and feeder_id in m.get("thumbnailUrl", "") + ] + ) + and item.get("species", None) + ]