diff --git a/homeassistant/components/ai_task/media_source.py b/homeassistant/components/ai_task/media_source.py index 2906acf7a2d31..61a212be5b065 100644 --- a/homeassistant/components/ai_task/media_source.py +++ b/homeassistant/components/ai_task/media_source.py @@ -2,21 +2,31 @@ from __future__ import annotations +from pathlib import Path + from homeassistant.components.media_source import MediaSource, local_source from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .const import DATA_MEDIA_SOURCE, DOMAIN, IMAGE_DIR async def async_get_media_source(hass: HomeAssistant) -> MediaSource: """Set up local media source.""" - media_dir = hass.config.path(f"{DOMAIN}/{IMAGE_DIR}") + media_dirs = list(hass.config.media_dirs.values()) + + if not media_dirs: + raise HomeAssistantError( + "AI Task media source requires at least one media directory configured" + ) + + media_dir = Path(media_dirs[0]) / DOMAIN / IMAGE_DIR hass.data[DATA_MEDIA_SOURCE] = source = local_source.LocalSource( hass, DOMAIN, "AI Generated Images", - {IMAGE_DIR: media_dir}, + {IMAGE_DIR: str(media_dir)}, f"/{DOMAIN}", ) return source diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py index e6d86bee978c8..1d27f75b6c7f1 100644 --- a/homeassistant/components/ai_task/task.py +++ b/homeassistant/components/ai_task/task.py @@ -12,7 +12,7 @@ import voluptuous as vol -from homeassistant.components import camera, conversation, media_source +from homeassistant.components import camera, conversation, image, media_source from homeassistant.components.http.auth import async_sign_path from homeassistant.core import HomeAssistant, ServiceResponse, callback from homeassistant.exceptions import HomeAssistantError @@ -31,14 +31,14 @@ ) -def _save_camera_snapshot(image: camera.Image) -> Path: +def _save_camera_snapshot(image_data: camera.Image | image.Image) -> Path: """Save camera snapshot to temp file.""" with tempfile.NamedTemporaryFile( mode="wb", - suffix=mimetypes.guess_extension(image.content_type, False), + suffix=mimetypes.guess_extension(image_data.content_type, False), delete=False, ) as temp_file: - temp_file.write(image.content) + temp_file.write(image_data.content) return Path(temp_file.name) @@ -54,26 +54,31 @@ async def _resolve_attachments( for attachment in attachments or []: media_content_id = attachment["media_content_id"] - # Special case for camera media sources - if media_content_id.startswith("media-source://camera/"): + # Special case for certain media sources + for integration in camera, image: + media_source_prefix = f"media-source://{integration.DOMAIN}/" + if not media_content_id.startswith(media_source_prefix): + continue + # Extract entity_id from the media content ID - entity_id = media_content_id.removeprefix("media-source://camera/") + entity_id = media_content_id.removeprefix(media_source_prefix) - # Get snapshot from camera - image = await camera.async_get_image(hass, entity_id) + # Get snapshot from entity + image_data = await integration.async_get_image(hass, entity_id) temp_filename = await hass.async_add_executor_job( - _save_camera_snapshot, image + _save_camera_snapshot, image_data ) created_files.append(temp_filename) resolved_attachments.append( conversation.Attachment( media_content_id=media_content_id, - mime_type=image.content_type, + mime_type=image_data.content_type, path=temp_filename, ) ) + break else: # Handle regular media sources media = await media_source.async_resolve_media(hass, media_content_id, None) diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 00922b75ed800..3a18bea1a8a92 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -467,7 +467,10 @@ async def async_setup_entry( # periodical (or manual) self test since last daemon restart. It might not be available # when we set up the integration, and we do not know if it would ever be available. Here we # add it anyway and mark it as unknown initially. - for resource in available_resources | {LAST_S_TEST}: + # + # We also sort the resources to ensure the order of entities created is deterministic since + # "APCMODEL" and "MODEL" resources map to the same "Model" name. + for resource in sorted(available_resources | {LAST_S_TEST}): if resource not in SENSORS: _LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper()) continue diff --git a/homeassistant/components/backup/const.py b/homeassistant/components/backup/const.py index 1cfb796bd2e6f..773deaef1741c 100644 --- a/homeassistant/components/backup/const.py +++ b/homeassistant/components/backup/const.py @@ -26,7 +26,6 @@ "tmp_backups/*.tar", "OZW_Log.txt", "tts/*", - "ai_task/*", ] EXCLUDE_DATABASE_FROM_BACKUP = [ diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index 0a3b9bf9af7a3..7bf0060f593f5 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -105,6 +105,20 @@ async def _async_get_image(image_entity: ImageEntity, timeout: int) -> Image: raise HomeAssistantError("Unable to get image") +async def async_get_image( + hass: HomeAssistant, + entity_id: str, + timeout: int = 10, +) -> Image: + """Fetch an image from an image entity.""" + component = hass.data[DATA_COMPONENT] + + if (image := component.get_entity(entity_id)) is None: + raise HomeAssistantError(f"Image entity {entity_id} not found") + + return await _async_get_image(image, timeout) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the image component.""" component = hass.data[DATA_COMPONENT] = EntityComponent[ImageEntity]( diff --git a/tests/components/ai_task/test_media_source.py b/tests/components/ai_task/test_media_source.py index 18f1834e08255..fd3aa0bdaae0d 100644 --- a/tests/components/ai_task/test_media_source.py +++ b/tests/components/ai_task/test_media_source.py @@ -1,7 +1,11 @@ """Test ai_task media source.""" +import pytest + from homeassistant.components import media_source +from homeassistant.components.ai_task.media_source import async_get_media_source from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError async def test_local_media_source(hass: HomeAssistant, init_components: None) -> None: @@ -9,3 +13,26 @@ async def test_local_media_source(hass: HomeAssistant, init_components: None) -> item = await media_source.async_browse_media(hass, "media-source://") assert any(c.title == "AI Generated Images" for c in item.children) + + source = await async_get_media_source(hass) + assert isinstance(source, media_source.local_source.LocalSource) + assert source.name == "AI Generated Images" + assert source.domain == "ai_task" + assert list(source.media_dirs) == ["image"] + # Depending on Docker, the default is one of the two paths + assert source.media_dirs["image"] in ( + "/media/ai_task/image", + hass.config.path("media/ai_task/image"), + ) + assert source.url_prefix == "/ai_task" + + hass.config.media_dirs = {} + + with pytest.raises( + HomeAssistantError, + match="AI Task media source requires at least one media directory configured", + ): + await async_get_media_source(hass) + + +# The following is from media_source/__init__.py for reference diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py index 345d6c3098152..4f8616d3f8179 100644 --- a/tests/components/ai_task/test_task.py +++ b/tests/components/ai_task/test_task.py @@ -187,7 +187,11 @@ async def test_generate_data_mixed_attachments( patch( "homeassistant.components.camera.async_get_image", return_value=Image(content_type="image/jpeg", content=b"fake_camera_jpeg"), - ) as mock_get_image, + ) as mock_get_camera_image, + patch( + "homeassistant.components.image.async_get_image", + return_value=Image(content_type="image/jpeg", content=b"fake_image_jpeg"), + ) as mock_get_image_image, patch( "homeassistant.components.media_source.async_resolve_media", return_value=media_source.PlayMedia( @@ -207,6 +211,10 @@ async def test_generate_data_mixed_attachments( "media_content_id": "media-source://camera/camera.front_door", "media_content_type": "image/jpeg", }, + { + "media_content_id": "media-source://image/image.floorplan", + "media_content_type": "image/jpeg", + }, { "media_content_id": "media-source://media_player/video.mp4", "media_content_type": "video/mp4", @@ -215,7 +223,8 @@ async def test_generate_data_mixed_attachments( ) # Verify both methods were called - mock_get_image.assert_called_once_with(hass, "camera.front_door") + mock_get_camera_image.assert_called_once_with(hass, "camera.front_door") + mock_get_image_image.assert_called_once_with(hass, "image.floorplan") mock_resolve_media.assert_called_once_with( hass, "media-source://media_player/video.mp4", None ) @@ -224,7 +233,7 @@ async def test_generate_data_mixed_attachments( assert len(mock_ai_task_entity.mock_generate_data_tasks) == 1 task = mock_ai_task_entity.mock_generate_data_tasks[0] assert task.attachments is not None - assert len(task.attachments) == 2 + assert len(task.attachments) == 3 # Check camera attachment camera_attachment = task.attachments[0] @@ -240,6 +249,18 @@ async def test_generate_data_mixed_attachments( content = await hass.async_add_executor_job(camera_attachment.path.read_bytes) assert content == b"fake_camera_jpeg" + # Check image attachment + image_attachment = task.attachments[1] + assert image_attachment.media_content_id == "media-source://image/image.floorplan" + assert image_attachment.mime_type == "image/jpeg" + assert isinstance(image_attachment.path, Path) + assert image_attachment.path.suffix == ".jpg" + + # Verify image snapshot content + assert image_attachment.path.exists() + content = await hass.async_add_executor_job(image_attachment.path.read_bytes) + assert content == b"fake_image_jpeg" + # Trigger clean up async_fire_time_changed( hass, @@ -249,9 +270,10 @@ async def test_generate_data_mixed_attachments( # Verify the temporary file cleaned up assert not camera_attachment.path.exists() + assert not image_attachment.path.exists() # Check regular media attachment - media_attachment = task.attachments[1] + media_attachment = task.attachments[2] assert media_attachment.media_content_id == "media-source://media_player/video.mp4" assert media_attachment.mime_type == "video/mp4" assert media_attachment.path == Path("/media/test.mp4") diff --git a/tests/components/apcupsd/__init__.py b/tests/components/apcupsd/__init__.py index ac18d4e4277db..27ddd478b9bc3 100644 --- a/tests/components/apcupsd/__init__.py +++ b/tests/components/apcupsd/__init__.py @@ -16,6 +16,7 @@ "DRIVER": "USB UPS Driver", "UPSMODE": "Stand Alone", "UPSNAME": "MyUPS", + "APCMODEL": "Back-UPS ES 600", "MODEL": "Back-UPS ES 600", "STATUS": "ONLINE", "LINEV": "124.0 Volts", diff --git a/tests/components/apcupsd/snapshots/test_diagnostics.ambr b/tests/components/apcupsd/snapshots/test_diagnostics.ambr index a3c4d16da2ff6..669654c75bb9d 100644 --- a/tests/components/apcupsd/snapshots/test_diagnostics.ambr +++ b/tests/components/apcupsd/snapshots/test_diagnostics.ambr @@ -3,6 +3,7 @@ dict({ 'ALARMDEL': '30 Seconds', 'APC': '001,038,0985', + 'APCMODEL': 'Back-UPS ES 600', 'BATTDATE': '1970-01-01', 'BATTV': '13.7 Volts', 'BCHARGE': '100.0 Percent', diff --git a/tests/components/apcupsd/snapshots/test_sensor.ambr b/tests/components/apcupsd/snapshots/test_sensor.ambr index a873607180f88..4e9626bec6b5a 100644 --- a/tests/components/apcupsd/snapshots/test_sensor.ambr +++ b/tests/components/apcupsd/snapshots/test_sensor.ambr @@ -934,8 +934,8 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'model', - 'unique_id': 'XXXXXXXXXXXX_model', + 'translation_key': 'apc_model', + 'unique_id': 'XXXXXXXXXXXX_apcmodel', 'unit_of_measurement': None, }) # --- @@ -952,6 +952,54 @@ 'state': 'Back-UPS ES 600', }) # --- +# name: test_sensor[sensor.myups_model_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.myups_model_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Model', + 'platform': 'apcupsd', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'model', + 'unique_id': 'XXXXXXXXXXXX_model', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.myups_model_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'MyUPS Model', + }), + 'context': , + 'entity_id': 'sensor.myups_model_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Back-UPS ES 600', + }) +# --- # name: test_sensor[sensor.myups_name-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/image/test_init.py b/tests/components/image/test_init.py index bb8762f17e269..0a1c939c474d2 100644 --- a/tests/components/image/test_init.py +++ b/tests/components/image/test_init.py @@ -407,6 +407,15 @@ async def _wrap_async_get_still_stream(*args, **kwargs): await close_future +async def test_get_image_action(hass: HomeAssistant, mock_image_platform: None) -> None: + """Test get_image action.""" + image_data = await image.async_get_image(hass, "image.test") + assert image_data == image.Image(content_type="image/jpeg", content=b"Test") + + with pytest.raises(HomeAssistantError, match="not found"): + await image.async_get_image(hass, "image.unknown") + + async def test_snapshot_service(hass: HomeAssistant) -> None: """Test snapshot service.""" mopen = mock_open()