diff --git a/homeassistant/components/devolo_home_network/coordinator.py b/homeassistant/components/devolo_home_network/coordinator.py index d23aa0e935ec4a..5af9afb12ae22f 100644 --- a/homeassistant/components/devolo_home_network/coordinator.py +++ b/homeassistant/components/devolo_home_network/coordinator.py @@ -207,7 +207,7 @@ async def async_update_last_restart(self) -> int: class DevoloWifiConnectedStationsGetCoordinator( - DevoloDataUpdateCoordinator[list[ConnectedStationInfo]] + DevoloDataUpdateCoordinator[dict[str, ConnectedStationInfo]] ): """Class to manage fetching data from the WifiGuestAccessGet endpoint.""" @@ -230,10 +230,11 @@ def __init__( ) self.update_method = self.async_get_wifi_connected_station - async def async_get_wifi_connected_station(self) -> list[ConnectedStationInfo]: + async def async_get_wifi_connected_station(self) -> dict[str, ConnectedStationInfo]: """Fetch data from API endpoint.""" assert self.device.device - return await self.device.device.async_get_wifi_connected_station() + clients = await self.device.device.async_get_wifi_connected_station() + return {client.mac_address: client for client in clients} class DevoloWifiGuestAccessGetCoordinator( diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index ad3d3e1cffa3cd..a0cdd3812612df 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -28,9 +28,9 @@ async def async_setup_entry( ) -> None: """Get all devices and sensors and setup them via config entry.""" device = entry.runtime_data.device - coordinators: dict[str, DevoloDataUpdateCoordinator[list[ConnectedStationInfo]]] = ( - entry.runtime_data.coordinators - ) + coordinators: dict[ + str, DevoloDataUpdateCoordinator[dict[str, ConnectedStationInfo]] + ] = entry.runtime_data.coordinators registry = er.async_get(hass) tracked = set() @@ -38,16 +38,16 @@ async def async_setup_entry( def new_device_callback() -> None: """Add new devices if needed.""" new_entities = [] - for station in coordinators[CONNECTED_WIFI_CLIENTS].data: - if station.mac_address in tracked: + for mac_address in coordinators[CONNECTED_WIFI_CLIENTS].data: + if mac_address in tracked: continue new_entities.append( DevoloScannerEntity( - coordinators[CONNECTED_WIFI_CLIENTS], device, station.mac_address + coordinators[CONNECTED_WIFI_CLIENTS], device, mac_address ) ) - tracked.add(station.mac_address) + tracked.add(mac_address) async_add_entities(new_entities) @callback @@ -82,7 +82,7 @@ def restore_entities() -> None: # The pylint disable is needed because of https://github.com/pylint-dev/pylint/issues/9138 class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module - CoordinatorEntity[DevoloDataUpdateCoordinator[list[ConnectedStationInfo]]], + CoordinatorEntity[DevoloDataUpdateCoordinator[dict[str, ConnectedStationInfo]]], ScannerEntity, ): """Representation of a devolo device tracker.""" @@ -92,7 +92,7 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module def __init__( self, - coordinator: DevoloDataUpdateCoordinator[list[ConnectedStationInfo]], + coordinator: DevoloDataUpdateCoordinator[dict[str, ConnectedStationInfo]], device: Device, mac: str, ) -> None: @@ -109,14 +109,8 @@ def extra_state_attributes(self) -> dict[str, str]: if not self.coordinator.data: return {} - station = next( - ( - station - for station in self.coordinator.data - if station.mac_address == self.mac_address - ), - None, - ) + assert self.mac_address + station = self.coordinator.data.get(self.mac_address) if station: attrs["wifi"] = WIFI_APTYPE.get(station.vap_type, STATE_UNKNOWN) attrs["band"] = ( @@ -129,11 +123,8 @@ def extra_state_attributes(self) -> dict[str, str]: @property def is_connected(self) -> bool: """Return true if the device is connected to the network.""" - return any( - station - for station in self.coordinator.data - if station.mac_address == self.mac_address - ) + assert self.mac_address + return self.coordinator.data.get(self.mac_address) is not None @property def unique_id(self) -> str: diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index be437314ae4c19..79b9b846463f76 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -21,7 +21,7 @@ type _DataType = ( LogicalNetwork | DataRate - | list[ConnectedStationInfo] + | dict[str, ConnectedStationInfo] | list[NeighborAPInfo] | WifiGuestAccessGet | bool diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index f4c911bf78765b..941eec4215d1da 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -47,7 +47,11 @@ def _last_restart(runtime: int) -> datetime: type _CoordinatorDataType = ( - LogicalNetwork | DataRate | list[ConnectedStationInfo] | list[NeighborAPInfo] | int + LogicalNetwork + | DataRate + | dict[str, ConnectedStationInfo] + | list[NeighborAPInfo] + | int ) type _SensorDataType = int | float | datetime @@ -79,7 +83,7 @@ class DevoloSensorEntityDescription[ ), ), CONNECTED_WIFI_CLIENTS: DevoloSensorEntityDescription[ - list[ConnectedStationInfo], int + dict[str, ConnectedStationInfo], int ]( key=CONNECTED_WIFI_CLIENTS, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/google_generative_ai_conversation/ai_task.py b/homeassistant/components/google_generative_ai_conversation/ai_task.py index ab34af71ebeec4..b4f9d73e38d565 100644 --- a/homeassistant/components/google_generative_ai_conversation/ai_task.py +++ b/homeassistant/components/google_generative_ai_conversation/ai_task.py @@ -2,11 +2,14 @@ from __future__ import annotations +from json import JSONDecodeError + from homeassistant.components import ai_task, conversation from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.json import json_loads from .const import LOGGER from .entity import ERROR_GETTING_RESPONSE, GoogleGenerativeAILLMBaseEntity @@ -42,7 +45,7 @@ async def _async_generate_data( chat_log: conversation.ChatLog, ) -> ai_task.GenDataTaskResult: """Handle a generate data task.""" - await self._async_handle_chat_log(chat_log) + await self._async_handle_chat_log(chat_log, task.structure) if not isinstance(chat_log.content[-1], conversation.AssistantContent): LOGGER.error( @@ -51,7 +54,25 @@ async def _async_generate_data( ) raise HomeAssistantError(ERROR_GETTING_RESPONSE) + text = chat_log.content[-1].content or "" + + if not task.structure: + return ai_task.GenDataTaskResult( + conversation_id=chat_log.conversation_id, + data=text, + ) + + try: + data = json_loads(text) + except JSONDecodeError as err: + LOGGER.error( + "Failed to parse JSON response: %s. Response: %s", + err, + text, + ) + raise HomeAssistantError(ERROR_GETTING_RESPONSE) from err + return ai_task.GenDataTaskResult( conversation_id=chat_log.conversation_id, - data=chat_log.content[-1].content or "", + data=data, ) diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index dea875212efe49..d471da36a8cfab 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -21,6 +21,7 @@ Schema, Tool, ) +import voluptuous as vol from voluptuous_openapi import convert from homeassistant.components import conversation @@ -324,6 +325,7 @@ def __init__( async def _async_handle_chat_log( self, chat_log: conversation.ChatLog, + structure: vol.Schema | None = None, ) -> None: """Generate an answer for the chat log.""" options = self.subentry.data @@ -402,6 +404,18 @@ async def _async_handle_chat_log( generateContentConfig.automatic_function_calling = ( AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None) ) + if structure: + generateContentConfig.response_mime_type = "application/json" + generateContentConfig.response_schema = _format_schema( + convert( + structure, + custom_serializer=( + chat_log.llm_api.custom_serializer + if chat_log.llm_api + else llm.selector_serializer + ), + ) + ) if not supports_system_instruction: messages = [ diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index fd2f8631cd2026..a40df909e145cc 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -1314,7 +1314,7 @@ class PlatePowerStep(MieleEnum): plate_step_11 = 11 plate_step_12 = 12 plate_step_13 = 13 - plate_step_14 = 4 + plate_step_14 = 14 plate_step_15 = 15 plate_step_16 = 16 plate_step_17 = 17 diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 03df289a2fdee2..bab4f90c6d13a6 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -221,12 +221,16 @@ def _get_item_thumbnail( ) -> str | None: """Construct path to thumbnail image.""" item_thumbnail: str | None = None - if artwork_track_id := item.get("artwork_track_id"): + track_id = item.get("artwork_track_id") or ( + item.get("id") if item_type == "track" else None + ) + + if track_id: if internal_request: - item_thumbnail = player.generate_image_url_from_track_id(artwork_track_id) + item_thumbnail = player.generate_image_url_from_track_id(track_id) elif item_type is not None: item_thumbnail = entity.get_browse_image_url( - item_type, item["id"], artwork_track_id + item_type, item["id"], track_id ) elif search_type in ["apps", "radios"]: @@ -311,8 +315,7 @@ async def build_item_response( title=item["title"], media_content_type=item_type, media_class=CONTENT_TYPE_MEDIA_CLASS[item_type]["item"], - can_expand=CONTENT_TYPE_MEDIA_CLASS[item_type]["children"] - is not None, + can_expand=bool(CONTENT_TYPE_MEDIA_CLASS[item_type]["children"]), can_play=True, ) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index bf89e693870c14..b239ad99119cd3 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -331,7 +331,7 @@ class NamespacedTool(Tool): def __init__(self, namespace: str, tool: Tool) -> None: """Init the class.""" self.namespace = namespace - self.name = f"{namespace}.{tool.name}" + self.name = f"{namespace}__{tool.name}" self.description = tool.description self.parameters = tool.parameters self.tool = tool @@ -458,7 +458,7 @@ async def async_get_api_instance(self, llm_context: LLMContext) -> APIInstance: api_prompt=self._async_get_api_prompt(llm_context, exposed_entities), llm_context=llm_context, tools=self._async_get_tools(llm_context, exposed_entities), - custom_serializer=_selector_serializer, + custom_serializer=selector_serializer, ) @callback @@ -701,7 +701,7 @@ def _get_exposed_entities( return data -def _selector_serializer(schema: Any) -> Any: # noqa: C901 +def selector_serializer(schema: Any) -> Any: # noqa: C901 """Convert selectors into OpenAPI schema.""" if not isinstance(schema, selector.Selector): return UNSUPPORTED @@ -782,7 +782,7 @@ def _selector_serializer(schema: Any) -> Any: # noqa: C901 result["properties"] = { field: convert( selector.selector(field_schema["selector"]), - custom_serializer=_selector_serializer, + custom_serializer=selector_serializer, ) for field, field_schema in fields.items() } @@ -915,7 +915,7 @@ def __init__( """Init the class.""" self._domain = domain self._action = action - self.name = f"{domain}.{action}" + self.name = f"{domain}__{action}" # Note: _get_cached_action_parameters only works for services which # add their description directly to the service description cache. # This is not the case for most services, but it is for scripts. diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index 244ac518fbd863..da5976f46c47f8 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -112,19 +112,26 @@ async def setup_ha(hass: HomeAssistant) -> None: @pytest.fixture -def mock_send_message_stream() -> Generator[AsyncMock]: +def mock_chat_create() -> Generator[AsyncMock]: """Mock stream response.""" async def mock_generator(stream): for value in stream: yield value + mock_send_message_stream = AsyncMock() + mock_send_message_stream.side_effect = lambda **kwargs: mock_generator( + mock_send_message_stream.return_value.pop(0) + ) + with patch( - "google.genai.chats.AsyncChat.send_message_stream", - AsyncMock(), - ) as mock_send_message_stream: - mock_send_message_stream.side_effect = lambda **kwargs: mock_generator( - mock_send_message_stream.return_value.pop(0) - ) + "google.genai.chats.AsyncChats.create", + return_value=AsyncMock(send_message_stream=mock_send_message_stream), + ) as mock_create: + yield mock_create - yield mock_send_message_stream + +@pytest.fixture +def mock_send_message_stream(mock_chat_create) -> Generator[AsyncMock]: + """Mock stream response.""" + return mock_chat_create.return_value.send_message_stream diff --git a/tests/components/google_generative_ai_conversation/test_ai_task.py b/tests/components/google_generative_ai_conversation/test_ai_task.py index 72b62b64615828..b2b44aa1cd6f94 100644 --- a/tests/components/google_generative_ai_conversation/test_ai_task.py +++ b/tests/components/google_generative_ai_conversation/test_ai_task.py @@ -4,10 +4,12 @@ from google.genai.types import GenerateContentResponse import pytest +import voluptuous as vol from homeassistant.components import ai_task from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er, selector from tests.common import MockConfigEntry from tests.components.conversation import ( @@ -17,14 +19,15 @@ @pytest.mark.usefixtures("mock_init_component") -async def test_run_task( +async def test_generate_data( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_chat_log: MockChatLog, # noqa: F811 mock_send_message_stream: AsyncMock, + mock_chat_create: AsyncMock, entity_registry: er.EntityRegistry, ) -> None: - """Test empty response.""" + """Test generating data.""" entity_id = "ai_task.google_ai_task" # Ensure it's linked to the subentry @@ -60,3 +63,68 @@ async def test_run_task( instructions="Test prompt", ) assert result.data == "Hi there!" + + mock_send_message_stream.return_value = [ + [ + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [{"text": '{"characters": ["Mario", "Luigi"]}'}], + "role": "model", + }, + } + ], + ), + ], + ] + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Give me 2 mario characters", + structure=vol.Schema( + { + vol.Required("characters"): selector.selector( + { + "text": { + "multiple": True, + } + } + ) + }, + ), + ) + assert result.data == {"characters": ["Mario", "Luigi"]} + + assert len(mock_chat_create.mock_calls) == 2 + config = mock_chat_create.mock_calls[-1][2]["config"] + assert config.response_mime_type == "application/json" + assert config.response_schema == { + "properties": {"characters": {"items": {"type": "STRING"}, "type": "ARRAY"}}, + "required": ["characters"], + "type": "OBJECT", + } + # Raise error on invalid JSON response + mock_send_message_stream.return_value = [ + [ + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [{"text": "INVALID JSON RESPONSE"}], + "role": "model", + }, + } + ], + ), + ], + ] + with pytest.raises(HomeAssistantError): + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Test prompt", + structure=vol.Schema({vol.Required("bla"): str}), + ) diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index b1691c28b1949d..dfc12a52c08272 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -103,6 +103,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -160,6 +161,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -197,6 +199,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -254,6 +257,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -291,6 +295,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -348,6 +353,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -385,6 +391,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -442,6 +449,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -479,6 +487,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -536,6 +545,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index b978559130c455..78ff675f0b6584 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -1542,18 +1542,18 @@ async def async_call( """ ) assert [(tool.name, tool.description) for tool in instance.tools] == [ - ("api-1.Tool_1", "Description 1"), - ("api-2.Tool_2", "Description 2"), + ("api-1__Tool_1", "Description 1"), + ("api-2__Tool_2", "Description 2"), ] # The test tool returns back the provided arguments so we can verify # the original tool is invoked with the correct tool name and args. result = await instance.async_call_tool( - llm.ToolInput(tool_name="api-1.Tool_1", tool_args={"arg1": "value1"}) + llm.ToolInput(tool_name="api-1__Tool_1", tool_args={"arg1": "value1"}) ) assert result == {"result": {"Tool_1": {"arg1": "value1"}}} result = await instance.async_call_tool( - llm.ToolInput(tool_name="api-2.Tool_2", tool_args={"arg2": "value2"}) + llm.ToolInput(tool_name="api-2__Tool_2", tool_args={"arg2": "value2"}) ) assert result == {"result": {"Tool_2": {"arg2": "value2"}}}