diff --git a/homeassistant/components/indi_allsky/__init__.py b/homeassistant/components/indi_allsky/__init__.py index 22ac34f9a1fa..4f35c40d4ab2 100644 --- a/homeassistant/components/indi_allsky/__init__.py +++ b/homeassistant/components/indi_allsky/__init__.py @@ -5,7 +5,12 @@ from homeassistant.core import HomeAssistant from .coordinator import IndiAllSkyConfigEntry, IndiAllSkyDataUpdateCoordinator -_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.CAMERA, Platform.SENSOR] +_PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.CAMERA, + Platform.IMAGE, + Platform.SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: IndiAllSkyConfigEntry) -> bool: diff --git a/homeassistant/components/indi_allsky/camera.py b/homeassistant/components/indi_allsky/camera.py index 5cf5b2ca3fa4..2c5b1d54a72e 100644 --- a/homeassistant/components/indi_allsky/camera.py +++ b/homeassistant/components/indi_allsky/camera.py @@ -28,7 +28,7 @@ async def async_setup_entry( class IndiAllSkyCamera(IndiAllSkyEntity, Camera): """Representation of an INDI Allsky camera.""" - _attr_name = None + translation_key = "latest_capture" def __init__( self, diff --git a/homeassistant/components/indi_allsky/coordinator.py b/homeassistant/components/indi_allsky/coordinator.py index 60590917aa4e..6441b759d986 100644 --- a/homeassistant/components/indi_allsky/coordinator.py +++ b/homeassistant/components/indi_allsky/coordinator.py @@ -4,7 +4,13 @@ from dataclasses import dataclass import logging from typing import override -from aioindiallsky import ExposureData, IndiAllSkyClient, IndiAllSkyError, SensorData +from aioindiallsky import ( + ExposureData, + IndiAllSkyClient, + IndiAllSkyError, + MediaData, + SensorData, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL @@ -25,6 +31,8 @@ class IndiAllSkyData: """Data model for INDI Allsky coordinator data.""" exposure: ExposureData | None = None + latest_keogram: MediaData | None = None + latest_startrail: MediaData | None = None sensor: SensorData | None = None @@ -43,6 +51,8 @@ class IndiAllSkyDataUpdateCoordinator(DataUpdateCoordinator[IndiAllSkyData]): session=async_get_clientsession(hass), ) self.latest_exposure: ExposureData | None = None + self.latest_keogram: MediaData | None = None + self.latest_startrail: MediaData | None = None self.latest_sensor: SensorData | None = None entry.async_on_unload( @@ -50,6 +60,16 @@ class IndiAllSkyDataUpdateCoordinator(DataUpdateCoordinator[IndiAllSkyData]): "exposure_complete", self._handle_exposure_complete ) ) + entry.async_on_unload( + self.client.register_callback( + "keogram_complete", self._handle_keogram_complete + ) + ) + entry.async_on_unload( + self.client.register_callback( + "startrail_complete", self._handle_startrail_complete + ) + ) entry.async_on_unload( self.client.register_callback("sensor_update", self._handle_sensor_update) ) @@ -69,6 +89,32 @@ class IndiAllSkyDataUpdateCoordinator(DataUpdateCoordinator[IndiAllSkyData]): self.async_set_updated_data( IndiAllSkyData( exposure=exposure, + latest_keogram=self.latest_keogram, + latest_startrail=self.latest_startrail, + sensor=self.latest_sensor, + ) + ) + + def _handle_keogram_complete(self, media: MediaData) -> None: + """Handle new keogram_complete event from WebSocket stream.""" + self.latest_keogram = media + self.async_set_updated_data( + IndiAllSkyData( + exposure=self.latest_exposure, + latest_keogram=media, + latest_startrail=self.latest_startrail, + sensor=self.latest_sensor, + ) + ) + + def _handle_startrail_complete(self, media: MediaData) -> None: + """Handle new startrail_complete event from WebSocket stream.""" + self.latest_startrail = media + self.async_set_updated_data( + IndiAllSkyData( + exposure=self.latest_exposure, + latest_keogram=self.latest_keogram, + latest_startrail=media, sensor=self.latest_sensor, ) ) @@ -137,6 +183,8 @@ class IndiAllSkyDataUpdateCoordinator(DataUpdateCoordinator[IndiAllSkyData]): self.async_set_updated_data( IndiAllSkyData( exposure=self.latest_exposure, + latest_keogram=self.latest_keogram, + latest_startrail=self.latest_startrail, sensor=self.latest_sensor, ) ) @@ -158,5 +206,7 @@ class IndiAllSkyDataUpdateCoordinator(DataUpdateCoordinator[IndiAllSkyData]): return IndiAllSkyData( exposure=self.latest_exposure, + latest_keogram=self.latest_keogram, + latest_startrail=self.latest_startrail, sensor=self.latest_sensor, ) diff --git a/homeassistant/components/indi_allsky/image.py b/homeassistant/components/indi_allsky/image.py new file mode 100644 index 000000000000..a713bcd42d46 --- /dev/null +++ b/homeassistant/components/indi_allsky/image.py @@ -0,0 +1,116 @@ +"""Support for INDI Allsky image entities.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from typing import override + +from aioindiallsky import IndiAllSkyError, MediaData + +from homeassistant.components.image import ( + ImageEntity, + ImageEntityDescription, + infer_image_type, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util + +from .coordinator import ( + IndiAllSkyConfigEntry, + IndiAllSkyData, + IndiAllSkyDataUpdateCoordinator, +) +from .entity import IndiAllSkyEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class IndiAllSkyImageEntityDescription(ImageEntityDescription): + """Class describing INDI Allsky image entities.""" + + media_fn: Callable[[IndiAllSkyData], MediaData | None] + fallback_filename: str + + +IMAGE_DESCRIPTIONS: tuple[IndiAllSkyImageEntityDescription, ...] = ( + IndiAllSkyImageEntityDescription( + key="latest_keogram", + translation_key="latest_keogram", + media_fn=lambda data: data.latest_keogram, + fallback_filename="latestkeogram", + ), + IndiAllSkyImageEntityDescription( + key="latest_startrail", + translation_key="latest_startrail", + media_fn=lambda data: data.latest_startrail, + fallback_filename="lateststartrail", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IndiAllSkyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up INDI Allsky image entities based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + IndiAllSkyImageEntity(hass, coordinator, entry, description) + for description in IMAGE_DESCRIPTIONS + ) + + +class IndiAllSkyImageEntity(IndiAllSkyEntity, ImageEntity): + """Representation of an INDI Allsky image entity.""" + + entity_description: IndiAllSkyImageEntityDescription + + def __init__( + self, + hass: HomeAssistant, + coordinator: IndiAllSkyDataUpdateCoordinator, + entry: IndiAllSkyConfigEntry, + description: IndiAllSkyImageEntityDescription, + ) -> None: + """Initialize the image entity.""" + super().__init__(coordinator, entry) + ImageEntity.__init__(self, hass) + self.entity_description = description + self._attr_unique_id = f"{entry.entry_id}_{description.key}" + self._last_fetched: datetime | None = None + + @property + @override + def image_last_updated(self) -> datetime | None: + """Return the timestamp when the image was last updated.""" + media = self.entity_description.media_fn(self.coordinator.data) + if media and media.day_date: + if dt := dt_util.parse_datetime(media.day_date): + if dt.tzinfo is None: + return dt.replace(tzinfo=dt_util.UTC) + return dt_util.as_utc(dt) + return self._last_fetched + + @override + async def async_image(self) -> bytes | None: + """Return bytes of the image.""" + media = self.entity_description.media_fn(self.coordinator.data) + filename = ( + media.filename + if (media and media.filename) + else self.entity_description.fallback_filename + ) + try: + image_bytes = await self.coordinator.client.fetch_image(filename) + except IndiAllSkyError: + return None + else: + if content_type := infer_image_type(image_bytes): + self._attr_content_type = content_type + if media is None and self._last_fetched is None: + self._last_fetched = dt_util.utcnow() + self.async_write_ha_state() + return image_bytes diff --git a/homeassistant/components/indi_allsky/strings.json b/homeassistant/components/indi_allsky/strings.json index 6695c077ebed..a1d425523d63 100644 --- a/homeassistant/components/indi_allsky/strings.json +++ b/homeassistant/components/indi_allsky/strings.json @@ -32,6 +32,19 @@ "name": "Night" } }, + "camera": { + "latest_capture": { + "name": "Latest capture" + } + }, + "image": { + "latest_keogram": { + "name": "Latest keogram" + }, + "latest_startrail": { + "name": "Latest star trail" + } + }, "sensor": { "ambient_temperature": { "name": "Ambient temperature" diff --git a/tests/components/indi_allsky/snapshots/test_camera.ambr b/tests/components/indi_allsky/snapshots/test_camera.ambr index d1be924bc2ce..e02c40adc87e 100644 --- a/tests/components/indi_allsky/snapshots/test_camera.ambr +++ b/tests/components/indi_allsky/snapshots/test_camera.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_camera_setup_and_states[camera.indi_allsky-entry] +# name: test_camera_setup_and_states[camera.indi_allsky_latest_capture-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -13,7 +13,7 @@ 'disabled_by': None, 'domain': 'camera', 'entity_category': None, - 'entity_id': 'camera.indi_allsky', + 'entity_id': 'camera.indi_allsky_latest_capture', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -21,31 +21,31 @@ 'labels': set({ }), 'name': None, - 'object_id_base': None, + 'object_id_base': 'Latest capture', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': None, + 'original_name': 'Latest capture', 'platform': 'indi_allsky', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'latest_capture', 'unique_id': '1234567890abcdef1234567890abcdef', 'unit_of_measurement': None, }) # --- -# name: test_camera_setup_and_states[camera.indi_allsky-state] +# name: test_camera_setup_and_states[camera.indi_allsky_latest_capture-state] StateSnapshot({ 'attributes': ReadOnlyDict({ : '1caab5c3b3', - : '/api/camera_proxy/camera.indi_allsky?token=1caab5c3b3', - : 'INDI Allsky', + : '/api/camera_proxy/camera.indi_allsky_latest_capture?token=1caab5c3b3', + : 'INDI Allsky Latest capture', : , }), 'context': , - 'entity_id': 'camera.indi_allsky', + 'entity_id': 'camera.indi_allsky_latest_capture', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/indi_allsky/snapshots/test_image.ambr b/tests/components/indi_allsky/snapshots/test_image.ambr new file mode 100644 index 000000000000..120512a753de --- /dev/null +++ b/tests/components/indi_allsky/snapshots/test_image.ambr @@ -0,0 +1,105 @@ +# serializer version: 1 +# name: test_image_setup_and_states[image.indi_allsky_latest_keogram-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.indi_allsky_latest_keogram', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Latest keogram', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latest keogram', + 'platform': 'indi_allsky', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'latest_keogram', + 'unique_id': '1234567890abcdef1234567890abcdef_latest_keogram', + 'unit_of_measurement': None, + }) +# --- +# name: test_image_setup_and_states[image.indi_allsky_latest_keogram-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '1caab5c3b3', + : '/api/image_proxy/image.indi_allsky_latest_keogram?token=1caab5c3b3', + : 'INDI Allsky Latest keogram', + }), + 'context': , + 'entity_id': 'image.indi_allsky_latest_keogram', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_image_setup_and_states[image.indi_allsky_latest_star_trail-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.indi_allsky_latest_star_trail', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Latest star trail', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latest star trail', + 'platform': 'indi_allsky', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'latest_startrail', + 'unique_id': '1234567890abcdef1234567890abcdef_latest_startrail', + 'unit_of_measurement': None, + }) +# --- +# name: test_image_setup_and_states[image.indi_allsky_latest_star_trail-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : '1caab5c3b3', + : '/api/image_proxy/image.indi_allsky_latest_star_trail?token=1caab5c3b3', + : 'INDI Allsky Latest star trail', + }), + 'context': , + 'entity_id': 'image.indi_allsky_latest_star_trail', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/indi_allsky/test_camera.py b/tests/components/indi_allsky/test_camera.py index eadb003eb6a0..6f8f051d081e 100644 --- a/tests/components/indi_allsky/test_camera.py +++ b/tests/components/indi_allsky/test_camera.py @@ -50,7 +50,7 @@ async def test_camera_image_and_update( mock_indi_allsky_client.fetch_image.return_value = image_bytes await setup_integration(hass, mock_config_entry) - image = await async_get_image(hass, "camera.indi_allsky") + image = await async_get_image(hass, "camera.indi_allsky_latest_capture") assert image.content == image_bytes assert image.content_type == expected_content_type @@ -66,4 +66,4 @@ async def test_camera_image_fetch_failure( mock_indi_allsky_client.fetch_image.side_effect = IndiAllSkyError("Fetch error") with pytest.raises(HomeAssistantError, match="Unable to get image"): - await async_get_image(hass, "camera.indi_allsky") + await async_get_image(hass, "camera.indi_allsky_latest_capture") diff --git a/tests/components/indi_allsky/test_image.py b/tests/components/indi_allsky/test_image.py new file mode 100644 index 000000000000..ca0b549db16e --- /dev/null +++ b/tests/components/indi_allsky/test_image.py @@ -0,0 +1,144 @@ +"""Tests for the INDI Allsky image platform.""" + +from unittest.mock import AsyncMock, patch + +from aioindiallsky import IndiAllSkyError, MediaData +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import image +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture +def mock_keogram_data() -> MediaData: + """Fixture to provide sample keogram MediaData.""" + return MediaData.from_dict( + "keogram", + { + "filename": "keogram_20260813.jpg", + "dayDate": "2026-08-13 22:53:41", + "night": True, + "camera_id": 1, + "id": 1, + }, + ) + + +@pytest.fixture +def mock_startrail_data() -> MediaData: + """Fixture to provide sample startrail MediaData.""" + return MediaData.from_dict( + "startrail", + { + "filename": "startrail_20260813.jpg", + "dayDate": "2026-08-13 22:53:41", + "night": True, + "camera_id": 1, + "id": 2, + }, + ) + + +@pytest.mark.usefixtures("mock_indi_allsky_client") +async def test_image_setup_and_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test standard successful setup and image entity snapshots.""" + with patch("homeassistant.components.indi_allsky._PLATFORMS", [Platform.IMAGE]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +async def test_image_events_and_fetching( + hass: HomeAssistant, + mock_indi_allsky_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_keogram_data: MediaData, + mock_startrail_data: MediaData, +) -> None: + """Test image entities update state and return image bytes on media events.""" + with patch("homeassistant.components.indi_allsky._PLATFORMS", [Platform.IMAGE]): + await setup_integration(hass, mock_config_entry) + + for callback in mock_indi_allsky_client.callbacks.get("keogram_complete", []): + callback(mock_keogram_data) + for callback in mock_indi_allsky_client.callbacks.get("startrail_complete", []): + callback(mock_startrail_data) + await hass.async_block_till_done() + + state = hass.states.get("image.indi_allsky_latest_keogram") + assert state is not None + assert state.state == "2026-08-13T22:53:41+00:00" + + mock_indi_allsky_client.fetch_image.return_value = b"\xff\xd8\xff\xe0keogram_bytes" + img = await image.async_get_image(hass, "image.indi_allsky_latest_keogram") + assert img.content == b"\xff\xd8\xff\xe0keogram_bytes" + mock_indi_allsky_client.fetch_image.assert_called_with("keogram_20260813.jpg") + + state = hass.states.get("image.indi_allsky_latest_star_trail") + assert state is not None + assert state.state == "2026-08-13T22:53:41+00:00" + + mock_indi_allsky_client.fetch_image.return_value = ( + b"\xff\xd8\xff\xe0startrail_bytes" + ) + img = await image.async_get_image(hass, "image.indi_allsky_latest_star_trail") + assert img.content == b"\xff\xd8\xff\xe0startrail_bytes" + mock_indi_allsky_client.fetch_image.assert_called_with("startrail_20260813.jpg") + + +async def test_image_fetch_error( + hass: HomeAssistant, + mock_indi_allsky_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_keogram_data: MediaData, +) -> None: + """Test handling of image fetch errors.""" + with patch("homeassistant.components.indi_allsky._PLATFORMS", [Platform.IMAGE]): + await setup_integration(hass, mock_config_entry) + + for callback in mock_indi_allsky_client.callbacks.get("keogram_complete", []): + callback(mock_keogram_data) + await hass.async_block_till_done() + + mock_indi_allsky_client.fetch_image.side_effect = IndiAllSkyError("HTTP Error") + + with pytest.raises(HomeAssistantError): + await image.async_get_image(hass, "image.indi_allsky_latest_keogram") + + +async def test_image_fallback_fetching_before_events( + hass: HomeAssistant, + mock_indi_allsky_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test fetching fallback alias images before any media event arrives.""" + with patch("homeassistant.components.indi_allsky._PLATFORMS", [Platform.IMAGE]): + await setup_integration(hass, mock_config_entry) + + mock_indi_allsky_client.fetch_image.return_value = ( + b"\xff\xd8\xff\xe0fallback_keogram" + ) + img = await image.async_get_image(hass, "image.indi_allsky_latest_keogram") + assert img.content == b"\xff\xd8\xff\xe0fallback_keogram" + mock_indi_allsky_client.fetch_image.assert_called_with("latestkeogram") + + mock_indi_allsky_client.fetch_image.return_value = ( + b"\xff\xd8\xff\xe0fallback_startrail" + ) + img = await image.async_get_image(hass, "image.indi_allsky_latest_star_trail") + assert img.content == b"\xff\xd8\xff\xe0fallback_startrail" + mock_indi_allsky_client.fetch_image.assert_called_with("lateststartrail") diff --git a/tests/components/indi_allsky/test_init.py b/tests/components/indi_allsky/test_init.py index bc1ec6844264..4479fd28c5e7 100644 --- a/tests/components/indi_allsky/test_init.py +++ b/tests/components/indi_allsky/test_init.py @@ -21,10 +21,9 @@ async def test_setup_and_unload_entry( """Test successful setup and unload of entry.""" await setup_integration(hass, mock_config_entry) - assert mock_config_entry.state is ConfigEntryState.LOADED mock_indi_allsky_client.listen.assert_called_once_with(auto_reconnect=True) - await hass.config_entries.async_unload(mock_config_entry.entry_id) + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED