Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions homeassistant/components/ai_task/media_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
27 changes: 16 additions & 11 deletions homeassistant/components/ai_task/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)


Expand All @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion homeassistant/components/apcupsd/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion homeassistant/components/backup/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
"tmp_backups/*.tar",
"OZW_Log.txt",
"tts/*",
"ai_task/*",
]

EXCLUDE_DATABASE_FROM_BACKUP = [
Expand Down
14 changes: 14 additions & 0 deletions homeassistant/components/image/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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](
Expand Down
27 changes: 27 additions & 0 deletions tests/components/ai_task/test_media_source.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,38 @@
"""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:
"""Test that the image media source is created."""
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
30 changes: 26 additions & 4 deletions tests/components/ai_task/test_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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",
Expand All @@ -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
)
Expand All @@ -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]
Expand All @@ -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,
Expand All @@ -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")
Expand Down
1 change: 1 addition & 0 deletions tests/components/apcupsd/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions tests/components/apcupsd/snapshots/test_diagnostics.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
52 changes: 50 additions & 2 deletions tests/components/apcupsd/snapshots/test_sensor.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
# ---
Expand All @@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.myups_model_2',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'sensor.myups_model_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'Back-UPS ES 600',
})
# ---
# name: test_sensor[sensor.myups_name-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
Expand Down
9 changes: 9 additions & 0 deletions tests/components/image/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading