From 2262942c98f69cab6b7186430db1a282deb50f78 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:50:30 +0200 Subject: [PATCH 01/13] Use ClimateEntityStateAttribute enum in Teslemetry climate (#176379) Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/teslemetry/climate.py | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index a4268319b3bc84..a06b9a9610bb4c 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -12,6 +12,7 @@ HVAC_MODES, ClimateEntity, ClimateEntityFeature, + ClimateEntityStateAttribute, HVACMode, ) from homeassistant.const import ( @@ -287,9 +288,15 @@ async def async_added_to_hass(self) -> None: self._attr_hvac_mode = ( HVACMode(state.state) if state.state in HVAC_MODES else None ) - self._attr_current_temperature = state.attributes.get("current_temperature") - self._attr_target_temperature = state.attributes.get("temperature") - self._attr_preset_mode = state.attributes.get("preset_mode") + self._attr_current_temperature = state.attributes.get( + ClimateEntityStateAttribute.CURRENT_TEMPERATURE + ) + self._attr_target_temperature = state.attributes.get( + ClimateEntityStateAttribute.TEMPERATURE + ) + self._attr_preset_mode = state.attributes.get( + ClimateEntityStateAttribute.PRESET_MODE + ) self.async_on_remove( self.vehicle.stream_vehicle.listen_InsideTemp( @@ -531,8 +538,12 @@ async def async_added_to_hass(self) -> None: self._attr_hvac_mode = ( HVACMode(state.state) if state.state in HVAC_MODES else None ) - self._attr_current_temperature = state.attributes.get("current_temperature") - self._attr_target_temperature = state.attributes.get("temperature") + self._attr_current_temperature = state.attributes.get( + ClimateEntityStateAttribute.CURRENT_TEMPERATURE + ) + self._attr_target_temperature = state.attributes.get( + ClimateEntityStateAttribute.TEMPERATURE + ) self.async_on_remove( self.vehicle.stream_vehicle.listen_InsideTemp( From 9fba0932f4b55f4663551f0ef3d38d28aa2ed1ad Mon Sep 17 00:00:00 2001 From: Manu Date: Tue, 14 Jul 2026 08:25:34 +0200 Subject: [PATCH 02/13] Add reconfigure flow to LED Infrared integration (#176461) --- .../components/led_infrared/config_flow.py | 45 +++++++++ .../led_infrared/quality_scale.yaml | 2 +- .../components/led_infrared/strings.json | 12 ++- .../led_infrared/test_config_flow.py | 94 +++++++++++++++++++ 4 files changed, 151 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/led_infrared/config_flow.py b/homeassistant/components/led_infrared/config_flow.py index d15c734d4ea837..dc5998297723dd 100644 --- a/homeassistant/components/led_infrared/config_flow.py +++ b/homeassistant/components/led_infrared/config_flow.py @@ -92,3 +92,48 @@ async def async_step_user( "docs_url": "https://www.home-assistant.io/integrations/led_infrared" }, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfigure flow.""" + errors: dict[str, str] = {} + + entry = self._get_reconfigure_entry() + + emitter_entity_ids = async_get_emitters(self.hass) + if not emitter_entity_ids: + return self.async_abort(reason="no_infrared_entities") + + if user_input is not None: + emitter_id = user_input.get(CONF_INFRARED_ENTITY_ID) + if emitter_id: + self._async_abort_entries_match( + { + CONF_DEVICE_TYPE: entry.data[CONF_DEVICE_TYPE], + CONF_INFRARED_ENTITY_ID: emitter_id, + } + ) + return self.async_update_reload_and_abort( + entry, data_updates=user_input + ) + + errors["base"] = "missing_infrared_entity" + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Optional(CONF_INFRARED_ENTITY_ID): EntitySelector( + EntitySelectorConfig( + domain=INFRARED_DOMAIN, + include_entities=emitter_entity_ids, + ) + ) + } + ), + entry.data, + ), + errors=errors, + ) diff --git a/homeassistant/components/led_infrared/quality_scale.yaml b/homeassistant/components/led_infrared/quality_scale.yaml index b3909f093af278..a1fe453f610202 100644 --- a/homeassistant/components/led_infrared/quality_scale.yaml +++ b/homeassistant/components/led_infrared/quality_scale.yaml @@ -97,7 +97,7 @@ rules: comment: | This integration does not raise exceptions. icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: | diff --git a/homeassistant/components/led_infrared/strings.json b/homeassistant/components/led_infrared/strings.json index 5b13ae6caefa43..a7735543a8b6f1 100644 --- a/homeassistant/components/led_infrared/strings.json +++ b/homeassistant/components/led_infrared/strings.json @@ -2,12 +2,22 @@ "config": { "abort": { "already_configured": "This device has already been configured with this infrared entity.", - "no_infrared_entities": "[%key:common::config_flow::abort::no_infrared_entities%]" + "no_infrared_entities": "[%key:common::config_flow::abort::no_infrared_entities%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "missing_infrared_entity": "Select an infrared emitter." }, "step": { + "reconfigure": { + "data": { + "infrared_entity_id": "[%key:common::config_flow::data::infrared_entity_id%]" + }, + "data_description": { + "infrared_entity_id": "[%key:common::config_flow::data_description::infrared_entity_id%]" + }, + "title": "Reconfigure LED Infrared device" + }, "user": { "data": { "device_type": "[%key:common::generic::device_type%]", diff --git a/tests/components/led_infrared/test_config_flow.py b/tests/components/led_infrared/test_config_flow.py index 760b1c2b9c38bd..dcbdab4cd4eabb 100644 --- a/tests/components/led_infrared/test_config_flow.py +++ b/tests/components/led_infrared/test_config_flow.py @@ -97,3 +97,97 @@ async def test_user_flow_no_emitters(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_infrared_entities" + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_flow_reconfigure(hass: HomeAssistant) -> None: + """Test reconfigure flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="LED Infrared via Test IR emitter", + entry_id="1234567890", + data={ + CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY, + CONF_INFRARED_ENTITY_ID: None, + }, + ) + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_INFRARED_ENTITY_ID] == EMITTER_ENTITY_ID + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_reconfigure_flow_requires_emitter( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow requires an infrared emitter.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "missing_infrared_entity"} + + +@pytest.mark.usefixtures("mock_infrared_emitter_entity") +async def test_flow_reconfigure_already_configured( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow.""" + config_entry_2 = MockConfigEntry( + domain=DOMAIN, + title="LED Infrared via Test IR emitter", + entry_id="0987654321", + data={ + CONF_DEVICE_TYPE: LEDIrDeviceType.GENERIC_24_KEY, + CONF_INFRARED_ENTITY_ID: None, + }, + ) + config_entry.add_to_hass(hass) + config_entry_2.add_to_hass(hass) + result = await config_entry_2.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("init_infrared") +async def test_reconfigure_flow_no_emitters( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test reconfigure flow aborts when no infrared emitters exist.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_infrared_entities" From 1a031ab6f85cd17e060250f803fa61d7e8ab9be3 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Tue, 14 Jul 2026 09:26:54 +0300 Subject: [PATCH 03/13] OpenAI GPT-5.6 support (#176450) --- .../openai_conversation/config_flow.py | 15 +++++++ .../components/openai_conversation/const.py | 2 + .../components/openai_conversation/entity.py | 9 +++- .../openai_conversation/strings.json | 5 +++ .../openai_conversation/conftest.py | 6 ++- .../snapshots/test_conversation.ambr | 21 +++++++++ .../openai_conversation/test_config_flow.py | 17 ++++--- .../openai_conversation/test_conversation.py | 44 +++++++++++++++++++ 8 files changed, 111 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index c773d33996951b..05ed4fe18f5f13 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -48,6 +48,7 @@ CONF_CODE_INTERPRETER, CONF_IMAGE_MODEL, CONF_MAX_TOKENS, + CONF_PRO_MODE, CONF_REASONING_EFFORT, CONF_REASONING_SUMMARY, CONF_RECOMMENDED, @@ -77,6 +78,7 @@ RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_IMAGE_MODEL, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_PRO_MODE, RECOMMENDED_REASONING_EFFORT, RECOMMENDED_REASONING_SUMMARY, RECOMMENDED_SERVICE_TIER, @@ -421,6 +423,18 @@ async def async_step_model( elif CONF_REASONING_EFFORT in options: options.pop(CONF_REASONING_EFFORT) + if model.startswith("gpt-5.6"): + step_schema.update( + { + vol.Optional( + CONF_PRO_MODE, + default=RECOMMENDED_PRO_MODE, + ): bool, + } + ) + elif CONF_PRO_MODE in options: + options.pop(CONF_PRO_MODE) + if model.startswith("gpt-5"): step_schema.update( { @@ -592,6 +606,7 @@ def _get_reasoning_options(self, model: str) -> list[str]: return [] models_reasoning_map: dict[str | tuple[str, ...], list[str]] = { + "gpt-5.6": ["none", "low", "medium", "high", "xhigh", "max"], ("gpt-5.2-pro", "gpt-5.4-pro", "gpt-5.5-pro"): ["medium", "high", "xhigh"], ("gpt-5.2", "gpt-5.3", "gpt-5.4", "gpt-5.5"): [ "none", diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 5236a0d9f53ad6..6f76455b0c027c 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -20,6 +20,7 @@ CONF_CODE_INTERPRETER = "code_interpreter" CONF_FILENAMES = "filenames" CONF_MAX_TOKENS = "max_tokens" +CONF_PRO_MODE = "pro_mode" CONF_REASONING_EFFORT = "reasoning_effort" CONF_REASONING_SUMMARY = "reasoning_summary" CONF_RECOMMENDED = "recommended" @@ -41,6 +42,7 @@ RECOMMENDED_CHAT_MODEL = "gpt-4o-mini" RECOMMENDED_IMAGE_MODEL = "gpt-image-2" RECOMMENDED_MAX_TOKENS = 3000 +RECOMMENDED_PRO_MODE = False RECOMMENDED_REASONING_EFFORT = "low" RECOMMENDED_STORE_RESPONSES = False RECOMMENDED_REASONING_SUMMARY = "auto" diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 5ac94beb19a51e..5fa447e9b92569 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -73,6 +73,7 @@ CONF_CODE_INTERPRETER, CONF_IMAGE_MODEL, CONF_MAX_TOKENS, + CONF_PRO_MODE, CONF_REASONING_EFFORT, CONF_REASONING_SUMMARY, CONF_SERVICE_TIER, @@ -93,6 +94,7 @@ RECOMMENDED_CHAT_MODEL, RECOMMENDED_IMAGE_MODEL, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_PRO_MODE, RECOMMENDED_REASONING_EFFORT, RECOMMENDED_REASONING_SUMMARY, RECOMMENDED_SERVICE_TIER, @@ -497,7 +499,7 @@ def __init__(self, entry: OpenAIConfigEntry, subentry: ConfigSubentry) -> None: entry_type=dr.DeviceEntryType.SERVICE, ) - async def _async_handle_chat_log( + async def _async_handle_chat_log( # noqa: C901 self, chat_log: conversation.ChatLog, structure_name: str | None = None, @@ -528,11 +530,16 @@ async def _async_handle_chat_log( if not model_args["model"].startswith("gpt-5-pro") else "high", # GPT-5 pro only supports reasoning.effort: high } + reasoning_summary = options.get( CONF_REASONING_SUMMARY, RECOMMENDED_REASONING_SUMMARY ) if reasoning_summary != "off": reasoning["summary"] = reasoning_summary + + if options.get(CONF_PRO_MODE, RECOMMENDED_PRO_MODE): + reasoning["mode"] = "pro" + model_args["reasoning"] = reasoning model_args["include"] = ["reasoning.encrypted_content"] diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 03637baf486813..6b7d21ea44c251 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -71,6 +71,7 @@ "code_interpreter": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::code_interpreter%]", "image_model": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::image_model%]", "inline_citations": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::inline_citations%]", + "pro_mode": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::pro_mode%]", "reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_effort%]", "reasoning_summary": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_summary%]", "search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::search_context_size%]", @@ -82,6 +83,7 @@ "code_interpreter": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::code_interpreter%]", "image_model": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::image_model%]", "inline_citations": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::inline_citations%]", + "pro_mode": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::pro_mode%]", "reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_effort%]", "reasoning_summary": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_summary%]", "search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::search_context_size%]", @@ -138,6 +140,7 @@ "code_interpreter": "Enable code interpreter tool", "image_model": "Image generation model", "inline_citations": "Include links in web search results", + "pro_mode": "Pro mode", "reasoning_effort": "Reasoning effort", "reasoning_summary": "Reasoning summary", "search_context_size": "Search context size", @@ -149,6 +152,7 @@ "code_interpreter": "This tool, also known as the python tool to the model, allows it to run code to answer questions", "image_model": "The model to use when generating images", "inline_citations": "If disabled, additional prompt is added to ask the model to not include source citations", + "pro_mode": "Perform more model work to improve reliability on difficult tasks and return a single final answer", "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt", "reasoning_summary": "Controls the length and detail of reasoning summaries provided by the model", "search_context_size": "High level guidance for the amount of context window space to use for the search", @@ -233,6 +237,7 @@ "options": { "high": "[%key:common::state::high%]", "low": "[%key:common::state::low%]", + "max": "Max", "medium": "[%key:common::state::medium%]", "minimal": "Minimal", "none": "None", diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index 2839fe10a0cae6..22a18743394b65 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -92,7 +92,7 @@ def mock_config_entry( @pytest.fixture -def mock_config_entry_with_assist( +async def mock_config_entry_with_assist( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: """Mock a config entry with assist.""" @@ -101,11 +101,12 @@ def mock_config_entry_with_assist( next(iter(mock_config_entry.subentries.values())), data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST}, ) + await hass.async_block_till_done() return mock_config_entry @pytest.fixture -def mock_config_entry_with_reasoning_model( +async def mock_config_entry_with_reasoning_model( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: """Mock a config entry with assist.""" @@ -114,6 +115,7 @@ def mock_config_entry_with_reasoning_model( next(iter(mock_config_entry.subentries.values())), data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST, CONF_CHAT_MODEL: "gpt-5-mini"}, ) + await hass.async_block_till_done() return mock_config_entry diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index caf16e6990da06..dac962c59c7f8e 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -297,6 +297,27 @@ }), ]) # --- +# name: test_model_args[subentry_options0] + dict({ + 'include': list([ + 'reasoning.encrypted_content', + ]), + 'max_output_tokens': 3000, + 'model': 'gpt-5.6-sol', + 'prompt_cache_retention': '24h', + 'reasoning': dict({ + 'effort': 'low', + 'mode': 'pro', + 'summary': 'auto', + }), + 'service_tier': 'auto', + 'store': False, + 'stream': True, + 'text': dict({ + 'verbosity': 'medium', + }), + }) +# --- # name: test_web_search[False] list([ dict({ diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index a3cb3999e91606..d83c1f263d1e33 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -16,6 +16,7 @@ CONF_CODE_INTERPRETER, CONF_IMAGE_MODEL, CONF_MAX_TOKENS, + CONF_PRO_MODE, CONF_REASONING_EFFORT, CONF_REASONING_SUMMARY, CONF_RECOMMENDED, @@ -273,6 +274,7 @@ async def test_subentry_unsupported_model( ("gpt-5.4-pro", ["medium", "high", "xhigh"]), ("gpt-5.5", ["none", "low", "medium", "high", "xhigh"]), ("gpt-5.5-pro", ["medium", "high", "xhigh"]), + ("gpt-5.6", ["none", "low", "medium", "high", "xhigh", "max"]), ], ) async def test_subentry_reasoning_effort_list( @@ -466,6 +468,8 @@ async def test_subentry_reasoning_summary_default_sanitized_on_model_switch( @pytest.mark.parametrize( ("model", "service_tier_options"), [ + ("gpt-5.6", ["auto", "flex", "default", "priority"]), + ("gpt-5.5", ["auto", "flex", "default", "priority"]), ("gpt-5.4", ["auto", "flex", "default", "priority"]), ("gpt-5.4-pro", ["auto", "flex", "default", "priority"]), ("gpt-5.2", ["auto", "flex", "default", "priority"]), @@ -817,12 +821,12 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non }, { CONF_TEMPERATURE: 0.8, - CONF_CHAT_MODEL: "gpt-5", + CONF_CHAT_MODEL: "gpt-5.6", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, }, { - CONF_REASONING_EFFORT: "minimal", + CONF_REASONING_EFFORT: "max", CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY, CONF_CODE_INTERPRETER: False, CONF_VERBOSITY: "high", @@ -831,17 +835,18 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: False, CONF_WEB_SEARCH_INLINE_CITATIONS: True, + CONF_PRO_MODE: True, }, ), { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", CONF_TEMPERATURE: 0.8, - CONF_CHAT_MODEL: "gpt-5", + CONF_CHAT_MODEL: "gpt-5.6", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_STORE_RESPONSES: False, - CONF_REASONING_EFFORT: "minimal", + CONF_REASONING_EFFORT: "max", CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY, CONF_CODE_INTERPRETER: False, CONF_VERBOSITY: "high", @@ -850,6 +855,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: False, CONF_WEB_SEARCH_INLINE_CITATIONS: True, + CONF_PRO_MODE: True, }, ), # Test that old options are removed after reconfiguration @@ -966,7 +972,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_PROMPT: "Speak like a pirate", CONF_LLM_HASS_API: ["assist"], CONF_TEMPERATURE: 0.8, - CONF_CHAT_MODEL: "gpt-5", + CONF_CHAT_MODEL: "gpt-5.6", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_REASONING_EFFORT: "low", @@ -974,6 +980,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_SERVICE_TIER: "flex", CONF_CODE_INTERPRETER: True, CONF_VERBOSITY: "medium", + CONF_PRO_MODE: True, }, ( { diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index d1e7ec528bd9d0..933e57bc7d1b76 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -21,6 +21,7 @@ from homeassistant.components.openai_conversation.const import ( CONF_CHAT_MODEL, CONF_CODE_INTERPRETER, + CONF_PRO_MODE, CONF_REASONING_SUMMARY, CONF_SERVICE_TIER, CONF_STORE_RESPONSES, @@ -817,3 +818,46 @@ async def test_flex_tier_retry( ) assert mock_create_stream.mock_calls[0][2]["service_tier"] == "flex" assert mock_create_stream.mock_calls[1][2]["service_tier"] == "default" + + +@pytest.mark.parametrize( + "subentry_options", [{CONF_CHAT_MODEL: "gpt-5.6-sol", CONF_PRO_MODE: True}] +) +async def test_model_args( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + mock_create_stream: AsyncMock, + snapshot: SnapshotAssertion, + subentry_options: dict, +) -> None: + """Test model arguments for various configuration.""" + + subentry = next( + entry + for entry in mock_config_entry.subentries.values() + if entry.subentry_type == "conversation" + ) + hass.config_entries.async_update_subentry( + mock_config_entry, + subentry, + data=subentry_options, + ) + await hass.async_block_till_done() + + mock_create_stream.return_value = [ + create_message_item(id="msg_A", text="Hi!", output_index=0), + ] + + result = await conversation.async_converse( + hass, + "Hello", + None, + Context(), + agent_id="conversation.openai_conversation", + ) + + model_args = mock_create_stream.call_args.kwargs.copy() + model_args.pop("input") + assert model_args.pop("user") == result.conversation_id + assert model_args == snapshot From e51ee5cdcfbf29c452f13fe196a669a6c988cd34 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:57:02 +0200 Subject: [PATCH 04/13] Use EntityStateAttribute enum in Geofency (#176465) --- homeassistant/components/geofency/device_tracker.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index 788a5dffac7984..8d7c3b24cc42f8 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -3,7 +3,7 @@ from typing import override from homeassistant.components.device_tracker import TrackerEntity -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.const import EntityStateAttribute from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -92,8 +92,8 @@ async def async_added_to_hass(self) -> None: return attr = state.attributes - self._attr_latitude = attr.get(ATTR_LATITUDE) - self._attr_longitude = attr.get(ATTR_LONGITUDE) + self._attr_latitude = attr.get(EntityStateAttribute.LATITUDE) + self._attr_longitude = attr.get(EntityStateAttribute.LONGITUDE) @override async def async_will_remove_from_hass(self) -> None: From 40553198b9f5d69c81819a9f9de376bfcf1ffda2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:57:21 +0200 Subject: [PATCH 05/13] Use EntityStateAttribute enum in Proximity (#176469) --- homeassistant/components/proximity/diagnostics.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/proximity/diagnostics.py b/homeassistant/components/proximity/diagnostics.py index c304b4822f3747..a5e4d179bcec31 100644 --- a/homeassistant/components/proximity/diagnostics.py +++ b/homeassistant/components/proximity/diagnostics.py @@ -7,12 +7,11 @@ from homeassistant.components.person import ATTR_USER_ID from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, STATE_HOME, STATE_NOT_HOME, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant @@ -21,8 +20,8 @@ TO_REDACT = { ATTR_GPS, ATTR_IP, - ATTR_LATITUDE, - ATTR_LONGITUDE, + EntityStateAttribute.LATITUDE, + EntityStateAttribute.LONGITUDE, ATTR_MAC, ATTR_USER_ID, "context", From 710b3be2c1788d7de270cd0d73b39ecb66587f90 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:57:33 +0200 Subject: [PATCH 06/13] Use EntityStateAttribute enum in Prometheus (#176468) --- homeassistant/components/prometheus/__init__.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index d1ba2dede5d703..9ed5cfcea4de60 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -39,8 +39,6 @@ ) from homeassistant.const import ( ATTR_BATTERY_LEVEL, - ATTR_LATITUDE, - ATTR_LONGITUDE, CONTENT_TYPE_TEXT_PLAIN, EVENT_STATE_CHANGED, PERCENTAGE, @@ -770,14 +768,18 @@ def _handle_geo_location(self, state: State) -> None: "Distance of the geo location event from home in meters", labels, ).set(value) - if (latitude := state.attributes.get(ATTR_LATITUDE)) is not None: + if ( + latitude := state.attributes.get(EntityStateAttribute.LATITUDE) + ) is not None: self._metric( "geo_location_latitude_degrees", prometheus_client.Gauge, "Latitude of the geo location event in degrees", labels, ).set(latitude) - if (longitude := state.attributes.get(ATTR_LONGITUDE)) is not None: + if ( + longitude := state.attributes.get(EntityStateAttribute.LONGITUDE) + ) is not None: self._metric( "geo_location_longitude_degrees", prometheus_client.Gauge, From 0779d4831c1093797cb0fa2ad7433f982f2cd0f4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:57:54 +0200 Subject: [PATCH 07/13] Fix restoring the location of Traccar device trackers (#176470) --- .../components/traccar/device_tracker.py | 20 +++--- .../components/traccar/test_device_tracker.py | 72 +++++++++++++++++++ 2 files changed, 83 insertions(+), 9 deletions(-) create mode 100644 tests/components/traccar/test_device_tracker.py diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index 45faad54767fc1..d260410f433836 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -5,8 +5,12 @@ import logging from typing import override -from homeassistant.components.device_tracker import TrackerEntity +from homeassistant.components.device_tracker import ( + TrackerEntity, + TrackerEntityStateAttribute, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_BATTERY_LEVEL, EntityStateAttribute from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -16,12 +20,8 @@ from . import DOMAIN, TRACKER_UPDATE from .const import ( - ATTR_ACCURACY, ATTR_ALTITUDE, - ATTR_BATTERY, ATTR_BEARING, - ATTR_LATITUDE, - ATTR_LONGITUDE, ATTR_SPEED, EVENT_ALARM, EVENT_ALL_EVENTS, @@ -162,15 +162,17 @@ async def async_added_to_hass(self) -> None: return attr = state.attributes - self._attr_latitude = attr.get(ATTR_LATITUDE) - self._attr_longitude = attr.get(ATTR_LONGITUDE) - self._attr_location_accuracy = attr.get(ATTR_ACCURACY, 0) + self._attr_latitude = attr.get(EntityStateAttribute.LATITUDE) + self._attr_longitude = attr.get(EntityStateAttribute.LONGITUDE) + self._attr_location_accuracy = attr.get( + TrackerEntityStateAttribute.GPS_ACCURACY, 0 + ) self._attr_extra_state_attributes = { ATTR_ALTITUDE: attr.get(ATTR_ALTITUDE), ATTR_BEARING: attr.get(ATTR_BEARING), ATTR_SPEED: attr.get(ATTR_SPEED), } - self._battery = attr.get(ATTR_BATTERY) + self._battery = attr.get(ATTR_BATTERY_LEVEL) @override async def async_will_remove_from_hass(self) -> None: diff --git a/tests/components/traccar/test_device_tracker.py b/tests/components/traccar/test_device_tracker.py new file mode 100644 index 00000000000000..6d830b6c8d6e02 --- /dev/null +++ b/tests/components/traccar/test_device_tracker.py @@ -0,0 +1,72 @@ +"""The tests for the Traccar device tracker platform.""" + +import pytest + +from homeassistant.components.device_tracker import ( + DOMAIN as DEVICE_TRACKER_DOMAIN, + TrackerEntityStateAttribute, +) +from homeassistant.components.device_tracker.legacy import Device +from homeassistant.components.traccar import DOMAIN +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + CONF_WEBHOOK_ID, + STATE_NOT_HOME, + EntityStateAttribute, +) +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, mock_restore_cache + +DEVICE_ID = "device_1" +ENTITY_ID = f"{DEVICE_TRACKER_DOMAIN}.{DEVICE_ID}" + + +@pytest.fixture(autouse=True) +def mock_dev_track(mock_device_tracker_conf: list[Device]) -> None: + """Mock device tracker config loading.""" + + +async def test_restore_state(hass: HomeAssistant) -> None: + """Test that the previous location is restored for a known device.""" + assert await async_setup_component(hass, DEVICE_TRACKER_DOMAIN, {}) + + entry = MockConfigEntry(domain=DOMAIN, data={CONF_WEBHOOK_ID: "webhook_id"}) + entry.add_to_hass(hass) + dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, DEVICE_ID)}, + ) + + mock_restore_cache( + hass, + [ + State( + ENTITY_ID, + STATE_NOT_HOME, + { + EntityStateAttribute.LATITUDE: 1.0, + EntityStateAttribute.LONGITUDE: 2.0, + TrackerEntityStateAttribute.GPS_ACCURACY: 30, + ATTR_BATTERY_LEVEL: 40, + "altitude": 50, + "bearing": 60, + "speed": 70, + }, + ) + ], + ) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.attributes[EntityStateAttribute.LATITUDE] == 1.0 + assert state.attributes[EntityStateAttribute.LONGITUDE] == 2.0 + assert state.attributes[TrackerEntityStateAttribute.GPS_ACCURACY] == 30 + assert state.attributes[ATTR_BATTERY_LEVEL] == 40 + assert state.attributes["altitude"] == 50 + assert state.attributes["bearing"] == 60 + assert state.attributes["speed"] == 70 From fbc2eb8d271c7b675bb31792bbe602f6465fb3ad Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Tue, 14 Jul 2026 10:00:45 +0200 Subject: [PATCH 08/13] Refactor perform action in MELCloud Home (#176449) --- .../components/melcloud_home/common.py | 39 ++++++++++++++++- .../components/melcloud_home/number.py | 37 ++-------------- .../components/melcloud_home/switch.py | 43 +++---------------- 3 files changed, 45 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/melcloud_home/common.py b/homeassistant/components/melcloud_home/common.py index d3e1417018a7b2..4a58bba960d8fa 100644 --- a/homeassistant/components/melcloud_home/common.py +++ b/homeassistant/components/melcloud_home/common.py @@ -1,13 +1,22 @@ """Commonly shared code for the MELCloud Home integration.""" -from collections.abc import Callable, Iterable +from collections.abc import Callable, Coroutine, Iterable +from typing import Any -from aiomelcloudhome import ATAUnit, ATWUnit +from aiomelcloudhome import ( + ATAUnit, + ATWUnit, + MelCloudHomeAuthenticationError, + MelCloudHomeConnectionError, + MelCloudHomeTimeoutError, +) from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import DOMAIN from .coordinator import MelCloudHomeCoordinator @@ -33,6 +42,32 @@ def _async_add_new_atw_units(units: list[ATWUnit]) -> None: _async_add_new_atw_units(list(coordinator.atw_units.values())) +async def perform_action( + coordinator: MelCloudHomeCoordinator, + coroutine: Coroutine[Any, Any, None], +) -> None: + """Perform a MELCloud Home action with error handling and coordinator refresh.""" + try: + await coroutine + except MelCloudHomeAuthenticationError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err + except MelCloudHomeConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except MelCloudHomeTimeoutError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout_connect", + ) from err + else: + await coordinator.async_request_refresh() + + def unit_ids(unit: ATAUnit | ATWUnit) -> dict[str, list[str]]: """Return the client keyword argument selecting this unit.""" if isinstance(unit, ATAUnit): diff --git a/homeassistant/components/melcloud_home/number.py b/homeassistant/components/melcloud_home/number.py index 7cb18e6f045549..2c78d443922a82 100644 --- a/homeassistant/components/melcloud_home/number.py +++ b/homeassistant/components/melcloud_home/number.py @@ -5,11 +5,6 @@ from typing import Any, override from aiomelcloudhome import ATAUnit, ATWUnit, MELCloudHome -from aiomelcloudhome.exceptions import ( - MelCloudHomeAuthenticationError, - MelCloudHomeConnectionError, - MelCloudHomeTimeoutError, -) from homeassistant.components.number import ( NumberDeviceClass, @@ -21,7 +16,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .common import async_setup_unit_entities, unit_ids +from .common import async_setup_unit_entities, perform_action, unit_ids from .const import DOMAIN from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity @@ -182,32 +177,6 @@ def _number_descriptions[_UnitT: ATAUnit | ATWUnit]( ) -async def _perform_action( - coordinator: MelCloudHomeCoordinator, - coroutine: Coroutine[Any, Any, None], -) -> None: - """Perform a MELCloud Home action with error handling and coordinator refresh.""" - try: - await coroutine - except MelCloudHomeAuthenticationError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="invalid_auth", - ) from err - except MelCloudHomeConnectionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="cannot_connect", - ) from err - except MelCloudHomeTimeoutError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="timeout_connect", - ) from err - else: - await coordinator.async_request_refresh() - - async def async_setup_entry( hass: HomeAssistant, entry: MelCloudHomeConfigEntry, @@ -269,7 +238,7 @@ async def async_set_native_value(self, value: float) -> None: translation_domain=DOMAIN, translation_key=error_key, ) - await _perform_action( + await perform_action( self.coordinator, self.entity_description.set_value_fn( self.coordinator.client, self.unit, value @@ -315,7 +284,7 @@ async def async_set_native_value(self, value: float) -> None: translation_domain=DOMAIN, translation_key=error_key, ) - await _perform_action( + await perform_action( self.coordinator, self.entity_description.set_value_fn( self.coordinator.client, self.unit, value diff --git a/homeassistant/components/melcloud_home/switch.py b/homeassistant/components/melcloud_home/switch.py index 67d130d77947b9..2d0f6ab230e076 100644 --- a/homeassistant/components/melcloud_home/switch.py +++ b/homeassistant/components/melcloud_home/switch.py @@ -5,11 +5,6 @@ from typing import Any, override from aiomelcloudhome import ATAUnit, ATWUnit, MELCloudHome -from aiomelcloudhome.exceptions import ( - MelCloudHomeAuthenticationError, - MelCloudHomeConnectionError, - MelCloudHomeTimeoutError, -) from homeassistant.components.switch import ( SwitchDeviceClass, @@ -18,11 +13,9 @@ ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .common import async_setup_unit_entities, unit_ids -from .const import DOMAIN +from .common import async_setup_unit_entities, perform_action, unit_ids from .coordinator import MelCloudHomeConfigEntry, MelCloudHomeCoordinator from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity @@ -109,32 +102,6 @@ def _switch_descriptions[_UnitT: ATAUnit | ATWUnit]( ) -async def _perform_action( - coordinator: MelCloudHomeCoordinator, - coroutine: Coroutine[Any, Any, None], -) -> None: - """Perform a MELCloud Home action with error handling and coordinator refresh.""" - try: - await coroutine - except MelCloudHomeAuthenticationError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="invalid_auth", - ) from err - except MelCloudHomeConnectionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="cannot_connect", - ) from err - except MelCloudHomeTimeoutError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="timeout_connect", - ) from err - else: - await coordinator.async_request_refresh() - - async def async_setup_entry( hass: HomeAssistant, entry: MelCloudHomeConfigEntry, @@ -189,7 +156,7 @@ def is_on(self) -> bool | None: @override async def async_turn_on(self, **kwargs: Any) -> None: """Enable the protection.""" - await _perform_action( + await perform_action( self.coordinator, self.entity_description.turn_on_fn(self.coordinator.client, self.unit), ) @@ -197,7 +164,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: @override async def async_turn_off(self, **kwargs: Any) -> None: """Disable the protection.""" - await _perform_action( + await perform_action( self.coordinator, self.entity_description.turn_off_fn(self.coordinator.client, self.unit), ) @@ -234,7 +201,7 @@ def is_on(self) -> bool | None: @override async def async_turn_on(self, **kwargs: Any) -> None: """Enable the protection.""" - await _perform_action( + await perform_action( self.coordinator, self.entity_description.turn_on_fn(self.coordinator.client, self.unit), ) @@ -242,7 +209,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: @override async def async_turn_off(self, **kwargs: Any) -> None: """Disable the protection.""" - await _perform_action( + await perform_action( self.coordinator, self.entity_description.turn_off_fn(self.coordinator.client, self.unit), ) From 9eb29b8494a67682d592f6784f3690100284d37c Mon Sep 17 00:00:00 2001 From: Martin Hoefling Date: Tue, 14 Jul 2026 10:09:11 +0200 Subject: [PATCH 09/13] Add PostgreSQL backend and storage backend selection for KNX telegrams (#175673) Co-authored-by: Claude Opus 4.8 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/knx/__init__.py | 16 +- homeassistant/components/knx/config_flow.py | 171 ++++++++++ homeassistant/components/knx/const.py | 14 + homeassistant/components/knx/diagnostics.py | 2 + homeassistant/components/knx/manifest.json | 2 +- homeassistant/components/knx/strings.json | 36 ++ homeassistant/components/knx/telegrams.py | 70 +++- homeassistant/components/knx/websocket.py | 13 +- requirements_all.txt | 2 +- tests/components/knx/conftest.py | 3 + .../knx/snapshots/test_diagnostic.ambr | 5 + tests/components/knx/test_config_flow.py | 319 ++++++++++++++++++ tests/components/knx/test_diagnostic.py | 6 + tests/components/knx/test_init.py | 29 ++ tests/components/knx/test_telegrams.py | 68 ++++ tests/components/knx/test_websocket.py | 39 +++ 16 files changed, 774 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 6ae46c3173be4e..6dff1f12f51215 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -24,11 +24,13 @@ CONF_KNX_KNXKEY_FILENAME, CONF_KNX_RATE_LIMIT, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_DB_BACKEND, CONF_KNX_TELEGRAM_DB_LOAD_HOURS, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, DATA_HASS_CONFIG, DOMAIN, KNX_MODULE_KEY, + KNX_TELEGRAM_BACKEND_SQLITE, KNX_TELEGRAM_DB_PATH_SQLITE, KNX_TELEGRAM_DB_RETENTION_DEFAULT, KNX_TELEGRAM_LOAD_HOURS_DEFAULT, @@ -188,11 +190,23 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: new_options.setdefault(CONF_KNX_STATE_UPDATER, CONF_KNX_DEFAULT_STATE_UPDATER) new_options.setdefault(CONF_KNX_RATE_LIMIT, CONF_KNX_DEFAULT_RATE_LIMIT) + new_options[CONF_KNX_TELEGRAM_DB_BACKEND] = KNX_TELEGRAM_BACKEND_SQLITE + hass.config_entries.async_update_entry( - entry, data=new_data, options=new_options, version=2 + entry, data=new_data, options=new_options, version=2, minor_version=2 ) _LOGGER.info("Migration to version 2 successful") + if entry.version == 2 and entry.minor_version < 2: + # version 2.2 introduced in 2026.8 + new_options = {**entry.options} + if CONF_KNX_TELEGRAM_DB_BACKEND not in new_options: + new_options[CONF_KNX_TELEGRAM_DB_BACKEND] = KNX_TELEGRAM_BACKEND_SQLITE + hass.config_entries.async_update_entry( + entry, options=new_options, minor_version=2 + ) + _LOGGER.info("Migration to version 2.2 successful") + return True diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 50a2c7206b4455..c612f26714d4d9 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -1,8 +1,12 @@ """Config flow for KNX.""" +import asyncio from collections.abc import AsyncGenerator from typing import Any, Final, Literal, override +from urllib.parse import quote, unquote, urlparse, urlunparse +from knx_telegram_store import ConnectionErrorKind +from knx_telegram_store.backends.postgres import PostgresStore import voluptuous as vol from xknx import XKNX from xknx.exceptions.exception import ( @@ -49,8 +53,16 @@ CONF_KNX_SECURE_USER_ID, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_DB_BACKEND, + CONF_KNX_TELEGRAM_DB_DATABASE, + CONF_KNX_TELEGRAM_DB_HOST, CONF_KNX_TELEGRAM_DB_LOAD_HOURS, + CONF_KNX_TELEGRAM_DB_PASSWORD, + CONF_KNX_TELEGRAM_DB_PORT, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, + CONF_KNX_TELEGRAM_DB_TLS, + CONF_KNX_TELEGRAM_DB_USER, CONF_KNX_TUNNEL_ENDPOINT_IA, CONF_KNX_TUNNELING, CONF_KNX_TUNNELING_TCP, @@ -58,6 +70,8 @@ DEFAULT_ROUTING_IA, DOMAIN, KNX_MODULE_KEY, + KNX_TELEGRAM_BACKEND_POSTGRES, + KNX_TELEGRAM_BACKEND_SQLITE, KNX_TELEGRAM_DB_RETENTION_DEFAULT, KNX_TELEGRAM_LOAD_HOURS_DEFAULT, KNXConfigEntryData, @@ -82,12 +96,17 @@ state_updater=CONF_KNX_DEFAULT_STATE_UPDATER, telegram_db_retention_days=KNX_TELEGRAM_DB_RETENTION_DEFAULT, telegram_db_load_hours=KNX_TELEGRAM_LOAD_HOURS_DEFAULT, + telegram_db_backend=KNX_TELEGRAM_BACKEND_SQLITE, ) CONF_KEYRING_FILE: Final = "knxkeys_file" CONF_KNX_TELEGRAM_STORE_SECTION: Final = "telegram_store_section" +# Timeout for the PostgreSQL connection check, so an unreachable host cannot +# block the options flow until the driver/OS connection timeout expires. +DSN_CHECK_TIMEOUT = 10 + CONF_KNX_TUNNELING_TYPE: Final = "tunneling_type" CONF_KNX_TUNNELING_TYPE_LABELS: Final = { CONF_KNX_TUNNELING: "UDP (Tunneling v1)", @@ -113,6 +132,7 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a KNX config flow.""" VERSION = 2 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize KNX config flow.""" @@ -951,6 +971,7 @@ async def async_step_communication_settings( """Manage KNX communication settings.""" if user_input is not None: telegram_store_section = user_input[CONF_KNX_TELEGRAM_STORE_SECTION] + backend = telegram_store_section[CONF_KNX_TELEGRAM_DB_BACKEND] self.new_entry_options |= KNXConfigEntryOptions( state_updater=user_input[CONF_KNX_STATE_UPDATER], rate_limit=user_input[CONF_KNX_RATE_LIMIT], @@ -960,7 +981,10 @@ async def async_step_communication_settings( telegram_db_retention_days=telegram_store_section[ CONF_KNX_TELEGRAM_DB_RETENTION_DAYS ], + telegram_db_backend=backend, ) + if backend == KNX_TELEGRAM_BACKEND_POSTGRES: + return await self.async_step_telegram_store_postgres() return self.finish_flow() data_schema = { @@ -1020,6 +1044,22 @@ async def async_step_communication_settings( ), vol.Coerce(int), ), + vol.Required( + CONF_KNX_TELEGRAM_DB_BACKEND, + default=self.initial_options.get( + CONF_KNX_TELEGRAM_DB_BACKEND, + KNX_TELEGRAM_BACKEND_SQLITE, + ), + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + KNX_TELEGRAM_BACKEND_SQLITE, + KNX_TELEGRAM_BACKEND_POSTGRES, + ], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="telegram_backend", + ) + ), } ), ), @@ -1027,5 +1067,136 @@ async def async_step_communication_settings( return self.async_show_form( step_id="communication_settings", data_schema=vol.Schema(data_schema), + last_step=False, + ) + + async def async_step_telegram_store_postgres( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Collect and validate the PostgreSQL telegram store connection.""" + current_dsn = self.initial_options.get(CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, "") + parsed = _parse_dsn(current_dsn) + errors: dict[str, str] = {} + + if user_input is not None: + # Reuse the stored password when the field is left blank. + params = { + **user_input, + CONF_KNX_TELEGRAM_DB_PASSWORD: ( + user_input.get(CONF_KNX_TELEGRAM_DB_PASSWORD) + or parsed.get(CONF_KNX_TELEGRAM_DB_PASSWORD, "") + ), + } + dsn = _build_dsn(params) + errors = await _async_check_postgres_dsn(dsn) + if not errors: + self.new_entry_options |= KNXConfigEntryOptions( + telegram_db_postgres_dsn=dsn + ) + return self.finish_flow() + + data_schema = vol.Schema( + { + vol.Required( + CONF_KNX_TELEGRAM_DB_HOST, + default=parsed.get(CONF_KNX_TELEGRAM_DB_HOST, "localhost"), + ): selector.TextSelector(), + vol.Required( + CONF_KNX_TELEGRAM_DB_PORT, + default=parsed.get(CONF_KNX_TELEGRAM_DB_PORT, 5432), + ): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, + max=65535, + mode=selector.NumberSelectorMode.BOX, + ) + ), + vol.Coerce(int), + ), + vol.Required( + CONF_KNX_TELEGRAM_DB_USER, + default=parsed.get(CONF_KNX_TELEGRAM_DB_USER, ""), + ): selector.TextSelector(), + vol.Required( + CONF_KNX_TELEGRAM_DB_PASSWORD, default="" + ): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + vol.Required( + CONF_KNX_TELEGRAM_DB_DATABASE, + default=parsed.get(CONF_KNX_TELEGRAM_DB_DATABASE, "knx_telegrams"), + ): selector.TextSelector(), + vol.Required( + CONF_KNX_TELEGRAM_DB_TLS, + default=parsed.get(CONF_KNX_TELEGRAM_DB_TLS, False), + ): selector.BooleanSelector(), + } + ) + if user_input is not None: + data_schema = self.add_suggested_values_to_schema(data_schema, user_input) + return self.async_show_form( + step_id="telegram_store_postgres", + data_schema=data_schema, + errors=errors, last_step=True, ) + + +async def _async_check_postgres_dsn(dsn: str) -> dict[str, str]: + """Validate a PostgreSQL DSN, returning form errors on failure.""" + connection_errors = { + ConnectionErrorKind.AUTH: "invalid_auth", + ConnectionErrorKind.HOST_UNREACHABLE: "host_unreachable", + ConnectionErrorKind.DATABASE_MISSING: "database_missing", + ConnectionErrorKind.PERMISSION: "permission", + ConnectionErrorKind.TIMEOUT: "timeout", + ConnectionErrorKind.MISSING_DEPENDENCY: "missing_dependency", + } + try: + async with asyncio.timeout(DSN_CHECK_TIMEOUT): + check_result = await PostgresStore.check_config(dsn) + except TimeoutError: + return {"base": "timeout"} + except ValueError: + return {"base": "cannot_connect"} + if not check_result.ok: + return {"base": connection_errors.get(check_result.kind, "cannot_connect")} + return {} + + +def _build_dsn(params: dict[str, Any]) -> str: + """Build a PostgreSQL DSN from form params.""" + quoted_user = quote(params.get(CONF_KNX_TELEGRAM_DB_USER, ""), safe="") + quoted_password = quote(params.get(CONF_KNX_TELEGRAM_DB_PASSWORD, ""), safe="") + host = params.get(CONF_KNX_TELEGRAM_DB_HOST, "localhost") + if ":" in host and not host.startswith("["): + # IPv6 literals must be bracketed in the URL netloc + host = f"[{host}]" + port = int(params.get(CONF_KNX_TELEGRAM_DB_PORT, 5432)) + quoted_database = quote( + params.get(CONF_KNX_TELEGRAM_DB_DATABASE, "knx_telegrams"), safe="" + ) + tls = params.get(CONF_KNX_TELEGRAM_DB_TLS, False) + + netloc = f"{quoted_user}:{quoted_password}@{host}:{port}" + query = "sslmode=require" if tls else "" + return urlunparse(("postgresql", netloc, f"/{quoted_database}", "", query, "")) + + +def _parse_dsn(dsn: str) -> dict[str, Any]: + """Parse a PostgreSQL DSN into form params.""" + if not dsn: + return {} + try: + url = urlparse(dsn) + return { + CONF_KNX_TELEGRAM_DB_USER: unquote(url.username or ""), + CONF_KNX_TELEGRAM_DB_PASSWORD: unquote(url.password or ""), + CONF_KNX_TELEGRAM_DB_HOST: url.hostname or "localhost", + CONF_KNX_TELEGRAM_DB_PORT: url.port or 5432, + CONF_KNX_TELEGRAM_DB_DATABASE: unquote(url.path.lstrip("/")), + CONF_KNX_TELEGRAM_DB_TLS: "sslmode=require" in url.query, + } + except ValueError, AttributeError: + return {} diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 84f73b4255e277..f1c203d18a3379 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -53,8 +53,20 @@ DEFAULT_ROUTING_IA: Final = "0.0.240" +CONF_KNX_TELEGRAM_DB_BACKEND: Final = "telegram_db_backend" CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: Final = "telegram_db_retention_days" CONF_KNX_TELEGRAM_DB_LOAD_HOURS: Final = "telegram_db_load_hours" +CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: Final = "telegram_db_postgres_dsn" + +CONF_KNX_TELEGRAM_DB_HOST: Final = "host" +CONF_KNX_TELEGRAM_DB_PORT: Final = "port" +CONF_KNX_TELEGRAM_DB_USER: Final = "user" +CONF_KNX_TELEGRAM_DB_PASSWORD: Final = "password" +CONF_KNX_TELEGRAM_DB_DATABASE: Final = "database" +CONF_KNX_TELEGRAM_DB_TLS: Final = "tls" + +KNX_TELEGRAM_BACKEND_SQLITE: Final = "sqlite" +KNX_TELEGRAM_BACKEND_POSTGRES: Final = "postgres" KNX_TELEGRAM_DB_RETENTION_DEFAULT: Final = 10 # days KNX_TELEGRAM_LOAD_HOURS_DEFAULT: Final = 24 # 1 day @@ -139,6 +151,8 @@ class KNXConfigEntryOptions(TypedDict, total=False): # Integration only (not forwarded to xknx) telegram_db_retention_days: int telegram_db_load_hours: int + telegram_db_backend: str # sqlite | postgres + telegram_db_postgres_dsn: str class ColorTempModes(Enum): diff --git a/homeassistant/components/knx/diagnostics.py b/homeassistant/components/knx/diagnostics.py index c685a5123b0ca6..d637eb55188806 100644 --- a/homeassistant/components/knx/diagnostics.py +++ b/homeassistant/components/knx/diagnostics.py @@ -15,6 +15,7 @@ CONF_KNX_ROUTING_BACKBONE_KEY, CONF_KNX_SECURE_DEVICE_AUTHENTICATION, CONF_KNX_SECURE_USER_PASSWORD, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, DOMAIN, KNX_MODULE_KEY, ) @@ -24,6 +25,7 @@ CONF_KNX_KNXKEY_PASSWORD, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_SECURE_DEVICE_AUTHENTICATION, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, } diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index a67d99cc3c6aca..e0f5ba00e766cb 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -14,7 +14,7 @@ "xknx==3.16.0", "xknxproject==3.9.0", "knx-frontend==2026.6.23.203726", - "knx-telegram-store[sqlite]==0.3.2" + "knx-telegram-store[sqlite,postgres]==0.9.1" ], "single_config_entry": true } diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 59ff173b8b2037..6cf052bfb633d7 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -1162,6 +1162,15 @@ } }, "options": { + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "database_missing": "The specified database does not exist.", + "host_unreachable": "Could not reach the database host.", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "missing_dependency": "Required database driver is not installed.", + "permission": "Insufficient privileges to access the database.", + "timeout": "Connection timed out." + }, "step": { "communication_settings": { "data": { @@ -1175,10 +1184,12 @@ "sections": { "telegram_store_section": { "data": { + "telegram_db_backend": "Telegram storage backend", "telegram_db_load_hours": "Group monitor history", "telegram_db_retention_days": "Retention period" }, "data_description": { + "telegram_db_backend": "Select where to store KNX telegram history.", "telegram_db_load_hours": "Number of hours of telegram history to load when the group monitor is opened.", "telegram_db_retention_days": "Number of days to keep telegram history. Older telegrams are automatically deleted nightly at 3 AM. Set to `0` to delete all telegram history on every nightly run." }, @@ -1186,6 +1197,25 @@ } }, "title": "Communication settings" + }, + "telegram_store_postgres": { + "data": { + "database": "Database name", + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]", + "tls": "Use TLS", + "user": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "database": "Name of the database to store telegrams in.", + "host": "Hostname or IP address of the PostgreSQL server.", + "password": "Password for the PostgreSQL user. Leave blank to keep the current password.", + "port": "Port the PostgreSQL server is listening on.", + "tls": "Encrypt the connection to the PostgreSQL server (`sslmode=require`). Note that the server certificate is not verified.", + "user": "Username to authenticate with the PostgreSQL server." + }, + "title": "PostgreSQL connection" } } }, @@ -1260,6 +1290,12 @@ "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" } + }, + "telegram_backend": { + "options": { + "postgres": "PostgreSQL (External)", + "sqlite": "Internal storage (Default)" + } } }, "services": { diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py index 3d48589d2451fd..0e7acb36dfe8a0 100644 --- a/homeassistant/components/knx/telegrams.py +++ b/homeassistant/components/knx/telegrams.py @@ -8,6 +8,7 @@ from typing import Any, TypedDict from knx_telegram_store import ( + BufferedPostgresStore, BufferedSqliteStore, KnxTelegramStoreException, StoredTelegram, @@ -26,7 +27,10 @@ from homeassistant.util import dt as dt_util from .const import ( + CONF_KNX_TELEGRAM_DB_BACKEND, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, + KNX_TELEGRAM_BACKEND_POSTGRES, KNX_TELEGRAM_DB_PATH_SQLITE, SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, SIGNAL_KNX_TELEGRAM, @@ -48,6 +52,15 @@ # at risk from a longer interval are those buffered during an ungraceful shutdown. FLUSH_INTERVAL_SECONDS = 600 +# The buffer drops the oldest telegrams when full. Size it to cover a full +# flush interval at ~50 telegrams/s, the maximum rate of a KNX TP line, so +# nothing is dropped while the database is healthy. +MAX_BUFFER_TELEGRAMS = FLUSH_INTERVAL_SECONDS * 50 + +# Timeout for the migration probe and store initialization, so an unreachable +# database cannot block KNX setup until the driver/OS connection timeout expires. +STORE_INIT_TIMEOUT = 10 + class DecodedTelegramPayload(TypedDict): """Decoded payload value and metadata.""" @@ -89,19 +102,32 @@ def __init__( self.project = project self.config = config + self.backend: str = config[CONF_KNX_TELEGRAM_DB_BACKEND] + self.dsn: str = str(config.get(CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, "")) self.retention_days: int = config[CONF_KNX_TELEGRAM_DB_RETENTION_DAYS] - self.store: BufferedSqliteStore | None = None - self._uninitialized_store: BufferedSqliteStore | None = None + self.store: BufferedSqliteStore | BufferedPostgresStore | None = None + self._uninitialized_store: ( + BufferedSqliteStore | BufferedPostgresStore | None + ) = None self._evict_expired_unsub: CALLBACK_TYPE | None = None - full_path = hass.config.path(STORAGE_DIR, KNX_TELEGRAM_DB_PATH_SQLITE) - os.makedirs(os.path.dirname(full_path), exist_ok=True) - self._uninitialized_store = BufferedSqliteStore( - full_path, - retention_days=self.retention_days, - flush_interval=FLUSH_INTERVAL_SECONDS, - ) + if self.backend == KNX_TELEGRAM_BACKEND_POSTGRES: + self._uninitialized_store = BufferedPostgresStore( + self.dsn, + retention_days=self.retention_days, + flush_interval=FLUSH_INTERVAL_SECONDS, + max_buffer_size=MAX_BUFFER_TELEGRAMS, + ) + else: + full_path = hass.config.path(STORAGE_DIR, KNX_TELEGRAM_DB_PATH_SQLITE) + os.makedirs(os.path.dirname(full_path), exist_ok=True) + self._uninitialized_store = BufferedSqliteStore( + full_path, + retention_days=self.retention_days, + flush_interval=FLUSH_INTERVAL_SECONDS, + max_buffer_size=MAX_BUFFER_TELEGRAMS, + ) self._xknx_telegram_cb_handle = ( xknx.telegram_queue.register_telegram_received_cb( @@ -121,7 +147,8 @@ async def load_history(self) -> None: if self._uninitialized_store is None: return try: - needs_migration = await self._uninitialized_store.needs_migration() + async with asyncio.timeout(STORE_INIT_TIMEOUT): + needs_migration = await self._uninitialized_store.needs_migration() if needs_migration: _LOGGER.warning( "KNX telegram history database schema upgrade/migration is required. " @@ -129,24 +156,35 @@ async def load_history(self) -> None: ) await self._uninitialized_store.initialize() else: - _LOGGER.debug("Initializing KNX telegram storage") - async with asyncio.timeout(10): + _LOGGER.debug( + "Initializing KNX telegram storage backend '%s'", + self.backend, + ) + async with asyncio.timeout(STORE_INIT_TIMEOUT): await self._uninitialized_store.initialize() - _LOGGER.info("Successfully initialized KNX telegram storage") + _LOGGER.info( + "Successfully initialized KNX telegram storage backend '%s'", + self.backend, + ) except TimeoutError: - _LOGGER.error("Timeout initializing KNX telegram storage") + _LOGGER.error( + "Timeout initializing KNX telegram storage backend '%s'", + self.backend, + ) await self._abort_store_init() return except KnxTelegramStoreException as err: _LOGGER.error( - "Database error initializing KNX telegram storage: %s", + "Database error initializing KNX telegram storage backend '%s': %s", + self.backend, err, ) await self._abort_store_init() return except Exception as err: # noqa: BLE001 _LOGGER.error( - "Error initializing KNX telegram storage: %s", + "Error initializing KNX telegram storage backend '%s': %s", + self.backend, err, ) await self._abort_store_init() diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 4a79f7cdd9b08f..568de4fe8220c3 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -8,7 +8,12 @@ from typing import TYPE_CHECKING, Any, Final, overload import knx_frontend as knx_panel -from knx_telegram_store import KnxTelegramStoreException, TelegramQuery +from knx_telegram_store import ( + BufferedPostgresStore, + BufferedSqliteStore, + KnxTelegramStoreException, + TelegramQuery, +) import voluptuous as vol from xknx.telegram import Telegram from xknxproject.exceptions import XknxProjectException @@ -200,7 +205,11 @@ def ws_get_base_data( "connected": knx.xknx.connection_manager.connected.is_set(), "current_address": str(knx.xknx.current_address), "telegram_backend": ( - "sqlite" if knx.telegrams.store is not None else "unknown" + "sqlite" + if isinstance(knx.telegrams.store, BufferedSqliteStore) + else "postgres" + if isinstance(knx.telegrams.store, BufferedPostgresStore) + else "unknown" ), "telegram_retention": knx.telegrams.store.retention_days if knx.telegrams.store is not None diff --git a/requirements_all.txt b/requirements_all.txt index 766f784bb585db..7a4230a825cc02 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1435,7 +1435,7 @@ knocki==0.4.2 knx-frontend==2026.6.23.203726 # homeassistant.components.knx -knx-telegram-store[sqlite]==0.3.2 +knx-telegram-store[sqlite,postgres]==0.9.1 # homeassistant.components.kraken krakenex==2.2.2 diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 7d69cda3d788c7..5cfab33adf34fe 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -32,10 +32,12 @@ CONF_KNX_MCAST_PORT, CONF_KNX_RATE_LIMIT, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_DB_BACKEND, CONF_KNX_TELEGRAM_DB_LOAD_HOURS, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, DEFAULT_ROUTING_IA, DOMAIN, + KNX_TELEGRAM_BACKEND_SQLITE, KNX_TELEGRAM_DB_RETENTION_DEFAULT, KNX_TELEGRAM_LOAD_HOURS_DEFAULT, ) @@ -364,6 +366,7 @@ def mock_config_entry() -> MockConfigEntry: CONF_KNX_STATE_UPDATER: CONF_KNX_DEFAULT_STATE_UPDATER, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: KNX_TELEGRAM_DB_RETENTION_DEFAULT, CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT, + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE, }, ) diff --git a/tests/components/knx/snapshots/test_diagnostic.ambr b/tests/components/knx/snapshots/test_diagnostic.ambr index 314a856fe17f41..1cc0d93c238276 100644 --- a/tests/components/knx/snapshots/test_diagnostic.ambr +++ b/tests/components/knx/snapshots/test_diagnostic.ambr @@ -10,6 +10,7 @@ 'config_entry_options': dict({ 'rate_limit': 0, 'state_updater': True, + 'telegram_db_backend': 'sqlite', 'telegram_db_load_hours': 24, 'telegram_db_retention_days': 10, }), @@ -48,7 +49,9 @@ 'config_entry_options': dict({ 'rate_limit': 0, 'state_updater': True, + 'telegram_db_backend': 'sqlite', 'telegram_db_load_hours': 24, + 'telegram_db_postgres_dsn': '**REDACTED**', 'telegram_db_retention_days': 10, }), 'config_store': dict({ @@ -79,6 +82,7 @@ 'config_entry_options': dict({ 'rate_limit': 0, 'state_updater': True, + 'telegram_db_backend': 'sqlite', 'telegram_db_load_hours': 24, 'telegram_db_retention_days': 10, }), @@ -110,6 +114,7 @@ 'config_entry_options': dict({ 'rate_limit': 0, 'state_updater': True, + 'telegram_db_backend': 'sqlite', 'telegram_db_load_hours': 24, 'telegram_db_retention_days': 10, }), diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 982284db1803c0..27be1a6f5d4b94 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -1,8 +1,10 @@ """Test the KNX config flow.""" +import asyncio from contextlib import contextmanager from unittest.mock import AsyncMock, MagicMock, Mock, patch +from knx_telegram_store.connection import ConnectionCheckResult, ConnectionErrorKind import pytest from xknx.exceptions import XKNXException from xknx.exceptions.exception import CommunicationError, InvalidSecureConfiguration @@ -21,6 +23,8 @@ DEFAULT_ENTRY_DATA, DEFAULT_ENTRY_OPTIONS, OPTION_MANUAL_TUNNEL, + _build_dsn, + _parse_dsn, ) from homeassistant.components.knx.const import ( CONF_KNX_AUTOMATIC, @@ -41,13 +45,17 @@ CONF_KNX_SECURE_USER_ID, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_DB_BACKEND, CONF_KNX_TELEGRAM_DB_LOAD_HOURS, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, CONF_KNX_TUNNEL_ENDPOINT_IA, CONF_KNX_TUNNELING, CONF_KNX_TUNNELING_TCP, CONF_KNX_TUNNELING_TCP_SECURE, DOMAIN, + KNX_TELEGRAM_BACKEND_POSTGRES, + KNX_TELEGRAM_BACKEND_SQLITE, KNX_TELEGRAM_DB_RETENTION_DEFAULT, KNX_TELEGRAM_LOAD_HOURS_DEFAULT, ) @@ -1065,6 +1073,7 @@ async def test_form_with_automatic_connection_handling( CONF_KNX_STATE_UPDATER: True, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: KNX_TELEGRAM_DB_RETENTION_DEFAULT, CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT, + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE, } knx_setup.assert_called_once() @@ -1690,6 +1699,7 @@ async def test_options_communication_settings( CONF_KNX_TELEGRAM_STORE_SECTION: { CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: 30, + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE, }, }, ) @@ -1699,6 +1709,7 @@ async def test_options_communication_settings( CONF_KNX_RATE_LIMIT: 40, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: 30, CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT, + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE, } assert mock_config_entry.data == initial_data assert mock_config_entry.options == { @@ -1706,5 +1717,313 @@ async def test_options_communication_settings( CONF_KNX_RATE_LIMIT: 40, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: 30, CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT, + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE, } assert len(knx_setup.mock_calls) == 2 + + +async def _advance_to_postgres_step( + hass: HomeAssistant, flow_id: str, *, retention_days: int = 14 +) -> config_entries.ConfigFlowResult: + """Select the PostgreSQL backend and land on its connection step.""" + result = await hass.config_entries.options.async_configure( + flow_id, + user_input={ + CONF_KNX_STATE_UPDATER: False, + CONF_KNX_RATE_LIMIT: 40, + CONF_KNX_TELEGRAM_STORE_SECTION: { + CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT, + CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: retention_days, + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_POSTGRES, + }, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "telegram_store_postgres" + assert not result["errors"] + return result + + +async def test_options_telegram_store_postgres( + hass: HomeAssistant, knx_setup: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test options flow selecting the PostgreSQL telegram store backend.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await _advance_to_postgres_step(hass, result["flow_id"]) + with patch( + "knx_telegram_store.backends.postgres.PostgresStore.check_config", + return_value=ConnectionCheckResult.success(), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "host": "db.local", + "port": 5432, + "user": "knx", + "password": "s3cret", + "database": "knx_telegrams", + "tls": True, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + mock_config_entry.options[CONF_KNX_TELEGRAM_DB_BACKEND] + == KNX_TELEGRAM_BACKEND_POSTGRES + ) + assert mock_config_entry.options[CONF_KNX_TELEGRAM_DB_RETENTION_DAYS] == 14 + assert ( + mock_config_entry.options[CONF_KNX_TELEGRAM_DB_POSTGRES_DSN] + == "postgresql://knx:s3cret@db.local:5432/knx_telegrams?sslmode=require" + ) + assert len(knx_setup.mock_calls) == 2 + + +async def test_options_telegram_store_postgres_reuses_password( + hass: HomeAssistant, knx_setup: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test the PostgreSQL store reuses the stored password when left blank.""" + existing_dsn = "postgresql://olduser:oldpass@old.host:6543/olddb?sslmode=require" + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + **mock_config_entry.options, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: existing_dsn, + }, + ) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await _advance_to_postgres_step(hass, result["flow_id"], retention_days=7) + + # Submit with an empty password - the existing one (parsed from the DSN) + # must be reused. + with patch( + "knx_telegram_store.backends.postgres.PostgresStore.check_config", + return_value=ConnectionCheckResult.success(), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "host": "new.host", + "port": 5432, + "user": "newuser", + "password": "", + "database": "newdb", + "tls": False, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert ( + mock_config_entry.options[CONF_KNX_TELEGRAM_DB_POSTGRES_DSN] + == "postgresql://newuser:oldpass@new.host:5432/newdb" + ) + assert len(knx_setup.mock_calls) == 2 + + +@pytest.mark.parametrize( + ("error_kind", "expected_error"), + [ + pytest.param(ConnectionErrorKind.AUTH, "invalid_auth", id="invalid_auth"), + pytest.param( + ConnectionErrorKind.HOST_UNREACHABLE, + "host_unreachable", + id="host_unreachable", + ), + ], +) +async def test_options_telegram_store_postgres_connection_failure( + hass: HomeAssistant, + knx_setup: AsyncMock, + mock_config_entry: MockConfigEntry, + error_kind: ConnectionErrorKind, + expected_error: str, +) -> None: + """Test the PostgreSQL step maps connection check failures to form errors.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await _advance_to_postgres_step(hass, result["flow_id"]) + with patch( + "knx_telegram_store.backends.postgres.PostgresStore.check_config", + return_value=ConnectionCheckResult.failure(error_kind, "check failed"), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "host": "db.local", + "port": 5432, + "user": "knx", + "password": "wrong_password", + "database": "knx_telegrams", + "tls": True, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "telegram_store_postgres" + assert result["errors"] == {"base": expected_error} + + +async def test_options_telegram_store_postgres_timeout( + hass: HomeAssistant, knx_setup: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test options flow surfaces a timeout when the connection check hangs.""" + + async def hanging_check(dsn: str) -> None: + await asyncio.Event().wait() + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await _advance_to_postgres_step(hass, result["flow_id"]) + with ( + patch("homeassistant.components.knx.config_flow.DSN_CHECK_TIMEOUT", 0.05), + patch( + "knx_telegram_store.backends.postgres.PostgresStore.check_config", + side_effect=hanging_check, + ), + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "host": "db.local", + "port": 5432, + "user": "knx", + "password": "s3cret", + "database": "knx_telegrams", + "tls": True, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "telegram_store_postgres" + assert result["errors"] == {"base": "timeout"} + + +async def test_options_telegram_store_postgres_malformed_dsn( + hass: HomeAssistant, knx_setup: AsyncMock, mock_config_entry: MockConfigEntry +) -> None: + """Test the PostgreSQL step maps a DSN the driver rejects to a form error.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await _advance_to_postgres_step(hass, result["flow_id"]) + # An unterminated bracketed IPv6 address makes engine creation + # raise ValueError before any connection attempt. + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "host": "[::1", + "port": 5432, + "user": "knx", + "password": "s3cret", + "database": "knx_telegrams", + "tls": False, + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "telegram_store_postgres" + assert result["errors"] == {"base": "cannot_connect"} + + +@pytest.mark.parametrize( + ("dsn", "expected"), + [ + pytest.param("", {}, id="empty"), + # Invalid port makes urlparse.port raise ValueError -> {} + pytest.param("postgresql://host:notaport/db", {}, id="invalid_port"), + pytest.param( + "postgresql://u:p@h:5432/db?sslmode=require", + { + "user": "u", + "password": "p", + "host": "h", + "port": 5432, + "database": "db", + "tls": True, + }, + id="full", + ), + pytest.param( + "postgresql://user%40domain:p%40ss%25word@h:5432/db", + { + "user": "user@domain", + "password": "p@ss%word", + "host": "h", + "port": 5432, + "database": "db", + "tls": False, + }, + id="percent_encoded_credentials", + ), + pytest.param( + "postgresql://u:p@[2001:db8::1]:5432/db", + { + "user": "u", + "password": "p", + "host": "2001:db8::1", + "port": 5432, + "database": "db", + "tls": False, + }, + id="ipv6_host", + ), + pytest.param( + "postgresql://u:p@h:5432/db%3Fquery%23hash", + { + "user": "u", + "password": "p", + "host": "h", + "port": 5432, + "database": "db?query#hash", + "tls": False, + }, + id="percent_encoded_database", + ), + ], +) +def test_parse_dsn(dsn: str, expected: dict) -> None: + """Test PostgreSQL DSN parsing, including malformed input.""" + assert _parse_dsn(dsn) == expected + + +@pytest.mark.parametrize( + ("user", "password", "host", "database"), + [ + pytest.param("simple", "plain", "localhost", "knx", id="plain"), + pytest.param("user@domain", "p@ss", "localhost", "knx", id="at_sign"), + pytest.param("user", "p@ss%word", "localhost", "knx", id="percent_sign"), + pytest.param( + "us:er", "p/a:s@s", "localhost", "knx", id="multiple_special_chars" + ), + pytest.param("user", "pass", "2001:db8::1", "knx", id="ipv6_host"), + pytest.param( + "user", "pass", "localhost", "knx?query#hash", id="database_special_chars" + ), + ], +) +def test_dsn_round_trip(user: str, password: str, host: str, database: str) -> None: + """Test _build_dsn -> _parse_dsn -> _build_dsn produces identical DSNs. + + Catches double percent-encoding: urlparse returns percent-encoded values, + so _parse_dsn must decode them before they are fed back into _build_dsn. + IPv6 hosts must be bracketed in the netloc for the DSN to stay parseable. + Database names with URL delimiters are percent-encoded to prevent truncation. + """ + params = { + "user": user, + "password": password, + "host": host, + "port": 5432, + "database": database, + "tls": False, + } + dsn1 = _build_dsn(params) + parsed = _parse_dsn(dsn1) + dsn2 = _build_dsn(parsed) + assert dsn1 == dsn2 diff --git a/tests/components/knx/test_diagnostic.py b/tests/components/knx/test_diagnostic.py index f35bad74eb46aa..2f1aa1e8c0a08e 100644 --- a/tests/components/knx/test_diagnostic.py +++ b/tests/components/knx/test_diagnostic.py @@ -20,6 +20,7 @@ CONF_KNX_SECURE_DEVICE_AUTHENTICATION, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, DEFAULT_ROUTING_IA, DOMAIN, ) @@ -100,6 +101,11 @@ async def test_diagnostic_redact( CONF_KNX_SECURE_DEVICE_AUTHENTICATION: "device_authentication", CONF_KNX_ROUTING_BACKBONE_KEY: "bbaacc44bbaacc44bbaacc44bbaacc44", }, + options={ + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: ( + "postgresql://knx:supersecret@localhost:5432/knx_telegrams" + ), + }, ) knx: KNXTestKit = KNXTestKit(hass, mock_config_entry, hass_storage) await knx.setup_integration() diff --git a/tests/components/knx/test_init.py b/tests/components/knx/test_init.py index 5a114762f649a5..87ddd2f8c048dd 100644 --- a/tests/components/knx/test_init.py +++ b/tests/components/knx/test_init.py @@ -38,12 +38,14 @@ CONF_KNX_SECURE_USER_ID, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_DB_BACKEND, CONF_KNX_TELEGRAM_DB_LOAD_HOURS, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, CONF_KNX_TUNNELING, CONF_KNX_TUNNELING_TCP, CONF_KNX_TUNNELING_TCP_SECURE, DOMAIN, + KNX_TELEGRAM_BACKEND_SQLITE, KNX_TELEGRAM_DB_RETENTION_DEFAULT, KNX_TELEGRAM_LOAD_HOURS_DEFAULT, KNXConfigEntryData, @@ -437,3 +439,30 @@ async def test_async_migrate_entry_future_version(hass: HomeAssistant) -> None: with patch("homeassistant.components.knx.async_setup_entry", return_value=True): assert not await hass.config_entries.async_setup(config_entry.entry_id) + + +async def test_async_migrate_entry_v2_to_v2_2(hass: HomeAssistant) -> None: + """Test KNX config entry migration from v2.x to v2.2.""" + config_entry = MockConfigEntry( + title="KNX", + domain=DOMAIN, + version=2, + minor_version=1, + data={ + "other_setting": "some_value", + }, + options={ + "some_option": "value", + }, + ) + config_entry.add_to_hass(hass) + + with patch("homeassistant.components.knx.async_setup_entry", return_value=True): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.version == 2 + assert config_entry.minor_version == 2 + assert ( + config_entry.options[CONF_KNX_TELEGRAM_DB_BACKEND] + == KNX_TELEGRAM_BACKEND_SQLITE + ) diff --git a/tests/components/knx/test_telegrams.py b/tests/components/knx/test_telegrams.py index add2fff644f863..5912938256c2d6 100644 --- a/tests/components/knx/test_telegrams.py +++ b/tests/components/knx/test_telegrams.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from copy import copy from datetime import datetime from unittest.mock import AsyncMock, patch @@ -11,9 +12,12 @@ import pytest from homeassistant.components.knx.const import ( + CONF_KNX_TELEGRAM_DB_BACKEND, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, DOMAIN, KNX_MODULE_KEY, + KNX_TELEGRAM_BACKEND_POSTGRES, REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR, ) from homeassistant.components.knx.telegrams import TelegramDict @@ -156,6 +160,34 @@ async def test_store_telegram_history_error_handling( assert issue is not None +async def test_store_telegram_history_needs_migration_timeout( + hass: HomeAssistant, + knx: KNXTestKit, +) -> None: + """Test that store initialization is aborted when needs_migration times out.""" + + async def hanging_probe() -> bool: + await asyncio.Event().wait() + return False + + with ( + patch("homeassistant.components.knx.telegrams.STORE_INIT_TIMEOUT", 0.05), + patch( + "knx_telegram_store.BufferedSqliteStore.needs_migration", + side_effect=hanging_probe, + ), + ): + await knx.setup_integration() + + telegrams_module = hass.data[KNX_MODULE_KEY].telegrams + assert telegrams_module.store is None + + # Check that the repair issue was created + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR) + assert issue is not None + + async def test_migrate_telegrams_from_json( hass: HomeAssistant, knx: KNXTestKit, @@ -483,3 +515,39 @@ async def test_nightly_eviction_error_handling( assert "Database error evicting expired KNX telegrams" in caplog.text # Store remains operational after the failed eviction assert telegrams_module.store is not None + + +async def test_postgres_backend_init_error( + hass: HomeAssistant, + knx: KNXTestKit, +) -> None: + """Test PostgreSQL backend DSN handling and init failure path.""" + dsn = "postgresql://user:secret@db.local:5432/knx" + knx.mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + knx.mock_config_entry, + options=knx.mock_config_entry.options + | { + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_POSTGRES, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: dsn, + }, + ) + + # Mock the store to avoid constructing a real SQLAlchemy engine / connecting. + mock_store = AsyncMock() + mock_store.needs_migration.return_value = False + mock_store.initialize.side_effect = KnxTelegramStoreException("no server") + with patch( + "homeassistant.components.knx.telegrams.BufferedPostgresStore", + return_value=mock_store, + ): + await knx.setup_integration(add_entry_to_hass=False) + + telegrams_module = hass.data[KNX_MODULE_KEY].telegrams + assert telegrams_module.store is None + + issue_registry = ir.async_get(hass) + assert ( + issue_registry.async_get_issue(DOMAIN, REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR) + is not None + ) diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index 0f5f9af1c37f56..124122d991178a 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -10,8 +10,11 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.knx.const import ( + CONF_KNX_TELEGRAM_DB_BACKEND, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, KNX_ADDRESS, KNX_MODULE_KEY, + KNX_TELEGRAM_BACKEND_POSTGRES, SUPPORTED_PLATFORMS_UI, ) from homeassistant.components.knx.project import STORAGE_KEY as KNX_PROJECT_STORAGE_KEY @@ -37,10 +40,46 @@ async def test_knx_get_base_data_command( assert res["result"]["connection_info"]["version"] is not None assert res["result"]["connection_info"]["connected"] assert res["result"]["connection_info"]["current_address"] == "0.0.0" + assert res["result"]["connection_info"]["telegram_backend"] == "sqlite" assert res["result"]["project_info"] is None assert not SUPPORTED_PLATFORMS_UI.difference(res["result"]["supported_platforms"]) +async def test_knx_get_base_data_command_postgres( + hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator +) -> None: + """Test knx/get_base_data reports the PostgreSQL telegram backend.""" + knx.mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + knx.mock_config_entry, + options=knx.mock_config_entry.options + | { + CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_POSTGRES, + CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: "postgresql://user:pw@db.local:5432/knx", + }, + ) + # Patch methods on the real class so the isinstance check in the + # websocket handler still sees a BufferedPostgresStore instance. + with ( + patch( + "knx_telegram_store.BufferedPostgresStore.needs_migration", + return_value=False, + ), + patch("knx_telegram_store.BufferedPostgresStore.initialize"), + patch( + "knx_telegram_store.BufferedPostgresStore.get_last_unique_telegrams", + return_value=[], + ), + ): + await knx.setup_integration(add_entry_to_hass=False) + client = await hass_ws_client(hass) + await client.send_json_auto_id({"type": "knx/get_base_data"}) + res = await client.receive_json() + + assert res["success"], res + assert res["result"]["connection_info"]["telegram_backend"] == "postgres" + + @pytest.mark.usefixtures("load_knxproj") async def test_knx_get_base_data_command_with_project( hass: HomeAssistant, From 062b347ba6bdfd7b7012b2574b0a4ed084f8add4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:45:00 +0200 Subject: [PATCH 10/13] Use entity state attribute enums in MQTT (#176467) --- .../components/mqtt/device_tracker.py | 33 ++++++++++++------- homeassistant/components/mqtt/diagnostics.py | 12 +++---- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 0efba71bbf72b9..8cf181e7699d4c 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -7,16 +7,18 @@ import voluptuous as vol from homeassistant.components import device_tracker -from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.components.device_tracker import ( + SourceType, + TrackerEntity, + TrackerEntityStateAttribute, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_GPS_ACCURACY, - ATTR_LATITUDE, - ATTR_LONGITUDE, CONF_NAME, CONF_VALUE_TEMPLATE, STATE_HOME, STATE_NOT_HOME, + EntityStateAttribute, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv @@ -162,16 +164,18 @@ def _process_update_extra_state_attributes( ) -> None: """Extract the location from the extra state attributes.""" if ( - ATTR_LATITUDE in extra_state_attributes - or ATTR_LONGITUDE in extra_state_attributes + EntityStateAttribute.LATITUDE in extra_state_attributes + or EntityStateAttribute.LONGITUDE in extra_state_attributes ): latitude: float | None longitude: float | None gps_accuracy: float if isinstance( - latitude := extra_state_attributes.get(ATTR_LATITUDE), (int, float) + latitude := extra_state_attributes.get(EntityStateAttribute.LATITUDE), + (int, float), ) and isinstance( - longitude := extra_state_attributes.get(ATTR_LONGITUDE), (int, float) + longitude := extra_state_attributes.get(EntityStateAttribute.LONGITUDE), + (int, float), ): self._attr_latitude = latitude self._attr_longitude = longitude @@ -187,9 +191,11 @@ def _process_update_extra_state_attributes( extra_state_attributes, ) - if ATTR_GPS_ACCURACY in extra_state_attributes: + if TrackerEntityStateAttribute.GPS_ACCURACY in extra_state_attributes: if isinstance( - gps_accuracy := extra_state_attributes[ATTR_GPS_ACCURACY], + gps_accuracy := extra_state_attributes[ + TrackerEntityStateAttribute.GPS_ACCURACY + ], (int, float), ): self._attr_location_accuracy = gps_accuracy @@ -210,5 +216,10 @@ def _process_update_extra_state_attributes( self._attr_extra_state_attributes = { attribute: value for attribute, value in extra_state_attributes.items() - if attribute not in {ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE} + if attribute + not in { + TrackerEntityStateAttribute.GPS_ACCURACY, + EntityStateAttribute.LATITUDE, + EntityStateAttribute.LONGITUDE, + } } diff --git a/homeassistant/components/mqtt/diagnostics.py b/homeassistant/components/mqtt/diagnostics.py index 68d4b2fb9c7ce8..5ab4861201f4f1 100644 --- a/homeassistant/components/mqtt/diagnostics.py +++ b/homeassistant/components/mqtt/diagnostics.py @@ -5,12 +5,7 @@ from homeassistant.components import device_tracker from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, - CONF_PASSWORD, - CONF_USERNAME, -) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, EntityStateAttribute from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry @@ -18,7 +13,10 @@ from . import debug_info, is_connected REDACT_CONFIG = {CONF_PASSWORD, CONF_USERNAME} -REDACT_STATE_DEVICE_TRACKER = {ATTR_LATITUDE, ATTR_LONGITUDE} +REDACT_STATE_DEVICE_TRACKER = { + EntityStateAttribute.LATITUDE, + EntityStateAttribute.LONGITUDE, +} async def async_get_config_entry_diagnostics( From 4fae33830264a94350629de4252b08641f84d7c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Tue, 14 Jul 2026 10:59:30 +0200 Subject: [PATCH 11/13] Flip nobo_hub Gold documentation quality scale rules to done (#176445) --- homeassistant/components/nobo_hub/quality_scale.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/nobo_hub/quality_scale.yaml b/homeassistant/components/nobo_hub/quality_scale.yaml index 1812cab9b10f87..28d7df4e24d186 100644 --- a/homeassistant/components/nobo_hub/quality_scale.yaml +++ b/homeassistant/components/nobo_hub/quality_scale.yaml @@ -51,13 +51,13 @@ rules: diagnostics: todo discovery: done discovery-update-info: done - docs-data-update: todo + docs-data-update: done docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: todo entity-category: todo entity-device-class: done From 6f5992e66a9206f1cd4da011e251a37bab23200c Mon Sep 17 00:00:00 2001 From: Manu Date: Tue, 14 Jul 2026 11:03:57 +0200 Subject: [PATCH 12/13] Deprecate sensor attributes in Steam integration (#176417) --- homeassistant/components/steam_online/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/steam_online/sensor.py b/homeassistant/components/steam_online/sensor.py index 0d8c4ba8f4bd3a..45c1cdac4de03c 100644 --- a/homeassistant/components/steam_online/sensor.py +++ b/homeassistant/components/steam_online/sensor.py @@ -60,6 +60,8 @@ class SteamSensorEntityDescription(SensorEntityDescription): options=list(STEAM_STATUSES.values()), entity_picture_fn=lambda x, _: x.avatarfull, name=None, + # Attributes game, game_id, game_image_header, game_image_main, game_icon, + # last_online, and level are deprecated and can be removed in 2027.2 extra_state_attributes_fn=lambda x, icons: { "real_name": x.realname, "created": ( From 2f5ed221505db9821ab565e3526e2ccea1ec0f0e Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:04:24 +0200 Subject: [PATCH 13/13] Bump uiprotect to 15.12.2 (#176420) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index b279a5015c30c5..66513f125d71ef 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["uiprotect"], "quality_scale": "platinum", - "requirements": ["uiprotect==15.12.1"] + "requirements": ["uiprotect==15.12.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7a4230a825cc02..b399346d904583 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3258,7 +3258,7 @@ uasiren==0.0.1 uhooapi==1.2.8 # homeassistant.components.unifiprotect -uiprotect==15.12.1 +uiprotect==15.12.2 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.6.1