From 0f36759a383085162dc329aee0bce8943830333e Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Mon, 3 Feb 2025 00:55:16 +0300 Subject: [PATCH 01/36] Add support for OpenAI reasoning models (#137139) * Add support for OpenAI reasoning models * Apply suggestions from code review * Remove o1-mini* and o1-preview* model support * List unsupported models * Reenable audio models (they also support text) --- .../openai_conversation/config_flow.py | 37 ++++++++++++++---- .../components/openai_conversation/const.py | 14 +++++++ .../openai_conversation/conversation.py | 38 +++++++++++++------ .../openai_conversation/strings.json | 18 ++++++++- .../openai_conversation/test_config_flow.py | 25 ++++++++++++ 5 files changed, 111 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 2a1764e6b5e..c631884ea0b 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -24,6 +24,7 @@ from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, SelectSelectorConfig, + SelectSelectorMode, TemplateSelector, ) from homeassistant.helpers.typing import VolDictType @@ -32,14 +33,17 @@ from .const import ( CONF_CHAT_MODEL, CONF_MAX_TOKENS, CONF_PROMPT, + CONF_REASONING_EFFORT, CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_TOP_P, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_REASONING_EFFORT, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, + UNSUPPORTED_MODELS, ) _LOGGER = logging.getLogger(__name__) @@ -124,26 +128,32 @@ class OpenAIOptionsFlow(OptionsFlow): ) -> ConfigFlowResult: """Manage the options.""" options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options + errors: dict[str, str] = {} if user_input is not None: if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: if user_input[CONF_LLM_HASS_API] == "none": user_input.pop(CONF_LLM_HASS_API) - return self.async_create_entry(title="", data=user_input) - # Re-render the options again, now with the recommended options shown/hidden - self.last_rendered_recommended = user_input[CONF_RECOMMENDED] + if user_input.get(CONF_CHAT_MODEL) in UNSUPPORTED_MODELS: + errors[CONF_CHAT_MODEL] = "model_not_supported" + else: + return self.async_create_entry(title="", data=user_input) + else: + # Re-render the options again, now with the recommended options shown/hidden + self.last_rendered_recommended = user_input[CONF_RECOMMENDED] - options = { - CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], - CONF_PROMPT: user_input[CONF_PROMPT], - CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], - } + options = { + CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], + CONF_PROMPT: user_input[CONF_PROMPT], + CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API], + } schema = openai_config_option_schema(self.hass, options) return self.async_show_form( step_id="init", data_schema=vol.Schema(schema), + errors=errors, ) @@ -210,6 +220,17 @@ def openai_config_option_schema( description={"suggested_value": options.get(CONF_TEMPERATURE)}, default=RECOMMENDED_TEMPERATURE, ): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)), + vol.Optional( + CONF_REASONING_EFFORT, + description={"suggested_value": options.get(CONF_REASONING_EFFORT)}, + default=RECOMMENDED_REASONING_EFFORT, + ): SelectSelector( + SelectSelectorConfig( + options=["low", "medium", "high"], + translation_key="reasoning_effort", + mode=SelectSelectorMode.DROPDOWN, + ) + ), } ) return schema diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index e8ee003fcca..793e021e332 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -15,3 +15,17 @@ CONF_TOP_P = "top_p" RECOMMENDED_TOP_P = 1.0 CONF_TEMPERATURE = "temperature" RECOMMENDED_TEMPERATURE = 1.0 +CONF_REASONING_EFFORT = "reasoning_effort" +RECOMMENDED_REASONING_EFFORT = "low" + +UNSUPPORTED_MODELS = [ + "o1-mini", + "o1-mini-2024-09-12", + "o1-preview", + "o1-preview-2024-09-12", + "gpt-4o-realtime-preview", + "gpt-4o-realtime-preview-2024-12-17", + "gpt-4o-realtime-preview-2024-10-01", + "gpt-4o-mini-realtime-preview", + "gpt-4o-mini-realtime-preview-2024-12-17", +] diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index e19ad9becaf..aced98eaa97 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -31,12 +31,14 @@ from .const import ( CONF_CHAT_MODEL, CONF_MAX_TOKENS, CONF_PROMPT, + CONF_REASONING_EFFORT, CONF_TEMPERATURE, CONF_TOP_P, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_REASONING_EFFORT, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, ) @@ -97,12 +99,15 @@ def _chat_message_convert( | conversation.NativeContent[ChatCompletionMessageParam], ) -> ChatCompletionMessageParam: """Convert any native chat message for this agent to the native format.""" - if message.role == "native": + role = message.role + if role == "native": # mypy doesn't understand that checking role ensures content type return message.content # type: ignore[return-value] + if role == "system": + role = "developer" return cast( ChatCompletionMessageParam, - {"role": message.role, "content": message.content}, + {"role": role, "content": message.content}, ) @@ -189,6 +194,8 @@ class OpenAIConversationEntity( for tool in session.llm_api.tools ] + model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + messages = [ _chat_message_convert(message) for message in session.async_get_messages() ] @@ -197,16 +204,25 @@ class OpenAIConversationEntity( # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): - try: - result = await client.chat.completions.create( - model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), - messages=messages, - tools=tools or NOT_GIVEN, - max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), - top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P), - temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), - user=session.conversation_id, + model_args = { + "model": model, + "messages": messages, + "tools": tools or NOT_GIVEN, + "max_completion_tokens": options.get( + CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS + ), + "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), + "temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), + "user": session.conversation_id, + } + + if model.startswith("o"): + model_args["reasoning_effort"] = options.get( + CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT ) + + try: + result = await client.chat.completions.create(**model_args) except openai.OpenAIError as err: LOGGER.error("Error talking to OpenAI: %s", err) raise HomeAssistantError("Error talking to OpenAI") from err diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 2477155e3cb..b8768f8abbe 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -23,12 +23,26 @@ "temperature": "Temperature", "top_p": "Top P", "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", - "recommended": "Recommended model settings" + "recommended": "Recommended model settings", + "reasoning_effort": "Reasoning effort" }, "data_description": { - "prompt": "Instruct how the LLM should respond. This can be a template." + "prompt": "Instruct how the LLM should respond. This can be a template.", + "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt (for certain reasoning models)" } } + }, + "error": { + "model_not_supported": "This model is not supported, please select a different model" + } + }, + "selector": { + "reasoning_effort": { + "options": { + "low": "Low", + "medium": "Medium", + "high": "High" + } } }, "services": { diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index f5017c124b1..90a08471f39 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -12,12 +12,14 @@ from homeassistant.components.openai_conversation.const import ( CONF_CHAT_MODEL, CONF_MAX_TOKENS, CONF_PROMPT, + CONF_REASONING_EFFORT, CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_TOP_P, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_REASONING_EFFORT, RECOMMENDED_TOP_P, ) from homeassistant.const import CONF_LLM_HASS_API @@ -88,6 +90,27 @@ async def test_options( assert options["data"][CONF_CHAT_MODEL] == RECOMMENDED_CHAT_MODEL +async def test_options_unsupported_model( + hass: HomeAssistant, mock_config_entry, mock_init_component +) -> None: + """Test the options form giving error about models not supported.""" + options_flow = await hass.config_entries.options.async_init( + mock_config_entry.entry_id + ) + result = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_CHAT_MODEL: "o1-mini", + CONF_LLM_HASS_API: "assist", + }, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"chat_model": "model_not_supported"} + + @pytest.mark.parametrize( ("side_effect", "error"), [ @@ -148,6 +171,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, CONF_TOP_P: RECOMMENDED_TOP_P, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, }, ), ( @@ -158,6 +182,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, CONF_TOP_P: RECOMMENDED_TOP_P, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, }, { CONF_RECOMMENDED: True, From 0f641fcb745fc1f80a3c64038f772b4697e8588f Mon Sep 17 00:00:00 2001 From: TimL Date: Mon, 3 Feb 2025 10:08:32 +1100 Subject: [PATCH 02/36] Switch to using IP Addresses for connecting to smlight devices (#137204) --- .../components/smlight/config_flow.py | 53 +++++++---- .../components/smlight/manifest.json | 5 + homeassistant/generated/dhcp.py | 4 + tests/components/smlight/conftest.py | 3 +- .../smlight/snapshots/test_init.ambr | 2 +- tests/components/smlight/test_config_flow.py | 93 ++++++++++++++++--- 6 files changed, 128 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py index 34bd0758174..88ac3cde008 100644 --- a/homeassistant/components/smlight/config_flow.py +++ b/homeassistant/components/smlight/config_flow.py @@ -14,6 +14,7 @@ from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResu from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN @@ -35,7 +36,8 @@ STEP_AUTH_DATA_SCHEMA = vol.Schema( class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for SMLIGHT Zigbee.""" - host: str + _host: str + _device_name: str client: Api2 async def async_step_user( @@ -45,11 +47,13 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - self.host = user_input[CONF_HOST] - self.client = Api2(self.host, session=async_get_clientsession(self.hass)) + self._host = user_input[CONF_HOST] + self.client = Api2(self._host, session=async_get_clientsession(self.hass)) try: info = await self.client.get_info() + self._host = str(info.device_ip) + self._device_name = str(info.hostname) if info.model not in Devices: return self.async_abort(reason="unsupported_device") @@ -93,15 +97,14 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered Lan coordinator.""" - local_name = discovery_info.hostname[:-1] - node_name = local_name.removesuffix(".local") + mac: str | None = discovery_info.properties.get("mac") + self._device_name = discovery_info.hostname.removesuffix(".local.") + self._host = discovery_info.host - self.host = local_name - self.context["title_placeholders"] = {CONF_NAME: node_name} - self.client = Api2(self.host, session=async_get_clientsession(self.hass)) + self.context["title_placeholders"] = {CONF_NAME: self._device_name} + self.client = Api2(self._host, session=async_get_clientsession(self.hass)) - mac = discovery_info.properties.get("mac") - # fallback for legacy firmware + # fallback for legacy firmware older than v2.3.x if mac is None: try: info = await self.client.get_info() @@ -111,7 +114,7 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): mac = info.MAC await self.async_set_unique_id(format_mac(mac)) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) return await self.async_step_confirm_discovery() @@ -122,7 +125,6 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - user_input[CONF_HOST] = self.host try: info = await self.client.get_info() @@ -142,7 +144,7 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="confirm_discovery", - description_placeholders={"host": self.host}, + description_placeholders={"host": self._device_name}, errors=errors, ) @@ -151,8 +153,8 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle reauth when API Authentication failed.""" - self.host = entry_data[CONF_HOST] - self.client = Api2(self.host, session=async_get_clientsession(self.hass)) + self._host = entry_data[CONF_HOST] + self.client = Api2(self._host, session=async_get_clientsession(self.hass)) return await self.async_step_reauth_confirm() @@ -182,6 +184,16 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + await self.async_set_unique_id(format_mac(discovery_info.macaddress)) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + # This should never happen since we only listen to DHCP requests + # for configured devices. + return self.async_abort(reason="already_configured") + async def _async_check_auth_required(self, user_input: dict[str, Any]) -> bool: """Check if auth required and attempt to authenticate.""" if await self.client.check_auth_needed(): @@ -200,11 +212,14 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id( format_mac(info.MAC), raise_on_progress=self.source != SOURCE_USER ) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) - if user_input.get(CONF_HOST) is None: - user_input[CONF_HOST] = self.host + user_input[CONF_HOST] = self._host assert info.model is not None - title = self.context.get("title_placeholders", {}).get(CONF_NAME) or info.model + title = ( + self.context.get("title_placeholders", {}).get(CONF_NAME) + or self._device_name + or info.model + ) return self.async_create_entry(title=title, data=user_input) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index 3691c211838..afd186f4b9a 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -3,6 +3,11 @@ "name": "SMLIGHT SLZB", "codeowners": ["@tl-sl"], "config_flow": true, + "dhcp": [ + { + "registered_devices": true + } + ], "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index b9d51ac1006..3dba5a98f3c 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -616,6 +616,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "hub*", "macaddress": "286D97*", }, + { + "domain": "smlight", + "registered_devices": True, + }, { "domain": "solaredge", "hostname": "target", diff --git a/tests/components/smlight/conftest.py b/tests/components/smlight/conftest.py index 665a55ba880..80e89e4eb16 100644 --- a/tests/components/smlight/conftest.py +++ b/tests/components/smlight/conftest.py @@ -18,7 +18,8 @@ from tests.common import ( load_json_object_fixture, ) -MOCK_HOST = "slzb-06.local" +MOCK_DEVICE_NAME = "slzb-06" +MOCK_HOST = "192.168.1.161" MOCK_USERNAME = "test-user" MOCK_PASSWORD = "test-pass" diff --git a/tests/components/smlight/snapshots/test_init.ambr b/tests/components/smlight/snapshots/test_init.ambr index 598166e537b..457a529065c 100644 --- a/tests/components/smlight/snapshots/test_init.ambr +++ b/tests/components/smlight/snapshots/test_init.ambr @@ -3,7 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , - 'configuration_url': 'http://slzb-06.local', + 'configuration_url': 'http://192.168.1.161', 'connections': set({ tuple( 'mac', diff --git a/tests/components/smlight/test_config_flow.py b/tests/components/smlight/test_config_flow.py index 4dad06b0fa3..a1c9c9d6945 100644 --- a/tests/components/smlight/test_config_flow.py +++ b/tests/components/smlight/test_config_flow.py @@ -8,19 +8,20 @@ from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError import pytest from homeassistant.components.smlight.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .conftest import MOCK_HOST, MOCK_PASSWORD, MOCK_USERNAME +from .conftest import MOCK_DEVICE_NAME, MOCK_HOST, MOCK_PASSWORD, MOCK_USERNAME from tests.common import MockConfigEntry DISCOVERY_INFO = ZeroconfServiceInfo( - ip_address=ip_address("127.0.0.1"), - ip_addresses=[ip_address("127.0.0.1")], + ip_address=ip_address("192.168.1.161"), + ip_addresses=[ip_address("192.168.1.161")], hostname="slzb-06.local.", name="mock_name", port=6638, @@ -29,8 +30,8 @@ DISCOVERY_INFO = ZeroconfServiceInfo( ) DISCOVERY_INFO_LEGACY = ZeroconfServiceInfo( - ip_address=ip_address("127.0.0.1"), - ip_addresses=[ip_address("127.0.0.1")], + ip_address=ip_address("192.168.1.161"), + ip_addresses=[ip_address("192.168.1.161")], hostname="slzb-06.local.", name="mock_name", port=6638, @@ -52,7 +53,7 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_HOST: MOCK_HOST, + CONF_HOST: "slzb-06p7.local", }, ) @@ -76,7 +77,7 @@ async def test_zeroconf_flow( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO ) - assert result["description_placeholders"] == {"host": MOCK_HOST} + assert result["description_placeholders"] == {"host": MOCK_DEVICE_NAME} assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_discovery" @@ -113,7 +114,7 @@ async def test_zeroconf_flow_auth( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO ) - assert result["description_placeholders"] == {"host": MOCK_HOST} + assert result["description_placeholders"] == {"host": MOCK_DEVICE_NAME} assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_discovery" @@ -167,7 +168,7 @@ async def test_zeroconf_unsupported_abort( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO ) - assert result["description_placeholders"] == {"host": MOCK_HOST} + assert result["description_placeholders"] == {"host": MOCK_DEVICE_NAME} assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_discovery" @@ -489,7 +490,7 @@ async def test_zeroconf_legacy_mac( data=DISCOVERY_INFO_LEGACY, ) - assert result["description_placeholders"] == {"host": MOCK_HOST} + assert result["description_placeholders"] == {"host": MOCK_DEVICE_NAME} result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -507,6 +508,76 @@ async def test_zeroconf_legacy_mac( assert len(mock_smlight_client.get_info.mock_calls) == 3 +@pytest.mark.usefixtures("mock_smlight_client") +async def test_zeroconf_updates_host( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test zeroconf discovery updates host ip.""" + mock_config_entry.add_to_hass(hass) + + service_info = DISCOVERY_INFO + service_info.ip_address = ip_address("192.168.1.164") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_config_entry.data[CONF_HOST] == "192.168.1.164" + + +@pytest.mark.usefixtures("mock_smlight_client") +async def test_dhcp_discovery_updates_host( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test dhcp discovery updates host ip.""" + mock_config_entry.add_to_hass(hass) + + service_info = DhcpServiceInfo( + ip="192.168.1.164", + hostname="slzb-06", + macaddress="aabbccddeeff", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_config_entry.data[CONF_HOST] == "192.168.1.164" + + +@pytest.mark.usefixtures("mock_smlight_client") +async def test_dhcp_discovery_aborts( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test dhcp discovery updates host ip.""" + mock_config_entry.add_to_hass(hass) + + service_info = DhcpServiceInfo( + ip="192.168.1.161", + hostname="slzb-06", + macaddress="000000000000", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_config_entry.data[CONF_HOST] == "192.168.1.161" + + async def test_reauth_flow( hass: HomeAssistant, mock_smlight_client: MagicMock, From f846aa47054b57415d26b4dd43922152538cd311 Mon Sep 17 00:00:00 2001 From: TimL Date: Mon, 3 Feb 2025 10:46:27 +1100 Subject: [PATCH 03/36] Simplify config entry title for SMLIGHT (#137206) --- homeassistant/components/smlight/config_flow.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py index 88ac3cde008..667e6e2884b 100644 --- a/homeassistant/components/smlight/config_flow.py +++ b/homeassistant/components/smlight/config_flow.py @@ -217,9 +217,5 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_HOST] = self._host assert info.model is not None - title = ( - self.context.get("title_placeholders", {}).get(CONF_NAME) - or self._device_name - or info.model - ) + title = self._device_name or info.model return self.async_create_entry(title=title, data=user_input) From 1860794cac9b6fd6fa5e5e83774ffacdaa1bcbfd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Feb 2025 20:22:49 -0600 Subject: [PATCH 04/36] Bump bleak-esphome to 2.7.0 (#137199) changelog: https://github.com/Bluetooth-Devices/bleak-esphome/compare/v2.6.0...v2.7.0 --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 3a55730c60f..e7db70acf5c 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.6.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.7.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 9585be72c63..1f8b505ec45 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -18,7 +18,7 @@ "requirements": [ "aioesphomeapi==29.0.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==2.6.0" + "bleak-esphome==2.7.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index d8bd4ec6a64..bf88b9fa810 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -597,7 +597,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.6.0 +bleak-esphome==2.7.0 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7515a1cc342..0a7efb10f60 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -528,7 +528,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.6.0 +bleak-esphome==2.7.0 # homeassistant.components.bluetooth bleak-retry-connector==3.8.0 From ce93cb9467c7dce55f079675c2f76a68350dd69c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Feb 2025 20:22:58 -0600 Subject: [PATCH 05/36] Bump dbus-fast to 2.23.0 (#137205) changelog: https://github.com/Bluetooth-Devices/dbus-fast/compare/v2.31.0...v2.32.0 --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index cd2530e1717..22db886ef3f 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==0.21.1", "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.23.3", - "dbus-fast==2.31.0", + "dbus-fast==2.32.0", "habluetooth==3.21.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bc6002e72b8..949d1885511 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 cryptography==44.0.0 -dbus-fast==2.31.0 +dbus-fast==2.32.0 fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 diff --git a/requirements_all.txt b/requirements_all.txt index bf88b9fa810..2f03c42d70b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -738,7 +738,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.31.0 +dbus-fast==2.32.0 # homeassistant.components.debugpy debugpy==1.8.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0a7efb10f60..cb05db76aea 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -634,7 +634,7 @@ datadog==0.15.0 datapoint==0.9.9 # homeassistant.components.bluetooth -dbus-fast==2.31.0 +dbus-fast==2.32.0 # homeassistant.components.debugpy debugpy==1.8.11 From 9679fc787851dab28742c9476d7917ceacbdb463 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Feb 2025 00:05:20 -0500 Subject: [PATCH 06/36] Chat session rev2 (#137209) * Chat Session rev 2 * Rename session to chat_log * Simplify typing * Typing * Address comments * Fix anthropic and ollama --- .../components/anthropic/conversation.py | 1 + .../components/assist_pipeline/pipeline.py | 14 +- .../components/conversation/__init__.py | 16 +- .../conversation/{session.py => chat_log.py} | 151 +++++++------ .../components/conversation/default_agent.py | 12 +- .../conversation.py | 157 ++++++++----- .../openai_conversation/conversation.py | 121 +++++----- homeassistant/helpers/llm.py | 5 +- .../components/anthropic/test_conversation.py | 2 + .../{test_session.ambr => test_chat_log.ambr} | 0 .../{test_session.py => test_chat_log.py} | 212 +++++++----------- .../test_conversation.py | 16 +- tests/components/ollama/test_conversation.py | 9 + .../openai_conversation/test_conversation.py | 2 + 14 files changed, 388 insertions(+), 330 deletions(-) rename homeassistant/components/conversation/{session.py => chat_log.py} (68%) rename tests/components/conversation/snapshots/{test_session.ambr => test_chat_log.ambr} (100%) rename tests/components/conversation/{test_session.py => test_chat_log.py} (67%) diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index e45e849adf6..259d1295809 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -272,6 +272,7 @@ class AnthropicConversationEntity( continue tool_input = llm.ToolInput( + id=tool_call.id, tool_name=tool_call.name, tool_args=cast(dict[str, Any], tool_call.input), ) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index cfc7261410a..c5f9098623a 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1063,11 +1063,11 @@ class PipelineRun: agent_id=self.intent_agent, extra_system_prompt=conversation_extra_system_prompt, ) - processed_locally = self.intent_agent == conversation.HOME_ASSISTANT_AGENT - agent_id = user_input.agent_id + agent_id = self.intent_agent + processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT intent_response: intent.IntentResponse | None = None - if user_input.agent_id != conversation.HOME_ASSISTANT_AGENT: + if not processed_locally: # Sentence triggers override conversation agent if ( trigger_response_text @@ -1105,13 +1105,13 @@ class PipelineRun: speech: str = intent_response.speech.get("plain", {}).get( "speech", "" ) - chat_log.async_add_message( - conversation.Content( - role="assistant", + async for _ in chat_log.async_add_assistant_content( + conversation.AssistantContent( agent_id=agent_id, content=speech, ) - ) + ): + pass conversation_result = conversation.ConversationResult( response=intent_response, conversation_id=session.conversation_id, diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 13152beff51..69e738205c5 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -30,6 +30,16 @@ from .agent_manager import ( async_get_agent, get_agent_manager, ) +from .chat_log import ( + AssistantContent, + ChatLog, + Content, + ConverseError, + SystemContent, + ToolResultContent, + UserContent, + async_get_chat_log, +) from .const import ( ATTR_AGENT_ID, ATTR_CONVERSATION_ID, @@ -48,13 +58,13 @@ from .default_agent import DefaultAgent, async_setup_default_agent from .entity import ConversationEntity from .http import async_setup as async_setup_conversation_http from .models import AbstractConversationAgent, ConversationInput, ConversationResult -from .session import ChatLog, Content, ConverseError, NativeContent, async_get_chat_log from .trace import ConversationTraceEventType, async_conversation_trace_append __all__ = [ "DOMAIN", "HOME_ASSISTANT_AGENT", "OLD_HOME_ASSISTANT_AGENT", + "AssistantContent", "ChatLog", "Content", "ConversationEntity", @@ -63,7 +73,9 @@ __all__ = [ "ConversationResult", "ConversationTraceEventType", "ConverseError", - "NativeContent", + "SystemContent", + "ToolResultContent", + "UserContent", "async_conversation_trace_append", "async_converse", "async_get_agent_info", diff --git a/homeassistant/components/conversation/session.py b/homeassistant/components/conversation/chat_log.py similarity index 68% rename from homeassistant/components/conversation/session.py rename to homeassistant/components/conversation/chat_log.py index c32d61333a0..d053d114a11 100644 --- a/homeassistant/components/conversation/session.py +++ b/homeassistant/components/conversation/chat_log.py @@ -2,19 +2,16 @@ from __future__ import annotations -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator from contextlib import contextmanager from dataclasses import dataclass, field, replace -from datetime import datetime import logging -from typing import Literal import voluptuous as vol from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.helpers import chat_session, intent, llm, template -from homeassistant.util import dt as dt_util from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import JsonObjectType @@ -31,7 +28,7 @@ LOGGER = logging.getLogger(__name__) def async_get_chat_log( hass: HomeAssistant, session: chat_session.ChatSession, - user_input: ConversationInput, + user_input: ConversationInput | None = None, ) -> Generator[ChatLog]: """Return chat log for a specific chat session.""" all_history = hass.data.get(DATA_CHAT_HISTORY) @@ -42,9 +39,9 @@ def async_get_chat_log( history = all_history.get(session.conversation_id) if history: - history = replace(history, messages=history.messages.copy()) + history = replace(history, content=history.content.copy()) else: - history = ChatLog(hass, session.conversation_id, user_input.agent_id) + history = ChatLog(hass, session.conversation_id) @callback def do_cleanup() -> None: @@ -53,22 +50,19 @@ def async_get_chat_log( session.async_on_cleanup(do_cleanup) - message: Content = Content( - role="user", - agent_id=user_input.agent_id, - content=user_input.text, - ) - history.async_add_message(message) + if user_input is not None: + history.async_add_user_content(UserContent(content=user_input.text)) + + last_message = history.content[-1] yield history - if history.messages[-1] is message: + if history.content[-1] is last_message: LOGGER.debug( "History opened but no assistant message was added, ignoring update" ) return - history.last_updated = dt_util.utcnow() all_history[session.conversation_id] = history @@ -94,63 +88,94 @@ class ConverseError(HomeAssistantError): ) -@dataclass -class Content: +@dataclass(frozen=True) +class SystemContent: """Base class for chat messages.""" - role: Literal["system", "assistant", "user"] - agent_id: str | None + role: str = field(init=False, default="system") content: str @dataclass(frozen=True) -class NativeContent[_NativeT]: - """Native content.""" +class UserContent: + """Assistant content.""" - role: str = field(init=False, default="native") + role: str = field(init=False, default="user") + content: str + + +@dataclass(frozen=True) +class AssistantContent: + """Assistant content.""" + + role: str = field(init=False, default="assistant") agent_id: str - content: _NativeT + content: str + tool_calls: list[llm.ToolInput] | None = None + + +@dataclass(frozen=True) +class ToolResultContent: + """Tool result content.""" + + role: str = field(init=False, default="tool_result") + agent_id: str + tool_call_id: str + tool_name: str + tool_result: JsonObjectType + + +Content = SystemContent | UserContent | AssistantContent | ToolResultContent @dataclass -class ChatLog[_NativeT]: +class ChatLog: """Class holding the chat history of a specific conversation.""" hass: HomeAssistant conversation_id: str - agent_id: str | None - user_name: str | None = None - messages: list[Content | NativeContent[_NativeT]] = field( - default_factory=lambda: [Content(role="system", agent_id=None, content="")] - ) + content: list[Content] = field(default_factory=lambda: [SystemContent(content="")]) extra_system_prompt: str | None = None llm_api: llm.APIInstance | None = None - last_updated: datetime = field(default_factory=dt_util.utcnow) @callback - def async_add_message(self, message: Content | NativeContent[_NativeT]) -> None: - """Process intent.""" - if message.role == "system": - raise ValueError("Cannot add system messages to history") - if message.role != "native" and self.messages[-1].role == message.role: - raise ValueError("Cannot add two assistant or user messages in a row") + def async_add_user_content(self, content: UserContent) -> None: + """Add user content to the log.""" + self.content.append(content) - self.messages.append(message) + async def async_add_assistant_content( + self, content: AssistantContent + ) -> AsyncGenerator[ToolResultContent]: + """Add assistant content.""" + self.content.append(content) - @callback - def async_get_messages( - self, agent_id: str | None = None - ) -> list[Content | NativeContent[_NativeT]]: - """Get messages for a specific agent ID. + if content.tool_calls is None: + return - This will filter out any native message tied to other agent IDs. - It can still include assistant/user messages generated by other agents. - """ - return [ - message - for message in self.messages - if message.role != "native" or message.agent_id == agent_id - ] + if self.llm_api is None: + raise ValueError("No LLM API configured") + + for tool_input in content.tool_calls: + LOGGER.debug( + "Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args + ) + + try: + tool_result = await self.llm_api.async_call_tool(tool_input) + except (HomeAssistantError, vol.Invalid) as e: + tool_result = {"error": type(e).__name__} + if str(e): + tool_result["error_text"] = str(e) + LOGGER.debug("Tool response: %s", tool_result) + + response_content = ToolResultContent( + agent_id=content.agent_id, + tool_call_id=tool_input.id, + tool_name=tool_input.tool_name, + tool_result=tool_result, + ) + self.content.append(response_content) + yield response_content async def async_update_llm_data( self, @@ -250,36 +275,16 @@ class ChatLog[_NativeT]: prompt = "\n".join(prompt_parts) self.llm_api = llm_api - self.user_name = user_name self.extra_system_prompt = extra_system_prompt - self.messages[0] = Content( - role="system", - agent_id=user_input.agent_id, - content=prompt, - ) + self.content[0] = SystemContent(content=prompt) - LOGGER.debug("Prompt: %s", self.messages) + LOGGER.debug("Prompt: %s", self.content) LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None) trace.async_conversation_trace_append( trace.ConversationTraceEventType.AGENT_DETAIL, { - "messages": self.messages, + "messages": self.content, "tools": self.llm_api.tools if self.llm_api else None, }, ) - - async def async_call_tool(self, tool_input: llm.ToolInput) -> JsonObjectType: - """Invoke LLM tool for the configured LLM API.""" - if not self.llm_api: - raise ValueError("No LLM API configured") - LOGGER.debug("Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args) - - try: - tool_response = await self.llm_api.async_call_tool(tool_input) - except (HomeAssistantError, vol.Invalid) as e: - tool_response = {"error": type(e).__name__} - if str(e): - tool_response["error_text"] = str(e) - LOGGER.debug("Tool response: %s", tool_response) - return tool_response diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index c4a8f7ea7eb..5e1709c0404 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -55,6 +55,7 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_added_domain from homeassistant.util.json import JsonObjectType, json_loads_object +from .chat_log import AssistantContent, async_get_chat_log from .const import ( DATA_DEFAULT_ENTITY, DEFAULT_EXPOSED_ATTRIBUTES, @@ -63,7 +64,6 @@ from .const import ( ) from .entity import ConversationEntity from .models import ConversationInput, ConversationResult -from .session import Content, async_get_chat_log from .trace import ConversationTraceEventType, async_conversation_trace_append _LOGGER = logging.getLogger(__name__) @@ -379,13 +379,13 @@ class DefaultAgent(ConversationEntity): ) speech: str = response.speech.get("plain", {}).get("speech", "") - chat_log.async_add_message( - Content( - role="assistant", - agent_id=user_input.agent_id, + async for _tool_result in chat_log.async_add_assistant_content( + AssistantContent( + agent_id=user_input.agent_id, # type: ignore[arg-type] content=speech, ) - ) + ): + pass return ConversationResult( response=response, conversation_id=session.conversation_id diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 53ee4e1f880..8a6c5563601 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -4,7 +4,7 @@ from __future__ import annotations import codecs from collections.abc import Callable -from typing import Any, Literal +from typing import Any, Literal, cast from google.api_core.exceptions import GoogleAPIError import google.generativeai as genai @@ -149,15 +149,53 @@ def _escape_decode(value: Any) -> Any: return value -def _chat_message_convert( - message: conversation.Content | conversation.NativeContent[genai_types.ContentDict], -) -> genai_types.ContentDict: - """Convert any native chat message for this agent to the native format.""" - if message.role == "native": - return message.content +def _create_google_tool_response_content( + content: list[conversation.ToolResultContent], +) -> protos.Content: + """Create a Google tool response content.""" + return protos.Content( + parts=[ + protos.Part( + function_response=protos.FunctionResponse( + name=tool_result.tool_name, response=tool_result.tool_result + ) + ) + for tool_result in content + ] + ) - role = "model" if message.role == "assistant" else message.role - return {"role": role, "parts": message.content} + +def _convert_content( + content: conversation.UserContent + | conversation.AssistantContent + | conversation.SystemContent, +) -> genai_types.ContentDict: + """Convert HA content to Google content.""" + if content.role != "assistant" or not content.tool_calls: # type: ignore[union-attr] + role = "model" if content.role == "assistant" else content.role + return {"role": role, "parts": content.content} + + # Handle the Assistant content with tool calls. + assert type(content) is conversation.AssistantContent + parts = [] + + if content.content: + parts.append(protos.Part(text=content.content)) + + if content.tool_calls: + parts.extend( + [ + protos.Part( + function_call=protos.FunctionCall( + name=tool_call.tool_name, + args=_escape_decode(tool_call.tool_args), + ) + ) + for tool_call in content.tool_calls + ] + ) + + return protos.Content({"role": "model", "parts": parts}) class GoogleGenerativeAIConversationEntity( @@ -220,7 +258,7 @@ class GoogleGenerativeAIConversationEntity( async def _async_handle_message( self, user_input: conversation.ConversationInput, - session: conversation.ChatLog[genai_types.ContentDict], + chat_log: conversation.ChatLog, ) -> conversation.ConversationResult: """Call the API.""" @@ -228,7 +266,7 @@ class GoogleGenerativeAIConversationEntity( options = self.entry.options try: - await session.async_update_llm_data( + await chat_log.async_update_llm_data( DOMAIN, user_input, options.get(CONF_LLM_HASS_API), @@ -238,10 +276,10 @@ class GoogleGenerativeAIConversationEntity( return err.as_conversation_result() tools: list[dict[str, Any]] | None = None - if session.llm_api: + if chat_log.llm_api: tools = [ - _format_tool(tool, session.llm_api.custom_serializer) - for tool in session.llm_api.tools + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools ] model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) @@ -252,9 +290,36 @@ class GoogleGenerativeAIConversationEntity( "gemini-1.0" not in model_name and "gemini-pro" not in model_name ) - prompt, *messages = [ - _chat_message_convert(message) for message in session.async_get_messages() - ] + prompt = chat_log.content[0].content # type: ignore[union-attr] + messages: list[genai_types.ContentDict] = [] + + # Google groups tool results, we do not. Group them before sending. + tool_results: list[conversation.ToolResultContent] = [] + + for chat_content in chat_log.content[1:]: + if chat_content.role == "tool_result": + # mypy doesn't like picking a type based on checking shared property 'role' + tool_results.append(cast(conversation.ToolResultContent, chat_content)) + continue + + if tool_results: + messages.append(_create_google_tool_response_content(tool_results)) + tool_results.clear() + + messages.append( + _convert_content( + cast( + conversation.UserContent + | conversation.SystemContent + | conversation.AssistantContent, + chat_content, + ) + ) + ) + + if tool_results: + messages.append(_create_google_tool_response_content(tool_results)) + model = genai.GenerativeModel( model_name=model_name, generation_config={ @@ -282,12 +347,12 @@ class GoogleGenerativeAIConversationEntity( ), }, tools=tools or None, - system_instruction=prompt["parts"] if supports_system_instruction else None, + system_instruction=prompt if supports_system_instruction else None, ) if not supports_system_instruction: messages = [ - {"role": "user", "parts": prompt["parts"]}, + {"role": "user", "parts": prompt}, {"role": "model", "parts": "Ok"}, *messages, ] @@ -325,50 +390,40 @@ class GoogleGenerativeAIConversationEntity( content = " ".join( [part.text.strip() for part in chat_response.parts if part.text] ) - if content: - session.async_add_message( - conversation.Content( - role="assistant", - agent_id=user_input.agent_id, - content=content, - ) - ) - function_calls = [ - part.function_call for part in chat_response.parts if part.function_call - ] - - if not function_calls or not session.llm_api: - break - - tool_responses = [] - for function_call in function_calls: - tool_call = MessageToDict(function_call._pb) # noqa: SLF001 + tool_calls = [] + for part in chat_response.parts: + if not part.function_call: + continue + tool_call = MessageToDict(part.function_call._pb) # noqa: SLF001 tool_name = tool_call["name"] tool_args = _escape_decode(tool_call["args"]) - tool_input = llm.ToolInput(tool_name=tool_name, tool_args=tool_args) - function_response = await session.async_call_tool(tool_input) - tool_responses.append( - protos.Part( - function_response=protos.FunctionResponse( - name=tool_name, response=function_response + tool_calls.append( + llm.ToolInput(tool_name=tool_name, tool_args=tool_args) + ) + + chat_request = _create_google_tool_response_content( + [ + tool_response + async for tool_response in chat_log.async_add_assistant_content( + conversation.AssistantContent( + agent_id=user_input.agent_id, + content=content, + tool_calls=tool_calls or None, ) ) - ) - chat_request = protos.Content(parts=tool_responses) - session.async_add_message( - conversation.NativeContent( - agent_id=user_input.agent_id, - content=chat_request, - ) + ] ) + if not tool_calls: + break + response = intent.IntentResponse(language=user_input.language) response.async_set_speech( " ".join([part.text.strip() for part in chat_response.parts if part.text]) ) return conversation.ConversationResult( - response=response, conversation_id=session.conversation_id + response=response, conversation_id=chat_log.conversation_id ) async def _async_entry_update_listener( diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index aced98eaa97..73dafa1c48d 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -70,7 +70,9 @@ def _format_tool( return ChatCompletionToolParam(type="function", function=tool_spec) -def _message_convert(message: ChatCompletionMessage) -> ChatCompletionMessageParam: +def _convert_message_to_param( + message: ChatCompletionMessage, +) -> ChatCompletionMessageParam: """Convert from class to TypedDict.""" tool_calls: list[ChatCompletionMessageToolCallParam] = [] if message.tool_calls: @@ -94,20 +96,42 @@ def _message_convert(message: ChatCompletionMessage) -> ChatCompletionMessagePar return param -def _chat_message_convert( - message: conversation.Content - | conversation.NativeContent[ChatCompletionMessageParam], +def _convert_content_to_param( + content: conversation.Content, ) -> ChatCompletionMessageParam: """Convert any native chat message for this agent to the native format.""" - role = message.role - if role == "native": - # mypy doesn't understand that checking role ensures content type - return message.content # type: ignore[return-value] - if role == "system": - role = "developer" - return cast( - ChatCompletionMessageParam, - {"role": role, "content": message.content}, + if content.role == "tool_result": + assert type(content) is conversation.ToolResultContent + return ChatCompletionToolMessageParam( + role="tool", + tool_call_id=content.tool_call_id, + content=json.dumps(content.tool_result), + ) + if content.role != "assistant" or not content.tool_calls: # type: ignore[union-attr] + role = content.role + if role == "system": + role = "developer" + return cast( + ChatCompletionMessageParam, + {"role": content.role, "content": content.content}, # type: ignore[union-attr] + ) + + # Handle the Assistant content including tool calls. + assert type(content) is conversation.AssistantContent + return ChatCompletionAssistantMessageParam( + role="assistant", + content=content.content, + tool_calls=[ + ChatCompletionMessageToolCallParam( + id=tool_call.id, + function=Function( + arguments=json.dumps(tool_call.tool_args), + name=tool_call.tool_name, + ), + type="function", + ) + for tool_call in content.tool_calls + ], ) @@ -171,14 +195,14 @@ class OpenAIConversationEntity( async def _async_handle_message( self, user_input: conversation.ConversationInput, - session: conversation.ChatLog[ChatCompletionMessageParam], + chat_log: conversation.ChatLog, ) -> conversation.ConversationResult: """Call the API.""" assert user_input.agent_id options = self.entry.options try: - await session.async_update_llm_data( + await chat_log.async_update_llm_data( DOMAIN, user_input, options.get(CONF_LLM_HASS_API), @@ -188,17 +212,14 @@ class OpenAIConversationEntity( return err.as_conversation_result() tools: list[ChatCompletionToolParam] | None = None - if session.llm_api: + if chat_log.llm_api: tools = [ - _format_tool(tool, session.llm_api.custom_serializer) - for tool in session.llm_api.tools + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools ] model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) - - messages = [ - _chat_message_convert(message) for message in session.async_get_messages() - ] + messages = [_convert_content_to_param(content) for content in chat_log.content] client = self.entry.runtime_data @@ -213,7 +234,7 @@ class OpenAIConversationEntity( ), "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), "temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), - "user": session.conversation_id, + "user": chat_log.conversation_id, } if model.startswith("o"): @@ -229,43 +250,39 @@ class OpenAIConversationEntity( LOGGER.debug("Response %s", result) response = result.choices[0].message - messages.append(_message_convert(response)) + messages.append(_convert_message_to_param(response)) - session.async_add_message( - conversation.Content( - role=response.role, - agent_id=user_input.agent_id, - content=response.content or "", - ), + tool_calls: list[llm.ToolInput] | None = None + if response.tool_calls: + tool_calls = [ + llm.ToolInput( + id=tool_call.id, + tool_name=tool_call.function.name, + tool_args=json.loads(tool_call.function.arguments), + ) + for tool_call in response.tool_calls + ] + + messages.extend( + [ + _convert_content_to_param(tool_response) + async for tool_response in chat_log.async_add_assistant_content( + conversation.AssistantContent( + agent_id=user_input.agent_id, + content=response.content or "", + tool_calls=tool_calls, + ) + ) + ] ) - if not response.tool_calls or not session.llm_api: + if not tool_calls: break - for tool_call in response.tool_calls: - tool_input = llm.ToolInput( - tool_name=tool_call.function.name, - tool_args=json.loads(tool_call.function.arguments), - ) - tool_response = await session.async_call_tool(tool_input) - messages.append( - ChatCompletionToolMessageParam( - role="tool", - tool_call_id=tool_call.id, - content=json.dumps(tool_response), - ) - ) - session.async_add_message( - conversation.NativeContent( - agent_id=user_input.agent_id, - content=messages[-1], - ) - ) - intent_response = intent.IntentResponse(language=user_input.language) intent_response.async_set_speech(response.content or "") return conversation.ConversationResult( - response=intent_response, conversation_id=session.conversation_id + response=intent_response, conversation_id=chat_log.conversation_id ) async def _async_entry_update_listener( diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 2bca4c8528b..b7c4951d8de 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -4,7 +4,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field as dc_field from datetime import timedelta from decimal import Decimal from enum import Enum @@ -36,6 +36,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.util import dt as dt_util, yaml as yaml_util from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import JsonObjectType +from homeassistant.util.ulid import ulid_now from . import ( area_registry as ar, @@ -139,6 +140,8 @@ class ToolInput: tool_name: str tool_args: dict[str, Any] + # Using lambda for default to allow patching in tests + id: str = dc_field(default_factory=lambda: ulid_now()) # pylint: disable=unnecessary-lambda class Tool: diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index fa5bcb8137a..bb77e2ff926 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -236,6 +236,7 @@ async def test_function_call( mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="toolu_0123456789AbCdEfGhIjKlM", tool_name="test_tool", tool_args={"param1": "test_value"}, ), @@ -373,6 +374,7 @@ async def test_function_exception( mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="toolu_0123456789AbCdEfGhIjKlM", tool_name="test_tool", tool_args={"param1": "test_value"}, ), diff --git a/tests/components/conversation/snapshots/test_session.ambr b/tests/components/conversation/snapshots/test_chat_log.ambr similarity index 100% rename from tests/components/conversation/snapshots/test_session.ambr rename to tests/components/conversation/snapshots/test_chat_log.ambr diff --git a/tests/components/conversation/test_session.py b/tests/components/conversation/test_chat_log.py similarity index 67% rename from tests/components/conversation/test_session.py rename to tests/components/conversation/test_chat_log.py index 3943f41a62b..a37d4408756 100644 --- a/tests/components/conversation/test_session.py +++ b/tests/components/conversation/test_chat_log.py @@ -9,13 +9,13 @@ from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.components.conversation import ( - Content, + AssistantContent, ConversationInput, ConverseError, - NativeContent, + ToolResultContent, async_get_chat_log, ) -from homeassistant.components.conversation.session import DATA_CHAT_HISTORY +from homeassistant.components.conversation.chat_log import DATA_CHAT_HISTORY from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import chat_session, llm @@ -40,7 +40,7 @@ def mock_conversation_input(hass: HomeAssistant) -> ConversationInput: @pytest.fixture def mock_ulid() -> Generator[Mock]: """Mock the ulid library.""" - with patch("homeassistant.util.ulid.ulid_now") as mock_ulid_now: + with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: mock_ulid_now.return_value = "mock-ulid" yield mock_ulid_now @@ -56,13 +56,13 @@ async def test_cleanup( ): conversation_id = session.conversation_id # Add message so it persists - chat_log.async_add_message( - Content( - role="assistant", - agent_id=mock_conversation_input.agent_id, - content="", + async for _tool_result in chat_log.async_add_assistant_content( + AssistantContent( + agent_id="mock-agent-id", + content="Hey!", ) - ) + ): + pytest.fail("should not reach here") assert conversation_id in hass.data[DATA_CHAT_HISTORY] @@ -79,7 +79,7 @@ async def test_cleanup( assert conversation_id not in hass.data[DATA_CHAT_HISTORY] -async def test_add_message( +async def test_default_content( hass: HomeAssistant, mock_conversation_input: ConversationInput ) -> None: """Test filtering of messages.""" @@ -87,95 +87,11 @@ async def test_add_message( chat_session.async_get_chat_session(hass) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - assert len(chat_log.messages) == 2 - - with pytest.raises(ValueError): - chat_log.async_add_message( - Content(role="system", agent_id=None, content="") - ) - - # No 2 user messages in a row - assert chat_log.messages[1].role == "user" - - with pytest.raises(ValueError): - chat_log.async_add_message(Content(role="user", agent_id=None, content="")) - - # No 2 assistant messages in a row - chat_log.async_add_message(Content(role="assistant", agent_id=None, content="")) - assert len(chat_log.messages) == 3 - assert chat_log.messages[-1].role == "assistant" - - with pytest.raises(ValueError): - chat_log.async_add_message( - Content(role="assistant", agent_id=None, content="") - ) - - -async def test_message_filtering( - hass: HomeAssistant, mock_conversation_input: ConversationInput -) -> None: - """Test filtering of messages.""" - with ( - chat_session.async_get_chat_session(hass) as session, - async_get_chat_log(hass, session, mock_conversation_input) as chat_log, - ): - messages = chat_log.async_get_messages(agent_id=None) - assert len(messages) == 2 - assert messages[0] == Content( - role="system", - agent_id=None, - content="", - ) - assert messages[1] == Content( - role="user", - agent_id="mock-agent-id", - content=mock_conversation_input.text, - ) - # Cannot add a second user message in a row - with pytest.raises(ValueError): - chat_log.async_add_message( - Content( - role="user", - agent_id="mock-agent-id", - content="Hey!", - ) - ) - - chat_log.async_add_message( - Content( - role="assistant", - agent_id="mock-agent-id", - content="Hey!", - ) - ) - # Different agent, native messages will be filtered out. - chat_log.async_add_message( - NativeContent(agent_id="another-mock-agent-id", content=1) - ) - chat_log.async_add_message(NativeContent(agent_id="mock-agent-id", content=1)) - # A non-native message from another agent is not filtered out. - chat_log.async_add_message( - Content( - role="assistant", - agent_id="another-mock-agent-id", - content="Hi!", - ) - ) - - assert len(chat_log.messages) == 6 - - messages = chat_log.async_get_messages(agent_id="mock-agent-id") - assert len(messages) == 5 - - assert messages[2] == Content( - role="assistant", - agent_id="mock-agent-id", - content="Hey!", - ) - assert messages[3] == NativeContent(agent_id="mock-agent-id", content=1) - assert messages[4] == Content( - role="assistant", agent_id="another-mock-agent-id", content="Hi!" - ) + assert len(chat_log.content) == 2 + assert chat_log.content[0].role == "system" + assert chat_log.content[0].content == "" + assert chat_log.content[1].role == "user" + assert chat_log.content[1].content == mock_conversation_input.text async def test_llm_api( @@ -268,12 +184,10 @@ async def test_template_variables( ), ) - assert chat_log.user_name == "Test User" - - assert "The instance name is test home." in chat_log.messages[0].content - assert "The user name is Test User." in chat_log.messages[0].content - assert "The user id is 12345." in chat_log.messages[0].content - assert "The calling platform is test." in chat_log.messages[0].content + assert "The instance name is test home." in chat_log.content[0].content + assert "The user name is Test User." in chat_log.content[0].content + assert "The user id is 12345." in chat_log.content[0].content + assert "The calling platform is test." in chat_log.content[0].content async def test_extra_systen_prompt( @@ -296,16 +210,16 @@ async def test_extra_systen_prompt( user_llm_hass_api=None, user_llm_prompt=None, ) - chat_log.async_add_message( - Content( - role="assistant", + async for _tool_result in chat_log.async_add_assistant_content( + AssistantContent( agent_id="mock-agent-id", content="Hey!", ) - ) + ): + pytest.fail("should not reach here") assert chat_log.extra_system_prompt == extra_system_prompt - assert chat_log.messages[0].content.endswith(extra_system_prompt) + assert chat_log.content[0].content.endswith(extra_system_prompt) # Verify that follow-up conversations with no system prompt take previous one conversation_id = chat_log.conversation_id @@ -323,7 +237,7 @@ async def test_extra_systen_prompt( ) assert chat_log.extra_system_prompt == extra_system_prompt - assert chat_log.messages[0].content.endswith(extra_system_prompt) + assert chat_log.content[0].content.endswith(extra_system_prompt) # Verify that we take new system prompts mock_conversation_input.extra_system_prompt = extra_system_prompt2 @@ -338,17 +252,17 @@ async def test_extra_systen_prompt( user_llm_hass_api=None, user_llm_prompt=None, ) - chat_log.async_add_message( - Content( - role="assistant", + async for _tool_result in chat_log.async_add_assistant_content( + AssistantContent( agent_id="mock-agent-id", content="Hey!", ) - ) + ): + pytest.fail("should not reach here") assert chat_log.extra_system_prompt == extra_system_prompt2 - assert chat_log.messages[0].content.endswith(extra_system_prompt2) - assert extra_system_prompt not in chat_log.messages[0].content + assert chat_log.content[0].content.endswith(extra_system_prompt2) + assert extra_system_prompt not in chat_log.content[0].content # Verify that follow-up conversations with no system prompt take previous one mock_conversation_input.extra_system_prompt = None @@ -365,7 +279,7 @@ async def test_extra_systen_prompt( ) assert chat_log.extra_system_prompt == extra_system_prompt2 - assert chat_log.messages[0].content.endswith(extra_system_prompt2) + assert chat_log.content[0].content.endswith(extra_system_prompt2) async def test_tool_call( @@ -383,8 +297,7 @@ async def test_tool_call( mock_tool.async_call.return_value = "Test response" with patch( - "homeassistant.components.conversation.session.llm.AssistAPI._async_get_tools", - return_value=[], + "homeassistant.helpers.llm.AssistAPI._async_get_tools", return_value=[] ) as mock_get_tools: mock_get_tools.return_value = [mock_tool] @@ -398,14 +311,29 @@ async def test_tool_call( user_llm_hass_api="assist", user_llm_prompt=None, ) - result = await chat_log.async_call_tool( - llm.ToolInput( - tool_name="test_tool", - tool_args={"param1": "Test Param"}, + result = None + async for tool_result_content in chat_log.async_add_assistant_content( + AssistantContent( + agent_id=mock_conversation_input.agent_id, + content="", + tool_calls=[ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="test_tool", + tool_args={"param1": "Test Param"}, + ) + ], ) - ) + ): + assert result is None + result = tool_result_content - assert result == "Test response" + assert result == ToolResultContent( + agent_id=mock_conversation_input.agent_id, + tool_call_id="mock-tool-call-id", + tool_result="Test response", + tool_name="test_tool", + ) async def test_tool_call_exception( @@ -423,8 +351,7 @@ async def test_tool_call_exception( mock_tool.async_call.side_effect = HomeAssistantError("Test error") with patch( - "homeassistant.components.conversation.session.llm.AssistAPI._async_get_tools", - return_value=[], + "homeassistant.helpers.llm.AssistAPI._async_get_tools", return_value=[] ) as mock_get_tools: mock_get_tools.return_value = [mock_tool] @@ -438,11 +365,26 @@ async def test_tool_call_exception( user_llm_hass_api="assist", user_llm_prompt=None, ) - result = await chat_log.async_call_tool( - llm.ToolInput( - tool_name="test_tool", - tool_args={"param1": "Test Param"}, + result = None + async for tool_result_content in chat_log.async_add_assistant_content( + AssistantContent( + agent_id=mock_conversation_input.agent_id, + content="", + tool_calls=[ + llm.ToolInput( + id="mock-tool-call-id", + tool_name="test_tool", + tool_args={"param1": "Test Param"}, + ) + ], ) - ) + ): + assert result is None + result = tool_result_content - assert result == {"error": "HomeAssistantError", "error_text": "Test error"} + assert result == ToolResultContent( + agent_id=mock_conversation_input.agent_id, + tool_call_id="mock-tool-call-id", + tool_result={"error": "HomeAssistantError", "error_text": "Test error"}, + tool_name="test_tool", + ) diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index a87056275dc..72a5390f4b1 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -36,6 +36,13 @@ def freeze_the_time(): yield +@pytest.fixture(autouse=True) +def mock_ulid_tools(): + """Mock generated ULIDs for tool calls.""" + with patch("homeassistant.helpers.llm.ulid_now", return_value="mock-tool-call"): + yield + + @pytest.mark.parametrize( "agent_id", [None, "conversation.google_generative_ai_conversation"] ) @@ -177,6 +184,7 @@ async def test_chat_history( "homeassistant.components.google_generative_ai_conversation.conversation.llm.AssistAPI._async_get_tools" ) @pytest.mark.usefixtures("mock_init_component") +@pytest.mark.usefixtures("mock_ulid_tools") async def test_function_call( mock_get_tools, hass: HomeAssistant, @@ -256,6 +264,7 @@ async def test_function_call( mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="mock-tool-call", tool_name="test_tool", tool_args={ "param1": ["test_value", "param1's value"], @@ -287,9 +296,7 @@ async def test_function_call( detail_event = trace_events[1] assert "Answer in plain text" in detail_event["data"]["messages"][0]["content"] assert [ - p.function_response.name - for p in detail_event["data"]["messages"][2]["content"].parts - if p.function_response + p["tool_name"] for p in detail_event["data"]["messages"][2]["tool_calls"] ] == ["test_tool"] @@ -362,6 +369,7 @@ async def test_function_call_without_parameters( mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="mock-tool-call", tool_name="test_tool", tool_args={}, ), @@ -451,6 +459,7 @@ async def test_function_exception( mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="mock-tool-call", tool_name="test_tool", tool_args={"param1": 1}, ), @@ -605,6 +614,7 @@ async def test_template_variables( mock_chat.send_message_async.return_value = chat_response mock_part = MagicMock() mock_part.text = "Model response" + mock_part.function_call = None chat_response.parts = [mock_part] result = await conversation.async_converse( hass, "hello", None, context, agent_id=mock_config_entry.entry_id diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 202f7385697..b8e299f5e77 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -18,6 +18,13 @@ from homeassistant.helpers import intent, llm from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +def mock_ulid_tools(): + """Mock generated ULIDs for tool calls.""" + with patch("homeassistant.helpers.llm.ulid_now", return_value="mock-tool-call"): + yield + + @pytest.mark.parametrize("agent_id", [None, "conversation.mock_title"]) async def test_chat( hass: HomeAssistant, @@ -205,6 +212,7 @@ async def test_function_call( mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="mock-tool-call", tool_name="test_tool", tool_args=expected_tool_args, ), @@ -285,6 +293,7 @@ async def test_function_exception( mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="mock-tool-call", tool_name="test_tool", tool_args={"param1": "test_value"}, ), diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 9ee19cd330c..39ca1b53e28 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -195,6 +195,7 @@ async def test_function_call( mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="call_AbCdEfGhIjKlMnOpQrStUvWx", tool_name="test_tool", tool_args={"param1": "test_value"}, ), @@ -359,6 +360,7 @@ async def test_function_exception( mock_tool.async_call.assert_awaited_once_with( hass, llm.ToolInput( + id="call_AbCdEfGhIjKlMnOpQrStUvWx", tool_name="test_tool", tool_args={"param1": "test_value"}, ), From 00e0a5bc108b5f0771456e6fbc113fc3c4710a61 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Mon, 3 Feb 2025 08:26:08 +0100 Subject: [PATCH 07/36] Bump pypck to 0.8.5 (#137176) --- homeassistant/components/lcn/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 2ac183dcc97..c1dd7751940 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.8.3", "lcn-frontend==0.2.3"] + "requirements": ["pypck==0.8.5", "lcn-frontend==0.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2f03c42d70b..116a4ddce8a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2202,7 +2202,7 @@ pypalazzetti==0.1.19 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.8.3 +pypck==0.8.5 # homeassistant.components.pjlink pypjlink2==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb05db76aea..7e45d426113 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1795,7 +1795,7 @@ pyownet==0.10.0.post1 pypalazzetti==0.1.19 # homeassistant.components.lcn -pypck==0.8.3 +pypck==0.8.5 # homeassistant.components.pjlink pypjlink2==1.2.1 From d18fb4e6f9cf40c27c7be0879358c09970da1089 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Mon, 3 Feb 2025 00:58:33 -0700 Subject: [PATCH 08/36] Vesync bump pyvesync library (#137208) --- homeassistant/components/vesync/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../vesync/snapshots/test_diagnostics.ambr | 33 ++++++++++++++++++- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index cdb5ed96652..b3697844f19 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", "loggers": ["pyvesync"], - "requirements": ["pyvesync==2.1.16"] + "requirements": ["pyvesync==2.1.17"] } diff --git a/requirements_all.txt b/requirements_all.txt index 116a4ddce8a..3ace9370565 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2513,7 +2513,7 @@ pyvera==0.3.15 pyversasense==0.0.6 # homeassistant.components.vesync -pyvesync==2.1.16 +pyvesync==2.1.17 # homeassistant.components.vizio pyvizio==0.1.61 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e45d426113..6e3edb33e36 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2031,7 +2031,7 @@ pyuptimerobot==22.2.0 pyvera==0.3.15 # homeassistant.components.vesync -pyvesync==2.1.16 +pyvesync==2.1.17 # homeassistant.components.vizio pyvizio==0.1.61 diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr index 54ed8acf2d7..1c409dbab00 100644 --- a/tests/components/vesync/snapshots/test_diagnostics.ambr +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -128,7 +128,7 @@ 'sleep', 'manual', ]), - 'mode': None, + 'mode': 'humidity', 'night_light': True, 'pid': None, 'speed': None, @@ -160,6 +160,30 @@ # --- # name: test_async_get_device_diagnostics__single_fan dict({ + '_config_dict': dict({ + 'features': list([ + 'air_quality', + ]), + 'levels': list([ + 1, + 2, + ]), + 'models': list([ + 'LV-PUR131S', + 'LV-RH131S', + ]), + 'modes': list([ + 'manual', + 'auto', + 'sleep', + 'off', + ]), + 'module': 'VeSyncAir131', + }), + '_features': list([ + 'air_quality', + ]), + 'air_quality_feature': True, 'cid': 'abcdefghabcdefghabcdefghabcdefgh', 'config': dict({ }), @@ -180,6 +204,7 @@ 'device_region': 'US', 'device_status': 'unknown', 'device_type': 'LV-PUR131S', + 'enabled': True, 'extension': None, 'home_assistant': dict({ 'disabled': False, @@ -271,6 +296,12 @@ 'mac_id': '**REDACTED**', 'manager': '**REDACTED**', 'mode': None, + 'modes': list([ + 'manual', + 'auto', + 'sleep', + 'off', + ]), 'pid': None, 'speed': None, 'sub_device_no': None, From d2092315f5eb875afc4f64d96b6b426aac42dbea Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 3 Feb 2025 09:06:51 +0100 Subject: [PATCH 09/36] Fix spelling of "SharkClean" and sentence-casing of some words (#137183) --- homeassistant/components/sharkiq/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/sharkiq/strings.json b/homeassistant/components/sharkiq/strings.json index 40b569e13b7..3c4c98db38f 100644 --- a/homeassistant/components/sharkiq/strings.json +++ b/homeassistant/components/sharkiq/strings.json @@ -1,16 +1,16 @@ { "config": { - "flow_title": "Add Shark IQ Account", + "flow_title": "Add Shark IQ account", "step": { "user": { - "description": "Sign into your Shark Clean account to control your devices.", + "description": "Sign into your SharkClean account to control your devices.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "region": "Region" }, "data_description": { - "region": "Shark IQ uses different services in the EU. Select your region to connect to the correct service for your account." + "region": "Shark IQ uses different services in the EU. Select your region to connect to the correct service for your account." } }, "reauth_confirm": { @@ -37,18 +37,18 @@ "region": { "options": { "europe": "Europe", - "elsewhere": "Everywhere Else" + "elsewhere": "Everywhere else" } } }, "exceptions": { "invalid_room": { - "message": "The room {room} is unavailable to your vacuum. Make sure all rooms match the Shark App, including capitalization." + "message": "The room {room} is unavailable to your vacuum. Make sure all rooms match the SharkClean app, including capitalization." } }, "services": { "clean_room": { - "name": "Clean Room", + "name": "Clean room", "description": "Cleans a specific user-defined room or set of rooms.", "fields": { "rooms": { From 0b2b222fcad23fcd52fbb8b3f9f2fb3440394bba Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 3 Feb 2025 10:54:32 +0100 Subject: [PATCH 10/36] Fixes in user-facing strings of Tado integration (#137158) --- homeassistant/components/tado/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index 735fe34bcf4..f1550517457 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -14,7 +14,7 @@ }, "reconfigure": { "title": "Reconfigure your Tado", - "description": "Reconfigure the entry, for your account: `{username}`.", + "description": "Reconfigure the entry for your account: `{username}`.", "data": { "password": "[%key:common::config_flow::data::password%]" }, @@ -25,7 +25,7 @@ }, "error": { "unknown": "[%key:common::config_flow::error::unknown%]", - "no_homes": "There are no homes linked to this tado account.", + "no_homes": "There are no homes linked to this Tado account.", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } @@ -33,7 +33,7 @@ "options": { "step": { "init": { - "description": "Fallback mode lets you choose when to fallback to Smart Schedule from your manual zone overlay. (NEXT_TIME_BLOCK:= Change at next Smart Schedule change; MANUAL:= Dont change until you cancel; TADO_DEFAULT:= Change based on your setting in Tado App).", + "description": "Fallback mode lets you choose when to fallback to Smart Schedule from your manual zone overlay. (NEXT_TIME_BLOCK:= Change at next Smart Schedule change; MANUAL:= Don't change until you cancel; TADO_DEFAULT:= Change based on your setting in the Tado app).", "data": { "fallback": "Choose fallback mode." }, @@ -102,11 +102,11 @@ }, "time_period": { "name": "Time period", - "description": "Choose this or Overlay. Set the time period for the change if you want to be specific. Alternatively use Overlay." + "description": "Choose this or 'Overlay'. Set the time period for the change if you want to be specific." }, "requested_overlay": { "name": "Overlay", - "description": "Choose this or Time Period. Allows you to choose an overlay. MANUAL:=Overlay until user removes; NEXT_TIME_BLOCK:=Overlay until next timeblock; TADO_DEFAULT:=Overlay based on tado app setting." + "description": "Choose this or 'Time period'. Allows you to choose an overlay. MANUAL:=Overlay until user removes; NEXT_TIME_BLOCK:=Overlay until next timeblock; TADO_DEFAULT:=Overlay based on Tado app setting." } } }, @@ -151,8 +151,8 @@ }, "issues": { "water_heater_fallback": { - "title": "Tado Water Heater entities now support fallback options", - "description": "Due to added support for water heaters entities, these entities may use different overlay. Please configure integration entity and tado app water heater zone overlay options. Otherwise, please configure the integration entity and Tado app water heater zone overlay options (under Settings -> Rooms & Devices -> Hot Water)." + "title": "Tado water heater entities now support fallback options", + "description": "Due to added support for water heaters entities, these entities may use a different overlay. Please configure the integration entity and Tado app water heater zone overlay options (under Settings -> Rooms & Devices -> Hot Water)." } } } From 9cfe1092104455df55e3ff22ace7a39d5c82d01b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Feb 2025 11:51:29 +0100 Subject: [PATCH 11/36] Check for errors when restoring backups using supervisor (#137217) * Check for errors when restoring backups using supervisor * Break long line in test * Improve comments --- homeassistant/components/hassio/backup.py | 25 +++- tests/components/hassio/test_backup.py | 143 ++++++++++++++++++++-- 2 files changed, 157 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 495e953df9d..34d1c62aed7 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -517,17 +517,22 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): raise HomeAssistantError(message) from err restore_complete = asyncio.Event() + restore_errors: list[dict[str, str]] = [] @callback def on_job_progress(data: Mapping[str, Any]) -> None: """Handle backup restore progress.""" if data.get("done") is True: restore_complete.set() + restore_errors.extend(data.get("errors", [])) unsub = self._async_listen_job_events(job.job_id, on_job_progress) try: await self._get_job_state(job.job_id, on_job_progress) await restore_complete.wait() + if restore_errors: + # We should add more specific error handling here in the future + raise BackupReaderWriterError(f"Restore failed: {restore_errors}") finally: unsub() @@ -554,11 +559,23 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): ) return - on_progress( - RestoreBackupEvent( - reason="", stage=None, state=RestoreBackupState.COMPLETED + restore_errors = data.get("errors", []) + if restore_errors: + _LOGGER.warning("Restore backup failed: %s", restore_errors) + # We should add more specific error handling here in the future + on_progress( + RestoreBackupEvent( + reason="unknown_error", + stage=None, + state=RestoreBackupState.FAILED, + ) + ) + else: + on_progress( + RestoreBackupEvent( + reason="", stage=None, state=RestoreBackupState.COMPLETED + ) ) - ) on_progress(IdleEvent()) unsub() diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index d001a358640..f35ddeaabbd 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -324,6 +324,24 @@ TEST_JOB_DONE = supervisor_jobs.Job( errors=[], child_jobs=[], ) +TEST_RESTORE_JOB_DONE_WITH_ERROR = supervisor_jobs.Job( + name="backup_manager_partial_restore", + reference="1ef41507", + uuid=UUID(TEST_JOB_ID), + progress=0.0, + stage="copy_additional_locations", + done=True, + errors=[ + supervisor_jobs.JobError( + type="BackupInvalidError", + message=( + "Backup was made on supervisor version 2025.02.2.dev3105, " + "can't restore on 2025.01.2.dev3105" + ), + ) + ], + child_jobs=[], +) @pytest.fixture(autouse=True) @@ -1946,6 +1964,97 @@ async def test_reader_writer_restore_error( assert response["error"]["code"] == expected_error_code +@pytest.mark.usefixtures("hassio_client", "setup_integration") +async def test_reader_writer_restore_late_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + supervisor_client: AsyncMock, +) -> None: + """Test restoring a backup with error.""" + client = await hass_ws_client(hass) + supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID + supervisor_client.backups.list.return_value = [TEST_BACKUP] + supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS + supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE + + await client.send_json_auto_id({"type": "backup/subscribe_events"}) + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + response = await client.receive_json() + assert response["success"] + + await client.send_json_auto_id( + {"type": "backup/restore", "agent_id": "hassio.local", "backup_id": "abc123"} + ) + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "in_progress", + } + + supervisor_client.backups.partial_restore.assert_called_once_with( + "abc123", + supervisor_backups.PartialRestoreOptions( + addons=None, + background=True, + folders=None, + homeassistant=True, + location=None, + password=None, + ), + ) + + event = { + "event": "job", + "data": { + "name": "backup_manager_partial_restore", + "reference": "7c54aeed", + "uuid": TEST_JOB_ID, + "progress": 0, + "stage": None, + "done": True, + "parent_id": None, + "errors": [ + { + "type": "BackupInvalidError", + "message": ( + "Backup was made on supervisor version 2025.02.2.dev3105, can't" + " restore on 2025.01.2.dev3105. Must update supervisor first." + ), + } + ], + "created": "2025-02-03T08:27:49.297997+00:00", + }, + } + await client.send_json_auto_id({"type": "supervisor/event", "data": event}) + response = await client.receive_json() + assert response["success"] + + response = await client.receive_json() + assert response["event"] == { + "manager_state": "restore_backup", + "reason": "backup_reader_writer_error", + "stage": None, + "state": "failed", + } + + response = await client.receive_json() + assert response["event"] == {"manager_state": "idle"} + + response = await client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "home_assistant_error", + "message": ( + "Restore failed: [{'type': 'BackupInvalidError', 'message': \"Backup " + "was made on supervisor version 2025.02.2.dev3105, can't restore on " + '2025.01.2.dev3105. Must update supervisor first."}]' + ), + } + + @pytest.mark.parametrize( ("backup", "backup_details", "parameters", "expected_error"), [ @@ -1999,15 +2108,40 @@ async def test_reader_writer_restore_wrong_parameters( } +@pytest.mark.parametrize( + ("get_job_result", "last_non_idle_event"), + [ + ( + TEST_JOB_DONE, + { + "manager_state": "restore_backup", + "reason": "", + "stage": None, + "state": "completed", + }, + ), + ( + TEST_RESTORE_JOB_DONE_WITH_ERROR, + { + "manager_state": "restore_backup", + "reason": "unknown_error", + "stage": None, + "state": "failed", + }, + ), + ], +) @pytest.mark.usefixtures("hassio_client") async def test_restore_progress_after_restart( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, supervisor_client: AsyncMock, + get_job_result: supervisor_jobs.Job, + last_non_idle_event: dict[str, Any], ) -> None: """Test restore backup progress after restart.""" - supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE + supervisor_client.jobs.get_job.return_value = get_job_result with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) @@ -2018,12 +2152,7 @@ async def test_restore_progress_after_restart( response = await client.receive_json() assert response["success"] - assert response["result"]["last_non_idle_event"] == { - "manager_state": "restore_backup", - "reason": "", - "stage": None, - "state": "completed", - } + assert response["result"]["last_non_idle_event"] == last_non_idle_event assert response["result"]["state"] == "idle" From cce6c735ad66fdac5818be2cca4a11ae30ac892d Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 3 Feb 2025 13:04:14 +0100 Subject: [PATCH 12/36] Add support for Shelly Flood gen4 (#136981) --- homeassistant/components/shelly/binary_sensor.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 108a8236733..fb253c682d8 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -272,6 +272,18 @@ RPC_SENSORS: Final = { entity_category=EntityCategory.DIAGNOSTIC, entity_class=RpcBluTrvBinarySensor, ), + "flood": RpcBinarySensorDescription( + key="flood", + sub_key="alarm", + name="Flood", + device_class=BinarySensorDeviceClass.MOISTURE, + ), + "mute": RpcBinarySensorDescription( + key="flood", + sub_key="mute", + name="Mute", + entity_category=EntityCategory.DIAGNOSTIC, + ), } From c2f94542aa2732d72c20260377fe1aaaf2f56832 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 3 Feb 2025 13:38:38 +0100 Subject: [PATCH 13/36] Fix uppercase / lowercase setup strings in Generic Camera (#137219) --- homeassistant/components/generic/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/generic/strings.json b/homeassistant/components/generic/strings.json index 854ceb93b3e..4a5d672fcde 100644 --- a/homeassistant/components/generic/strings.json +++ b/homeassistant/components/generic/strings.json @@ -28,14 +28,14 @@ "user": { "description": "Enter the settings to connect to the camera.", "data": { - "still_image_url": "Still Image URL (e.g. http://...)", - "stream_source": "Stream Source URL (e.g. rtsp://...)", + "still_image_url": "Still image URL (e.g. http://...)", + "stream_source": "Stream source URL (e.g. rtsp://...)", "rtsp_transport": "RTSP transport protocol", "authentication": "Authentication", - "limit_refetch_to_url_change": "Limit refetch to url change", + "limit_refetch_to_url_change": "Limit refetch to URL change", "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]", - "framerate": "Frame Rate (Hz)", + "framerate": "Frame rate (Hz)", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } }, From c950c69cb3a6316f586a665e494cd422a6a70596 Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Mon, 3 Feb 2025 13:42:47 +0100 Subject: [PATCH 14/36] Add parallel updates setting to Bang & Olufsen Event platform (#135850) --- homeassistant/components/bang_olufsen/event.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/bang_olufsen/event.py b/homeassistant/components/bang_olufsen/event.py index 80ad4060c5e..99e5c8bb6fd 100644 --- a/homeassistant/components/bang_olufsen/event.py +++ b/homeassistant/components/bang_olufsen/event.py @@ -19,6 +19,8 @@ from .const import ( ) from .entity import BangOlufsenEntity +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, From 6d31530811fa184ae512db7d5ebde00bcb20685c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 3 Feb 2025 14:00:16 +0100 Subject: [PATCH 15/36] Update license-expression to 30.4.1 (#137226) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index cf0a1e5473f..8c65ba58789 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -10,7 +10,7 @@ astroid==3.3.8 coverage==7.6.8 freezegun==1.5.1 -license-expression==30.4.0 +license-expression==30.4.1 mock-open==1.4.0 mypy-dev==1.16.0a1 pre-commit==4.0.0 From a5c01a4d4f4731a97811211069cb4dcc055669cc Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 3 Feb 2025 14:01:04 +0100 Subject: [PATCH 16/36] Update pipdeptree to 2.25.0 (#137228) --- requirements_test.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 8c65ba58789..4126b9e9e5a 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -17,7 +17,7 @@ pre-commit==4.0.0 pydantic==2.10.6 pylint==3.3.3 pylint-per-file-ignores==1.3.2 -pipdeptree==2.23.4 +pipdeptree==2.25.0 pytest-asyncio==0.24.0 pytest-aiohttp==1.0.5 pytest-cov==6.0.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 2c433ba362e..666c662151d 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -24,7 +24,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.21,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.9.1 \ + stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.66.5 ruff==0.9.1 \ PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.0 home-assistant-intents==2025.1.28 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" From a9e73d9253689f582bbff3cfde8263b55d043cdb Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 3 Feb 2025 14:01:20 +0100 Subject: [PATCH 17/36] Update pylint to 3.3.4 (#137227) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 4126b9e9e5a..d791ff80fa9 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -15,7 +15,7 @@ mock-open==1.4.0 mypy-dev==1.16.0a1 pre-commit==4.0.0 pydantic==2.10.6 -pylint==3.3.3 +pylint==3.3.4 pylint-per-file-ignores==1.3.2 pipdeptree==2.25.0 pytest-asyncio==0.24.0 From 52d7cfbe32bd1859e2ff762ef1bd6815a29c264b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 3 Feb 2025 14:03:41 +0100 Subject: [PATCH 18/36] Update coverage to 7.6.10 (#137229) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index d791ff80fa9..404a5e806ea 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,7 +8,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt astroid==3.3.8 -coverage==7.6.8 +coverage==7.6.10 freezegun==1.5.1 license-expression==30.4.1 mock-open==1.4.0 From 0034055ac8a755d0387193ab039703c004562e27 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Mon, 3 Feb 2025 14:05:11 +0100 Subject: [PATCH 19/36] Fix retrieving PIN when no pin is set on mount in motionmount integration (#137230) --- homeassistant/components/motionmount/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/motionmount/entity.py b/homeassistant/components/motionmount/entity.py index 81d4d0119b5..bbb79729a9e 100644 --- a/homeassistant/components/motionmount/entity.py +++ b/homeassistant/components/motionmount/entity.py @@ -30,7 +30,7 @@ class MotionMountEntity(Entity): self.config_entry = config_entry # We store the pin, as we might need it during reconnect - self.pin = config_entry.data[CONF_PIN] + self.pin = config_entry.data.get(CONF_PIN) mac = format_mac(mm.mac.hex()) From 48184e742a81a0c5e83dcc0506dffe1d0eed0a1a Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Mon, 3 Feb 2025 14:05:51 +0100 Subject: [PATCH 20/36] Fix minor issues in Homee (#137239) --- homeassistant/components/homee/const.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homee/const.py b/homeassistant/components/homee/const.py index d1d5be97ef7..1d7ce27335f 100644 --- a/homeassistant/components/homee/const.py +++ b/homeassistant/components/homee/const.py @@ -1,6 +1,7 @@ """Constants for the homee integration.""" from homeassistant.const import ( + DEGREE, LIGHT_LUX, PERCENTAGE, REVOLUTIONS_PER_MINUTE, @@ -32,6 +33,7 @@ HOMEE_UNIT_TO_HA_UNIT = { "W": UnitOfPower.WATT, "m/s": UnitOfSpeed.METERS_PER_SECOND, "km/h": UnitOfSpeed.KILOMETERS_PER_HOUR, + "°": DEGREE, "°F": UnitOfTemperature.FAHRENHEIT, "°C": UnitOfTemperature.CELSIUS, "K": UnitOfTemperature.KELVIN, @@ -51,7 +53,7 @@ OPEN_CLOSE_MAP_REVERSED = { 0.0: "closed", 1.0: "open", 2.0: "partial", - 3.0: "cosing", + 3.0: "closing", 4.0: "opening", } WINDOW_MAP = { From 0e73363d04a4866a6da19ea7c38133ef53b3457e Mon Sep 17 00:00:00 2001 From: TimL Date: Tue, 4 Feb 2025 00:06:27 +1100 Subject: [PATCH 21/36] Bump pysmlight to v0.2.2 (#137218) --- homeassistant/components/smlight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index afd186f4b9a..cec5d6a6d8b 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -11,7 +11,7 @@ "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pysmlight==0.2.1"], + "requirements": ["pysmlight==0.2.2"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 3ace9370565..f1900afffab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.2.1 +pysmlight==0.2.2 # homeassistant.components.snmp pysnmp==6.2.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e3edb33e36..fb6a4766884 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1882,7 +1882,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.2.1 +pysmlight==0.2.2 # homeassistant.components.snmp pysnmp==6.2.6 From 1d7e485aa32926d5f1020ce7dae6ac1273d7412f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 3 Feb 2025 14:11:03 +0100 Subject: [PATCH 22/36] Update pytest-freezer to 0.4.9 (#137232) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 404a5e806ea..9d73a53f55e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -21,7 +21,7 @@ pipdeptree==2.25.0 pytest-asyncio==0.24.0 pytest-aiohttp==1.0.5 pytest-cov==6.0.0 -pytest-freezer==0.4.8 +pytest-freezer==0.4.9 pytest-github-actions-annotate-failures==0.2.0 pytest-socket==0.7.0 pytest-sugar==1.0.0 From 85794568957c9e32c13b3206b79a53c4a078340a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 3 Feb 2025 14:25:06 +0100 Subject: [PATCH 23/36] Update pytest-picked to 0.5.1 (#137233) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 9d73a53f55e..788df636b8a 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -27,7 +27,7 @@ pytest-socket==0.7.0 pytest-sugar==1.0.0 pytest-timeout==2.3.1 pytest-unordered==0.6.1 -pytest-picked==0.5.0 +pytest-picked==0.5.1 pytest-xdist==3.6.1 pytest==8.3.4 requests-mock==1.12.1 From 34a229af528f3948de921ba845074c56317f560d Mon Sep 17 00:00:00 2001 From: Conor Eager Date: Tue, 4 Feb 2025 02:34:25 +1300 Subject: [PATCH 24/36] Add Starlink connectivity binary sensor (#133184) Co-authored-by: David Rapan Co-authored-by: Joost Lekkerkerker --- homeassistant/components/starlink/binary_sensor.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/starlink/binary_sensor.py b/homeassistant/components/starlink/binary_sensor.py index e48d28dcc44..b03648e81c5 100644 --- a/homeassistant/components/starlink/binary_sensor.py +++ b/homeassistant/components/starlink/binary_sensor.py @@ -65,6 +65,7 @@ BINARY_SENSORS = [ key="currently_obstructed", translation_key="currently_obstructed", device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status["currently_obstructed"], ), StarlinkBinarySensorEntityDescription( @@ -114,4 +115,9 @@ BINARY_SENSORS = [ entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.alert["alert_unexpected_location"], ), + StarlinkBinarySensorEntityDescription( + key="connection", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + value_fn=lambda data: data.status["state"] == "CONNECTED", + ), ] From c903658aa805e6e11e0286d033377b3650cf324d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 3 Feb 2025 14:46:22 +0100 Subject: [PATCH 25/36] Update syrupy to 4.8.1 (#137235) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 788df636b8a..c8dcb5a712b 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -32,7 +32,7 @@ pytest-xdist==3.6.1 pytest==8.3.4 requests-mock==1.12.1 respx==0.22.0 -syrupy==4.8.0 +syrupy==4.8.1 tqdm==4.66.5 types-aiofiles==24.1.0.20241221 types-atomicwrites==1.4.5.1 From 9bc110104d1652e25c64004b2ea0accd90a85525 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 3 Feb 2025 14:46:49 +0100 Subject: [PATCH 26/36] Update pyOpenSSL to 25.0.0 (#137236) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 949d1885511..311e05673bd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ psutil-home-assistant==0.0.1 PyJWT==2.10.1 pymicro-vad==1.0.1 PyNaCl==1.5.0 -pyOpenSSL==24.3.0 +pyOpenSSL==25.0.0 pyserial==3.5 pyspeex-noise==1.0.2 python-slugify==8.0.4 diff --git a/pyproject.toml b/pyproject.toml index afed8fd7091..2ad5103c67e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ dependencies = [ "cryptography==44.0.0", "Pillow==11.1.0", "propcache==0.2.1", - "pyOpenSSL==24.3.0", + "pyOpenSSL==25.0.0", "orjson==3.10.12", "packaging>=23.1", "psutil-home-assistant==0.0.1", diff --git a/requirements.txt b/requirements.txt index a58065a3a7a..9022e5c2e93 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,7 +31,7 @@ PyJWT==2.10.1 cryptography==44.0.0 Pillow==11.1.0 propcache==0.2.1 -pyOpenSSL==24.3.0 +pyOpenSSL==25.0.0 orjson==3.10.12 packaging>=23.1 psutil-home-assistant==0.0.1 From e24564147d302df8c20289136deb93813dc5212c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 3 Feb 2025 14:52:56 +0100 Subject: [PATCH 27/36] Update pytest-asyncio to 0.25.3 (#137231) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index c8dcb5a712b..c884197add8 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -18,7 +18,7 @@ pydantic==2.10.6 pylint==3.3.4 pylint-per-file-ignores==1.3.2 pipdeptree==2.25.0 -pytest-asyncio==0.24.0 +pytest-asyncio==0.25.3 pytest-aiohttp==1.0.5 pytest-cov==6.0.0 pytest-freezer==0.4.9 From dba4637aa93fbc540ae13a513d45c010b4767235 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 3 Feb 2025 15:40:38 +0100 Subject: [PATCH 28/36] Update pytest-github-actions-annotate-failures to 0.3.0 (#137243) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index c884197add8..5e58757def8 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -22,7 +22,7 @@ pytest-asyncio==0.25.3 pytest-aiohttp==1.0.5 pytest-cov==6.0.0 pytest-freezer==0.4.9 -pytest-github-actions-annotate-failures==0.2.0 +pytest-github-actions-annotate-failures==0.3.0 pytest-socket==0.7.0 pytest-sugar==1.0.0 pytest-timeout==2.3.1 From 71e28a4af3e6c20a9bb7ac9b71cb54d88d7376b6 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Mon, 3 Feb 2025 15:41:25 +0100 Subject: [PATCH 29/36] Add service to retrieve schedule configuration (#121904) --- homeassistant/components/schedule/__init__.py | 28 +++++++- homeassistant/components/schedule/const.py | 2 + homeassistant/components/schedule/icons.json | 3 + .../components/schedule/services.yaml | 4 ++ .../components/schedule/strings.json | 4 ++ .../schedule/snapshots/test_init.ambr | 59 ++++++++++++++++ tests/components/schedule/test_init.py | 67 +++++++++++++++++++ 7 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 tests/components/schedule/snapshots/test_init.ambr diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index 20dc9c1256a..ea569f4e277 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -18,7 +18,13 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.collection import ( CollectionEntity, @@ -44,6 +50,7 @@ from .const import ( CONF_TO, DOMAIN, LOGGER, + SERVICE_GET, WEEKDAY_TO_CONF, ) @@ -205,6 +212,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: reload_service_handler, ) + component.async_register_entity_service( + SERVICE_GET, + {}, + async_get_schedule_service, + supports_response=SupportsResponse.ONLY, + ) + await component.async_setup(config) + return True @@ -296,6 +311,10 @@ class Schedule(CollectionEntity): self.async_on_remove(self._clean_up_listener) self._update() + def get_schedule(self) -> ConfigType: + """Return the schedule.""" + return {d: self._config[d] for d in WEEKDAY_TO_CONF.values()} + @callback def _update(self, _: datetime | None = None) -> None: """Update the states of the schedule.""" @@ -390,3 +409,10 @@ class Schedule(CollectionEntity): data_keys.update(time_range_custom_data.keys()) return frozenset(data_keys) + + +async def async_get_schedule_service( + schedule: Schedule, service_call: ServiceCall +) -> ServiceResponse: + """Return the schedule configuration.""" + return schedule.get_schedule() diff --git a/homeassistant/components/schedule/const.py b/homeassistant/components/schedule/const.py index 6687dafefdb..410cd00c3a0 100644 --- a/homeassistant/components/schedule/const.py +++ b/homeassistant/components/schedule/const.py @@ -37,3 +37,5 @@ WEEKDAY_TO_CONF: Final = { 5: CONF_SATURDAY, 6: CONF_SUNDAY, } + +SERVICE_GET: Final = "get_schedule" diff --git a/homeassistant/components/schedule/icons.json b/homeassistant/components/schedule/icons.json index a9829425570..7d631cfd42d 100644 --- a/homeassistant/components/schedule/icons.json +++ b/homeassistant/components/schedule/icons.json @@ -2,6 +2,9 @@ "services": { "reload": { "service": "mdi:reload" + }, + "get_schedule": { + "service": "mdi:calendar-export" } } } diff --git a/homeassistant/components/schedule/services.yaml b/homeassistant/components/schedule/services.yaml index c983a105c93..1cb3f0280af 100644 --- a/homeassistant/components/schedule/services.yaml +++ b/homeassistant/components/schedule/services.yaml @@ -1 +1,5 @@ reload: +get_schedule: + target: + entity: + domain: schedule diff --git a/homeassistant/components/schedule/strings.json b/homeassistant/components/schedule/strings.json index a40c5814d36..8638e4a8a84 100644 --- a/homeassistant/components/schedule/strings.json +++ b/homeassistant/components/schedule/strings.json @@ -25,6 +25,10 @@ "reload": { "name": "[%key:common::action::reload%]", "description": "Reloads schedules from the YAML-configuration." + }, + "get_schedule": { + "name": "Get schedule", + "description": "Retrieve one or multiple schedules." } } } diff --git a/tests/components/schedule/snapshots/test_init.ambr b/tests/components/schedule/snapshots/test_init.ambr new file mode 100644 index 00000000000..93cde4f5733 --- /dev/null +++ b/tests/components/schedule/snapshots/test_init.ambr @@ -0,0 +1,59 @@ +# serializer version: 1 +# name: test_service_get[schedule.from_storage-get-after-update] + dict({ + 'friday': list([ + ]), + 'monday': list([ + ]), + 'saturday': list([ + ]), + 'sunday': list([ + ]), + 'thursday': list([ + ]), + 'tuesday': list([ + ]), + 'wednesday': list([ + dict({ + 'from': datetime.time(17, 0), + 'to': datetime.time(19, 0), + }), + ]), + }) +# --- +# name: test_service_get[schedule.from_storage-get] + dict({ + 'friday': list([ + dict({ + 'data': dict({ + 'party_level': 'epic', + }), + 'from': datetime.time(17, 0), + 'to': datetime.time(23, 59, 59), + }), + ]), + 'monday': list([ + ]), + 'saturday': list([ + dict({ + 'from': datetime.time(0, 0), + 'to': datetime.time(23, 59, 59), + }), + ]), + 'sunday': list([ + dict({ + 'data': dict({ + 'entry': 'VIPs only', + }), + 'from': datetime.time(0, 0), + 'to': datetime.time(23, 59, 59, 999999), + }), + ]), + 'thursday': list([ + ]), + 'tuesday': list([ + ]), + 'wednesday': list([ + ]), + }) +# --- diff --git a/tests/components/schedule/test_init.py b/tests/components/schedule/test_init.py index 18346122bfd..fef2ff745cd 100644 --- a/tests/components/schedule/test_init.py +++ b/tests/components/schedule/test_init.py @@ -8,10 +8,12 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.schedule import STORAGE_VERSION, STORAGE_VERSION_MINOR from homeassistant.components.schedule.const import ( ATTR_NEXT_EVENT, + CONF_ALL_DAYS, CONF_DATA, CONF_FRIDAY, CONF_FROM, @@ -23,12 +25,14 @@ from homeassistant.components.schedule.const import ( CONF_TUESDAY, CONF_WEDNESDAY, DOMAIN, + SERVICE_GET, ) from homeassistant.const import ( ATTR_EDITABLE, ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_NAME, + CONF_ENTITY_ID, CONF_ICON, CONF_ID, CONF_NAME, @@ -754,3 +758,66 @@ async def test_ws_create( assert result["party_mode"][CONF_MONDAY] == [ {CONF_FROM: "12:00:00", CONF_TO: saved_to} ] + + +async def test_service_get( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, + schedule_setup: Callable[..., Coroutine[Any, Any, bool]], +) -> None: + """Test getting a single schedule via service.""" + assert await schedule_setup() + + entity_id = "schedule.from_storage" + + # Test retrieving a single schedule via service call + service_result = await hass.services.async_call( + DOMAIN, + SERVICE_GET, + { + CONF_ENTITY_ID: entity_id, + }, + blocking=True, + return_response=True, + ) + result = service_result.get(entity_id) + + assert set(result) == CONF_ALL_DAYS + assert result == snapshot(name=f"{entity_id}-get") + + # Now we update the schedule via WS + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 1, + "type": f"{DOMAIN}/update", + f"{DOMAIN}_id": entity_id.rsplit(".", maxsplit=1)[-1], + CONF_NAME: "Party pooper", + CONF_ICON: "mdi:party-pooper", + CONF_MONDAY: [], + CONF_TUESDAY: [], + CONF_WEDNESDAY: [{CONF_FROM: "17:00:00", CONF_TO: "19:00:00"}], + CONF_THURSDAY: [], + CONF_FRIDAY: [], + CONF_SATURDAY: [], + CONF_SUNDAY: [], + } + ) + resp = await client.receive_json() + assert resp["success"] + + # Test retrieving the schedule via service call after WS update + service_result = await hass.services.async_call( + DOMAIN, + SERVICE_GET, + { + CONF_ENTITY_ID: entity_id, + }, + blocking=True, + return_response=True, + ) + result = service_result.get(entity_id) + + assert set(result) == CONF_ALL_DAYS + assert result == snapshot(name=f"{entity_id}-get-after-update") From b5662ded2c856fdc0268ef2b5fd9cd5bd3bd07b4 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 3 Feb 2025 15:42:21 +0100 Subject: [PATCH 30/36] Update pylint-per-file-ignores to 1.4.0 (#137242) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 5e58757def8..3a89c72c11a 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -16,7 +16,7 @@ mypy-dev==1.16.0a1 pre-commit==4.0.0 pydantic==2.10.6 pylint==3.3.4 -pylint-per-file-ignores==1.3.2 +pylint-per-file-ignores==1.4.0 pipdeptree==2.25.0 pytest-asyncio==0.25.3 pytest-aiohttp==1.0.5 From 37461d727a04212d537b89dca5a23a480a9391f5 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Mon, 3 Feb 2025 07:44:49 -0700 Subject: [PATCH 31/36] Migrate unique ID in vesync switches (#137099) --- homeassistant/components/vesync/__init__.py | 35 ++++++++++++ .../components/vesync/config_flow.py | 1 + homeassistant/components/vesync/switch.py | 2 + tests/components/vesync/conftest.py | 22 ++++++++ .../vesync/snapshots/test_switch.ambr | 4 +- tests/components/vesync/test_init.py | 55 +++++++++++++++++++ 6 files changed, 117 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index 27e626faeac..1c55d932425 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -7,6 +7,7 @@ from pyvesync import VeSync from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send from .common import async_generate_device_list @@ -91,3 +92,37 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.pop(DOMAIN) return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating VeSync config entry: %s minor version: %s", + config_entry.version, + config_entry.minor_version, + ) + if config_entry.minor_version == 1: + # Migrate switch/outlets entity to a new unique ID + _LOGGER.debug("Migrating VeSync config entry from version 1 to version 2") + entity_registry = er.async_get(hass) + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + for reg_entry in registry_entries: + if "-" not in reg_entry.unique_id and reg_entry.entity_id.startswith( + Platform.SWITCH + ): + _LOGGER.debug( + "Migrating switch/outlet entity from unique_id: %s to unique_id: %s", + reg_entry.unique_id, + reg_entry.unique_id + "-device_status", + ) + entity_registry.async_update_entity( + reg_entry.entity_id, + new_unique_id=reg_entry.unique_id + "-device_status", + ) + else: + _LOGGER.debug("Skipping entity with unique_id: %s", reg_entry.unique_id) + hass.config_entries.async_update_entry(config_entry, minor_version=2) + + return True diff --git a/homeassistant/components/vesync/config_flow.py b/homeassistant/components/vesync/config_flow.py index e19c46e5490..07543440e91 100644 --- a/homeassistant/components/vesync/config_flow.py +++ b/homeassistant/components/vesync/config_flow.py @@ -24,6 +24,7 @@ class VeSyncFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow.""" VERSION = 1 + MINOR_VERSION = 2 @callback def _show_form(self, errors: dict[str, str] | None = None) -> ConfigFlowResult: diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py index ef8e6c6051f..efae1192406 100644 --- a/homeassistant/components/vesync/switch.py +++ b/homeassistant/components/vesync/switch.py @@ -83,6 +83,7 @@ class VeSyncSwitchHA(VeSyncBaseSwitch, SwitchEntity): ) -> None: """Initialize the VeSync switch device.""" super().__init__(plug, coordinator) + self._attr_unique_id = f"{super().unique_id}-device_status" self.smartplug = plug @@ -94,4 +95,5 @@ class VeSyncLightSwitch(VeSyncBaseSwitch, SwitchEntity): ) -> None: """Initialize Light Switch device class.""" super().__init__(switch, coordinator) + self._attr_unique_id = f"{super().unique_id}-device_status" self.switch = switch diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index a80c2631088..9ec7bd23fa5 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -153,3 +153,25 @@ async def humidifier_config_entry( await hass.async_block_till_done() return entry + + +@pytest.fixture(name="switch_old_id_config_entry") +async def switch_old_id_config_entry( + hass: HomeAssistant, requests_mock: requests_mock.Mocker, config +) -> MockConfigEntry: + """Create a mock VeSync config entry for `switch` with the old unique ID approach.""" + entry = MockConfigEntry( + title="VeSync", + domain=DOMAIN, + data=config[DOMAIN], + version=1, + minor_version=1, + ) + entry.add_to_hass(hass) + + wall_switch = "Wall Switch" + humidifer = "Humidifier 200s" + + mock_multiple_device_responses(requests_mock, [wall_switch, humidifer]) + + return entry diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index ec9cbc4398c..da652b30ac5 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -367,7 +367,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'outlet', + 'unique_id': 'outlet-device_status', 'unit_of_measurement': None, }), ]) @@ -525,7 +525,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'switch', + 'unique_id': 'switch-device_status', 'unit_of_measurement': None, }), ]) diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index 883e850fc62..f1fb3931bf9 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -10,6 +10,9 @@ from homeassistant.components.vesync.const import DOMAIN, VS_DEVICES, VS_MANAGER from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry async def test_async_setup_entry__not_login( @@ -125,3 +128,55 @@ async def test_async_new_device_discovery( assert manager.login.call_count == 1 assert hass.data[DOMAIN][VS_MANAGER] == manager assert hass.data[DOMAIN][VS_DEVICES] == [fan, humidifier] + + +async def test_migrate_config_entry( + hass: HomeAssistant, + switch_old_id_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration of config entry. Only migrates switches to a new unique_id.""" + switch: er.RegistryEntry = entity_registry.async_get_or_create( + domain="switch", + platform="vesync", + unique_id="switch", + config_entry=switch_old_id_config_entry, + suggested_object_id="switch", + ) + + humidifer: er.RegistryEntry = entity_registry.async_get_or_create( + domain="humidifer", + platform="vesync", + unique_id="humidifer", + config_entry=switch_old_id_config_entry, + suggested_object_id="humidifer", + ) + + assert switch.unique_id == "switch" + assert switch_old_id_config_entry.minor_version == 1 + assert humidifer.unique_id == "humidifer" + + await hass.config_entries.async_setup(switch_old_id_config_entry.entry_id) + await hass.async_block_till_done() + + assert switch_old_id_config_entry.minor_version == 2 + + migrated_switch = entity_registry.async_get(switch.entity_id) + assert migrated_switch is not None + assert migrated_switch.entity_id.startswith("switch") + assert migrated_switch.unique_id == "switch-device_status" + # Confirm humidifer was not impacted + migrated_humidifer = entity_registry.async_get(humidifer.entity_id) + assert migrated_humidifer is not None + assert migrated_humidifer.unique_id == "humidifer" + + # Assert that only one entity exists in the switch domain + switch_entities = [ + e for e in entity_registry.entities.values() if e.domain == "switch" + ] + assert len(switch_entities) == 1 + + humidifer_entities = [ + e for e in entity_registry.entities.values() if e.domain == "humidifer" + ] + assert len(humidifer_entities) == 1 From 4531a46557dd0dc18554e753fd072e7bf961af8a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 3 Feb 2025 16:03:13 +0100 Subject: [PATCH 32/36] Bump python-homeassistant-analytics to 0.9.0 (#137240) --- homeassistant/components/analytics_insights/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/analytics_insights/manifest.json b/homeassistant/components/analytics_insights/manifest.json index bf99d89e073..ab3c2e2fe24 100644 --- a/homeassistant/components/analytics_insights/manifest.json +++ b/homeassistant/components/analytics_insights/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["python_homeassistant_analytics"], - "requirements": ["python-homeassistant-analytics==0.8.1"], + "requirements": ["python-homeassistant-analytics==0.9.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index f1900afffab..f0ad42f0eba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2388,7 +2388,7 @@ python-gitlab==1.6.0 python-google-drive-api==0.0.2 # homeassistant.components.analytics_insights -python-homeassistant-analytics==0.8.1 +python-homeassistant-analytics==0.9.0 # homeassistant.components.homewizard python-homewizard-energy==v8.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fb6a4766884..a1fee30ffd3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1933,7 +1933,7 @@ python-fullykiosk==0.0.14 python-google-drive-api==0.0.2 # homeassistant.components.analytics_insights -python-homeassistant-analytics==0.8.1 +python-homeassistant-analytics==0.9.0 # homeassistant.components.homewizard python-homewizard-energy==v8.3.2 From 8acab6c646b65a1aa79613d3d8a1170456449ca2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Feb 2025 10:13:09 -0500 Subject: [PATCH 33/36] Assist Satellite to use ChatSession for conversation ID (#137142) * Assist Satellite to use ChatSession for conversation ID * Adjust for changes main branch * Ensure the initial message is in the chat log --- .../components/assist_satellite/entity.py | 108 ++++++++++-------- tests/components/assist_satellite/conftest.py | 4 +- .../assist_satellite/test_entity.py | 15 ++- 3 files changed, 75 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 0229e0358b1..c901bc7d928 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from enum import StrEnum import logging import time -from typing import Any, Final, Literal, final +from typing import Any, Literal, final from homeassistant.components import conversation, media_source, stt, tts from homeassistant.components.assist_pipeline import ( @@ -28,14 +28,12 @@ from homeassistant.components.tts import ( ) from homeassistant.core import Context, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity +from homeassistant.helpers import chat_session, entity from homeassistant.helpers.entity import EntityDescription from .const import AssistSatelliteEntityFeature from .errors import AssistSatelliteError, SatelliteBusyError -_CONVERSATION_TIMEOUT_SEC: Final = 5 * 60 # 5 minutes - _LOGGER = logging.getLogger(__name__) @@ -114,7 +112,6 @@ class AssistSatelliteEntity(entity.Entity): _attr_vad_sensitivity_entity_id: str | None = None _conversation_id: str | None = None - _conversation_id_time: float | None = None _run_has_tts: bool = False _is_announcing = False @@ -260,6 +257,21 @@ class AssistSatelliteEntity(entity.Entity): else: self._extra_system_prompt = start_message or None + with ( + # Not passing in a conversation ID will force a new one to be created + chat_session.async_get_chat_session(self.hass) as session, + conversation.async_get_chat_log(self.hass, session) as chat_log, + ): + self._conversation_id = session.conversation_id + + if start_message: + async for _tool_response in chat_log.async_add_assistant_content( + conversation.AssistantContent( + agent_id=self.entity_id, content=start_message + ) + ): + pass # no tool responses. + try: await self.async_start_conversation(announcement) finally: @@ -325,51 +337,52 @@ class AssistSatelliteEntity(entity.Entity): assert self._context is not None - # Reset conversation id if necessary - if self._conversation_id_time and ( - (time.monotonic() - self._conversation_id_time) > _CONVERSATION_TIMEOUT_SEC - ): - self._conversation_id = None - self._conversation_id_time = None - # Set entity state based on pipeline events self._run_has_tts = False assert self.platform.config_entry is not None - self._pipeline_task = self.platform.config_entry.async_create_background_task( - self.hass, - async_pipeline_from_audio_stream( - self.hass, - context=self._context, - event_callback=self._internal_on_pipeline_event, - stt_metadata=stt.SpeechMetadata( - language="", # set in async_pipeline_from_audio_stream - format=stt.AudioFormats.WAV, - codec=stt.AudioCodecs.PCM, - bit_rate=stt.AudioBitRates.BITRATE_16, - sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, - channel=stt.AudioChannels.CHANNEL_MONO, - ), - stt_stream=audio_stream, - pipeline_id=self._resolve_pipeline(), - conversation_id=self._conversation_id, - device_id=device_id, - tts_audio_output=self.tts_options, - wake_word_phrase=wake_word_phrase, - audio_settings=AudioSettings( - silence_seconds=self._resolve_vad_sensitivity() - ), - start_stage=start_stage, - end_stage=end_stage, - conversation_extra_system_prompt=extra_system_prompt, - ), - f"{self.entity_id}_pipeline", - ) - try: - await self._pipeline_task - finally: - self._pipeline_task = None + with chat_session.async_get_chat_session( + self.hass, self._conversation_id + ) as session: + # Store the conversation ID. If it is no longer valid, get_chat_session will reset it + self._conversation_id = session.conversation_id + self._pipeline_task = ( + self.platform.config_entry.async_create_background_task( + self.hass, + async_pipeline_from_audio_stream( + self.hass, + context=self._context, + event_callback=self._internal_on_pipeline_event, + stt_metadata=stt.SpeechMetadata( + language="", # set in async_pipeline_from_audio_stream + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_stream, + pipeline_id=self._resolve_pipeline(), + conversation_id=session.conversation_id, + device_id=device_id, + tts_audio_output=self.tts_options, + wake_word_phrase=wake_word_phrase, + audio_settings=AudioSettings( + silence_seconds=self._resolve_vad_sensitivity() + ), + start_stage=start_stage, + end_stage=end_stage, + conversation_extra_system_prompt=extra_system_prompt, + ), + f"{self.entity_id}_pipeline", + ) + ) + + try: + await self._pipeline_task + finally: + self._pipeline_task = None async def _cancel_running_pipeline(self) -> None: """Cancel the current pipeline if it's running.""" @@ -393,11 +406,6 @@ class AssistSatelliteEntity(entity.Entity): self._set_state(AssistSatelliteState.LISTENING) elif event.type is PipelineEventType.INTENT_START: self._set_state(AssistSatelliteState.PROCESSING) - elif event.type is PipelineEventType.INTENT_END: - assert event.data is not None - # Update timeout - self._conversation_id_time = time.monotonic() - self._conversation_id = event.data["intent_output"]["conversation_id"] elif event.type is PipelineEventType.TTS_START: # Wait until tts_response_finished is called to return to waiting state self._run_has_tts = True diff --git a/tests/components/assist_satellite/conftest.py b/tests/components/assist_satellite/conftest.py index 0cc0e94e149..79e4061bacc 100644 --- a/tests/components/assist_satellite/conftest.py +++ b/tests/components/assist_satellite/conftest.py @@ -94,7 +94,9 @@ class MockAssistSatellite(AssistSatelliteEntity): self, start_announcement: AssistSatelliteConfiguration ) -> None: """Start a conversation from the satellite.""" - self.start_conversations.append((self._extra_system_prompt, start_announcement)) + self.start_conversations.append( + (self._conversation_id, self._extra_system_prompt, start_announcement) + ) @pytest.fixture diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 46facb80844..b3437bf5c5d 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -1,7 +1,8 @@ """Test the Assist Satellite entity.""" import asyncio -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import Mock, patch import pytest @@ -31,6 +32,14 @@ from . import ENTITY_ID from .conftest import MockAssistSatellite +@pytest.fixture +def mock_chat_session_conversation_id() -> Generator[Mock]: + """Mock the ulid library.""" + with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: + mock_ulid_now.return_value = "mock-conversation-id" + yield mock_ulid_now + + @pytest.fixture(autouse=True) async def set_pipeline_tts(hass: HomeAssistant, init_components: ConfigEntry) -> None: """Set up a pipeline with a TTS engine.""" @@ -487,6 +496,7 @@ async def test_vad_sensitivity_entity_not_found( "extra_system_prompt": "Better system prompt", }, ( + "mock-conversation-id", "Better system prompt", AssistSatelliteAnnouncement( message="Hello", @@ -502,6 +512,7 @@ async def test_vad_sensitivity_entity_not_found( "start_media_id": "media-source://given", }, ( + "mock-conversation-id", "Hello", AssistSatelliteAnnouncement( message="Hello", @@ -514,6 +525,7 @@ async def test_vad_sensitivity_entity_not_found( ( {"start_media_id": "http://example.com/given.mp3"}, ( + "mock-conversation-id", None, AssistSatelliteAnnouncement( message="", @@ -525,6 +537,7 @@ async def test_vad_sensitivity_entity_not_found( ), ], ) +@pytest.mark.usefixtures("mock_chat_session_conversation_id") async def test_start_conversation( hass: HomeAssistant, init_components: ConfigEntry, From 05ca80f4ba096f75374903c41322aed9f7490f05 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Feb 2025 10:18:15 -0500 Subject: [PATCH 34/36] Assist Pipeline to use ChatSession for conversation ID (#137143) * Assist Pipeline to use ChatSession for conversation ID * Adjust to latest changes --- .../components/assist_pipeline/__init__.py | 44 ++++++------- .../components/assist_pipeline/pipeline.py | 15 ++--- .../assist_pipeline/websocket_api.py | 63 ++++++++++--------- .../assist_pipeline/snapshots/test_init.ambr | 23 ++++--- .../snapshots/test_websocket.ambr | 39 +++++++++--- tests/components/assist_pipeline/test_init.py | 24 +++++-- .../assist_pipeline/test_websocket.py | 11 +++- 7 files changed, 143 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index cc7ecc1c426..9a32821e3a0 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.components import stt from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import chat_session from homeassistant.helpers.typing import ConfigType from .const import ( @@ -114,24 +115,25 @@ async def async_pipeline_from_audio_stream( Raises PipelineNotFound if no pipeline is found. """ - pipeline_input = PipelineInput( - conversation_id=conversation_id, - device_id=device_id, - stt_metadata=stt_metadata, - stt_stream=stt_stream, - wake_word_phrase=wake_word_phrase, - conversation_extra_system_prompt=conversation_extra_system_prompt, - run=PipelineRun( - hass, - context=context, - pipeline=async_get_pipeline(hass, pipeline_id=pipeline_id), - start_stage=start_stage, - end_stage=end_stage, - event_callback=event_callback, - tts_audio_output=tts_audio_output, - wake_word_settings=wake_word_settings, - audio_settings=audio_settings or AudioSettings(), - ), - ) - await pipeline_input.validate() - await pipeline_input.execute() + with chat_session.async_get_chat_session(hass, conversation_id) as session: + pipeline_input = PipelineInput( + conversation_id=session.conversation_id, + device_id=device_id, + stt_metadata=stt_metadata, + stt_stream=stt_stream, + wake_word_phrase=wake_word_phrase, + conversation_extra_system_prompt=conversation_extra_system_prompt, + run=PipelineRun( + hass, + context=context, + pipeline=async_get_pipeline(hass, pipeline_id=pipeline_id), + start_stage=start_stage, + end_stage=end_stage, + event_callback=event_callback, + tts_audio_output=tts_audio_output, + wake_word_settings=wake_word_settings, + audio_settings=audio_settings or AudioSettings(), + ), + ) + await pipeline_input.validate() + await pipeline_input.execute() diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index c5f9098623a..262f4c59687 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -624,7 +624,7 @@ class PipelineRun: return pipeline_data.pipeline_debug[self.pipeline.id][self.id].events.append(event) - def start(self, device_id: str | None) -> None: + def start(self, conversation_id: str, device_id: str | None) -> None: """Emit run start event.""" self._device_id = device_id self._start_debug_recording_thread() @@ -632,6 +632,7 @@ class PipelineRun: data = { "pipeline": self.pipeline.id, "language": self.language, + "conversation_id": conversation_id, } if self.runner_data is not None: data["runner_data"] = self.runner_data @@ -1015,7 +1016,7 @@ class PipelineRun: async def recognize_intent( self, intent_input: str, - conversation_id: str | None, + conversation_id: str, device_id: str | None, conversation_extra_system_prompt: str | None, ) -> str: @@ -1409,12 +1410,15 @@ def _pipeline_debug_recording_thread_proc( wav_writer.close() -@dataclass +@dataclass(kw_only=True) class PipelineInput: """Input to a pipeline run.""" run: PipelineRun + conversation_id: str + """Identifier for the conversation.""" + stt_metadata: stt.SpeechMetadata | None = None """Metadata of stt input audio. Required when start_stage = stt.""" @@ -1430,9 +1434,6 @@ class PipelineInput: tts_input: str | None = None """Input for text-to-speech. Required when start_stage = tts.""" - conversation_id: str | None = None - """Identifier for the conversation.""" - conversation_extra_system_prompt: str | None = None """Extra prompt information for the conversation agent.""" @@ -1441,7 +1442,7 @@ class PipelineInput: async def execute(self) -> None: """Run pipeline.""" - self.run.start(device_id=self.device_id) + self.run.start(conversation_id=self.conversation_id, device_id=self.device_id) current_stage: PipelineStage | None = self.run.start_stage stt_audio_buffer: list[EnhancedAudioChunk] = [] stt_processed_stream: AsyncIterable[EnhancedAudioChunk] | None = None diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index 69f917fcf83..d2d54a1b7c3 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -14,7 +14,11 @@ import voluptuous as vol from homeassistant.components import conversation, stt, tts, websocket_api from homeassistant.const import ATTR_DEVICE_ID, ATTR_SECONDS, MATCH_ALL from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers import ( + chat_session, + config_validation as cv, + entity_registry as er, +) from homeassistant.util import language as language_util from .const import ( @@ -145,7 +149,6 @@ async def websocket_run( # Arguments to PipelineInput input_args: dict[str, Any] = { - "conversation_id": msg.get("conversation_id"), "device_id": msg.get("device_id"), } @@ -233,38 +236,42 @@ async def websocket_run( audio_settings=audio_settings or AudioSettings(), ) - pipeline_input = PipelineInput(**input_args) + with chat_session.async_get_chat_session( + hass, msg.get("conversation_id") + ) as session: + input_args["conversation_id"] = session.conversation_id + pipeline_input = PipelineInput(**input_args) - try: - await pipeline_input.validate() - except PipelineError as error: - # Report more specific error when possible - connection.send_error(msg["id"], error.code, error.message) - return + try: + await pipeline_input.validate() + except PipelineError as error: + # Report more specific error when possible + connection.send_error(msg["id"], error.code, error.message) + return - # Confirm subscription - connection.send_result(msg["id"]) + # Confirm subscription + connection.send_result(msg["id"]) - run_task = hass.async_create_task(pipeline_input.execute()) + run_task = hass.async_create_task(pipeline_input.execute()) - # Cancel pipeline if user unsubscribes - connection.subscriptions[msg["id"]] = run_task.cancel + # Cancel pipeline if user unsubscribes + connection.subscriptions[msg["id"]] = run_task.cancel - try: - # Task contains a timeout - async with asyncio.timeout(timeout): - await run_task - except TimeoutError: - pipeline_input.run.process_event( - PipelineEvent( - PipelineEventType.ERROR, - {"code": "timeout", "message": "Timeout running pipeline"}, + try: + # Task contains a timeout + async with asyncio.timeout(timeout): + await run_task + except TimeoutError: + pipeline_input.run.process_event( + PipelineEvent( + PipelineEventType.ERROR, + {"code": "timeout", "message": "Timeout running pipeline"}, + ) ) - ) - finally: - if unregister_handler is not None: - # Unregister binary handler - unregister_handler() + finally: + if unregister_handler is not None: + # Unregister binary handler + unregister_handler() @callback diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index 526e1bff151..11e6bc2339a 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -3,6 +3,7 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , }), @@ -32,7 +33,7 @@ }), dict({ 'data': dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', @@ -94,6 +95,7 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , }), @@ -123,7 +125,7 @@ }), dict({ 'data': dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', @@ -185,6 +187,7 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , }), @@ -214,7 +217,7 @@ }), dict({ 'data': dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', @@ -276,6 +279,7 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , }), @@ -329,7 +333,7 @@ }), dict({ 'data': dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', @@ -391,6 +395,7 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , }), @@ -427,6 +432,7 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-conversation-id', 'language': 'en', 'pipeline': , }), @@ -434,7 +440,7 @@ }), dict({ 'data': dict({ - 'conversation_id': None, + 'conversation_id': 'mock-conversation-id', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test input', @@ -478,6 +484,7 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-conversation-id', 'language': 'en', 'pipeline': , }), @@ -485,7 +492,7 @@ }), dict({ 'data': dict({ - 'conversation_id': None, + 'conversation_id': 'mock-conversation-id', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test input', @@ -529,6 +536,7 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-conversation-id', 'language': 'en', 'pipeline': , }), @@ -536,7 +544,7 @@ }), dict({ 'data': dict({ - 'conversation_id': None, + 'conversation_id': 'mock-conversation-id', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test input', @@ -580,6 +588,7 @@ list([ dict({ 'data': dict({ + 'conversation_id': 'mock-conversation-id', 'language': 'en', 'pipeline': , }), diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 5f06172404b..f677fa6d8cf 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -1,6 +1,7 @@ # serializer version: 1 # name: test_audio_pipeline dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -31,7 +32,7 @@ # --- # name: test_audio_pipeline.3 dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', @@ -84,6 +85,7 @@ # --- # name: test_audio_pipeline_debug dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -114,7 +116,7 @@ # --- # name: test_audio_pipeline_debug.3 dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', @@ -179,6 +181,7 @@ # --- # name: test_audio_pipeline_with_enhancements dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -209,7 +212,7 @@ # --- # name: test_audio_pipeline_with_enhancements.3 dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', @@ -262,6 +265,7 @@ # --- # name: test_audio_pipeline_with_wake_word_no_timeout dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -314,7 +318,7 @@ # --- # name: test_audio_pipeline_with_wake_word_no_timeout.5 dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'test transcript', @@ -367,6 +371,7 @@ # --- # name: test_audio_pipeline_with_wake_word_timeout dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -399,6 +404,7 @@ # --- # name: test_device_capture dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -425,6 +431,7 @@ # --- # name: test_device_capture_override dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -473,6 +480,7 @@ # --- # name: test_device_capture_queue_full dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -512,6 +520,7 @@ # --- # name: test_intent_failed dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -522,7 +531,7 @@ # --- # name: test_intent_failed.1 dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'Are the lights on?', @@ -535,6 +544,7 @@ # --- # name: test_intent_timeout dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -545,7 +555,7 @@ # --- # name: test_intent_timeout.1 dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'Are the lights on?', @@ -564,6 +574,7 @@ # --- # name: test_pipeline_empty_tts_output dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -574,7 +585,7 @@ # --- # name: test_pipeline_empty_tts_output.1 dict({ - 'conversation_id': None, + 'conversation_id': 'mock-ulid', 'device_id': None, 'engine': 'conversation.home_assistant', 'intent_input': 'never mind', @@ -611,6 +622,7 @@ # --- # name: test_stt_cooldown_different_ids dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -621,6 +633,7 @@ # --- # name: test_stt_cooldown_different_ids.1 dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -631,6 +644,7 @@ # --- # name: test_stt_cooldown_same_id dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -641,6 +655,7 @@ # --- # name: test_stt_cooldown_same_id.1 dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -651,6 +666,7 @@ # --- # name: test_stt_stream_failed dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -677,6 +693,7 @@ # --- # name: test_text_only_pipeline[extra_msg0] dict({ + 'conversation_id': 'mock-conversation-id', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -723,6 +740,7 @@ # --- # name: test_text_only_pipeline[extra_msg1] dict({ + 'conversation_id': 'mock-conversation-id', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -775,6 +793,7 @@ # --- # name: test_tts_failed dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -796,6 +815,7 @@ # --- # name: test_wake_word_cooldown_different_entities dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -806,6 +826,7 @@ # --- # name: test_wake_word_cooldown_different_entities.1 dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -857,6 +878,7 @@ # --- # name: test_wake_word_cooldown_different_ids dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -867,6 +889,7 @@ # --- # name: test_wake_word_cooldown_different_ids.1 dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -921,6 +944,7 @@ # --- # name: test_wake_word_cooldown_same_id dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ @@ -931,6 +955,7 @@ # --- # name: test_wake_word_cooldown_same_id.1 dict({ + 'conversation_id': 'mock-ulid', 'language': 'en', 'pipeline': , 'runner_data': dict({ diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index a2cb9ef382a..1651950c173 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -1,11 +1,12 @@ """Test Voice Assistant init.""" import asyncio +from collections.abc import Generator from dataclasses import asdict import itertools as it from pathlib import Path import tempfile -from unittest.mock import ANY, patch +from unittest.mock import ANY, Mock, patch import wave import hass_nabucasa @@ -41,6 +42,14 @@ from .conftest import ( from tests.typing import ClientSessionGenerator, WebSocketGenerator +@pytest.fixture(autouse=True) +def mock_ulid() -> Generator[Mock]: + """Mock the ulid of chat sessions.""" + with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: + mock_ulid_now.return_value = "mock-ulid" + yield mock_ulid_now + + def process_events(events: list[assist_pipeline.PipelineEvent]) -> list[dict]: """Process events to remove dynamic values.""" processed = [] @@ -684,7 +693,7 @@ async def test_wake_word_detection_aborted( pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) pipeline_input = assist_pipeline.pipeline.PipelineInput( - conversation_id=None, + conversation_id="mock-conversation-id", device_id=None, stt_metadata=stt.SpeechMetadata( language="", @@ -771,7 +780,7 @@ async def test_tts_audio_output( pipeline_input = assist_pipeline.pipeline.PipelineInput( tts_input="This is a test.", - conversation_id=None, + conversation_id="mock-conversation-id", device_id=None, run=assist_pipeline.pipeline.PipelineRun( hass, @@ -828,7 +837,7 @@ async def test_tts_wav_preferred_format( pipeline_input = assist_pipeline.pipeline.PipelineInput( tts_input="This is a test.", - conversation_id=None, + conversation_id="mock-conversation-id", device_id=None, run=assist_pipeline.pipeline.PipelineRun( hass, @@ -896,7 +905,7 @@ async def test_tts_dict_preferred_format( pipeline_input = assist_pipeline.pipeline.PipelineInput( tts_input="This is a test.", - conversation_id=None, + conversation_id="mock-conversation-id", device_id=None, run=assist_pipeline.pipeline.PipelineRun( hass, @@ -982,6 +991,7 @@ async def test_sentence_trigger_overrides_conversation_agent( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="test trigger sentence", + conversation_id="mock-conversation-id", run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), @@ -1059,6 +1069,7 @@ async def test_prefer_local_intents( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="I'd like to order a stout please", + conversation_id="mock-conversation-id", run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), @@ -1136,6 +1147,7 @@ async def test_stt_language_used_instead_of_conversation_language( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="test input", + conversation_id="mock-conversation-id", run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), @@ -1210,6 +1222,7 @@ async def test_tts_language_used_instead_of_conversation_language( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="test input", + conversation_id="mock-conversation-id", run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), @@ -1284,6 +1297,7 @@ async def test_pipeline_language_used_instead_of_conversation_language( pipeline_input = assist_pipeline.pipeline.PipelineInput( intent_input="test input", + conversation_id="mock-conversation-id", run=assist_pipeline.pipeline.PipelineRun( hass, context=Context(), diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index c1caf6f86a4..2cd56f094dd 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -2,8 +2,9 @@ import asyncio import base64 +from collections.abc import Generator from typing import Any -from unittest.mock import ANY, patch +from unittest.mock import ANY, Mock, patch import pytest from syrupy.assertion import SnapshotAssertion @@ -35,6 +36,14 @@ from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator +@pytest.fixture(autouse=True) +def mock_ulid() -> Generator[Mock]: + """Mock the ulid of chat sessions.""" + with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: + mock_ulid_now.return_value = "mock-ulid" + yield mock_ulid_now + + @pytest.mark.parametrize( "extra_msg", [ From 628e1ffb844bfcbf415bacf77b838abd94e451ae Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Mon, 3 Feb 2025 16:25:58 +0100 Subject: [PATCH 35/36] Migrate OneDrive to onedrive_personal_sdk library (#137064) --- homeassistant/components/onedrive/__init__.py | 123 +++------ homeassistant/components/onedrive/api.py | 35 +-- homeassistant/components/onedrive/backup.py | 238 +++++----------- .../components/onedrive/config_flow.py | 56 ++-- .../components/onedrive/manifest.json | 4 +- .../components/onedrive/strings.json | 15 +- requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- tests/components/onedrive/conftest.py | 119 ++------ tests/components/onedrive/const.py | 58 ++++ tests/components/onedrive/test_backup.py | 255 ++++-------------- tests/components/onedrive/test_config_flow.py | 49 +++- tests/components/onedrive/test_init.py | 67 +---- 13 files changed, 307 insertions(+), 724 deletions(-) diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py index 4ae5ac73560..ef7ddd04da6 100644 --- a/homeassistant/components/onedrive/__init__.py +++ b/homeassistant/components/onedrive/__init__.py @@ -5,34 +5,33 @@ from __future__ import annotations from dataclasses import dataclass import logging -from kiota_abstractions.api_error import APIError -from kiota_abstractions.authentication import BaseBearerTokenAuthenticationProvider -from msgraph import GraphRequestAdapter, GraphServiceClient -from msgraph.generated.drives.item.items.items_request_builder import ( - ItemsRequestBuilder, +from onedrive_personal_sdk import OneDriveClient +from onedrive_personal_sdk.exceptions import ( + AuthenticationError, + HttpRequestException, + OneDriveException, ) -from msgraph.generated.models.drive_item import DriveItem -from msgraph.generated.models.folder import Folder from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) -from homeassistant.helpers.httpx_client import create_async_httpx_client from homeassistant.helpers.instance_id import async_get as async_get_instance_id from .api import OneDriveConfigEntryAccessTokenProvider -from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN, OAUTH_SCOPES +from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN @dataclass class OneDriveRuntimeData: """Runtime data for the OneDrive integration.""" - items: ItemsRequestBuilder + client: OneDriveClient + token_provider: OneDriveConfigEntryAccessTokenProvider backup_folder_id: str @@ -47,29 +46,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> session = OAuth2Session(hass, entry, implementation) - auth_provider = BaseBearerTokenAuthenticationProvider( - access_token_provider=OneDriveConfigEntryAccessTokenProvider(session) - ) - adapter = GraphRequestAdapter( - auth_provider=auth_provider, - client=create_async_httpx_client(hass, follow_redirects=True), - ) + token_provider = OneDriveConfigEntryAccessTokenProvider(session) - graph_client = GraphServiceClient( - request_adapter=adapter, - scopes=OAUTH_SCOPES, - ) - assert entry.unique_id - drive_item = graph_client.drives.by_drive_id(entry.unique_id) + client = OneDriveClient(token_provider, async_get_clientsession(hass)) # get approot, will be created automatically if it does not exist try: - approot = await drive_item.special.by_drive_item_id("approot").get() - except APIError as err: - if err.response_status_code == 403: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, translation_key="authentication_failed" - ) from err + approot = await client.get_approot() + except AuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="authentication_failed" + ) from err + except (HttpRequestException, OneDriveException, TimeoutError) as err: _LOGGER.debug("Failed to get approot", exc_info=True) raise ConfigEntryNotReady( translation_domain=DOMAIN, @@ -77,24 +65,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> translation_placeholders={"folder": "approot"}, ) from err - if approot is None or not approot.id: - _LOGGER.debug("Failed to get approot, was None") + instance_id = await async_get_instance_id(hass) + backup_folder_name = f"backups_{instance_id[:8]}" + try: + backup_folder = await client.create_folder( + parent_id=approot.id, name=backup_folder_name + ) + except (HttpRequestException, OneDriveException, TimeoutError) as err: + _LOGGER.debug("Failed to create backup folder", exc_info=True) raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="failed_to_get_folder", - translation_placeholders={"folder": "approot"}, - ) - - instance_id = await async_get_instance_id(hass) - backup_folder_id = await _async_create_folder_if_not_exists( - items=drive_item.items, - base_folder_id=approot.id, - folder=f"backups_{instance_id[:8]}", - ) + translation_placeholders={"folder": backup_folder_name}, + ) from err entry.runtime_data = OneDriveRuntimeData( - items=drive_item.items, - backup_folder_id=backup_folder_id, + client=client, + token_provider=token_provider, + backup_folder_id=backup_folder.id, ) _async_notify_backup_listeners_soon(hass) @@ -116,54 +104,3 @@ def _async_notify_backup_listeners(hass: HomeAssistant) -> None: @callback def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None: hass.loop.call_soon(_async_notify_backup_listeners, hass) - - -async def _async_create_folder_if_not_exists( - items: ItemsRequestBuilder, - base_folder_id: str, - folder: str, -) -> str: - """Check if a folder exists and create it if it does not exist.""" - folder_item: DriveItem | None = None - - try: - folder_item = await items.by_drive_item_id(f"{base_folder_id}:/{folder}:").get() - except APIError as err: - if err.response_status_code != 404: - _LOGGER.debug("Failed to get folder %s", folder, exc_info=True) - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="failed_to_get_folder", - translation_placeholders={"folder": folder}, - ) from err - # is 404 not found, create folder - _LOGGER.debug("Creating folder %s", folder) - request_body = DriveItem( - name=folder, - folder=Folder(), - additional_data={ - "@microsoft_graph_conflict_behavior": "fail", - }, - ) - try: - folder_item = await items.by_drive_item_id(base_folder_id).children.post( - request_body - ) - except APIError as create_err: - _LOGGER.debug("Failed to create folder %s", folder, exc_info=True) - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="failed_to_create_folder", - translation_placeholders={"folder": folder}, - ) from create_err - _LOGGER.debug("Created folder %s", folder) - else: - _LOGGER.debug("Found folder %s", folder) - if folder_item is None or not folder_item.id: - _LOGGER.debug("Failed to get folder %s, was None", folder) - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="failed_to_get_folder", - translation_placeholders={"folder": folder}, - ) - return folder_item.id diff --git a/homeassistant/components/onedrive/api.py b/homeassistant/components/onedrive/api.py index 934a4f74ec9..d8f6ea188f3 100644 --- a/homeassistant/components/onedrive/api.py +++ b/homeassistant/components/onedrive/api.py @@ -1,28 +1,14 @@ """API for OneDrive bound to Home Assistant OAuth.""" -from typing import Any, cast +from typing import cast -from kiota_abstractions.authentication import AccessTokenProvider, AllowedHostsValidator +from onedrive_personal_sdk import TokenProvider from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.helpers import config_entry_oauth2_flow -class OneDriveAccessTokenProvider(AccessTokenProvider): - """Provide OneDrive authentication tied to an OAuth2 based config entry.""" - - def __init__(self) -> None: - """Initialize OneDrive auth.""" - super().__init__() - # currently allowing all hosts - self._allowed_hosts_validator = AllowedHostsValidator(allowed_hosts=[]) - - def get_allowed_hosts_validator(self) -> AllowedHostsValidator: - """Retrieve the allowed hosts validator.""" - return self._allowed_hosts_validator - - -class OneDriveConfigFlowAccessTokenProvider(OneDriveAccessTokenProvider): +class OneDriveConfigFlowAccessTokenProvider(TokenProvider): """Provide OneDrive authentication tied to an OAuth2 based config entry.""" def __init__(self, token: str) -> None: @@ -30,14 +16,12 @@ class OneDriveConfigFlowAccessTokenProvider(OneDriveAccessTokenProvider): super().__init__() self._token = token - async def get_authorization_token( # pylint: disable=dangerous-default-value - self, uri: str, additional_authentication_context: dict[str, Any] = {} - ) -> str: - """Return a valid authorization token.""" + def async_get_access_token(self) -> str: + """Return a valid access token.""" return self._token -class OneDriveConfigEntryAccessTokenProvider(OneDriveAccessTokenProvider): +class OneDriveConfigEntryAccessTokenProvider(TokenProvider): """Provide OneDrive authentication tied to an OAuth2 based config entry.""" def __init__(self, oauth_session: config_entry_oauth2_flow.OAuth2Session) -> None: @@ -45,9 +29,6 @@ class OneDriveConfigEntryAccessTokenProvider(OneDriveAccessTokenProvider): super().__init__() self._oauth_session = oauth_session - async def get_authorization_token( # pylint: disable=dangerous-default-value - self, uri: str, additional_authentication_context: dict[str, Any] = {} - ) -> str: - """Return a valid authorization token.""" - await self._oauth_session.async_ensure_token_valid() + def async_get_access_token(self) -> str: + """Return a valid access token.""" return cast(str, self._oauth_session.token[CONF_ACCESS_TOKEN]) diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py index a7bac5d01fc..43eac020538 100644 --- a/homeassistant/components/onedrive/backup.py +++ b/homeassistant/components/onedrive/backup.py @@ -2,37 +2,22 @@ from __future__ import annotations -import asyncio from collections.abc import AsyncIterator, Callable, Coroutine from functools import wraps import html import json import logging -from typing import Any, Concatenate, cast +from typing import Any, Concatenate -from httpx import Response, TimeoutException -from kiota_abstractions.api_error import APIError -from kiota_abstractions.authentication import AnonymousAuthenticationProvider -from kiota_abstractions.headers_collection import HeadersCollection -from kiota_abstractions.method import Method -from kiota_abstractions.native_response_handler import NativeResponseHandler -from kiota_abstractions.request_information import RequestInformation -from kiota_http.middleware.options import ResponseHandlerOption -from msgraph import GraphRequestAdapter -from msgraph.generated.drives.item.items.item.content.content_request_builder import ( - ContentRequestBuilder, +from aiohttp import ClientTimeout +from onedrive_personal_sdk.clients.large_file_upload import LargeFileUploadClient +from onedrive_personal_sdk.exceptions import ( + AuthenticationError, + HashMismatchError, + OneDriveException, ) -from msgraph.generated.drives.item.items.item.create_upload_session.create_upload_session_post_request_body import ( - CreateUploadSessionPostRequestBody, -) -from msgraph.generated.drives.item.items.item.drive_item_item_request_builder import ( - DriveItemItemRequestBuilder, -) -from msgraph.generated.models.drive_item import DriveItem -from msgraph.generated.models.drive_item_uploadable_properties import ( - DriveItemUploadableProperties, -) -from msgraph_core.models import LargeFileUploadSession +from onedrive_personal_sdk.models.items import File, Folder, ItemUpdate +from onedrive_personal_sdk.models.upload import FileInfo from homeassistant.components.backup import ( AgentBackup, @@ -41,14 +26,14 @@ from homeassistant.components.backup import ( suggested_filename, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import OneDriveConfigEntry from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN _LOGGER = logging.getLogger(__name__) UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB -MAX_RETRIES = 5 +TIMEOUT = ClientTimeout(connect=10, total=43200) # 12 hours async def async_get_backup_agents( @@ -92,18 +77,18 @@ def handle_backup_errors[_R, **P]( ) -> _R: try: return await func(self, *args, **kwargs) - except APIError as err: - if err.response_status_code == 403: - self._entry.async_start_reauth(self._hass) + except AuthenticationError as err: + self._entry.async_start_reauth(self._hass) + raise BackupAgentError("Authentication error") from err + except OneDriveException as err: _LOGGER.error( - "Error during backup in %s: Status %s, message %s", + "Error during backup in %s:, message %s", func.__name__, - err.response_status_code, - err.message, + err, ) _LOGGER.debug("Full error: %s", err, exc_info=True) raise BackupAgentError("Backup operation failed") from err - except TimeoutException as err: + except TimeoutError as err: _LOGGER.error( "Error during backup in %s: Timeout", func.__name__, @@ -123,7 +108,8 @@ class OneDriveBackupAgent(BackupAgent): super().__init__() self._hass = hass self._entry = entry - self._items = entry.runtime_data.items + self._client = entry.runtime_data.client + self._token_provider = entry.runtime_data.token_provider self._folder_id = entry.runtime_data.backup_folder_id self.name = entry.title assert entry.unique_id @@ -134,24 +120,12 @@ class OneDriveBackupAgent(BackupAgent): self, backup_id: str, **kwargs: Any ) -> AsyncIterator[bytes]: """Download a backup file.""" - # this forces the query to return a raw httpx response, but breaks typing - backup = await self._find_item_by_backup_id(backup_id) - if backup is None or backup.id is None: + item = await self._find_item_by_backup_id(backup_id) + if item is None: raise BackupAgentError("Backup not found") - request_config = ( - ContentRequestBuilder.ContentRequestBuilderGetRequestConfiguration( - options=[ResponseHandlerOption(NativeResponseHandler())], - ) - ) - response = cast( - Response, - await self._items.by_drive_item_id(backup.id).content.get( - request_configuration=request_config - ), - ) - - return response.aiter_bytes(chunk_size=1024) + stream = await self._client.download_drive_item(item.id, timeout=TIMEOUT) + return stream.iter_chunked(1024) @handle_backup_errors async def async_upload_backup( @@ -163,27 +137,20 @@ class OneDriveBackupAgent(BackupAgent): ) -> None: """Upload a backup.""" - # upload file in chunks to support large files - upload_session_request_body = CreateUploadSessionPostRequestBody( - item=DriveItemUploadableProperties( - additional_data={ - "@microsoft.graph.conflictBehavior": "fail", - }, + file = FileInfo( + suggested_filename(backup), + backup.size, + self._folder_id, + await open_stream(), + ) + try: + item = await LargeFileUploadClient.upload( + self._token_provider, file, session=async_get_clientsession(self._hass) ) - ) - file_item = self._get_backup_file_item(suggested_filename(backup)) - upload_session = await file_item.create_upload_session.post( - upload_session_request_body - ) - - if upload_session is None or upload_session.upload_url is None: + except HashMismatchError as err: raise BackupAgentError( - translation_domain=DOMAIN, translation_key="backup_no_upload_session" - ) - - await self._upload_file( - upload_session.upload_url, await open_stream(), backup.size - ) + "Hash validation failed, backup file might be corrupt" + ) from err # store metadata in description backup_dict = backup.as_dict() @@ -191,7 +158,10 @@ class OneDriveBackupAgent(BackupAgent): description = json.dumps(backup_dict) _LOGGER.debug("Creating metadata: %s", description) - await file_item.patch(DriveItem(description=description)) + await self._client.update_drive_item( + path_or_id=item.id, + data=ItemUpdate(description=description), + ) @handle_backup_errors async def async_delete_backup( @@ -200,35 +170,31 @@ class OneDriveBackupAgent(BackupAgent): **kwargs: Any, ) -> None: """Delete a backup file.""" - backup = await self._find_item_by_backup_id(backup_id) - if backup is None or backup.id is None: + item = await self._find_item_by_backup_id(backup_id) + if item is None: return - await self._items.by_drive_item_id(backup.id).delete() + await self._client.delete_drive_item(item.id) @handle_backup_errors async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: """List backups.""" - backups: list[AgentBackup] = [] - items = await self._items.by_drive_item_id(f"{self._folder_id}").children.get() - if items and (values := items.value): - for item in values: - if (description := item.description) is None: - continue - if "homeassistant_version" in description: - backups.append(self._backup_from_description(description)) - return backups + return [ + self._backup_from_description(item.description) + for item in await self._client.list_drive_items(self._folder_id) + if item.description and "homeassistant_version" in item.description + ] @handle_backup_errors async def async_get_backup( self, backup_id: str, **kwargs: Any ) -> AgentBackup | None: """Return a backup.""" - backup = await self._find_item_by_backup_id(backup_id) - if backup is None: - return None - - assert backup.description # already checked in _find_item_by_backup_id - return self._backup_from_description(backup.description) + item = await self._find_item_by_backup_id(backup_id) + return ( + self._backup_from_description(item.description) + if item and item.description + else None + ) def _backup_from_description(self, description: str) -> AgentBackup: """Create a backup object from a description.""" @@ -237,91 +203,13 @@ class OneDriveBackupAgent(BackupAgent): ) # OneDrive encodes the description on save automatically return AgentBackup.from_dict(json.loads(description)) - async def _find_item_by_backup_id(self, backup_id: str) -> DriveItem | None: - """Find a backup item by its backup ID.""" - - items = await self._items.by_drive_item_id(f"{self._folder_id}").children.get() - if items and (values := items.value): - for item in values: - if (description := item.description) is None: - continue - if backup_id in description: - return item - return None - - def _get_backup_file_item(self, backup_id: str) -> DriveItemItemRequestBuilder: - return self._items.by_drive_item_id(f"{self._folder_id}:/{backup_id}:") - - async def _upload_file( - self, upload_url: str, stream: AsyncIterator[bytes], total_size: int - ) -> None: - """Use custom large file upload; SDK does not support stream.""" - - adapter = GraphRequestAdapter( - auth_provider=AnonymousAuthenticationProvider(), - client=get_async_client(self._hass), + async def _find_item_by_backup_id(self, backup_id: str) -> File | Folder | None: + """Find an item by backup ID.""" + return next( + ( + item + for item in await self._client.list_drive_items(self._folder_id) + if item.description and backup_id in item.description + ), + None, ) - - async def async_upload( - start: int, end: int, chunk_data: bytes - ) -> LargeFileUploadSession: - info = RequestInformation() - info.url = upload_url - info.http_method = Method.PUT - info.headers = HeadersCollection() - info.headers.try_add("Content-Range", f"bytes {start}-{end}/{total_size}") - info.headers.try_add("Content-Length", str(len(chunk_data))) - info.headers.try_add("Content-Type", "application/octet-stream") - _LOGGER.debug(info.headers.get_all()) - info.set_stream_content(chunk_data) - result = await adapter.send_async(info, LargeFileUploadSession, {}) - _LOGGER.debug("Next expected range: %s", result.next_expected_ranges) - return result - - start = 0 - buffer: list[bytes] = [] - buffer_size = 0 - retries = 0 - - async for chunk in stream: - buffer.append(chunk) - buffer_size += len(chunk) - if buffer_size >= UPLOAD_CHUNK_SIZE: - chunk_data = b"".join(buffer) - uploaded_chunks = 0 - while ( - buffer_size > UPLOAD_CHUNK_SIZE - ): # Loop in case the buffer is >= UPLOAD_CHUNK_SIZE * 2 - slice_start = uploaded_chunks * UPLOAD_CHUNK_SIZE - try: - await async_upload( - start, - start + UPLOAD_CHUNK_SIZE - 1, - chunk_data[slice_start : slice_start + UPLOAD_CHUNK_SIZE], - ) - except APIError as err: - if ( - err.response_status_code and err.response_status_code < 500 - ): # no retry on 4xx errors - raise - if retries < MAX_RETRIES: - await asyncio.sleep(2**retries) - retries += 1 - continue - raise - except TimeoutException: - if retries < MAX_RETRIES: - retries += 1 - continue - raise - retries = 0 - start += UPLOAD_CHUNK_SIZE - uploaded_chunks += 1 - buffer_size -= UPLOAD_CHUNK_SIZE - buffer = [chunk_data[UPLOAD_CHUNK_SIZE * uploaded_chunks :]] - - # upload the remaining bytes - if buffer: - _LOGGER.debug("Last chunk") - chunk_data = b"".join(buffer) - await async_upload(start, start + len(chunk_data) - 1, chunk_data) diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py index 09c0d1b44cc..cbdf59648b9 100644 --- a/homeassistant/components/onedrive/config_flow.py +++ b/homeassistant/components/onedrive/config_flow.py @@ -4,16 +4,13 @@ from collections.abc import Mapping import logging from typing import Any, cast -from kiota_abstractions.api_error import APIError -from kiota_abstractions.authentication import BaseBearerTokenAuthenticationProvider -from kiota_abstractions.method import Method -from kiota_abstractions.request_information import RequestInformation -from msgraph import GraphRequestAdapter, GraphServiceClient +from onedrive_personal_sdk.clients.client import OneDriveClient +from onedrive_personal_sdk.exceptions import OneDriveException from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler -from homeassistant.helpers.httpx_client import get_async_client from .api import OneDriveConfigFlowAccessTokenProvider from .const import DOMAIN, OAUTH_SCOPES @@ -39,48 +36,24 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): data: dict[str, Any], ) -> ConfigFlowResult: """Handle the initial step.""" - auth_provider = BaseBearerTokenAuthenticationProvider( - access_token_provider=OneDriveConfigFlowAccessTokenProvider( - cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) - ) - ) - adapter = GraphRequestAdapter( - auth_provider=auth_provider, - client=get_async_client(self.hass), + token_provider = OneDriveConfigFlowAccessTokenProvider( + cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) ) - graph_client = GraphServiceClient( - request_adapter=adapter, - scopes=OAUTH_SCOPES, + graph_client = OneDriveClient( + token_provider, async_get_clientsession(self.hass) ) - # need to get adapter from client, as client changes it - request_adapter = cast(GraphRequestAdapter, graph_client.request_adapter) - - request_info = RequestInformation( - method=Method.GET, - url_template="{+baseurl}/me/drive/special/approot", - path_parameters={}, - ) - parent_span = request_adapter.start_tracing_span(request_info, "get_approot") - - # get the OneDrive id - # use low level methods, to avoid files.read permissions - # which would be required by drives.me.get() try: - response = await request_adapter.get_http_response_message( - request_info=request_info, parent_span=parent_span - ) - except APIError: + approot = await graph_client.get_approot() + except OneDriveException: self.logger.exception("Failed to connect to OneDrive") return self.async_abort(reason="connection_error") except Exception: self.logger.exception("Unknown error") return self.async_abort(reason="unknown") - drive: dict = response.json() - - await self.async_set_unique_id(drive["parentReference"]["driveId"]) + await self.async_set_unique_id(approot.parent_reference.drive_id) if self.source == SOURCE_REAUTH: reauth_entry = self._get_reauth_entry() @@ -94,10 +67,11 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): self._abort_if_unique_id_configured() - user = drive.get("createdBy", {}).get("user", {}).get("displayName") - - title = f"{user}'s OneDrive" if user else "OneDrive" - + title = ( + f"{approot.created_by.user.display_name}'s OneDrive" + if approot.created_by.user and approot.created_by.user.display_name + else "OneDrive" + ) return self.async_create_entry(title=title, data=data) async def async_step_reauth( diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json index 056e31864a4..767426058c1 100644 --- a/homeassistant/components/onedrive/manifest.json +++ b/homeassistant/components/onedrive/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/onedrive", "integration_type": "service", "iot_class": "cloud_polling", - "loggers": ["msgraph", "msgraph-core", "kiota"], + "loggers": ["onedrive_personal_sdk"], "quality_scale": "bronze", - "requirements": ["msgraph-sdk==1.16.0"] + "requirements": ["onedrive-personal-sdk==0.0.1"] } diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index 9cbdb2bdeae..7686e83e2a5 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -23,31 +23,18 @@ "connection_error": "Failed to connect to OneDrive.", "wrong_drive": "New account does not contain previously configured OneDrive.", "unknown": "[%key:common::config_flow::error::unknown%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "failed_to_create_folder": "Failed to create backup folder" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } }, "exceptions": { - "backup_not_found": { - "message": "Backup not found" - }, - "backup_no_content": { - "message": "Backup has no content" - }, - "backup_no_upload_session": { - "message": "Failed to start backup upload" - }, "authentication_failed": { "message": "Authentication failed" }, "failed_to_get_folder": { "message": "Failed to get {folder} folder" - }, - "failed_to_create_folder": { - "message": "Failed to create {folder} folder" } } } diff --git a/requirements_all.txt b/requirements_all.txt index f0ad42f0eba..18ebb5d4a09 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1434,9 +1434,6 @@ motioneye-client==0.3.14 # homeassistant.components.bang_olufsen mozart-api==4.1.1.116.4 -# homeassistant.components.onedrive -msgraph-sdk==1.16.0 - # homeassistant.components.mullvad mullvad-api==1.0.0 @@ -1558,6 +1555,9 @@ omnilogic==0.4.5 # homeassistant.components.ondilo_ico ondilo==0.5.0 +# homeassistant.components.onedrive +onedrive-personal-sdk==0.0.1 + # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a1fee30ffd3..575e6f6b404 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1206,9 +1206,6 @@ motioneye-client==0.3.14 # homeassistant.components.bang_olufsen mozart-api==4.1.1.116.4 -# homeassistant.components.onedrive -msgraph-sdk==1.16.0 - # homeassistant.components.mullvad mullvad-api==1.0.0 @@ -1306,6 +1303,9 @@ omnilogic==0.4.5 # homeassistant.components.ondilo_ico ondilo==0.5.0 +# homeassistant.components.onedrive +onedrive-personal-sdk==0.0.1 + # homeassistant.components.onvif onvif-zeep-async==3.2.5 diff --git a/tests/components/onedrive/conftest.py b/tests/components/onedrive/conftest.py index 205f5837ee7..e76ce1d01c8 100644 --- a/tests/components/onedrive/conftest.py +++ b/tests/components/onedrive/conftest.py @@ -1,18 +1,9 @@ """Fixtures for OneDrive tests.""" from collections.abc import AsyncIterator, Generator -from html import escape -from json import dumps import time from unittest.mock import AsyncMock, MagicMock, patch -from httpx import Response -from msgraph.generated.models.drive_item import DriveItem -from msgraph.generated.models.drive_item_collection_response import ( - DriveItemCollectionResponse, -) -from msgraph.generated.models.upload_session import UploadSession -from msgraph_core.models import LargeFileUploadSession import pytest from homeassistant.components.application_credentials import ( @@ -23,7 +14,13 @@ from homeassistant.components.onedrive.const import DOMAIN, OAUTH_SCOPES from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .const import BACKUP_METADATA, CLIENT_ID, CLIENT_SECRET +from .const import ( + CLIENT_ID, + CLIENT_SECRET, + MOCK_APPROOT, + MOCK_BACKUP_FILE, + MOCK_BACKUP_FOLDER, +) from tests.common import MockConfigEntry @@ -70,96 +67,41 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: ) -@pytest.fixture -def mock_adapter() -> Generator[MagicMock]: - """Return a mocked GraphAdapter.""" - with ( - patch( - "homeassistant.components.onedrive.config_flow.GraphRequestAdapter", - autospec=True, - ) as mock_adapter, - patch( - "homeassistant.components.onedrive.backup.GraphRequestAdapter", - new=mock_adapter, - ), - ): - adapter = mock_adapter.return_value - adapter.get_http_response_message.return_value = Response( - status_code=200, - json={ - "parentReference": {"driveId": "mock_drive_id"}, - "createdBy": {"user": {"displayName": "John Doe"}}, - }, - ) - yield adapter - adapter.send_async.return_value = LargeFileUploadSession( - next_expected_ranges=["2-"] - ) - - @pytest.fixture(autouse=True) -def mock_graph_client(mock_adapter: MagicMock) -> Generator[MagicMock]: +def mock_onedrive_client() -> Generator[MagicMock]: """Return a mocked GraphServiceClient.""" with ( patch( - "homeassistant.components.onedrive.config_flow.GraphServiceClient", + "homeassistant.components.onedrive.config_flow.OneDriveClient", autospec=True, - ) as graph_client, + ) as onedrive_client, patch( - "homeassistant.components.onedrive.GraphServiceClient", - new=graph_client, + "homeassistant.components.onedrive.OneDriveClient", + new=onedrive_client, ), ): - client = graph_client.return_value + client = onedrive_client.return_value + client.get_approot.return_value = MOCK_APPROOT + client.create_folder.return_value = MOCK_BACKUP_FOLDER + client.list_drive_items.return_value = [MOCK_BACKUP_FILE] + client.get_drive_item.return_value = MOCK_BACKUP_FILE - client.request_adapter = mock_adapter + class MockStreamReader: + async def iter_chunked(self, chunk_size: int) -> AsyncIterator[bytes]: + yield b"backup data" - drives = client.drives.by_drive_id.return_value - drives.special.by_drive_item_id.return_value.get = AsyncMock( - return_value=DriveItem(id="approot") - ) - - drive_items = drives.items.by_drive_item_id.return_value - drive_items.get = AsyncMock(return_value=DriveItem(id="folder_id")) - drive_items.children.post = AsyncMock(return_value=DriveItem(id="folder_id")) - drive_items.children.get = AsyncMock( - return_value=DriveItemCollectionResponse( - value=[ - DriveItem( - id=BACKUP_METADATA["backup_id"], - description=escape(dumps(BACKUP_METADATA)), - ), - DriveItem(), - ] - ) - ) - drive_items.delete = AsyncMock(return_value=None) - drive_items.create_upload_session.post = AsyncMock( - return_value=UploadSession(upload_url="https://test.tld") - ) - drive_items.patch = AsyncMock(return_value=None) - - async def generate_bytes() -> AsyncIterator[bytes]: - """Asynchronous generator that yields bytes.""" - yield b"backup data" - - drive_items.content.get = AsyncMock( - return_value=Response(status_code=200, content=generate_bytes()) - ) + client.download_drive_item.return_value = MockStreamReader() yield client @pytest.fixture -def mock_drive_items(mock_graph_client: MagicMock) -> MagicMock: - """Return a mocked DriveItems.""" - return mock_graph_client.drives.by_drive_id.return_value.items.by_drive_item_id.return_value - - -@pytest.fixture -def mock_get_special_folder(mock_graph_client: MagicMock) -> MagicMock: - """Mock the get special folder method.""" - return mock_graph_client.drives.by_drive_id.return_value.special.by_drive_item_id.return_value.get +def mock_large_file_upload_client() -> Generator[AsyncMock]: + """Return a mocked LargeFileUploadClient upload.""" + with patch( + "homeassistant.components.onedrive.backup.LargeFileUploadClient.upload" + ) as mock_upload: + yield mock_upload @pytest.fixture @@ -179,10 +121,3 @@ def mock_instance_id() -> Generator[AsyncMock]: return_value="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0", ): yield - - -@pytest.fixture(autouse=True) -def mock_asyncio_sleep() -> Generator[AsyncMock]: - """Mock asyncio.sleep.""" - with patch("homeassistant.components.onedrive.backup.asyncio.sleep", AsyncMock()): - yield diff --git a/tests/components/onedrive/const.py b/tests/components/onedrive/const.py index c187feef30a..ee3a5ce3dc4 100644 --- a/tests/components/onedrive/const.py +++ b/tests/components/onedrive/const.py @@ -1,5 +1,18 @@ """Consts for OneDrive tests.""" +from html import escape +from json import dumps + +from onedrive_personal_sdk.models.items import ( + AppRoot, + Contributor, + File, + Folder, + Hashes, + ItemParentReference, + User, +) + CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -17,3 +30,48 @@ BACKUP_METADATA = { "protected": False, "size": 34519040, } + +CONTRIBUTOR = Contributor( + user=User( + display_name="John Doe", + id="id", + email="john@doe.com", + ) +) + +MOCK_APPROOT = AppRoot( + id="id", + child_count=0, + size=0, + name="name", + parent_reference=ItemParentReference( + drive_id="mock_drive_id", id="id", path="path" + ), + created_by=CONTRIBUTOR, +) + +MOCK_BACKUP_FOLDER = Folder( + id="id", + name="name", + size=0, + child_count=0, + parent_reference=ItemParentReference( + drive_id="mock_drive_id", id="id", path="path" + ), + created_by=CONTRIBUTOR, +) + +MOCK_BACKUP_FILE = File( + id="id", + name="23e64aec.tar", + size=34519040, + parent_reference=ItemParentReference( + drive_id="mock_drive_id", id="id", path="path" + ), + hashes=Hashes( + quick_xor_hash="hash", + ), + mime_type="application/x-tar", + description=escape(dumps(BACKUP_METADATA)), + created_by=CONTRIBUTOR, +) diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index 0114d924e1a..3f8c29efa7e 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -3,15 +3,14 @@ from __future__ import annotations from collections.abc import AsyncGenerator -from html import escape from io import StringIO -from json import dumps from unittest.mock import Mock, patch -from httpx import TimeoutException -from kiota_abstractions.api_error import APIError -from msgraph.generated.models.drive_item import DriveItem -from msgraph_core.models import LargeFileUploadSession +from onedrive_personal_sdk.exceptions import ( + AuthenticationError, + HashMismatchError, + OneDriveException, +) import pytest from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup @@ -102,14 +101,10 @@ async def test_agents_list_backups( async def test_agents_get_backup( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mock_drive_items: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test agent get backup.""" - mock_drive_items.get = AsyncMock( - return_value=DriveItem(description=escape(dumps(BACKUP_METADATA))) - ) backup_id = BACKUP_METADATA["backup_id"] client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) @@ -140,7 +135,7 @@ async def test_agents_get_backup( async def test_agents_delete( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mock_drive_items: MagicMock, + mock_onedrive_client: MagicMock, ) -> None: """Test agent delete backup.""" client = await hass_ws_client(hass) @@ -155,37 +150,15 @@ async def test_agents_delete( assert response["success"] assert response["result"] == {"agent_errors": {}} - mock_drive_items.delete.assert_called_once() - - -async def test_agents_delete_not_found_does_not_throw( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_drive_items: MagicMock, -) -> None: - """Test agent delete backup.""" - mock_drive_items.children.get = AsyncMock(return_value=[]) - client = await hass_ws_client(hass) - - await client.send_json_auto_id( - { - "type": "backup/delete", - "backup_id": BACKUP_METADATA["backup_id"], - } - ) - response = await client.receive_json() - - assert response["success"] - assert response["result"] == {"agent_errors": {}} - assert mock_drive_items.delete.call_count == 0 + mock_onedrive_client.delete_drive_item.assert_called_once() async def test_agents_upload( hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, - mock_drive_items: MagicMock, + mock_onedrive_client: MagicMock, + mock_large_file_upload_client: AsyncMock, mock_config_entry: MockConfigEntry, - mock_adapter: MagicMock, ) -> None: """Test agent upload backup.""" client = await hass_client() @@ -200,7 +173,6 @@ async def test_agents_upload( return_value=test_backup, ), patch("pathlib.Path.open") as mocked_open, - patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), ): mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) fetch_backup.return_value = test_backup @@ -211,31 +183,22 @@ async def test_agents_upload( assert resp.status == 201 assert f"Uploading backup {test_backup.backup_id}" in caplog.text - mock_drive_items.create_upload_session.post.assert_called_once() - mock_drive_items.patch.assert_called_once() - assert mock_adapter.send_async.call_count == 2 - assert mock_adapter.method_calls[0].args[0].content == b"tes" - assert mock_adapter.method_calls[0].args[0].headers.get("Content-Range") == { - "bytes 0-2/34519040" - } - assert mock_adapter.method_calls[1].args[0].content == b"t" - assert mock_adapter.method_calls[1].args[0].headers.get("Content-Range") == { - "bytes 3-3/34519040" - } + mock_large_file_upload_client.assert_called_once() + mock_onedrive_client.update_drive_item.assert_called_once() -async def test_broken_upload_session( +async def test_agents_upload_corrupt_upload( hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, - mock_drive_items: MagicMock, + mock_onedrive_client: MagicMock, + mock_large_file_upload_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: - """Test broken upload session.""" + """Test hash validation fails.""" + mock_large_file_upload_client.side_effect = HashMismatchError("test") client = await hass_client() test_backup = AgentBackup.from_dict(BACKUP_METADATA) - mock_drive_items.create_upload_session.post = AsyncMock(return_value=None) - with ( patch( "homeassistant.components.backup.manager.BackupManager.async_get_backup", @@ -254,152 +217,18 @@ async def test_broken_upload_session( ) assert resp.status == 201 - assert "Failed to start backup upload" in caplog.text - - -@pytest.mark.parametrize( - "side_effect", - [ - APIError(response_status_code=500), - TimeoutException("Timeout"), - ], -) -async def test_agents_upload_errors_retried( - hass_client: ClientSessionGenerator, - caplog: pytest.LogCaptureFixture, - mock_drive_items: MagicMock, - mock_config_entry: MockConfigEntry, - mock_adapter: MagicMock, - side_effect: Exception, -) -> None: - """Test agent upload backup.""" - client = await hass_client() - test_backup = AgentBackup.from_dict(BACKUP_METADATA) - - mock_adapter.send_async.side_effect = [ - side_effect, - LargeFileUploadSession(next_expected_ranges=["2-"]), - LargeFileUploadSession(next_expected_ranges=["2-"]), - ] - - with ( - patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backup", - ) as fetch_backup, - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=test_backup, - ), - patch("pathlib.Path.open") as mocked_open, - patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), - ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) - fetch_backup.return_value = test_backup - resp = await client.post( - f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", - data={"file": StringIO("test")}, - ) - - assert resp.status == 201 - assert mock_adapter.send_async.call_count == 3 assert f"Uploading backup {test_backup.backup_id}" in caplog.text - mock_drive_items.patch.assert_called_once() - - -async def test_agents_upload_4xx_errors_not_retried( - hass_client: ClientSessionGenerator, - caplog: pytest.LogCaptureFixture, - mock_drive_items: MagicMock, - mock_config_entry: MockConfigEntry, - mock_adapter: MagicMock, -) -> None: - """Test agent upload backup.""" - client = await hass_client() - test_backup = AgentBackup.from_dict(BACKUP_METADATA) - - mock_adapter.send_async.side_effect = APIError(response_status_code=404) - - with ( - patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backup", - ) as fetch_backup, - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=test_backup, - ), - patch("pathlib.Path.open") as mocked_open, - patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), - ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) - fetch_backup.return_value = test_backup - resp = await client.post( - f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", - data={"file": StringIO("test")}, - ) - - assert resp.status == 201 - assert mock_adapter.send_async.call_count == 1 - assert f"Uploading backup {test_backup.backup_id}" in caplog.text - assert mock_drive_items.patch.call_count == 0 - assert "Backup operation failed" in caplog.text - - -@pytest.mark.parametrize( - ("side_effect", "error"), - [ - (APIError(response_status_code=500), "Backup operation failed"), - (TimeoutException("Timeout"), "Backup operation timed out"), - ], -) -async def test_agents_upload_fails_after_max_retries( - hass_client: ClientSessionGenerator, - caplog: pytest.LogCaptureFixture, - mock_drive_items: MagicMock, - mock_config_entry: MockConfigEntry, - mock_adapter: MagicMock, - side_effect: Exception, - error: str, -) -> None: - """Test agent upload backup.""" - client = await hass_client() - test_backup = AgentBackup.from_dict(BACKUP_METADATA) - - mock_adapter.send_async.side_effect = side_effect - - with ( - patch( - "homeassistant.components.backup.manager.BackupManager.async_get_backup", - ) as fetch_backup, - patch( - "homeassistant.components.backup.manager.read_backup", - return_value=test_backup, - ), - patch("pathlib.Path.open") as mocked_open, - patch("homeassistant.components.onedrive.backup.UPLOAD_CHUNK_SIZE", 3), - ): - mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) - fetch_backup.return_value = test_backup - resp = await client.post( - f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}", - data={"file": StringIO("test")}, - ) - - assert resp.status == 201 - assert mock_adapter.send_async.call_count == 6 - assert f"Uploading backup {test_backup.backup_id}" in caplog.text - assert mock_drive_items.patch.call_count == 0 - assert error in caplog.text + mock_large_file_upload_client.assert_called_once() + assert mock_onedrive_client.update_drive_item.call_count == 0 + assert "Hash validation failed, backup file might be corrupt" in caplog.text async def test_agents_download( hass_client: ClientSessionGenerator, - mock_drive_items: MagicMock, + mock_onedrive_client: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test agent download backup.""" - mock_drive_items.get = AsyncMock( - return_value=DriveItem(description=escape(dumps(BACKUP_METADATA))) - ) client = await hass_client() backup_id = BACKUP_METADATA["backup_id"] @@ -408,29 +237,30 @@ async def test_agents_download( ) assert resp.status == 200 assert await resp.content.read() == b"backup data" - mock_drive_items.content.get.assert_called_once() @pytest.mark.parametrize( ("side_effect", "error"), [ ( - APIError(response_status_code=500), + OneDriveException(), "Backup operation failed", ), - (TimeoutException("Timeout"), "Backup operation timed out"), + (TimeoutError(), "Backup operation timed out"), ], ) async def test_delete_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mock_drive_items: MagicMock, + mock_onedrive_client: MagicMock, mock_config_entry: MockConfigEntry, side_effect: Exception, error: str, ) -> None: """Test error during delete.""" - mock_drive_items.delete = AsyncMock(side_effect=side_effect) + mock_onedrive_client.delete_drive_item.side_effect = AsyncMock( + side_effect=side_effect + ) client = await hass_ws_client(hass) @@ -448,14 +278,35 @@ async def test_delete_error( } +async def test_agents_delete_not_found_does_not_throw( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_onedrive_client: MagicMock, +) -> None: + """Test agent delete backup.""" + mock_onedrive_client.list_drive_items.return_value = [] + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "backup/delete", + "backup_id": BACKUP_METADATA["backup_id"], + } + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"agent_errors": {}} + + async def test_agents_backup_not_found( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mock_drive_items: MagicMock, + mock_onedrive_client: MagicMock, ) -> None: """Test backup not found.""" - mock_drive_items.children.get = AsyncMock(return_value=[]) + mock_onedrive_client.list_drive_items.return_value = [] backup_id = BACKUP_METADATA["backup_id"] client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "backup/details", "backup_id": backup_id}) @@ -468,13 +319,13 @@ async def test_agents_backup_not_found( async def test_reauth_on_403( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mock_drive_items: MagicMock, + mock_onedrive_client: MagicMock, mock_config_entry: MockConfigEntry, ) -> None: """Test we re-authenticate on 403.""" - mock_drive_items.children.get = AsyncMock( - side_effect=APIError(response_status_code=403) + mock_onedrive_client.list_drive_items.side_effect = AuthenticationError( + 403, "Auth failed" ) backup_id = BACKUP_METADATA["backup_id"] client = await hass_ws_client(hass) @@ -483,7 +334,7 @@ async def test_reauth_on_403( assert response["success"] assert response["result"]["agent_errors"] == { - f"{DOMAIN}.{mock_config_entry.unique_id}": "Backup operation failed" + f"{DOMAIN}.{mock_config_entry.unique_id}": "Authentication error" } await hass.async_block_till_done() diff --git a/tests/components/onedrive/test_config_flow.py b/tests/components/onedrive/test_config_flow.py index 8be6aadfd0f..9acfd8ada3c 100644 --- a/tests/components/onedrive/test_config_flow.py +++ b/tests/components/onedrive/test_config_flow.py @@ -3,8 +3,7 @@ from http import HTTPStatus from unittest.mock import AsyncMock, MagicMock -from httpx import Response -from kiota_abstractions.api_error import APIError +from onedrive_personal_sdk.exceptions import OneDriveException import pytest from homeassistant import config_entries @@ -20,7 +19,7 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow from . import setup_integration -from .const import CLIENT_ID +from .const import CLIENT_ID, MOCK_APPROOT from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -89,25 +88,52 @@ async def test_full_flow( assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow_with_owner_not_found( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: AsyncMock, + mock_onedrive_client: MagicMock, +) -> None: + """Ensure we get a default title if the drive's owner can't be read.""" + + mock_onedrive_client.get_approot.return_value.created_by.user = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert result["title"] == "OneDrive" + assert result["result"].unique_id == "mock_drive_id" + assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token" + assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token" + + @pytest.mark.usefixtures("current_request_with_host") @pytest.mark.parametrize( ("exception", "error"), [ (Exception, "unknown"), - (APIError, "connection_error"), + (OneDriveException, "connection_error"), ], ) async def test_flow_errors( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, - mock_adapter: MagicMock, + mock_onedrive_client: MagicMock, exception: Exception, error: str, ) -> None: """Test errors during flow.""" - mock_adapter.get_http_response_message.side_effect = exception + mock_onedrive_client.get_approot.side_effect = exception result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -172,15 +198,12 @@ async def test_reauth_flow_id_changed( aioclient_mock: AiohttpClientMocker, mock_setup_entry: AsyncMock, mock_config_entry: MockConfigEntry, - mock_adapter: MagicMock, + mock_onedrive_client: MagicMock, ) -> None: """Test that the reauth flow fails on a different drive id.""" - mock_adapter.get_http_response_message.return_value = Response( - status_code=200, - json={ - "parentReference": {"driveId": "other_drive_id"}, - }, - ) + app_root = MOCK_APPROOT + app_root.parent_reference.drive_id = "other_drive_id" + mock_onedrive_client.get_approot.return_value = app_root await setup_integration(hass, mock_config_entry) diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index bc5c22c3ce6..674bc2d38d9 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from kiota_abstractions.api_error import APIError +from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException import pytest from homeassistant.config_entries import ConfigEntryState @@ -31,82 +31,31 @@ async def test_load_unload_config_entry( @pytest.mark.parametrize( ("side_effect", "state"), [ - (APIError(response_status_code=403), ConfigEntryState.SETUP_ERROR), - (APIError(response_status_code=500), ConfigEntryState.SETUP_RETRY), + (AuthenticationError(403, "Auth failed"), ConfigEntryState.SETUP_ERROR), + (OneDriveException(), ConfigEntryState.SETUP_RETRY), ], ) async def test_approot_errors( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_get_special_folder: MagicMock, + mock_onedrive_client: MagicMock, side_effect: Exception, state: ConfigEntryState, ) -> None: """Test errors during approot retrieval.""" - mock_get_special_folder.side_effect = side_effect + mock_onedrive_client.get_approot.side_effect = side_effect await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is state -async def test_faulty_approot( +async def test_get_integration_folder_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_get_special_folder: MagicMock, + mock_onedrive_client: MagicMock, caplog: pytest.LogCaptureFixture, ) -> None: """Test faulty approot retrieval.""" - mock_get_special_folder.return_value = None - await setup_integration(hass, mock_config_entry) - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - assert "Failed to get approot folder" in caplog.text - - -async def test_faulty_integration_folder( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_drive_items: MagicMock, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test faulty approot retrieval.""" - mock_drive_items.get.return_value = None + mock_onedrive_client.create_folder.side_effect = OneDriveException() await setup_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY assert "Failed to get backups_9f86d081 folder" in caplog.text - - -async def test_500_error_during_backup_folder_get( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_drive_items: MagicMock, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test error during backup folder creation.""" - mock_drive_items.get.side_effect = APIError(response_status_code=500) - await setup_integration(hass, mock_config_entry) - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - assert "Failed to get backups_9f86d081 folder" in caplog.text - - -async def test_error_during_backup_folder_creation( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_drive_items: MagicMock, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test error during backup folder creation.""" - mock_drive_items.get.side_effect = APIError(response_status_code=404) - mock_drive_items.children.post.side_effect = APIError() - await setup_integration(hass, mock_config_entry) - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - assert "Failed to create backups_9f86d081 folder" in caplog.text - - -async def test_successful_backup_folder_creation( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_drive_items: MagicMock, -) -> None: - """Test successful backup folder creation.""" - mock_drive_items.get.side_effect = APIError(response_status_code=404) - await setup_integration(hass, mock_config_entry) - assert mock_config_entry.state is ConfigEntryState.LOADED From 2682f4a323d27311801d01347b44fbd38571d662 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 3 Feb 2025 17:34:02 +0200 Subject: [PATCH 36/36] Add tests for Shelly Flood gen4 (#137246) --- tests/components/shelly/conftest.py | 2 + .../shelly/snapshots/test_binary_sensor.ambr | 93 +++++++++++++++++++ tests/components/shelly/test_binary_sensor.py | 19 ++++ 3 files changed, 114 insertions(+) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 85cd558e918..2279a605403 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -180,6 +180,7 @@ MOCK_CONFIG = { "xcounts": {"expr": None, "unit": None}, "xfreq": {"expr": None, "unit": None}, }, + "flood:0": {"id": 0, "name": "Test name"}, "light:0": {"name": "test light_0"}, "light:1": {"name": "test light_1"}, "light:2": {"name": "test light_2"}, @@ -326,6 +327,7 @@ MOCK_STATUS_RPC = { "em1:1": {"act_power": 123.3}, "em1data:0": {"total_act_energy": 123456.4}, "em1data:1": {"total_act_energy": 987654.3}, + "flood:0": {"id": 0, "alarm": False, "mute": False}, "thermostat:0": { "id": 0, "enable": True, diff --git a/tests/components/shelly/snapshots/test_binary_sensor.ambr b/tests/components/shelly/snapshots/test_binary_sensor.ambr index 8dcb7b00a42..942bcaad8ab 100644 --- a/tests/components/shelly/snapshots/test_binary_sensor.ambr +++ b/tests/components/shelly/snapshots/test_binary_sensor.ambr @@ -46,3 +46,96 @@ 'state': 'off', }) # --- +# name: test_rpc_flood_entities[binary_sensor.test_name_flood-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_name_flood', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Test name flood', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-flood:0-flood', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_flood_entities[binary_sensor.test_name_flood-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'moisture', + 'friendly_name': 'Test name flood', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_flood', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_rpc_flood_entities[binary_sensor.test_name_mute-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_name_mute', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Test name mute', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-flood:0-mute', + 'unit_of_measurement': None, + }) +# --- +# name: test_rpc_flood_entities[binary_sensor.test_name_mute-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test name mute', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_mute', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index bff6d199d0e..7f2d07b1ccc 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -496,3 +496,22 @@ async def test_blu_trv_binary_sensor_entity( entry = entity_registry.async_get(entity_id) assert entry == snapshot(name=f"{entity_id}-entry") + + +async def test_rpc_flood_entities( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test RPC flood sensor entities.""" + await init_integration(hass, 4) + + for entity in ("flood", "mute"): + entity_id = f"{BINARY_SENSOR_DOMAIN}.test_name_{entity}" + + state = hass.states.get(entity_id) + assert state == snapshot(name=f"{entity_id}-state") + + entry = entity_registry.async_get(entity_id) + assert entry == snapshot(name=f"{entity_id}-entry")