From 7eda592c72be48ca2e7ff7a8cd1e3502ad8c31cb Mon Sep 17 00:00:00 2001 From: Matt Philips Date: Sat, 4 Apr 2026 09:50:35 -0400 Subject: [PATCH 01/69] Improve handling of disconnected meters with Rainforest Automation Eagle-200 (#161185) Co-authored-by: Joostlek --- .../rainforest_eagle/config_flow.py | 40 ++- .../components/rainforest_eagle/data.py | 13 +- .../components/rainforest_eagle/strings.json | 5 +- .../rainforest_eagle/test_config_flow.py | 257 +++++++++++++++++- 4 files changed, 291 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/rainforest_eagle/config_flow.py b/homeassistant/components/rainforest_eagle/config_flow.py index 867bc5886dbf99..b7ac70527dcea4 100644 --- a/homeassistant/components/rainforest_eagle/config_flow.py +++ b/homeassistant/components/rainforest_eagle/config_flow.py @@ -10,7 +10,14 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_TYPE -from .const import CONF_CLOUD_ID, CONF_HARDWARE_ADDRESS, CONF_INSTALL_CODE, DOMAIN +from .const import ( + CONF_CLOUD_ID, + CONF_HARDWARE_ADDRESS, + CONF_INSTALL_CODE, + DOMAIN, + TYPE_EAGLE_100, + TYPE_EAGLE_200, +) from .data import CannotConnect, InvalidAuth, async_get_type _LOGGER = logging.getLogger(__name__) @@ -63,11 +70,32 @@ async def async_step_user( _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - user_input[CONF_TYPE] = eagle_type - user_input[CONF_HARDWARE_ADDRESS] = hardware_address - return self.async_create_entry( - title=user_input[CONF_CLOUD_ID], data=user_input - ) + # Verify it is a known device, first + if not eagle_type: + errors["base"] = "unknown_device_type" + elif eagle_type == TYPE_EAGLE_100: + user_input[CONF_TYPE] = eagle_type + + # For EAGLE-100, there is no hardware address to select, so set it to None and move on + user_input[CONF_HARDWARE_ADDRESS] = None + elif eagle_type == TYPE_EAGLE_200: + user_input[CONF_TYPE] = eagle_type + + # For EAGLE-200, a connected meter's hardware address is required to create the entry + if not hardware_address: + # hardware_address will be None if there are no meters at all or if none are currently Connected + errors["base"] = "no_meters_connected" + else: + user_input[CONF_HARDWARE_ADDRESS] = hardware_address + else: + # This is a device that isn't supported, yet, but was detected by async_get_type + errors["base"] = "unsupported_device_type" + + # All information gathering is done, so if there are no errors at this point, create the entry + if not errors: + return self.async_create_entry( + title=user_input[CONF_CLOUD_ID], data=user_input + ) return self.async_show_form( step_id="user", data_schema=create_schema(user_input), errors=errors diff --git a/homeassistant/components/rainforest_eagle/data.py b/homeassistant/components/rainforest_eagle/data.py index 01f373f3178c6c..adf135d53f5983 100644 --- a/homeassistant/components/rainforest_eagle/data.py +++ b/homeassistant/components/rainforest_eagle/data.py @@ -34,7 +34,7 @@ class InvalidAuth(RainforestError): async def async_get_type(hass, cloud_id, install_code, host): """Try API call 'get_network_info' to see if target device is Eagle-100 or Eagle-200.""" - # For EAGLE-200, fetch the hardware address of the meter too. + # For EAGLE-200, fetch the hardware address of the first connected meter, too. hub = aioeagle.EagleHub( aiohttp_client.async_get_clientsession(hass), cloud_id, install_code, host=host ) @@ -50,8 +50,17 @@ async def async_get_type(hass, cloud_id, install_code, host): if meters is not None: if meters: - hardware_address = meters[0].hardware_address + # If there is at least one meter, use the first one with a connection status of "Connected" + hardware_address = next( + ( + m.hardware_address + for m in meters + if getattr(m, "connection_status", None) == "Connected" + ), + None, + ) else: + # If there are no meters (empty list, since None was already checked for), set the hardware address to None hardware_address = None return TYPE_EAGLE_200, hardware_address diff --git a/homeassistant/components/rainforest_eagle/strings.json b/homeassistant/components/rainforest_eagle/strings.json index a874770baa9a84..b3eed05110c60f 100644 --- a/homeassistant/components/rainforest_eagle/strings.json +++ b/homeassistant/components/rainforest_eagle/strings.json @@ -6,7 +6,10 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "no_meters_connected": "No meters are currently connected. Ensure your meter is connected and try again.", + "unknown": "[%key:common::config_flow::error::unknown%]", + "unknown_device_type": "Unable to determine the type of Rainforest Eagle device. Please ensure your device is supported.", + "unsupported_device_type": "This type of Rainforest Eagle device is not supported." }, "step": { "user": { diff --git a/tests/components/rainforest_eagle/test_config_flow.py b/tests/components/rainforest_eagle/test_config_flow.py index 0d3b477b3d5c95..adf705e3925967 100644 --- a/tests/components/rainforest_eagle/test_config_flow.py +++ b/tests/components/rainforest_eagle/test_config_flow.py @@ -8,6 +8,7 @@ CONF_HARDWARE_ADDRESS, CONF_INSTALL_CODE, DOMAIN, + TYPE_EAGLE_100, TYPE_EAGLE_200, ) from homeassistant.components.rainforest_eagle.data import CannotConnect, InvalidAuth @@ -16,8 +17,8 @@ from homeassistant.data_entry_flow import FlowResultType -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_form_multiple_meters_first_connected(hass: HomeAssistant) -> None: + """Test proper flow with an EAGLE-200 with a list of meters, one of which is connected (should auto-select it).""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -25,17 +26,29 @@ async def test_form(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] is None + # Simulate multiple meters with one connected + class MockElectricMeter: + def __init__(self, hardware_address, connection_status) -> None: + self.hardware_address = hardware_address + self.connection_status = connection_status + + meters = [ + MockElectricMeter("meter-1", "Not Joined"), + MockElectricMeter("meter-2", "Connected"), + MockElectricMeter("meter-3", "Not Joined"), + ] + with ( patch( - "homeassistant.components.rainforest_eagle.config_flow.async_get_type", - return_value=(TYPE_EAGLE_200, "mock-hw"), + "aioeagle.EagleHub.get_device_list", + return_value=meters, ), patch( "homeassistant.components.rainforest_eagle.async_setup_entry", return_value=True, ) as mock_setup_entry, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_CLOUD_ID: "abcdef", @@ -45,18 +58,232 @@ async def test_form(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "abcdef" - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "abcdef" + assert result["data"] == { CONF_TYPE: TYPE_EAGLE_200, CONF_HOST: "192.168.1.55", CONF_CLOUD_ID: "abcdef", CONF_INSTALL_CODE: "123456", - CONF_HARDWARE_ADDRESS: "mock-hw", + CONF_HARDWARE_ADDRESS: "meter-2", } + assert result["result"].unique_id == "abcdef" assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_eagle_200_meters_none_connected(hass: HomeAssistant) -> None: + """Test proper flow with an EAGLE-200 with a list of meters, but all are disconnected (Error should be shown).""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + # Simulate all meters being disconnected + class MockElectricMeter: + def __init__(self, hardware_address, connection_status) -> None: + self.hardware_address = hardware_address + self.connection_status = connection_status + + meters = [ + MockElectricMeter("meter-1", "Not Joined"), + MockElectricMeter("meter-2", "Not Joined"), + MockElectricMeter("meter-3", "Not Joined"), + ] + + with patch( + "aioeagle.EagleHub.get_device_list", + return_value=meters, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HOST: "192.168.1.55", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "no_meters_connected"} + + +async def test_form_eagle_200_no_meters(hass: HomeAssistant) -> None: + """Test proper flow with an EAGLE-200 with an empty list of meters (Error should be shown).""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + # Simulate no meters (empty list) + with ( + patch( + "aioeagle.EagleHub.get_device_list", + return_value=[], + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HOST: "192.168.1.55", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "no_meters_connected"} + + +async def test_form_eagle_100(hass: HomeAssistant) -> None: + """Test proper flow for EAGLE-100 (KeyError from get_device_list, then legacy response).""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + # Patch get_device_list to raise KeyError (expected from EAGLE-100), and async_add_executor_job to return proper EAGLE-100 response + eagle_100_response = {"NetworkInfo": {"ModelId": "Z109-EAGLE"}} + + with ( + patch( + "aioeagle.EagleHub.get_device_list", + side_effect=KeyError, + ), + patch( + "eagle100.Eagle.get_network_info", + return_value=eagle_100_response, + ), + patch( + "homeassistant.core.HomeAssistant.async_add_executor_job", + return_value=eagle_100_response, + ), + patch( + "homeassistant.components.rainforest_eagle.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HOST: "192.168.1.55", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "abcdef" + assert result["data"] == { + CONF_TYPE: TYPE_EAGLE_100, + CONF_HOST: "192.168.1.55", + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HARDWARE_ADDRESS: None, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_unknown_device_type(hass: HomeAssistant) -> None: + """Test flow when device type cannot be determined (get_device_list raises an error but other responses aren't the expected values).""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + # Patch get_device_list to raise KeyError (expected from EAGLE-100), and async_add_executor_job to return an unknown device response + unknown_device_response = {"NetworkInfo": {"ModelId": "UNKNOWN-DEVICE"}} + + with ( + patch( + "aioeagle.EagleHub.get_device_list", + side_effect=KeyError, + ), + patch( + "eagle100.Eagle.get_network_info", + return_value=unknown_device_response, + ), + patch( + "homeassistant.core.HomeAssistant.async_add_executor_job", + return_value=unknown_device_response, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HOST: "192.168.1.55", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown_device_type"} + + +async def test_form_unsupported_device_type(hass: HomeAssistant) -> None: + """Test flow when device type is unsupported (async_get_type returns an unexpected device type).""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.rainforest_eagle.config_flow.async_get_type", + return_value=("UNSUPPORTED_DEVICE_TYPE", None), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HOST: "192.168.1.55", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unsupported_device_type"} + + +async def test_form_unexpected_exception(hass: HomeAssistant) -> None: + """Test flow when an unexpected exception occurs.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.rainforest_eagle.config_flow.async_get_type", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CLOUD_ID: "abcdef", + CONF_INSTALL_CODE: "123456", + CONF_HOST: "192.168.1.55", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + async def test_form_invalid_auth(hass: HomeAssistant) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( @@ -67,7 +294,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: "aioeagle.EagleHub.get_device_list", side_effect=InvalidAuth, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_CLOUD_ID: "abcdef", @@ -76,8 +303,8 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} async def test_form_cannot_connect(hass: HomeAssistant) -> None: @@ -90,7 +317,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: "aioeagle.EagleHub.get_device_list", side_effect=CannotConnect, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_CLOUD_ID: "abcdef", @@ -99,5 +326,5 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} From ab601e5717f44bbd00f4a5f61ff58f8edc19efee Mon Sep 17 00:00:00 2001 From: Jeef Date: Thu, 9 Apr 2026 15:22:33 -0600 Subject: [PATCH 02/69] Prevent the intellifire client from polling independently of its coordinator (#165341) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Robert Resch Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/intellifire/__init__.py | 3 +- .../components/intellifire/coordinator.py | 12 +++++++- tests/components/intellifire/conftest.py | 2 ++ tests/components/intellifire/test_init.py | 29 +++++++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/intellifire/__init__.py b/homeassistant/components/intellifire/__init__.py index 8a325152120346..77171044e9b9a7 100644 --- a/homeassistant/components/intellifire/__init__.py +++ b/homeassistant/components/intellifire/__init__.py @@ -143,7 +143,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: IntellifireConfigEntry) try: fireplace: UnifiedFireplace = ( await UnifiedFireplace.build_fireplace_from_common( - _construct_common_data(entry) + _construct_common_data(entry), + polling_enabled=False, ) ) LOGGER.debug("Waiting for Fireplace to Initialize") diff --git a/homeassistant/components/intellifire/coordinator.py b/homeassistant/components/intellifire/coordinator.py index dc9aa45d58bcd0..c2eb374c3a14c7 100644 --- a/homeassistant/components/intellifire/coordinator.py +++ b/homeassistant/components/intellifire/coordinator.py @@ -4,6 +4,7 @@ from datetime import timedelta +import aiohttp from intellifire4py import UnifiedFireplace from intellifire4py.control import IntelliFireController from intellifire4py.model import IntelliFirePollData @@ -11,8 +12,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER @@ -52,6 +54,14 @@ def control_api(self) -> IntelliFireController: return self.fireplace.control_api async def _async_update_data(self) -> IntelliFirePollData: + try: + await self.fireplace.perform_poll() + except aiohttp.ClientResponseError as err: + if err.status == 403: + raise ConfigEntryAuthFailed("Authentication failed") from err + raise UpdateFailed(f"Error communicating with fireplace: {err}") from err + except (aiohttp.ClientError, TimeoutError) as err: + raise UpdateFailed(f"Error communicating with fireplace: {err}") from err return self.fireplace.data @property diff --git a/tests/components/intellifire/conftest.py b/tests/components/intellifire/conftest.py index a82deba64ee923..008e1db9fc3b48 100644 --- a/tests/components/intellifire/conftest.py +++ b/tests/components/intellifire/conftest.py @@ -257,6 +257,8 @@ def mock_fp(mock_common_data_local) -> Generator[AsyncMock]: mock_instance.set_read_mode = AsyncMock() mock_instance.set_control_mode = AsyncMock() + mock_instance.perform_poll = AsyncMock() + mock_instance.async_validate_connectivity = AsyncMock( return_value=(True, False) ) diff --git a/tests/components/intellifire/test_init.py b/tests/components/intellifire/test_init.py index 307a9df812c508..ac689a164b5bab 100644 --- a/tests/components/intellifire/test_init.py +++ b/tests/components/intellifire/test_init.py @@ -342,3 +342,32 @@ async def test_update_options_no_change( mock_fp.set_control_mode.assert_not_called() # But async_request_refresh should still be called coordinator.async_request_refresh.assert_called_once() + + +async def test_coordinator_performs_poll( + hass: HomeAssistant, + mock_config_entry_current: MockConfigEntry, + mock_apis_single_fp, +) -> None: + """Test that the library only polls when instructed by the coordinator. + + The library auto-polls by default; ensure the coordinator disables that + and drives polling explicitly via perform_poll(). + """ + _mock_local, _mock_cloud, mock_fp = mock_apis_single_fp + + with patch( + "homeassistant.components.intellifire.UnifiedFireplace.build_fireplace_from_common", + new_callable=AsyncMock, + return_value=mock_fp, + ) as mock_build: + mock_config_entry_current.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_current.entry_id) + await hass.async_block_till_done() + + # Verify the fireplace was constructed with library background polling disabled + mock_build.assert_awaited_once() + assert mock_build.call_args.kwargs.get("polling_enabled") is False + + # Verify the coordinator drove exactly one poll during initial refresh + mock_fp.perform_poll.assert_awaited_once() From b880876e0eeb455dbbcfdc44d233cfa711bd7891 Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Fri, 10 Apr 2026 01:43:13 +0800 Subject: [PATCH 03/69] Switchbot Cloud: Enable Webhook for Bot (#165647) --- .../components/switchbot_cloud/__init__.py | 72 ++++++++++--------- tests/components/switchbot_cloud/conftest.py | 23 ++++++ .../components/switchbot_cloud/test_button.py | 33 ++++++++- .../components/switchbot_cloud/test_switch.py | 32 ++++++++- 4 files changed, 124 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index aa32576e8a276d..1aec0ec73e1340 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -13,6 +13,7 @@ SwitchBotAPI, SwitchBotAuthenticationError, SwitchBotConnectionError, + SwitchBotDeviceOfflineError, ) from homeassistant.components import webhook @@ -202,7 +203,7 @@ async def make_device_data( if isinstance(device, Device) and device.device_type == "Bot": coordinator = await coordinator_for_device( - hass, entry, api, device, coordinators_by_id + hass, entry, api, device, coordinators_by_id, True ) devices_data.sensors.append((device, coordinator)) if coordinator.data is not None: @@ -405,42 +406,49 @@ async def _initialize_webhook( hass, entry.data[CONF_WEBHOOK_ID], ) - # check if webhook is configured in switchbot cloud - check_webhook_result = None - with contextlib.suppress(Exception): - check_webhook_result = await api.get_webook_configuration() - actual_webhook_urls = ( - check_webhook_result["urls"] - if check_webhook_result and "urls" in check_webhook_result - else [] - ) - need_add_webhook = ( - len(actual_webhook_urls) == 0 or webhook_url not in actual_webhook_urls - ) - need_clean_previous_webhook = ( - len(actual_webhook_urls) > 0 and webhook_url not in actual_webhook_urls - ) + try: + check_webhook_result = None + with contextlib.suppress(Exception): + check_webhook_result = await api.get_webook_configuration() - if need_clean_previous_webhook: - # it seems is impossible to register multiple webhook. - # So, if webhook already exists, we delete it - await api.delete_webhook(actual_webhook_urls[0]) - _LOGGER.debug( - "Deleted previous Switchbot cloud webhook url: %s", - actual_webhook_urls[0], + actual_webhook_urls = ( + check_webhook_result["urls"] + if check_webhook_result and "urls" in check_webhook_result + else [] + ) + need_add_webhook = ( + len(actual_webhook_urls) == 0 or webhook_url not in actual_webhook_urls + ) + need_clean_previous_webhook = ( + len(actual_webhook_urls) > 0 and webhook_url not in actual_webhook_urls ) - if need_add_webhook: - # call api for register webhookurl - await api.setup_webhook(webhook_url) - _LOGGER.debug("Registered Switchbot cloud webhook at hass: %s", webhook_url) - - for coordinator in coordinators_by_id.values(): - coordinator.webhook_subscription_listener(True) - - _LOGGER.debug("Registered Switchbot cloud webhook at: %s", webhook_url) + if need_clean_previous_webhook: + # it seems is impossible to register multiple webhook. + # So, if webhook already exists, we delete it + await api.delete_webhook(actual_webhook_urls[0]) + _LOGGER.debug( + "Deleted previous Switchbot cloud webhook url: %s", + actual_webhook_urls[0], + ) + + if need_add_webhook: + # call api for register webhookurl + await api.setup_webhook(webhook_url) + _LOGGER.debug( + "Registered Switchbot cloud webhook at hass: %s", webhook_url + ) + + for coordinator in coordinators_by_id.values(): + coordinator.webhook_subscription_listener(True) + + _LOGGER.debug("Registered Switchbot cloud webhook at: %s", webhook_url) + except SwitchBotDeviceOfflineError as e: + _LOGGER.error("Failed to connect Switchbot cloud device: %s", e) + except SwitchBotConnectionError as e: + _LOGGER.error("Failed to connect Switchbot cloud device: %s", e) def _create_handle_webhook( diff --git a/tests/components/switchbot_cloud/conftest.py b/tests/components/switchbot_cloud/conftest.py index 93a46ec3bbe2eb..7b116baa28cf40 100644 --- a/tests/components/switchbot_cloud/conftest.py +++ b/tests/components/switchbot_cloud/conftest.py @@ -32,6 +32,29 @@ def mock_get_status(): yield mock_get_status +@pytest.fixture +def mock_setup_webhook(): + """Mock setup_webhook.""" + with patch.object(SwitchBotAPI, "setup_webhook") as mock_setup_webhook: + yield mock_setup_webhook + + +@pytest.fixture +def mock_delete_webhook(): + """Mock delete_webhook.""" + with patch.object(SwitchBotAPI, "delete_webhook") as mock_delete_webhook: + yield mock_delete_webhook + + +@pytest.fixture +def mock_get_webook_configuration(): + """Mock get_webook_configuration.""" + with patch.object( + SwitchBotAPI, "get_webook_configuration" + ) as mock_get_webook_configuration: + yield mock_get_webook_configuration + + @pytest.fixture(scope="package", autouse=True) def mock_after_command_refresh(): """Mock after command refresh.""" diff --git a/tests/components/switchbot_cloud/test_button.py b/tests/components/switchbot_cloud/test_button.py index 9c3b25b4c9ade8..018122b946019c 100644 --- a/tests/components/switchbot_cloud/test_button.py +++ b/tests/components/switchbot_cloud/test_button.py @@ -16,7 +16,11 @@ async def test_pressmode_bot( - hass: HomeAssistant, mock_list_devices, mock_get_status + hass: HomeAssistant, + mock_list_devices, + mock_get_status, + mock_setup_webhook, + mock_get_webook_configuration, ) -> None: """Test press.""" mock_list_devices.return_value = [ @@ -31,6 +35,17 @@ async def test_pressmode_bot( mock_get_status.return_value = {"deviceMode": "pressMode"} + mock_setup_webhook.return_value = { + "statusCode": 100, + "body": {}, + "message": "success", + } + mock_get_webook_configuration.return_value = { + "statusCode": 100, + "body": {}, + "message": "success", + } + entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED @@ -49,7 +64,11 @@ async def test_pressmode_bot( async def test_switchmode_bot_no_button_entity( - hass: HomeAssistant, mock_list_devices, mock_get_status + hass: HomeAssistant, + mock_list_devices, + mock_get_status, + mock_setup_webhook, + mock_get_webook_configuration, ) -> None: """Test a switchMode bot isn't added as a button.""" mock_list_devices.return_value = [ @@ -63,6 +82,16 @@ async def test_switchmode_bot_no_button_entity( ] mock_get_status.return_value = {"deviceMode": "switchMode"} + mock_setup_webhook.return_value = { + "statusCode": 100, + "body": {}, + "message": "success", + } + mock_get_webook_configuration.return_value = { + "statusCode": 100, + "body": {}, + "message": "success", + } entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/switchbot_cloud/test_switch.py b/tests/components/switchbot_cloud/test_switch.py index 67d0d516713f09..34b41dc0603fbf 100644 --- a/tests/components/switchbot_cloud/test_switch.py +++ b/tests/components/switchbot_cloud/test_switch.py @@ -56,7 +56,11 @@ async def test_relay_switch( async def test_switchmode_bot( - hass: HomeAssistant, mock_list_devices, mock_get_status + hass: HomeAssistant, + mock_list_devices, + mock_get_status, + mock_setup_webhook, + mock_get_webook_configuration, ) -> None: """Test turn on and turn off.""" mock_list_devices.return_value = [ @@ -71,6 +75,16 @@ async def test_switchmode_bot( mock_get_status.return_value = {"deviceMode": "switchMode", "power": "off"} + mock_setup_webhook.return_value = { + "statusCode": 100, + "body": {}, + "message": "success", + } + mock_get_webook_configuration.return_value = { + "statusCode": 100, + "body": {}, + "message": "success", + } entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED @@ -91,7 +105,11 @@ async def test_switchmode_bot( async def test_pressmode_bot_no_switch_entity( - hass: HomeAssistant, mock_list_devices, mock_get_status + hass: HomeAssistant, + mock_list_devices, + mock_get_status, + mock_setup_webhook, + mock_get_webook_configuration, ) -> None: """Test a pressMode bot isn't added as a switch.""" mock_list_devices.return_value = [ @@ -105,6 +123,16 @@ async def test_pressmode_bot_no_switch_entity( ] mock_get_status.return_value = {"deviceMode": "pressMode"} + mock_setup_webhook.return_value = { + "statusCode": 100, + "body": {}, + "message": "success", + } + mock_get_webook_configuration.return_value = { + "statusCode": 100, + "body": {}, + "message": "success", + } entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED From 850b034a5f4631c7e5ca3a65b3b9a4b30b1ffc73 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Mon, 6 Apr 2026 12:38:55 +0200 Subject: [PATCH 04/69] Include port in BSB-LAN configuration URL when non-default (#166480) --- homeassistant/components/bsblan/entity.py | 10 +++-- tests/components/bsblan/test_init.py | 50 +++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bsblan/entity.py b/homeassistant/components/bsblan/entity.py index e95873ac85d996..536551fe6d026e 100644 --- a/homeassistant/components/bsblan/entity.py +++ b/homeassistant/components/bsblan/entity.py @@ -2,6 +2,9 @@ from __future__ import annotations +from yarl import URL + +from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, DeviceInfo, @@ -10,7 +13,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import BSBLanData -from .const import DOMAIN +from .const import DEFAULT_PORT, DOMAIN from .coordinator import BSBLanCoordinator, BSBLanFastCoordinator, BSBLanSlowCoordinator @@ -22,7 +25,8 @@ class BSBLanEntityBase[_T: BSBLanCoordinator](CoordinatorEntity[_T]): def __init__(self, coordinator: _T, data: BSBLanData) -> None: """Initialize BSBLan entity with device info.""" super().__init__(coordinator) - host = coordinator.config_entry.data["host"] + host = coordinator.config_entry.data[CONF_HOST] + port = coordinator.config_entry.data.get(CONF_PORT, DEFAULT_PORT) mac = data.device.MAC self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, mac)}, @@ -44,7 +48,7 @@ def __init__(self, coordinator: _T, data: BSBLanData) -> None: else None ), sw_version=data.device.version, - configuration_url=f"http://{host}", + configuration_url=str(URL.build(scheme="http", host=host, port=port)), ) diff --git a/tests/components/bsblan/test_init.py b/tests/components/bsblan/test_init.py index cced08a3daa947..c2a05b851fe51a 100644 --- a/tests/components/bsblan/test_init.py +++ b/tests/components/bsblan/test_init.py @@ -6,8 +6,11 @@ from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry, async_fire_time_changed @@ -221,3 +224,50 @@ async def test_coordinator_slow_no_dhw_support( # Verify slow coordinator handled the AttributeError gracefully assert mock_bsblan.hot_water_config.called + + +async def test_configuration_url_default_port( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_bsblan: MagicMock, +) -> None: + """Test configuration_url omits port 80 (HTTP default).""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, "00:80:41:19:69:90")} + ) + assert device is not None + assert device.configuration_url == "http://127.0.0.1" + + +async def test_configuration_url_non_default_port( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_bsblan: MagicMock, +) -> None: + """Test configuration_url includes port when it differs from the default.""" + config_entry = MockConfigEntry( + title="BSBLAN Setup", + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 8080, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + unique_id="00:80:41:19:69:90", + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, "00:80:41:19:69:90")} + ) + assert device is not None + assert device.configuration_url == "http://192.168.1.100:8080" From fb1365e9a4ee5f6e95f3c2b099dda16af1692437 Mon Sep 17 00:00:00 2001 From: Alex Merkel Date: Fri, 10 Apr 2026 17:10:18 +0200 Subject: [PATCH 05/69] [LG Soundbar] Fix incorrect state for some models (#167094) --- homeassistant/components/lg_soundbar/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lg_soundbar/media_player.py b/homeassistant/components/lg_soundbar/media_player.py index f440e0ba4adcbe..fd64eebff6745a 100644 --- a/homeassistant/components/lg_soundbar/media_player.py +++ b/homeassistant/components/lg_soundbar/media_player.py @@ -41,7 +41,7 @@ class LGDevice(MediaPlayerEntity): """Representation of an LG soundbar device.""" _attr_should_poll = False - _attr_state = MediaPlayerState.OFF + _attr_state = MediaPlayerState.ON # Default to ON to ensure compatibility with models that don't send a powerstatus message _attr_supported_features = ( MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_MUTE From 98a4e27e3527eaf1ac2f3ab7661c49a2eafe8cbe Mon Sep 17 00:00:00 2001 From: Marco Sousa Date: Sun, 5 Apr 2026 10:57:27 +0100 Subject: [PATCH 06/69] Bump aiopvpc to 4.3.1 (#167189) --- homeassistant/components/pvpc_hourly_pricing/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pvpc_hourly_pricing/manifest.json b/homeassistant/components/pvpc_hourly_pricing/manifest.json index c29cd52cf96780..18287a2d5e9efe 100644 --- a/homeassistant/components/pvpc_hourly_pricing/manifest.json +++ b/homeassistant/components/pvpc_hourly_pricing/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aiopvpc"], - "requirements": ["aiopvpc==4.2.2"] + "requirements": ["aiopvpc==4.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a22ad6796594a6..2d07e2815bf750 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -366,7 +366,7 @@ aiopurpleair==2025.08.1 aiopvapi==3.3.0 # homeassistant.components.pvpc_hourly_pricing -aiopvpc==4.2.2 +aiopvpc==4.3.1 # homeassistant.components.lidarr # homeassistant.components.radarr diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c5c570ff0ff97e..d393d5703fd21a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -351,7 +351,7 @@ aiopurpleair==2025.08.1 aiopvapi==3.3.0 # homeassistant.components.pvpc_hourly_pricing -aiopvpc==4.2.2 +aiopvpc==4.3.1 # homeassistant.components.lidarr # homeassistant.components.radarr From c32d523f630cffadbfef0554e63bb12b7ab79575 Mon Sep 17 00:00:00 2001 From: Patrick Date: Sun, 5 Apr 2026 05:58:46 -0400 Subject: [PATCH 07/69] Bump starlink-grpc-core to 1.2.5 (#167195) --- homeassistant/components/starlink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/starlink/manifest.json b/homeassistant/components/starlink/manifest.json index c66896e0d4ead0..fcc397238dc494 100644 --- a/homeassistant/components/starlink/manifest.json +++ b/homeassistant/components/starlink/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/starlink", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["starlink-grpc-core==1.2.4"] + "requirements": ["starlink-grpc-core==1.2.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2d07e2815bf750..f358c8cf4c49e0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3017,7 +3017,7 @@ starline==0.1.5 starlingbank==3.2 # homeassistant.components.starlink -starlink-grpc-core==1.2.4 +starlink-grpc-core==1.2.5 # homeassistant.components.statsd statsd==3.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d393d5703fd21a..e0efe49675f404 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2556,7 +2556,7 @@ srpenergy==1.3.8 starline==0.1.5 # homeassistant.components.starlink -starlink-grpc-core==1.2.4 +starlink-grpc-core==1.2.5 # homeassistant.components.statsd statsd==3.2.1 From ed0b68ec4aa94d05bd74e62f7cbcc0ebd8ecb63d Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 9 Apr 2026 11:16:05 +0200 Subject: [PATCH 08/69] Allow force alarm actions for Comelit (#167202) --- .../components/comelit/alarm_control_panel.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/comelit/alarm_control_panel.py b/homeassistant/components/comelit/alarm_control_panel.py index de2186cf7f3b10..aa6af33c50b8df 100644 --- a/homeassistant/components/comelit/alarm_control_panel.py +++ b/homeassistant/components/comelit/alarm_control_panel.py @@ -112,7 +112,7 @@ def _area(self) -> ComelitVedoAreaObject: @property def available(self) -> bool: """Return True if alarm is available.""" - if self._area.human_status in [AlarmAreaState.ANOMALY, AlarmAreaState.UNKNOWN]: + if self._area.human_status == AlarmAreaState.UNKNOWN: return False return super().available @@ -151,7 +151,7 @@ async def async_alarm_disarm(self, code: str | None = None) -> None: if code != str(self.coordinator.api.device_pin): return await self.coordinator.api.set_zone_status( - self._area.index, ALARM_ACTIONS[DISABLE] + self._area.index, ALARM_ACTIONS[DISABLE], self._area.anomaly ) await self._async_update_state( AlarmAreaState.DISARMED, ALARM_AREA_ARMED_STATUS[DISABLE] @@ -160,7 +160,7 @@ async def async_alarm_disarm(self, code: str | None = None) -> None: async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self.coordinator.api.set_zone_status( - self._area.index, ALARM_ACTIONS[AWAY] + self._area.index, ALARM_ACTIONS[AWAY], self._area.anomaly ) await self._async_update_state( AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[AWAY] @@ -169,7 +169,7 @@ async def async_alarm_arm_away(self, code: str | None = None) -> None: async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" await self.coordinator.api.set_zone_status( - self._area.index, ALARM_ACTIONS[HOME] + self._area.index, ALARM_ACTIONS[HOME], self._area.anomaly ) await self._async_update_state( AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[HOME_P1] @@ -178,7 +178,7 @@ async def async_alarm_arm_home(self, code: str | None = None) -> None: async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" await self.coordinator.api.set_zone_status( - self._area.index, ALARM_ACTIONS[NIGHT] + self._area.index, ALARM_ACTIONS[NIGHT], self._area.anomaly ) await self._async_update_state( AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[NIGHT] From dbfde9266c1582f35f35746e2ad4abdb17b78567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Fri, 3 Apr 2026 19:41:41 +0200 Subject: [PATCH 09/69] Add Hisense AC (0x138C/0x0101) to Matter dry and fan mode device lists (#167282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com> --- homeassistant/components/matter/climate.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 6f6f87cfc86cb0..5614dd04fce030 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -116,6 +116,7 @@ (0x1209, 0x8027), (0x1209, 0x8028), (0x1209, 0x8029), + (0x138C, 0x0101), } SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = { @@ -156,6 +157,7 @@ (0x1209, 0x8028), (0x1209, 0x8029), (0x131A, 0x1000), + (0x138C, 0x0101), } SystemModeEnum = clusters.Thermostat.Enums.SystemModeEnum From d644348dc85084a186c8573db66f0760198ffc4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Mon, 6 Apr 2026 17:29:46 +0200 Subject: [PATCH 10/69] Bump pyTibber to 0.37.0 (#167283) --- homeassistant/components/tibber/__init__.py | 2 +- homeassistant/components/tibber/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tibber/conftest.py | 2 +- tests/components/tibber/test_init.py | 6 +++--- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 40a882a5b04bae..0596a5a2dc07ca 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -55,7 +55,7 @@ async def async_get_client(self, hass: HomeAssistant) -> tibber.Tibber: time_zone=dt_util.get_default_time_zone(), ssl=ssl_util.get_default_context(), ) - self._client.set_access_token(access_token) + await self._client.set_access_token(access_token) return self._client diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 14f4f26a81bc14..ceda353e743a56 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tibber"], - "requirements": ["pyTibber==0.36.0"] + "requirements": ["pyTibber==0.37.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index f358c8cf4c49e0..20675b9c27f74d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1925,7 +1925,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.36.0 +pyTibber==0.37.0 # homeassistant.components.dlink pyW215==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e0efe49675f404..65705ab9fb5300 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1668,7 +1668,7 @@ pyHomee==1.3.8 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.36.0 +pyTibber==0.37.0 # homeassistant.components.dlink pyW215==0.8.0 diff --git a/tests/components/tibber/conftest.py b/tests/components/tibber/conftest.py index 9aee6b097b4606..befc3b68c87943 100644 --- a/tests/components/tibber/conftest.py +++ b/tests/components/tibber/conftest.py @@ -183,7 +183,7 @@ def tibber_mock() -> AsyncGenerator[MagicMock]: tibber_mock.send_notification = AsyncMock() tibber_mock.rt_disconnect = AsyncMock() tibber_mock.get_homes = MagicMock(return_value=[]) - tibber_mock.set_access_token = MagicMock() + tibber_mock.set_access_token = AsyncMock() data_api_mock = MagicMock() data_api_mock.get_all_devices = AsyncMock(return_value={}) diff --git a/tests/components/tibber/test_init.py b/tests/components/tibber/test_init.py index 111ff50c0c36ba..8aab4cf7c4bd81 100644 --- a/tests/components/tibber/test_init.py +++ b/tests/components/tibber/test_init.py @@ -40,7 +40,7 @@ async def test_data_api_runtime_creates_client(hass: HomeAssistant) -> None: with patch("homeassistant.components.tibber.tibber.Tibber") as mock_client_cls: mock_client = MagicMock() - mock_client.set_access_token = MagicMock() + mock_client.set_access_token = AsyncMock() mock_client_cls.return_value = mock_client client = await runtime.async_get_client(hass) @@ -49,7 +49,7 @@ async def test_data_api_runtime_creates_client(hass: HomeAssistant) -> None: access_token="access-token", websession=ANY, time_zone=ANY, ssl=ANY ) session.async_ensure_token_valid.assert_awaited_once() - mock_client.set_access_token.assert_called_once_with("access-token") + mock_client.set_access_token.assert_awaited_once_with("access-token") assert client is mock_client mock_client.set_access_token.reset_mock() @@ -59,7 +59,7 @@ async def test_data_api_runtime_creates_client(hass: HomeAssistant) -> None: mock_client_cls.assert_called_once() session.async_ensure_token_valid.assert_awaited_once() - mock_client.set_access_token.assert_called_once_with("access-token") + mock_client.set_access_token.assert_awaited_once_with("access-token") assert cached_client is client From 6a934b5fe308c62ba70fb5ff111540acf7a134f4 Mon Sep 17 00:00:00 2001 From: Raj Laud <50647620+rajlaud@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:48:41 -0400 Subject: [PATCH 11/69] Fix victron ble reauth flow title (#167307) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/victron_ble/config_flow.py | 2 +- .../components/victron_ble/strings.json | 2 +- .../victron_ble/test_config_flow.py | 20 +++++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/victron_ble/config_flow.py b/homeassistant/components/victron_ble/config_flow.py index bde04783a4f1c9..f003f1623750ce 100644 --- a/homeassistant/components/victron_ble/config_flow.py +++ b/homeassistant/components/victron_ble/config_flow.py @@ -54,7 +54,7 @@ async def async_step_bluetooth( self._discovered_devices_info[discovery_info.address] = discovery_info self._discovered_devices[discovery_info.address] = discovery_info.name - self.context["title_placeholders"] = {"title": discovery_info.name} + self.context["title_placeholders"] = {"name": discovery_info.name} return await self.async_step_access_token() diff --git a/homeassistant/components/victron_ble/strings.json b/homeassistant/components/victron_ble/strings.json index 901594b473efa3..5478a595cd4811 100644 --- a/homeassistant/components/victron_ble/strings.json +++ b/homeassistant/components/victron_ble/strings.json @@ -18,7 +18,7 @@ "invalid_access_token": "Invalid encryption key for instant readout", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" }, - "flow_title": "{title}", + "flow_title": "{name}", "step": { "access_token": { "data": { diff --git a/tests/components/victron_ble/test_config_flow.py b/tests/components/victron_ble/test_config_flow.py index 8f3115911867d6..0e6b7145815ab0 100644 --- a/tests/components/victron_ble/test_config_flow.py +++ b/tests/components/victron_ble/test_config_flow.py @@ -307,3 +307,23 @@ async def test_async_step_reauth_device_not_found( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" assert result["errors"] == {"base": "no_devices_found"} + + +async def test_reauth_flow_sets_title_placeholders( + hass: HomeAssistant, + mock_config_entry_added_to_hass: MockConfigEntry, + mock_discovered_service_info: AsyncMock, +) -> None: + """Test that reauth flow has title_placeholders set for flow_title rendering. + + Regression test for https://github.com/home-assistant/core/issues/167105 where + the flow_title used '{title}' but HA only automatically provides 'name' in + title_placeholders for reauth flows, causing a frontend MISSING_VALUE error. + """ + await mock_config_entry_added_to_hass.start_reauth_flow(hass) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["title_placeholders"]["name"] == ( + mock_config_entry_added_to_hass.title + ) From 6d3a93df81d48640d732f769959906a91c5a7f29 Mon Sep 17 00:00:00 2001 From: MarkGodwin <10632972+MarkGodwin@users.noreply.github.com> Date: Fri, 3 Apr 2026 18:46:53 +0100 Subject: [PATCH 12/69] Update to tplink-omada-client 1.5.7 (#167313) --- homeassistant/components/tplink_omada/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json index 3a68dfe91bfa88..4e348ecb1cff59 100644 --- a/homeassistant/components/tplink_omada/manifest.json +++ b/homeassistant/components/tplink_omada/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["tplink-omada-client==1.5.6"] + "requirements": ["tplink-omada-client==1.5.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 20675b9c27f74d..2899f6612c1467 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3136,7 +3136,7 @@ toonapi==0.3.0 total-connect-client==2025.12.2 # homeassistant.components.tplink_omada -tplink-omada-client==1.5.6 +tplink-omada-client==1.5.7 # homeassistant.components.transmission transmission-rpc==7.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 65705ab9fb5300..922dc3aed9d6e7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2648,7 +2648,7 @@ toonapi==0.3.0 total-connect-client==2025.12.2 # homeassistant.components.tplink_omada -tplink-omada-client==1.5.6 +tplink-omada-client==1.5.7 # homeassistant.components.transmission transmission-rpc==7.0.3 From c7bd673d01aec2936040d395a437920ac44c3ba9 Mon Sep 17 00:00:00 2001 From: 007hacky007 <007hacky007@users.noreply.github.com> Date: Sat, 4 Apr 2026 14:55:36 +0200 Subject: [PATCH 13/69] Bump afsapi to 0.3.1 (#167321) --- homeassistant/components/frontier_silicon/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontier_silicon/manifest.json b/homeassistant/components/frontier_silicon/manifest.json index baa684786bfb14..2a3fc0255e6568 100644 --- a/homeassistant/components/frontier_silicon/manifest.json +++ b/homeassistant/components/frontier_silicon/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/frontier_silicon", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["afsapi==0.2.7"], + "requirements": ["afsapi==0.3.1"], "ssdp": [ { "st": "urn:schemas-frontier-silicon-com:undok:fsapi:1" diff --git a/requirements_all.txt b/requirements_all.txt index 2899f6612c1467..14aaab4eb55b18 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -151,7 +151,7 @@ adguardhome==0.8.1 advantage-air==0.4.4 # homeassistant.components.frontier_silicon -afsapi==0.2.7 +afsapi==0.3.1 # homeassistant.components.agent_dvr agent-py==0.0.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 922dc3aed9d6e7..81d7530a7836d3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -142,7 +142,7 @@ adguardhome==0.8.1 advantage-air==0.4.4 # homeassistant.components.frontier_silicon -afsapi==0.2.7 +afsapi==0.3.1 # homeassistant.components.agent_dvr agent-py==0.0.24 From 5c7c0a6e830f3cac91e8c19675a2ee06644d5837 Mon Sep 17 00:00:00 2001 From: cdheiser <10488026+cdheiser@users.noreply.github.com> Date: Mon, 6 Apr 2026 03:31:10 -0700 Subject: [PATCH 14/69] Bump pylutron to 0.4.1 (#167324) --- homeassistant/components/lutron/__init__.py | 19 +++++++++++++--- homeassistant/components/lutron/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/lutron/test_init.py | 22 +++++++++++++++++++ 5 files changed, 41 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index 0a15d5a20f8fa1..86c84ae23b5c0d 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -4,11 +4,20 @@ import logging from typing import Any, cast -from pylutron import Button, Keypad, Led, Lutron, OccupancyGroup, Output +from pylutron import ( + Button, + Keypad, + Led, + Lutron, + LutronException, + OccupancyGroup, + Output, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import DOMAIN @@ -57,8 +66,12 @@ async def async_setup_entry( pwd = config_entry.data[CONF_PASSWORD] lutron_client = Lutron(host, uid, pwd) - await hass.async_add_executor_job(lutron_client.load_xml_db) - lutron_client.connect() + try: + await hass.async_add_executor_job(lutron_client.load_xml_db) + lutron_client.connect() + except LutronException as ex: + raise ConfigEntryNotReady(f"Failed to connect to Lutron repeater: {ex}") from ex + _LOGGER.debug("Connected to main repeater at %s", host) entity_registry = er.async_get(hass) diff --git a/homeassistant/components/lutron/manifest.json b/homeassistant/components/lutron/manifest.json index e40203a6ccafed..b08676082cba0f 100644 --- a/homeassistant/components/lutron/manifest.json +++ b/homeassistant/components/lutron/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pylutron"], - "requirements": ["pylutron==0.4.0"], + "requirements": ["pylutron==0.4.1"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 14aaab4eb55b18..a275f6fc0d2933 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2263,7 +2263,7 @@ pylitterbot==2025.2.0 pylutron-caseta==0.27.0 # homeassistant.components.lutron -pylutron==0.4.0 +pylutron==0.4.1 # homeassistant.components.mailgun pymailgunner==1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 81d7530a7836d3..963782a26afc4d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1940,7 +1940,7 @@ pylitterbot==2025.2.0 pylutron-caseta==0.27.0 # homeassistant.components.lutron -pylutron==0.4.0 +pylutron==0.4.1 # homeassistant.components.mailgun pymailgunner==1.4 diff --git a/tests/components/lutron/test_init.py b/tests/components/lutron/test_init.py index da7148218a7e51..26d419aa35b5a4 100644 --- a/tests/components/lutron/test_init.py +++ b/tests/components/lutron/test_init.py @@ -3,7 +3,11 @@ from typing import Any, cast from unittest.mock import MagicMock +from pylutron import LutronException +import pytest + from homeassistant.components.lutron.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -45,6 +49,24 @@ async def test_unload_entry( await hass.async_block_till_done() +@pytest.mark.parametrize("method", ["load_xml_db", "connect"]) +async def test_setup_entry_not_ready( + hass: HomeAssistant, + mock_lutron: MagicMock, + mock_config_entry: MockConfigEntry, + method: str, +) -> None: + """Test setting up the integration when Lutron repeater is not ready.""" + mock_config_entry.add_to_hass(hass) + + getattr(mock_lutron, method).side_effect = LutronException(f"{method} failed") + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + async def test_unique_id_migration( hass: HomeAssistant, mock_lutron: MagicMock, mock_config_entry: MockConfigEntry ) -> None: From e85430105e2ca2173d6660815f7f62078de191c0 Mon Sep 17 00:00:00 2001 From: Jordan Harvey Date: Sun, 5 Apr 2026 16:56:01 +0100 Subject: [PATCH 15/69] Bump cryptography to 46.0.6 (#167330) Co-authored-by: Robert Resch --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7b566c233192bd..3894922105e426 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ cached-ipaddress==1.0.1 certifi>=2021.5.30 ciso8601==2.3.3 cronsim==2.7 -cryptography==46.0.5 +cryptography==46.0.6 dbus-fast==3.1.2 file-read-backwards==2.0.0 fnv-hash-fast==2.0.0 diff --git a/pyproject.toml b/pyproject.toml index b316152e7d6c30..f6b2b03371774d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ dependencies = [ "lru-dict==1.3.0", "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. - "cryptography==46.0.5", + "cryptography==46.0.6", "Pillow==12.1.1", "propcache==0.4.1", "pyOpenSSL==26.0.0", diff --git a/requirements.txt b/requirements.txt index fca3d009ed0712..0527a296113749 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ bcrypt==5.0.0 certifi>=2021.5.30 ciso8601==2.3.3 cronsim==2.7 -cryptography==46.0.5 +cryptography==46.0.6 fnv-hash-fast==2.0.0 ha-ffmpeg==3.2.2 hass-nabucasa==2.2.0 From 040192c1035803d0edfd307c9a0e75f1a30fa9c5 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 5 Apr 2026 10:43:24 +0200 Subject: [PATCH 16/69] Align and cleanup tests data for Fritz (#167363) --- tests/components/fritz/const.py | 19 +++++++ .../fritz/snapshots/test_diagnostics.ambr | 1 + .../fritz/snapshots/test_switch.ambr | 51 +++++++++++++++++++ tests/components/fritz/test_image.py | 36 +++---------- 4 files changed, 79 insertions(+), 28 deletions(-) diff --git a/tests/components/fritz/const.py b/tests/components/fritz/const.py index a007b57d842af8..49ab453dac3c4f 100644 --- a/tests/components/fritz/const.py +++ b/tests/components/fritz/const.py @@ -200,6 +200,25 @@ "NewBeaconAdvertisementEnabled": True, }, }, + "WLANConfiguration2": { + "GetInfo": { + "NewEnable": True, + "NewStatus": "Up", + "NewSSID": "GuestWifi", + "NewBeaconType": "11iandWPA3", + "NewX_AVM-DE_PossibleBeaconTypes": "None,11i,11iandWPA3", + "NewStandard": "ax", + "NewBSSID": "1C:ED:6F:12:34:13", + "NewMACAddressControlEnabled": True, + }, + "GetSSID": { + "NewSSID": "GuestWifi", + }, + "GetSecurityKeys": {"NewKeyPassphrase": "1234567890"}, + "GetBeaconAdvertisement": { + "NewBeaconAdvertisementEnabled": True, + }, + }, "X_AVM-DE_Homeauto1": { "GetGenericDeviceInfos": [ { diff --git a/tests/components/fritz/snapshots/test_diagnostics.ambr b/tests/components/fritz/snapshots/test_diagnostics.ambr index 8bf3416df49067..bfea484e7f77d8 100644 --- a/tests/components/fritz/snapshots/test_diagnostics.ambr +++ b/tests/components/fritz/snapshots/test_diagnostics.ambr @@ -38,6 +38,7 @@ 'WANIPConn1', 'WANPPPConnection1', 'WLANConfiguration1', + 'WLANConfiguration2', 'X_AVM-DE_Homeauto1', 'X_AVM-DE_HostFilter1', 'X_AVM-DE_UPnP1', diff --git a/tests/components/fritz/snapshots/test_switch.ambr b/tests/components/fritz/snapshots/test_switch.ambr index 75e53fbf5b7045..8789576d970c79 100644 --- a/tests/components/fritz/snapshots/test_switch.ambr +++ b/tests/components/fritz/snapshots/test_switch.ambr @@ -920,6 +920,57 @@ 'state': 'on', }) # --- +# name: test_switch_setup[fc_data3][switch.mock_title_wi_fi_guestwifi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mock_title_wi_fi_guestwifi', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Mock Title Wi-Fi GuestWifi', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Mock Title Wi-Fi GuestWifi', + 'platform': 'fritz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1C:ED:6F:12:34:11-wi_fi_guestwifi', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_setup[fc_data3][switch.mock_title_wi_fi_guestwifi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Wi-Fi GuestWifi', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'switch.mock_title_wi_fi_guestwifi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_setup[fc_data3][switch.mock_title_wi_fi_mywifi-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/fritz/test_image.py b/tests/components/fritz/test_image.py index f7ff9178ab3ffc..8f553bfd3bd268 100644 --- a/tests/components/fritz/test_image.py +++ b/tests/components/fritz/test_image.py @@ -22,29 +22,9 @@ from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import ClientSessionGenerator -GUEST_WIFI_ENABLED: dict[str, dict] = { - "WLANConfiguration0": {}, - "WLANConfiguration1": { - "GetBeaconAdvertisement": {"NewBeaconAdvertisementEnabled": 1}, - "GetInfo": { - "NewEnable": True, - "NewStatus": "Up", - "NewSSID": "GuestWifi", - "NewBeaconType": "11iandWPA3", - "NewX_AVM-DE_PossibleBeaconTypes": "None,11i,11iandWPA3", - "NewStandard": "ax", - "NewBSSID": "1C:ED:6F:12:34:13", - }, - "GetSSID": { - "NewSSID": "GuestWifi", - }, - "GetSecurityKeys": {"NewKeyPassphrase": "1234567890"}, - }, -} - GUEST_WIFI_CHANGED: dict[str, dict] = { - "WLANConfiguration0": {}, - "WLANConfiguration1": { + "WLANConfiguration1": {}, + "WLANConfiguration2": { "GetBeaconAdvertisement": {"NewBeaconAdvertisementEnabled": 1}, "GetInfo": { "NewEnable": True, @@ -63,8 +43,8 @@ } GUEST_WIFI_DISABLED: dict[str, dict] = { - "WLANConfiguration0": {}, - "WLANConfiguration1": { + "WLANConfiguration1": {}, + "WLANConfiguration2": { "GetBeaconAdvertisement": {"NewBeaconAdvertisementEnabled": 1}, "GetInfo": { "NewEnable": False, @@ -86,7 +66,7 @@ @pytest.mark.parametrize( ("fc_data"), [ - ({**MOCK_FB_SERVICES, **GUEST_WIFI_ENABLED}), + ({**MOCK_FB_SERVICES}), ({**MOCK_FB_SERVICES, **GUEST_WIFI_DISABLED}), ], ) @@ -139,7 +119,7 @@ async def test_image_entity( assert body == snapshot -@pytest.mark.parametrize(("fc_data"), [({**MOCK_FB_SERVICES, **GUEST_WIFI_ENABLED})]) +@pytest.mark.parametrize(("fc_data"), [({**MOCK_FB_SERVICES})]) async def test_image_update( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -180,7 +160,7 @@ async def test_image_update( assert resp_body_new == snapshot -@pytest.mark.parametrize(("fc_data"), [({**MOCK_FB_SERVICES, **GUEST_WIFI_ENABLED})]) +@pytest.mark.parametrize(("fc_data"), [({**MOCK_FB_SERVICES})]) async def test_image_update_unavailable( hass: HomeAssistant, freezer: FrozenDateTimeFactory, @@ -244,7 +224,7 @@ async def test_migrate_to_new_unique_id( ) entry.add_to_hass(hass) - old_unique_id = slugify(f"{MOCK_MESH_MASTER_MAC}-MyWifi-qr-code") + old_unique_id = slugify(f"{MOCK_MESH_MASTER_MAC}-GuestWifi-qr-code") new_unique_id = f"{MOCK_MESH_MASTER_MAC}-guest_wifi_qr_code" entity_registry.async_get_or_create( From 5f2fe4ffd4fd5be30157d2598d13adeefbb4da8f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 4 Apr 2026 20:59:34 +0200 Subject: [PATCH 17/69] Bump aiohue to 4.8.1 (#167369) --- homeassistant/components/hue/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 0adc0dfc3b3e80..58272b5b1a53e0 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -10,6 +10,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiohue"], - "requirements": ["aiohue==4.8.0"], + "requirements": ["aiohue==4.8.1"], "zeroconf": ["_hue._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index a275f6fc0d2933..c1f73fe5a5e392 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -288,7 +288,7 @@ aiohomekit==3.2.20 aiohttp_sse==2.2.0 # homeassistant.components.hue -aiohue==4.8.0 +aiohue==4.8.1 # homeassistant.components.imap aioimaplib==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 963782a26afc4d..cf08bce41c062e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -276,7 +276,7 @@ aiohomekit==3.2.20 aiohttp_sse==2.2.0 # homeassistant.components.hue -aiohue==4.8.0 +aiohue==4.8.1 # homeassistant.components.imap aioimaplib==2.0.1 From 586d7ab52610cc98f43473c2e6c44b4a04b5ae46 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 6 Apr 2026 13:00:14 +0200 Subject: [PATCH 18/69] Improve ProxmoxVE permissions handling (#167370) --- homeassistant/components/proxmoxve/button.py | 26 ++++++++++++++----- homeassistant/components/proxmoxve/const.py | 1 + .../components/proxmoxve/strings.json | 2 +- tests/components/proxmoxve/__init__.py | 10 +++++-- tests/components/proxmoxve/test_button.py | 3 ++- 5 files changed, 32 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/proxmoxve/button.py b/homeassistant/components/proxmoxve/button.py index 833600d8ebddd6..3af7401c36ad26 100644 --- a/homeassistant/components/proxmoxve/button.py +++ b/homeassistant/components/proxmoxve/button.py @@ -28,14 +28,17 @@ from .entity import ProxmoxContainerEntity, ProxmoxNodeEntity, ProxmoxVMEntity from .helpers import is_granted +NO_PERM_VM_LXC_POWER = "no_permission_vm_lxc_power" + @dataclass(frozen=True, kw_only=True) class ProxmoxNodeButtonNodeEntityDescription(ButtonEntityDescription): """Class to hold Proxmox node button description.""" press_action: Callable[[ProxmoxCoordinator, str], None] - permission: ProxmoxPermission = ProxmoxPermission.POWER + permission: ProxmoxPermission = ProxmoxPermission.SYSPOWER permission_raise: str = "no_permission_node_power" + permission_target: str = "nodes" @dataclass(frozen=True, kw_only=True) @@ -44,7 +47,8 @@ class ProxmoxVMButtonEntityDescription(ButtonEntityDescription): press_action: Callable[[ProxmoxCoordinator, str, int], None] permission: ProxmoxPermission = ProxmoxPermission.POWER - permission_raise: str = "no_permission_vm_lxc_power" + permission_raise: str = NO_PERM_VM_LXC_POWER + permission_target: str = "vms" @dataclass(frozen=True, kw_only=True) @@ -53,7 +57,8 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): press_action: Callable[[ProxmoxCoordinator, str, int], None] permission: ProxmoxPermission = ProxmoxPermission.POWER - permission_raise: str = "no_permission_vm_lxc_power" + permission_raise: str = NO_PERM_VM_LXC_POWER + permission_target: str = "vms" NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = ( @@ -76,6 +81,9 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): ProxmoxNodeButtonNodeEntityDescription( key="start_all", translation_key="start_all", + permission=ProxmoxPermission.POWER, + permission_raise=NO_PERM_VM_LXC_POWER, + permission_target="vms", press_action=lambda coordinator, node: coordinator.proxmox.nodes( node ).startall.post(), @@ -84,6 +92,9 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): ProxmoxNodeButtonNodeEntityDescription( key="stop_all", translation_key="stop_all", + permission=ProxmoxPermission.POWER, + permission_raise=NO_PERM_VM_LXC_POWER, + permission_target="vms", press_action=lambda coordinator, node: coordinator.proxmox.nodes( node ).stopall.post(), @@ -92,6 +103,9 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): ProxmoxNodeButtonNodeEntityDescription( key="suspend_all", translation_key="suspend_all", + permission=ProxmoxPermission.POWER, + permission_raise=NO_PERM_VM_LXC_POWER, + permission_target="vms", press_action=lambda coordinator, node: coordinator.proxmox.nodes( node ).suspendall.post(), @@ -327,7 +341,7 @@ async def _async_press_call(self) -> None: node_id = self._node_data.node["node"] if not is_granted( self.coordinator.permissions, - p_type="nodes", + p_type=self.entity_description.permission_target, p_id=node_id, permission=self.entity_description.permission, ): @@ -352,7 +366,7 @@ async def _async_press_call(self) -> None: vmid = self.vm_data["vmid"] if not is_granted( self.coordinator.permissions, - p_type="vms", + p_type=self.entity_description.permission_target, p_id=vmid, permission=self.entity_description.permission, ): @@ -379,7 +393,7 @@ async def _async_press_call(self) -> None: # Container power actions fall under vms if not is_granted( self.coordinator.permissions, - p_type="vms", + p_type=self.entity_description.permission_target, p_id=vmid, permission=self.entity_description.permission, ): diff --git a/homeassistant/components/proxmoxve/const.py b/homeassistant/components/proxmoxve/const.py index babedc499a794d..cd7bd7db54b6b8 100644 --- a/homeassistant/components/proxmoxve/const.py +++ b/homeassistant/components/proxmoxve/const.py @@ -41,3 +41,4 @@ class ProxmoxPermission(StrEnum): POWER = "VM.PowerMgmt" SNAPSHOT = "VM.Snapshot" + SYSPOWER = "Sys.PowerMgmt" diff --git a/homeassistant/components/proxmoxve/strings.json b/homeassistant/components/proxmoxve/strings.json index 12ee765d9f23ed..695d2ef2d9d8b5 100644 --- a/homeassistant/components/proxmoxve/strings.json +++ b/homeassistant/components/proxmoxve/strings.json @@ -313,7 +313,7 @@ "message": "No active nodes were found on the Proxmox VE server." }, "no_permission_node_power": { - "message": "The configured Proxmox VE user does not have permission to manage the power state of nodes. Please grant the user the 'VM.PowerMgmt' permission and try again." + "message": "The configured Proxmox VE user does not have permission to manage the power state of nodes. Please grant the user the 'Sys.PowerMgmt' permission and try again." }, "no_permission_snapshot": { "message": "The configured Proxmox VE user does not have permission to create snapshots of VMs and containers. Please grant the user the 'VM.Snapshot' permission and try again." diff --git a/tests/components/proxmoxve/__init__.py b/tests/components/proxmoxve/__init__.py index e8c596b77dc375..1cf65ea7874689 100644 --- a/tests/components/proxmoxve/__init__.py +++ b/tests/components/proxmoxve/__init__.py @@ -25,8 +25,14 @@ } POWER_PERMISSIONS = { - "/": {"VM.PowerMgmt": 1}, - "/nodes": {"VM.PowerMgmt": 1}, + "/": { + "Sys.PowerMgmt": 1, + "VM.PowerMgmt": 1, + }, + "/nodes": { + "Sys.PowerMgmt": 1, + "VM.PowerMgmt": 1, + }, "/vms": {"VM.PowerMgmt": 1}, "/vms/101": {"VM.PowerMgmt": 0}, } diff --git a/tests/components/proxmoxve/test_button.py b/tests/components/proxmoxve/test_button.py index fb2c5a8850824c..eb91c17ab47b59 100644 --- a/tests/components/proxmoxve/test_button.py +++ b/tests/components/proxmoxve/test_button.py @@ -367,7 +367,8 @@ async def test_container_buttons_exceptions( @pytest.mark.parametrize( ("entity_id", "translation_key"), [ - ("button.pve1_start_all", "no_permission_node_power"), + ("button.pve1_shut_down", "no_permission_node_power"), + ("button.pve1_start_all", "no_permission_vm_lxc_power"), ("button.ct_nginx_start", "no_permission_vm_lxc_power"), ("button.vm_web_start", "no_permission_vm_lxc_power"), ("button.vm_web_create_snapshot", "no_permission_snapshot"), From 96a9b89412e9f04779855843f8c5bfd4e60a0fd8 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 6 Apr 2026 12:43:57 +0200 Subject: [PATCH 19/69] Bump axis to v68 to improve MQTT event resiliance (#167373) --- homeassistant/components/axis/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/axis/conftest.py | 14 ++++++----- tests/components/axis/test_hub.py | 28 +++++++++++++++++++++ 5 files changed, 39 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 072d0378ec0305..ed446f6c72ada1 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -29,7 +29,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["axis"], - "requirements": ["axis==67"], + "requirements": ["axis==68"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/requirements_all.txt b/requirements_all.txt index c1f73fe5a5e392..5e540167d858b1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -596,7 +596,7 @@ avea==1.6.1 # avion==0.10 # homeassistant.components.axis -axis==67 +axis==68 # homeassistant.components.fujitsu_fglair ayla-iot-unofficial==1.4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cf08bce41c062e..1cf47a0bb61014 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -548,7 +548,7 @@ autoskope_client==1.4.1 av==16.0.1 # homeassistant.components.axis -axis==67 +axis==68 # homeassistant.components.fujitsu_fglair ayla-iot-unofficial==1.4.7 diff --git a/tests/components/axis/conftest.py b/tests/components/axis/conftest.py index 8a452109d1db51..0684c9a23e35ef 100644 --- a/tests/components/axis/conftest.py +++ b/tests/components/axis/conftest.py @@ -68,8 +68,8 @@ def __call__( class _RtspClientMock(Protocol): - async def __call__( - self, data: dict[str, Any] | None = None, state: str = "" + def __call__( + self, data: bytes | None = None, state: Signal | None = None ) -> None: ... @@ -337,14 +337,16 @@ def stop_stream() -> None: rtsp_client_mock.return_value.stop = stop_stream - def make_rtsp_call(data: dict[str, Any] | None = None, state: str = "") -> None: + def make_rtsp_call( + data: bytes | None = None, state: Signal | None = None + ) -> None: """Generate a RTSP call.""" axis_streammanager_session_callback = rtsp_client_mock.call_args[0][4] - if data: - rtsp_client_mock.return_value.rtp.data = data + if data is not None: + rtsp_client_mock.return_value.data = data axis_streammanager_session_callback(signal=Signal.DATA) - elif state: + elif state is not None: axis_streammanager_session_callback(signal=state) else: raise NotImplementedError diff --git a/tests/components/axis/test_hub.py b/tests/components/axis/test_hub.py index 2d963cf56fbe49..7186ada3ce834e 100644 --- a/tests/components/axis/test_hub.py +++ b/tests/components/axis/test_hub.py @@ -2,6 +2,7 @@ from collections.abc import Callable from ipaddress import ip_address +import logging from types import MappingProxyType from typing import Any from unittest import mock @@ -73,6 +74,33 @@ async def test_device_support_mqtt( assert pir.name == f"{NAME} PIR 0" +@pytest.mark.parametrize("api_discovery_items", [API_DISCOVERY_MQTT]) +@pytest.mark.usefixtures("config_entry_setup") +async def test_device_support_mqtt_without_required_event_keys( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + caplog: pytest.LogCaptureFixture, +) -> None: + """Ignore non-event MQTT payloads without raising callback exceptions.""" + caplog.set_level(logging.ERROR, logger="homeassistant.components.mqtt.client") + + mqtt_call = call(f"axis/{MAC}/#", mock.ANY, 0, "utf-8", ANY) + assert mqtt_call in mqtt_mock.async_subscribe.call_args_list + + topic = f"axis/{MAC}/device" + message = ( + b'{"timestamp": 1775115420, "time": "2026-04-02T09:37:00+0200", ' + b'"zone": "CEST", "ip": "1.2.3.4", "host": "hostname", ' + b'"temperature": {"temp_main": 23.5, "temp_cpu": 24.0}, ' + b'"power": {"pwr": 4.76, "pwr-avg": 3.88, "pwr-max": 9.13}}' + ) + + async_fire_mqtt_message(hass, topic, message) + await hass.async_block_till_done() + + assert "Exception in _mqtt_message" not in caplog.text + + @pytest.mark.parametrize("api_discovery_items", [API_DISCOVERY_MQTT]) @pytest.mark.parametrize("mqtt_status_code", [401]) @pytest.mark.usefixtures("config_entry_setup") From 7188a09a590dbb41fe668623be54b07860e6428b Mon Sep 17 00:00:00 2001 From: Shai Ungar Date: Mon, 6 Apr 2026 13:39:49 +0300 Subject: [PATCH 20/69] Use dedicated session for seventeentrack to preserve login cookies (#167394) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/seventeentrack/__init__.py | 4 ++-- homeassistant/components/seventeentrack/config_flow.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/seventeentrack/__init__.py b/homeassistant/components/seventeentrack/__init__.py index 90fe9f325fae67..afb538c6b3257e 100644 --- a/homeassistant/components/seventeentrack/__init__.py +++ b/homeassistant/components/seventeentrack/__init__.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -31,7 +31,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up 17Track from a config entry.""" - session = async_get_clientsession(hass) + session = async_create_clientsession(hass) client = SeventeenTrackClient(session=session) try: diff --git a/homeassistant/components/seventeentrack/config_flow.py b/homeassistant/components/seventeentrack/config_flow.py index f4f3b3e82ae7fc..58cffbb1303b8f 100644 --- a/homeassistant/components/seventeentrack/config_flow.py +++ b/homeassistant/components/seventeentrack/config_flow.py @@ -99,5 +99,5 @@ async def async_step_user( @callback def _get_client(self): - session = aiohttp_client.async_get_clientsession(self.hass) + session = aiohttp_client.async_create_clientsession(self.hass) return SeventeenTrackClient(session=session) From 745860553c3b1984d1fddaff6f0103d094a46487 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 5 Apr 2026 12:02:32 +0200 Subject: [PATCH 21/69] Bump aiocomelit to 2.0.2 (#167414) --- homeassistant/components/comelit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index b5dbacdb66c468..f776cf6b3ee76a 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["aiocomelit"], "quality_scale": "platinum", - "requirements": ["aiocomelit==2.0.1"] + "requirements": ["aiocomelit==2.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5e540167d858b1..b36e86a113923f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -224,7 +224,7 @@ aiobafi6==0.9.0 aiobotocore==2.21.1 # homeassistant.components.comelit -aiocomelit==2.0.1 +aiocomelit==2.0.2 # homeassistant.components.dhcp aiodhcpwatcher==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1cf47a0bb61014..d31c4be57a10c6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -215,7 +215,7 @@ aiobafi6==0.9.0 aiobotocore==2.21.1 # homeassistant.components.comelit -aiocomelit==2.0.1 +aiocomelit==2.0.2 # homeassistant.components.dhcp aiodhcpwatcher==1.2.1 From 3333b8d019130b7085000036b6c635a545c95cdb Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Mon, 6 Apr 2026 12:38:27 +0200 Subject: [PATCH 22/69] Fix setup without dhw (#167423) --- .../components/bsblan/coordinator.py | 31 +++++++++++++------ .../components/bsblan/diagnostics.py | 4 ++- .../components/bsblan/water_heater.py | 24 +++++++++----- tests/components/bsblan/test_init.py | 24 ++++++++++++++ 4 files changed, 66 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/bsblan/coordinator.py b/homeassistant/components/bsblan/coordinator.py index e1869d5f772e94..a2805aa5ff13d8 100644 --- a/homeassistant/components/bsblan/coordinator.py +++ b/homeassistant/components/bsblan/coordinator.py @@ -10,6 +10,7 @@ BSBLAN, BSBLANAuthError, BSBLANConnectionError, + BSBLANError, HotWaterConfig, HotWaterSchedule, HotWaterState, @@ -50,7 +51,7 @@ class BSBLanFastData: state: State sensor: Sensor - dhw: HotWaterState + dhw: HotWaterState | None = None @dataclass @@ -111,7 +112,6 @@ async def _async_update_data(self) -> BSBLanFastData: # This reduces response time significantly (~0.2s per parameter) state = await self.client.state(include=STATE_INCLUDE) sensor = await self.client.sensor(include=SENSOR_INCLUDE) - dhw = await self.client.hot_water_state(include=DHW_STATE_INCLUDE) except BSBLANAuthError as err: raise ConfigEntryAuthFailed( @@ -126,6 +126,19 @@ async def _async_update_data(self) -> BSBLanFastData: translation_placeholders={"host": host}, ) from err + # Fetch DHW state separately - device may not support hot water + dhw: HotWaterState | None = None + try: + dhw = await self.client.hot_water_state(include=DHW_STATE_INCLUDE) + except BSBLANError: + # Preserve last known DHW state if available (entity may depend on it) + if self.data: + dhw = self.data.dhw + LOGGER.debug( + "DHW (Domestic Hot Water) state not available on device at %s", + self.config_entry.data[CONF_HOST], + ) + return BSBLanFastData( state=state, sensor=sensor, @@ -159,13 +172,6 @@ async def _async_update_data(self) -> BSBLanSlowData: dhw_config = await self.client.hot_water_config(include=DHW_CONFIG_INCLUDE) dhw_schedule = await self.client.hot_water_schedule() - except AttributeError: - # Device does not support DHW functionality - LOGGER.debug( - "DHW (Domestic Hot Water) not available on device at %s", - self.config_entry.data[CONF_HOST], - ) - return BSBLanSlowData() except (BSBLANConnectionError, BSBLANAuthError) as err: # If config update fails, keep existing data LOGGER.debug( @@ -177,6 +183,13 @@ async def _async_update_data(self) -> BSBLanSlowData: return self.data # First fetch failed, return empty data return BSBLanSlowData() + except BSBLANError, AttributeError: + # Device does not support DHW functionality + LOGGER.debug( + "DHW (Domestic Hot Water) not available on device at %s", + self.config_entry.data[CONF_HOST], + ) + return BSBLanSlowData() return BSBLanSlowData( dhw_config=dhw_config, diff --git a/homeassistant/components/bsblan/diagnostics.py b/homeassistant/components/bsblan/diagnostics.py index 55dedead85192b..324e2fc1497cd5 100644 --- a/homeassistant/components/bsblan/diagnostics.py +++ b/homeassistant/components/bsblan/diagnostics.py @@ -22,7 +22,9 @@ async def async_get_config_entry_diagnostics( "fast_coordinator_data": { "state": data.fast_coordinator.data.state.model_dump(), "sensor": data.fast_coordinator.data.sensor.model_dump(), - "dhw": data.fast_coordinator.data.dhw.model_dump(), + "dhw": data.fast_coordinator.data.dhw.model_dump() + if data.fast_coordinator.data.dhw + else None, }, "static": data.static.model_dump() if data.static is not None else None, } diff --git a/homeassistant/components/bsblan/water_heater.py b/homeassistant/components/bsblan/water_heater.py index ec8d01b9c710df..c91a9518f7b9a2 100644 --- a/homeassistant/components/bsblan/water_heater.py +++ b/homeassistant/components/bsblan/water_heater.py @@ -4,7 +4,7 @@ from typing import Any -from bsblan import BSBLANError, SetHotWaterParam +from bsblan import BSBLANError, HotWaterState, SetHotWaterParam from homeassistant.components.water_heater import ( STATE_ECO, @@ -46,8 +46,10 @@ async def async_setup_entry( data = entry.runtime_data # Only create water heater entity if DHW (Domestic Hot Water) is available - # Check if we have any DHW-related data indicating water heater support dhw_data = data.fast_coordinator.data.dhw + if dhw_data is None: + # Device does not support DHW, skip water heater setup + return if ( dhw_data.operating_mode is None and dhw_data.nominal_setpoint is None @@ -107,11 +109,21 @@ def __init__(self, data: BSBLanData) -> None: else: self._attr_max_temp = 65.0 # Default maximum + @property + def _dhw(self) -> HotWaterState: + """Return DHW state data. + + This entity is only created when DHW data is available. + """ + dhw = self.coordinator.data.dhw + assert dhw is not None + return dhw + @property def current_operation(self) -> str | None: """Return current operation.""" if ( - operating_mode := self.coordinator.data.dhw.operating_mode + operating_mode := self._dhw.operating_mode ) is None or operating_mode.value is None: return None return BSBLAN_TO_HA_OPERATION_MODE.get(operating_mode.value) @@ -119,16 +131,14 @@ def current_operation(self) -> str | None: @property def current_temperature(self) -> float | None: """Return the current temperature.""" - if ( - current_temp := self.coordinator.data.dhw.dhw_actual_value_top_temperature - ) is None: + if (current_temp := self._dhw.dhw_actual_value_top_temperature) is None: return None return current_temp.value @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - if (target_temp := self.coordinator.data.dhw.nominal_setpoint) is None: + if (target_temp := self._dhw.nominal_setpoint) is None: return None return target_temp.value diff --git a/tests/components/bsblan/test_init.py b/tests/components/bsblan/test_init.py index c2a05b851fe51a..bc847031a02b23 100644 --- a/tests/components/bsblan/test_init.py +++ b/tests/components/bsblan/test_init.py @@ -204,6 +204,30 @@ async def test_config_entry_timeout_error( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_coordinator_fast_no_dhw_support( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_bsblan: MagicMock, +) -> None: + """Test fast coordinator when device does not support DHW.""" + mock_bsblan.hot_water_state.side_effect = BSBLANError( + "None of the requested parameters are valid for this section" + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Integration should still load even if DHW is not supported + assert mock_config_entry.state is ConfigEntryState.LOADED + + # DHW data should be None in the fast coordinator + assert mock_config_entry.runtime_data.fast_coordinator.data.dhw is None + + # Water heater entity should not be created + assert hass.states.get("water_heater.bsb_lan") is None + + async def test_coordinator_slow_no_dhw_support( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From b5842b84844dc4064baf606d296b03c1e97dabfc Mon Sep 17 00:00:00 2001 From: Jordan Harvey Date: Mon, 6 Apr 2026 11:28:35 +0100 Subject: [PATCH 23/69] Fix handling of missing period statistics in Anglian Water coordinator (#167427) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/anglian_water/coordinator.py | 39 ++++++-- .../anglian_water/test_coordinator.py | 92 ++++++++++++++++++- 2 files changed, 121 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/anglian_water/coordinator.py b/homeassistant/components/anglian_water/coordinator.py index 81c845420a634e..7c2308148b6cb1 100644 --- a/homeassistant/components/anglian_water/coordinator.py +++ b/homeassistant/components/anglian_water/coordinator.py @@ -92,6 +92,7 @@ async def _insert_statistics(self) -> None: _LOGGER.debug("Updating statistics for the first time") usage_sum = 0.0 last_stats_time = None + allow_update_last_stored_hour = False else: if not meter.readings or len(meter.readings) == 0: _LOGGER.debug("No recent usage statistics found, skipping update") @@ -107,6 +108,7 @@ async def _insert_statistics(self) -> None: continue start = dt_util.as_local(parsed_read_at) - timedelta(hours=1) _LOGGER.debug("Getting statistics at %s", start) + stats: dict[str, list[Any]] = {} for end in (start + timedelta(seconds=1), None): stats = await get_instance(self.hass).async_add_executor_job( statistics_during_period, @@ -127,15 +129,28 @@ async def _insert_statistics(self) -> None: "Not found, trying to find oldest statistic after %s", start, ) - assert stats - def _safe_get_sum(records: list[Any]) -> float: - if records and "sum" in records[0]: - return float(records[0]["sum"]) - return 0.0 - - usage_sum = _safe_get_sum(stats.get(usage_statistic_id, [])) - last_stats_time = stats[usage_statistic_id][0]["start"] + if not stats or not stats.get(usage_statistic_id): + _LOGGER.debug( + "Could not find existing statistics during period lookup for %s, " + "falling back to last stored statistic", + usage_statistic_id, + ) + allow_update_last_stored_hour = True + last_records = last_stat[usage_statistic_id] + usage_sum = float(last_records[0].get("sum") or 0.0) + last_stats_time = last_records[0]["start"] + else: + allow_update_last_stored_hour = False + records = stats[usage_statistic_id] + + def _safe_get_sum(records: list[Any]) -> float: + if records and "sum" in records[0]: + return float(records[0]["sum"]) + return 0.0 + + usage_sum = _safe_get_sum(records) + last_stats_time = records[0]["start"] usage_statistics = [] @@ -148,7 +163,13 @@ def _safe_get_sum(records: list[Any]) -> float: ) continue start = dt_util.as_local(parsed_read_at) - timedelta(hours=1) - if last_stats_time is not None and start.timestamp() <= last_stats_time: + if last_stats_time is not None and ( + start.timestamp() < last_stats_time + or ( + start.timestamp() == last_stats_time + and not allow_update_last_stored_hour + ) + ): continue usage_state = max(0, read["consumption"] / 1000) usage_sum = max(0, read["read"]) diff --git a/tests/components/anglian_water/test_coordinator.py b/tests/components/anglian_water/test_coordinator.py index 1072b5312188d8..45d03a11f31522 100644 --- a/tests/components/anglian_water/test_coordinator.py +++ b/tests/components/anglian_water/test_coordinator.py @@ -1,6 +1,7 @@ """Tests for the Anglian Water coordinator.""" -from unittest.mock import AsyncMock +from datetime import timedelta +from unittest.mock import AsyncMock, patch from pyanglianwater.meter import SmartMeter import pytest @@ -162,3 +163,92 @@ async def test_coordinator_invalid_readings( "Could not parse read_at time also-invalid-date, skipping reading" in caplog.text ) + + +async def test_coordinator_subsequent_run_missing_period_statistics( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smart_meter: SmartMeter, + mock_anglian_water_client: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the coordinator handles missing period lookup statistics.""" + coordinator = AnglianWaterUpdateCoordinator( + hass, mock_anglian_water_client, mock_config_entry + ) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + # Correct the latest already-stored reading. Fallback should still update + # this hour instead of skipping it. + mock_smart_meter.readings[-1] = { + "read_at": "2024-06-01T14:00:00", + "consumption": 35, + "read": 70, + } + + # Add a new later reading to ensure fallback also accepts newer entries. + mock_smart_meter.readings.append( + {"read_at": "2024-06-01T15:00:00", "consumption": 20, "read": 90} + ) + + with patch( + "homeassistant.components.anglian_water.coordinator.statistics_during_period", + return_value={}, + ): + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + assert "Could not find existing statistics during period lookup" in caplog.text + + statistic_id = f"anglian_water:{ACCOUNT_NUMBER}_testsn_usage" + stats = await hass.async_add_executor_job( + get_last_statistics, hass, 1, statistic_id, True, {"sum"} + ) + assert stats[statistic_id][0]["sum"] >= 70 + + parsed_read_at = dt_util.parse_datetime("2024-06-01T14:00:00") + assert parsed_read_at is not None + corrected_start = dt_util.as_local(parsed_read_at) - timedelta(hours=1) + + corrected_stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + corrected_start, + corrected_start + timedelta(seconds=1), + { + statistic_id, + }, + "hour", + None, + {"sum"}, + ) + assert corrected_stats[statistic_id][0]["sum"] == 70 + + +async def test_coordinator_period_statistics_without_sum( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_anglian_water_client: AsyncMock, +) -> None: + """Test period lookup records without sum are handled safely.""" + coordinator = AnglianWaterUpdateCoordinator( + hass, mock_anglian_water_client, mock_config_entry + ) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + statistic_id = f"anglian_water:{ACCOUNT_NUMBER}_testsn_usage" + with patch( + "homeassistant.components.anglian_water.coordinator.statistics_during_period", + return_value={statistic_id: [{"start": 0.0}]}, + ): + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + stats = await hass.async_add_executor_job( + get_last_statistics, hass, 1, statistic_id, True, {"sum"} + ) + assert stats[statistic_id] From 3493517b6d5d3e5f33e137d30353c3ef58ed7984 Mon Sep 17 00:00:00 2001 From: Nils Ove Erstad Date: Tue, 7 Apr 2026 09:20:48 +0200 Subject: [PATCH 24/69] Fix missing color_mode initialization in MQTT JSON light schema (#167429) --- .../components/mqtt/light/schema_json.py | 2 +- tests/components/mqtt/test_light_json.py | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 6b1db79e269fbc..b388cdebb6516e 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -146,7 +146,6 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): _entity_id_format = ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED - _fixed_color_mode: ColorMode | str | None = None _flash_times: dict[str, int | None] _topic: dict[str, str | None] _optimistic: bool @@ -190,6 +189,7 @@ def _setup_from_config(self, config: ConfigType) -> None: self._attr_supported_features |= ( config[CONF_TRANSITION] and LightEntityFeature.TRANSITION ) + self._attr_color_mode = ColorMode.UNKNOWN if supported_color_modes := self._config.get(CONF_SUPPORTED_COLOR_MODES): self._attr_supported_color_modes = supported_color_modes if self.supported_color_modes and len(self.supported_color_modes) == 1: diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 570609a86c0e26..6ea2cb3a9d4e8f 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -513,6 +513,58 @@ async def test_brightness_only( assert state.state == STATE_OFF +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light", + "command_topic": "test_light/set", + "supported_color_modes": ["brightness"], + } + } + }, + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "state_topic": "test_light", + "command_topic": "test_light/set", + "supported_color_modes": ["color_temp"], + } + } + }, + ], +) +async def test_single_color_mode_turn_on( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test turning on a single color mode light does not raise. + + Regression test: PR #162715 changed _attr_color_mode default to None + and added a strict check. The JSON schema must initialize color_mode + during setup so that turn_on does not raise "does not report a color mode". + """ + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_UNKNOWN + + # This should not raise "does not report a color mode" + await common.async_turn_on(hass, "light.test") + mqtt_mock.async_publish.assert_called_once_with( + "test_light/set", '{"state":"ON"}', 0, False + ) + + async_fire_mqtt_message(hass, "test_light", '{"state":"ON", "brightness": 50}') + state = hass.states.get("light.test") + assert state.state == STATE_ON + + @pytest.mark.parametrize( "hass_config", [ From f57e682a986c6379aaa0487bf6626438129e0e75 Mon Sep 17 00:00:00 2001 From: Steve Easley Date: Mon, 6 Apr 2026 06:30:55 -0400 Subject: [PATCH 25/69] Bump jvcprojector dependency to pyjvcprojector 2.0.5 (#167450) --- homeassistant/components/jvc_projector/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/jvc_projector/manifest.json b/homeassistant/components/jvc_projector/manifest.json index c2b1243a993c5d..389b9ff2b55a97 100644 --- a/homeassistant/components/jvc_projector/manifest.json +++ b/homeassistant/components/jvc_projector/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["jvcprojector"], - "requirements": ["pyjvcprojector==2.0.3"] + "requirements": ["pyjvcprojector==2.0.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index b36e86a113923f..b90cf2400e41a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2206,7 +2206,7 @@ pyitachip2ir==0.0.7 pyituran==0.1.5 # homeassistant.components.jvc_projector -pyjvcprojector==2.0.3 +pyjvcprojector==2.0.5 # homeassistant.components.kaleidescape pykaleidescape==1.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d31c4be57a10c6..cbec360db64292 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1889,7 +1889,7 @@ pyisy==3.4.1 pyituran==0.1.5 # homeassistant.components.jvc_projector -pyjvcprojector==2.0.3 +pyjvcprojector==2.0.5 # homeassistant.components.kaleidescape pykaleidescape==1.1.3 From a892b5364dbb34d0ec7277271e90d5b77e841dac Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Mon, 6 Apr 2026 03:29:27 -0700 Subject: [PATCH 26/69] Fix nzbget positional argument mismatch in NZBGetAPI calls (#167456) --- homeassistant/components/nzbget/config_flow.py | 12 ++++++------ homeassistant/components/nzbget/coordinator.py | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/nzbget/config_flow.py b/homeassistant/components/nzbget/config_flow.py index a99d3d3f328b0e..c13333f7a94bdc 100644 --- a/homeassistant/components/nzbget/config_flow.py +++ b/homeassistant/components/nzbget/config_flow.py @@ -30,12 +30,12 @@ def _validate_input(data: dict[str, Any]) -> None: Data has the keys from DATA_SCHEMA with values provided by the user. """ nzbget_api = NZBGetAPI( - data[CONF_HOST], - data.get(CONF_USERNAME), - data.get(CONF_PASSWORD), - data[CONF_SSL], - data[CONF_VERIFY_SSL], - data[CONF_PORT], + host=data[CONF_HOST], + username=data.get(CONF_USERNAME), + password=data.get(CONF_PASSWORD), + secure=data[CONF_SSL], + verify_certificate=data[CONF_VERIFY_SSL], + port=data[CONF_PORT], ) nzbget_api.version() diff --git a/homeassistant/components/nzbget/coordinator.py b/homeassistant/components/nzbget/coordinator.py index 9e6b06da7609eb..da3da03b15d3b2 100644 --- a/homeassistant/components/nzbget/coordinator.py +++ b/homeassistant/components/nzbget/coordinator.py @@ -35,12 +35,12 @@ def __init__( ) -> None: """Initialize global NZBGet data updater.""" self.nzbget = NZBGetAPI( - config_entry.data[CONF_HOST], - config_entry.data.get(CONF_USERNAME), - config_entry.data.get(CONF_PASSWORD), - config_entry.data[CONF_SSL], - config_entry.data[CONF_VERIFY_SSL], - config_entry.data[CONF_PORT], + host=config_entry.data[CONF_HOST], + username=config_entry.data.get(CONF_USERNAME), + password=config_entry.data.get(CONF_PASSWORD), + secure=config_entry.data[CONF_SSL], + verify_certificate=config_entry.data[CONF_VERIFY_SSL], + port=config_entry.data[CONF_PORT], ) self._completed_downloads_init = False From 6f4aca495b5c47dd22255c8a241908853dd20874 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 6 Apr 2026 03:05:06 -0700 Subject: [PATCH 27/69] Update roborock services to raise ServiceNotSupported for new devices that don't yet support it (#167470) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/roborock/vacuum.py | 30 +++++++- tests/components/roborock/test_vacuum.py | 82 ++++++++++++++++++++- 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 623644379a97d9..68259aa15d7ec7 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -19,7 +19,11 @@ VacuumEntityFeature, ) from homeassistant.core import HomeAssistant, ServiceResponse, callback -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ( + HomeAssistantError, + ServiceNotSupported, + ServiceValidationError, +) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN @@ -484,6 +488,18 @@ async def async_send_command( }, ) from err + async def get_maps(self) -> ServiceResponse: + """Get map information such as map id and room ids.""" + raise ServiceNotSupported(DOMAIN, "get_maps", self.entity_id) + + async def get_vacuum_current_position(self) -> ServiceResponse: + """Get the current position of the vacuum from the map.""" + raise ServiceNotSupported(DOMAIN, "get_vacuum_current_position", self.entity_id) + + async def async_set_vacuum_goto_position(self, x: int, y: int) -> None: + """Set the vacuum to go to a specific position.""" + raise ServiceNotSupported(DOMAIN, "set_vacuum_goto_position", self.entity_id) + class RoborockQ10Vacuum(RoborockCoordinatedEntityB01Q10, StateVacuumEntity): """Representation of a Roborock Q10 vacuum.""" @@ -654,3 +670,15 @@ async def async_send_command( "command": command, }, ) from err + + async def get_maps(self) -> ServiceResponse: + """Get map information such as map id and room ids.""" + raise ServiceNotSupported(DOMAIN, "get_maps", self.entity_id) + + async def get_vacuum_current_position(self) -> ServiceResponse: + """Get the current position of the vacuum from the map.""" + raise ServiceNotSupported(DOMAIN, "get_vacuum_current_position", self.entity_id) + + async def async_set_vacuum_goto_position(self, x: int, y: int) -> None: + """Set the vacuum to go to a specific position.""" + raise ServiceNotSupported(DOMAIN, "set_vacuum_goto_position", self.entity_id) diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 8cc32b0eb60eda..953c390b8e148c 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -34,7 +34,11 @@ ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ( + HomeAssistantError, + ServiceNotSupported, + ServiceValidationError, +) from homeassistant.helpers import ( device_registry as dr, entity_registry as er, @@ -217,6 +221,31 @@ async def test_get_maps( assert response == snapshot +@pytest.mark.parametrize( + "entity_id", + [ + Q7_ENTITY_ID, + Q10_ENTITY_ID, + ], +) +async def test_get_maps_not_supported( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + entity_id: str, +) -> None: + """Test that unsupported vacuums raise ServiceNotSupported for get_maps.""" + with pytest.raises( + ServiceNotSupported, match="does not support action roborock.get_maps" + ): + await hass.services.async_call( + DOMAIN, + GET_MAPS_SERVICE_NAME, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + return_response=True, + ) + + async def test_goto( hass: HomeAssistant, setup_entry: MockConfigEntry, @@ -239,6 +268,31 @@ async def test_goto( ) +@pytest.mark.parametrize( + "entity_id", + [ + Q7_ENTITY_ID, + Q10_ENTITY_ID, + ], +) +async def test_goto_not_supported( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + entity_id: str, +) -> None: + """Test that unsupported vacuums raise ServiceNotSupported for goto.""" + with pytest.raises( + ServiceNotSupported, + match="does not support action roborock.set_vacuum_goto_position", + ): + await hass.services.async_call( + DOMAIN, + SET_VACUUM_GOTO_POSITION_SERVICE_NAME, + {ATTR_ENTITY_ID: entity_id, "x": 25500, "y": 25500}, + blocking=True, + ) + + async def test_get_current_position( hass: HomeAssistant, setup_entry: MockConfigEntry, @@ -305,6 +359,32 @@ async def test_get_current_position_no_robot_position( ) +@pytest.mark.parametrize( + "entity_id", + [ + Q7_ENTITY_ID, + Q10_ENTITY_ID, + ], +) +async def test_get_current_position_not_supported( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + entity_id: str, +) -> None: + """Test that the current-position service raises ServiceNotSupported.""" + with pytest.raises( + ServiceNotSupported, + match="does not support action roborock.get_vacuum_current_position", + ): + await hass.services.async_call( + DOMAIN, + GET_VACUUM_CURRENT_POSITION_SERVICE_NAME, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + return_response=True, + ) + + async def test_get_segments( hass: HomeAssistant, setup_entry: MockConfigEntry, From b028e2a6ae9a088b438c5b0baf1e17cf3a66b2e5 Mon Sep 17 00:00:00 2001 From: Andrea Turri Date: Mon, 6 Apr 2026 12:08:29 +0200 Subject: [PATCH 28/69] Miele - fix core temperature reading (#167476) --- homeassistant/components/miele/const.py | 4 + homeassistant/components/miele/sensor.py | 16 ++- tests/components/miele/test_sensor.py | 127 ++++++++++++++++++++++- 3 files changed, 141 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index 52d728ef9db966..96794aa1edb210 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -19,9 +19,13 @@ LIGHT_ON = 1 LIGHT_OFF = 2 +# API "no reading" sentinels. Most temperatures use centidegrees (-32768 -> -327.68 °C). +# Some devices report the int16 minimum already in degrees after scaling (-3276800 raw -> -32768 °C). DISABLED_TEMP_ENTITIES = ( -32768 / 100, -32766 / 100, + -32768.0, + -32766.0, ) diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index 9802000e8c42d4..a723763ea35669 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -93,7 +93,14 @@ def _convert_temperature( """Convert temperature object to readable value.""" if index >= len(value_list): return None - raw_value = cast(int, value_list[index].temperature) / 100.0 + raw = value_list[index].temperature + if raw is None: + return None + try: + raw_centi = int(raw) + except TypeError, ValueError: + return None + raw_value = raw_centi / 100.0 if raw_value in DISABLED_TEMP_ENTITIES: return None return raw_value @@ -639,6 +646,7 @@ class MieleSensorDefinition[T: (MieleDevice, MieleFillingLevel)]: MieleAppliance.OVEN, MieleAppliance.OVEN_MICROWAVE, MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MK2, ), description=MieleSensorDescription( key="state_core_temperature", @@ -840,9 +848,9 @@ def _is_sensor_enabled( and definition.description.value_fn(device) is None and definition.description.zone != 1 ): - # all appliances supporting temperature have at least zone 1, for other zones - # don't create entity if API signals that datapoint is disabled, unless the sensor - # already appeared in the past (= it provided a valid value) + # Optional temperature datapoints (extra fridge zones, oven food probe): only + # create the entity after the API first reports a valid reading, then keep it + # so state can return to unknown when the datapoint is inactive. return _is_entity_registered(unique_id) if ( definition.description.key == "state_plate_step" diff --git a/tests/components/miele/test_sensor.py b/tests/components/miele/test_sensor.py index d6b5106eccbf9b..45568c0d2184fb 100644 --- a/tests/components/miele/test_sensor.py +++ b/tests/components/miele/test_sensor.py @@ -4,11 +4,12 @@ from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory -from pymiele import MieleDevices +from pymiele import MieleDevices, MieleTemperature import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.miele.const import DOMAIN +from homeassistant.components.miele.sensor import _convert_temperature from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State @@ -96,7 +97,7 @@ async def test_oven_temperatures_scenario( ) -> None: """Parametrized test for verifying temperature sensors for oven devices.""" - # Initial state when the oven is and created for the first time - don't know if it supports core temperature (probe) + # Initial state when the oven is created for the first time — no core probe entities yet check_sensor_state(hass, "sensor.oven_temperature", "unknown", 0) check_sensor_state(hass, "sensor.oven_target_temperature", "unknown", 0) check_sensor_state(hass, "sensor.oven_core_temperature", None, 0) @@ -206,6 +207,95 @@ def check_sensor_state( ) +@pytest.mark.parametrize("load_device_file", ["oven.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +async def test_oven_core_probe_sensors_unknown_when_inactive( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, + device_fixture: MieleDevices, + freezer: FrozenDateTimeFactory, +) -> None: + """Oven food-probe (core) sensors must not expose API inactive sentinels as temperatures. + + Miele uses raw value -32768 (centidegrees) when the probe is not in use. After the + probe has reported a valid reading once, those entities must stay in the UI but + their state must be unknown—not a bogus numeric temperature. + """ + core_temp = "sensor.oven_core_temperature" + core_target = "sensor.oven_core_target_temperature" + + assert hass.states.get(core_temp) is None + assert hass.states.get(core_target) is None + + device_fixture["DummyOven"]["state"]["coreTargetTemperature"][0]["value_raw"] = 3000 + device_fixture["DummyOven"]["state"]["coreTargetTemperature"][0][ + "value_localized" + ] = 30.0 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_raw"] = 2200 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_localized"] = 22.0 + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(core_temp) is not None + assert hass.states.get(core_temp).state == "22.0" + assert hass.states.get(core_target) is not None + assert hass.states.get(core_target).state == "30.0" + + device_fixture["DummyOven"]["state"]["coreTargetTemperature"][0][ + "value_raw" + ] = -32768 + device_fixture["DummyOven"]["state"]["coreTargetTemperature"][0][ + "value_localized" + ] = None + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_raw"] = -32768 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_localized"] = None + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(core_temp).state == STATE_UNKNOWN + assert hass.states.get(core_target).state == STATE_UNKNOWN + + +@pytest.mark.parametrize("load_device_file", ["oven.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +async def test_oven_core_probe_unknown_when_inactive_raw_scaled( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, + device_fixture: MieleDevices, + freezer: FrozenDateTimeFactory, +) -> None: + """Some ovens report int16-min as centidegrees (-32768 -> -327.68 °C); others as -3276800 raw (-32768 °C). + + Both must map to unknown, not a numeric sensor state. + """ + core_temp = "sensor.oven_core_temperature" + + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_raw"] = 2200 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_localized"] = 22.0 + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(core_temp) is not None + assert hass.states.get(core_temp).state == "22.0" + + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_raw"] = -3276800 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_localized"] = None + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(core_temp).state == STATE_UNKNOWN + + @pytest.mark.parametrize("load_device_file", ["oven.json"]) @pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) async def test_temperature_sensor_registry_lookup( @@ -747,3 +837,36 @@ async def test_elapsed_time_sensor_restored( state = hass.states.get(entity_id_abs) assert state is not None assert state.state == "2025-05-31T14:15:00+00:00" + + +def _core_temperature_entry(value_raw: object | None) -> MieleTemperature: + """Build a MieleTemperature like the API returns for core/zone readings.""" + return MieleTemperature({"value_raw": value_raw}) + + +@pytest.mark.parametrize( + ("entries", "index", "expected"), + [ + ([], 0, None), + ([_core_temperature_entry(2200)], 1, None), + ([_core_temperature_entry(None)], 0, None), + ([_core_temperature_entry(-32768)], 0, None), + ([_core_temperature_entry(-32766)], 0, None), + ([_core_temperature_entry(-3276800)], 0, None), + ([_core_temperature_entry(-3276600)], 0, None), + ([_core_temperature_entry(2150)], 0, 21.5), + ], +) +def test_convert_temperature( + entries: list[MieleTemperature], + index: int, + expected: float | None, +) -> None: + """Cover _convert_temperature branches (sentinels, scaling, bounds, valid values).""" + assert _convert_temperature(entries, index) == expected + + +def test_convert_temperature_invalid_raw_types() -> None: + """int() must not raise: bad API payloads become unknown.""" + assert _convert_temperature([_core_temperature_entry("n/a")], 0) is None + assert _convert_temperature([_core_temperature_entry([1])], 0) is None From ca9945f75055d31c65141d3b9b663681751d091b Mon Sep 17 00:00:00 2001 From: Nick Haghiri <59633028+ElCruncharino@users.noreply.github.com> Date: Mon, 6 Apr 2026 06:11:25 -0400 Subject: [PATCH 29/69] Bump b2sdk to 2.10.4 (#167481) --- homeassistant/components/backblaze_b2/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/backblaze_b2/manifest.json b/homeassistant/components/backblaze_b2/manifest.json index 71eed534584a6a..13d3521519b863 100644 --- a/homeassistant/components/backblaze_b2/manifest.json +++ b/homeassistant/components/backblaze_b2/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["b2sdk"], "quality_scale": "bronze", - "requirements": ["b2sdk==2.10.1"] + "requirements": ["b2sdk==2.10.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index b90cf2400e41a1..b4492e7b948292 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -617,7 +617,7 @@ azure-servicebus==7.10.0 azure-storage-blob==12.24.0 # homeassistant.components.backblaze_b2 -b2sdk==2.10.1 +b2sdk==2.10.4 # homeassistant.components.holiday babel==2.15.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cbec360db64292..5e65d772e98e63 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -566,7 +566,7 @@ azure-kusto-ingest==4.5.1 azure-storage-blob==12.24.0 # homeassistant.components.backblaze_b2 -b2sdk==2.10.1 +b2sdk==2.10.4 # homeassistant.components.holiday babel==2.15.0 From e5ff7a9944b988cd220070a9b37c772b00bd302b Mon Sep 17 00:00:00 2001 From: Nick Haghiri <59633028+ElCruncharino@users.noreply.github.com> Date: Mon, 6 Apr 2026 06:12:55 -0400 Subject: [PATCH 30/69] Handle BadRequest exception in Backblaze B2 config flow and setup (#167482) --- homeassistant/components/backblaze_b2/__init__.py | 6 ++++++ .../components/backblaze_b2/config_flow.py | 8 ++++++++ .../components/backblaze_b2/strings.json | 4 ++++ tests/components/backblaze_b2/test_config_flow.py | 15 +++++++++++++++ tests/components/backblaze_b2/test_init.py | 1 + 5 files changed, 34 insertions(+) diff --git a/homeassistant/components/backblaze_b2/__init__.py b/homeassistant/components/backblaze_b2/__init__.py index 3a8d53f5b2a5eb..a2767d2f0afa28 100644 --- a/homeassistant/components/backblaze_b2/__init__.py +++ b/homeassistant/components/backblaze_b2/__init__.py @@ -74,6 +74,12 @@ def _authorize_and_get_bucket_sync() -> Bucket: translation_domain=DOMAIN, translation_key="invalid_bucket_name", ) from err + except exception.BadRequest as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="bad_request", + translation_placeholders={"error_message": str(err)}, + ) from err except ( exception.B2ConnectionError, exception.B2RequestTimeout, diff --git a/homeassistant/components/backblaze_b2/config_flow.py b/homeassistant/components/backblaze_b2/config_flow.py index 45acf01c874921..fc718505bd1800 100644 --- a/homeassistant/components/backblaze_b2/config_flow.py +++ b/homeassistant/components/backblaze_b2/config_flow.py @@ -174,6 +174,14 @@ def _authorize_and_get_bucket_sync() -> None: "Backblaze B2 bucket '%s' does not exist", user_input[CONF_BUCKET] ) errors[CONF_BUCKET] = "invalid_bucket_name" + except exception.BadRequest as err: + _LOGGER.error( + "Backblaze B2 API rejected the request for Key ID '%s': %s", + user_input[CONF_KEY_ID], + err, + ) + errors["base"] = "bad_request" + placeholders["error_message"] = str(err) except ( exception.B2ConnectionError, exception.B2RequestTimeout, diff --git a/homeassistant/components/backblaze_b2/strings.json b/homeassistant/components/backblaze_b2/strings.json index 15bc4a998d2a2a..ce8944a7375e97 100644 --- a/homeassistant/components/backblaze_b2/strings.json +++ b/homeassistant/components/backblaze_b2/strings.json @@ -6,6 +6,7 @@ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { + "bad_request": "The Backblaze B2 API rejected the request: {error_message}", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_bucket_name": "[%key:component::backblaze_b2::exceptions::invalid_bucket_name::message%]", "invalid_capability": "[%key:component::backblaze_b2::exceptions::invalid_capability::message%]", @@ -60,6 +61,9 @@ } }, "exceptions": { + "bad_request": { + "message": "The Backblaze B2 API rejected the request: {error_message}" + }, "cannot_connect": { "message": "Cannot connect to endpoint" }, diff --git a/tests/components/backblaze_b2/test_config_flow.py b/tests/components/backblaze_b2/test_config_flow.py index 2699576a6e2ba3..86841deb6a1fbc 100644 --- a/tests/components/backblaze_b2/test_config_flow.py +++ b/tests/components/backblaze_b2/test_config_flow.py @@ -177,6 +177,16 @@ async def test_already_configured( "cannot_connect", "base", ), + ( + "bad_request", + { + "patch": "b2sdk.v2.RawSimulator.authorize_account", + "exception": exception.BadRequest, + "args": ["test", "bad_request"], + }, + "bad_request", + "base", + ), ( "unknown_error", { @@ -252,6 +262,11 @@ async def test_config_flow_errors( "brand_name": "Backblaze B2", "allowed_prefix": "test/", } + elif error_type == "bad_request": + assert result.get("description_placeholders") == { + "brand_name": "Backblaze B2", + "error_message": "test (bad_request)", + } @pytest.mark.parametrize( diff --git a/tests/components/backblaze_b2/test_init.py b/tests/components/backblaze_b2/test_init.py index 5333643814750c..2a15e0bd5c4c50 100644 --- a/tests/components/backblaze_b2/test_init.py +++ b/tests/components/backblaze_b2/test_init.py @@ -57,6 +57,7 @@ async def test_setup_entry_invalid_auth( (exception.RestrictedBucket("testBucket"), ConfigEntryState.SETUP_RETRY), (exception.NonExistentBucket(), ConfigEntryState.SETUP_RETRY), (exception.ConnectionReset(), ConfigEntryState.SETUP_RETRY), + (exception.BadRequest("test", "bad_request"), ConfigEntryState.SETUP_RETRY), (exception.MissingAccountData("key"), ConfigEntryState.SETUP_ERROR), ], ) From b4f6a43a1476f680fb2acbf92aeb21add50c79fc Mon Sep 17 00:00:00 2001 From: Jordan Harvey Date: Mon, 6 Apr 2026 13:29:54 +0100 Subject: [PATCH 31/69] Bump pynintendoparental to 2.3.4 (#167510) --- .../components/nintendo_parental_controls/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nintendo_parental_controls/manifest.json b/homeassistant/components/nintendo_parental_controls/manifest.json index 53fb013cf6492e..fd1fe831b68740 100644 --- a/homeassistant/components/nintendo_parental_controls/manifest.json +++ b/homeassistant/components/nintendo_parental_controls/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["pynintendoauth", "pynintendoparental"], "quality_scale": "bronze", - "requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.3.3"] + "requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.3.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index b4492e7b948292..8a99665bb77fbe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2317,7 +2317,7 @@ pynina==1.0.2 pynintendoauth==1.0.2 # homeassistant.components.nintendo_parental_controls -pynintendoparental==2.3.3 +pynintendoparental==2.3.4 # homeassistant.components.nobo_hub pynobo==1.8.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5e65d772e98e63..b88089986a1b7b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1982,7 +1982,7 @@ pynina==1.0.2 pynintendoauth==1.0.2 # homeassistant.components.nintendo_parental_controls -pynintendoparental==2.3.3 +pynintendoparental==2.3.4 # homeassistant.components.nobo_hub pynobo==1.8.1 From 39fbdad77556a47a5968f3d92023891a4c906065 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 7 Apr 2026 12:47:48 +0200 Subject: [PATCH 32/69] Add missing Miele dishwasher program ID 201 (#167536) Co-authored-by: Claude Opus 4.6 (1M context) --- homeassistant/components/miele/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index 96794aa1edb210..2a3ea75a982183 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -498,7 +498,7 @@ class DishWasherProgramId(MieleEnum, missing_to_none=True): intensive = 1, 26, 205 maintenance = 2, 27, 214 eco = 3, 22, 28, 200 - automatic = 6, 7, 31, 32, 202 + automatic = 6, 7, 31, 32, 201, 202 solar_save = 9, 34 gentle = 10, 35, 210 extra_quiet = 11, 36, 207 From dc65646d8bb8b15242ec30e424d2866005db55aa Mon Sep 17 00:00:00 2001 From: Fabian Neundorf Date: Tue, 7 Apr 2026 12:20:22 +0200 Subject: [PATCH 33/69] Bump python-picnic-api2 to 1.3.4 (#167539) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/picnic/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/picnic/manifest.json b/homeassistant/components/picnic/manifest.json index c1bc18b6c65193..d75b145aecf8a8 100644 --- a/homeassistant/components/picnic/manifest.json +++ b/homeassistant/components/picnic/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["python_picnic_api2"], - "requirements": ["python-picnic-api2==1.3.1"] + "requirements": ["python-picnic-api2==1.3.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8a99665bb77fbe..ee2b444ee30fe7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2645,7 +2645,7 @@ python-otbr-api==2.9.0 python-overseerr==0.9.0 # homeassistant.components.picnic -python-picnic-api2==1.3.1 +python-picnic-api2==1.3.4 # homeassistant.components.pooldose python-pooldose==0.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b88089986a1b7b..7e9925214ae96e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2247,7 +2247,7 @@ python-otbr-api==2.9.0 python-overseerr==0.9.0 # homeassistant.components.picnic -python-picnic-api2==1.3.1 +python-picnic-api2==1.3.4 # homeassistant.components.pooldose python-pooldose==0.9.0 From 4a13ab9aff641fce7147999f15893aa16cda3ab5 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 6 Apr 2026 22:37:38 +0200 Subject: [PATCH 34/69] Bump incomfort-client to v0.7.0 (#167546) --- homeassistant/components/incomfort/manifest.json | 2 +- homeassistant/components/incomfort/strings.json | 3 +++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json index ad904f31c778ff..b87e82266cdaed 100644 --- a/homeassistant/components/incomfort/manifest.json +++ b/homeassistant/components/incomfort/manifest.json @@ -12,5 +12,5 @@ "iot_class": "local_polling", "loggers": ["incomfortclient"], "quality_scale": "platinum", - "requirements": ["incomfort-client==0.6.12"] + "requirements": ["incomfort-client==0.7.0"] } diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index 6b3ef1aa45ea31..8c331741a9954f 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -92,11 +92,13 @@ "central_heating": "Central heating", "central_heating_low": "Central heating low", "central_heating_rf": "Central heating rf", + "central_heating_wait": "Central heating waiting", "cv_temperature_too_high_e1": "Temperature too high", "flame_detection_fault_e6": "Flame detection fault", "frost": "Frost protection", "gas_valve_relay_faulty_e29": "Gas valve relay faulty", "gas_valve_relay_faulty_e30": "[%key:component::incomfort::entity::water_heater::boiler::state::gas_valve_relay_faulty_e29%]", + "hp_error_recovery": "Heat pump error recovery", "incorrect_fan_speed_e8": "Incorrect fan speed", "no_flame_signal_e4": "No flame signal", "off": "[%key:common::state::off%]", @@ -120,6 +122,7 @@ "service": "Service", "shortcut_outside_sensor_temperature_e27": "Shortcut outside temperature sensor", "standby": "[%key:common::state::standby%]", + "starting_ch": "Starting central heating", "tapwater": "Tap water", "tapwater_int": "Tap water internal", "unknown": "Unknown" diff --git a/requirements_all.txt b/requirements_all.txt index ee2b444ee30fe7..640c948b7538ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1310,7 +1310,7 @@ imeon_inverter_api==0.4.0 imgw_pib==2.0.2 # homeassistant.components.incomfort -incomfort-client==0.6.12 +incomfort-client==0.7.0 # homeassistant.components.indevolt indevolt-api==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e9925214ae96e..15919207ea1635 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1162,7 +1162,7 @@ imeon_inverter_api==0.4.0 imgw_pib==2.0.2 # homeassistant.components.incomfort -incomfort-client==0.6.12 +incomfort-client==0.7.0 # homeassistant.components.indevolt indevolt-api==1.2.3 From 0ce98cfb3487834f922c39ed2fb1a8affa0296e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20=C4=8Cerm=C3=A1k?= Date: Tue, 7 Apr 2026 14:52:05 +0200 Subject: [PATCH 35/69] Remove homeassistant/actions/helpers/info from builder workflow (#167573) --- .github/workflows/builder.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 15701f81a05153..4ce49e2dc450a8 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -47,10 +47,6 @@ jobs: with: python-version-file: ".python-version" - - name: Get information - id: info - uses: home-assistant/actions/helpers/info@master # zizmor: ignore[unpinned-uses] - - name: Get version id: version uses: home-assistant/actions/helpers/version@master # zizmor: ignore[unpinned-uses] From c56d67c02f7986374798a6fcd9ca48b89cf42bb2 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:50:37 +0200 Subject: [PATCH 36/69] Set up condition and trigger helpers in check config script (#167589) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/helpers/check_config.py | 8 +++++++- tests/helpers/test_check_config.py | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 836536da9ee434..1982cc4f0c8cbe 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -31,7 +31,7 @@ async_get_integration_with_requirements, ) -from . import config_validation as cv +from . import condition, config_validation as cv, trigger from .typing import ConfigType @@ -93,6 +93,12 @@ async def async_check_ha_config_file( # noqa: C901 result = HomeAssistantConfig() async_clear_install_history(hass) + # Set up condition and trigger helpers needed for config validation. + if condition.CONDITIONS not in hass.data: + await condition.async_setup(hass) + if trigger.TRIGGERS not in hass.data: + await trigger.async_setup(hass) + def _pack_error( hass: HomeAssistant, package: str, diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index fc2df8552e700c..075b4d3f2f9584 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -15,6 +15,8 @@ HomeAssistantConfig, async_check_ha_config_file, ) +from homeassistant.helpers.condition import CONDITIONS +from homeassistant.helpers.trigger import TRIGGERS from homeassistant.requirements import RequirementsNotFound from tests.common import ( @@ -493,6 +495,11 @@ async def test_missing_included_file(hass: HomeAssistant) -> None: async def test_automation_config_platform(hass: HomeAssistant) -> None: """Test automation async config.""" + # Remove keys pre-populated by the test fixture to simulate + # the check_config script which doesn't run bootstrap. + del hass.data[TRIGGERS] + del hass.data[CONDITIONS] + files = { YAML_CONFIG_FILE: BASE_CONFIG + """ @@ -514,6 +521,9 @@ async def test_automation_config_platform(hass: HomeAssistant) -> None: trigger: platform: event event_type: !input trigger_event +condition: + condition: template + value_template: "{{ true }}" action: service: !input service_to_call """, From a8cc099b66d8a7c93b6d657d32ff0d824a93fd46 Mon Sep 17 00:00:00 2001 From: Leo Periou Date: Tue, 7 Apr 2026 15:49:21 +0200 Subject: [PATCH 37/69] fix EWS deviceType problem (#167597) --- homeassistant/components/myneomitis/select.py | 8 ++------ tests/components/myneomitis/test_select.py | 4 ++-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/myneomitis/select.py b/homeassistant/components/myneomitis/select.py index c2d70e70346dfb..6ae7b6a0c79d45 100644 --- a/homeassistant/components/myneomitis/select.py +++ b/homeassistant/components/myneomitis/select.py @@ -104,12 +104,8 @@ async def async_setup_entry( def _create_entity(device: dict) -> MyNeoSelect: """Create a select entity for a device.""" if device["model"] == "EWS": - # According to the MyNeomitis API, EWS "relais" devices expose a "relayMode" - # field in their state, while "pilote" devices do not. We therefore use the - # presence of "relayMode" as an explicit heuristic to distinguish relais - # from pilote devices. If the upstream API changes this behavior, this - # detection logic must be revisited. - if "relayMode" in device.get("state", {}): + state = device.get("state") or {} + if state.get("deviceType") == 0: description = SELECT_TYPES["relais"] else: description = SELECT_TYPES["pilote"] diff --git a/tests/components/myneomitis/test_select.py b/tests/components/myneomitis/test_select.py index 8a3a9c7faf5456..ce905206c4e0d8 100644 --- a/tests/components/myneomitis/test_select.py +++ b/tests/components/myneomitis/test_select.py @@ -14,7 +14,7 @@ "_id": "relais1", "name": "Relais Device", "model": "EWS", - "state": {"relayMode": 1, "targetMode": 2}, + "state": {"deviceType": 0, "targetMode": 2}, "connected": True, "program": {"data": {}}, } @@ -23,7 +23,7 @@ "_id": "pilote1", "name": "Pilote Device", "model": "EWS", - "state": {"targetMode": 1}, + "state": {"deviceType": 1, "targetMode": 1}, "connected": True, "program": {"data": {}}, } From 1aca993c128dc3b6fdcbc19a208c63ff9a103b29 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 8 Apr 2026 07:44:45 +0200 Subject: [PATCH 38/69] Fix Tractive switch availability (#167599) --- homeassistant/components/tractive/__init__.py | 6 +++++- homeassistant/components/tractive/switch.py | 10 ++++------ tests/components/tractive/test_switch.py | 9 ++++----- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index e5c20e757eaef5..87e408b2a5849e 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -246,6 +246,7 @@ async def _listen(self) -> None: ): self._last_hw_time = event["hardware"]["time"] self._send_hardware_update(event) + self._send_switch_update(event) if ( "position" in event and self._last_pos_time != event["position"]["time"] @@ -302,7 +303,10 @@ def _send_switch_update(self, event: dict[str, Any]) -> None: for switch, key in SWITCH_KEY_MAP.items(): if switch_data := event.get(key): payload[switch] = switch_data["active"] - payload[ATTR_POWER_SAVING] = event.get("tracker_state_reason") == "POWER_SAVING" + if hardware := event.get("hardware", {}): + payload[ATTR_POWER_SAVING] = ( + hardware.get("power_saving_zone_id") is not None + ) self._dispatch_tracker_event( TRACKER_SWITCH_STATUS_UPDATED, event["tracker_id"], payload ) diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py index 0f05a20c0ec70f..d965e43a3086fb 100644 --- a/homeassistant/components/tractive/switch.py +++ b/homeassistant/components/tractive/switch.py @@ -100,13 +100,11 @@ def __init__( @callback def handle_status_update(self, event: dict[str, Any]) -> None: """Handle status update.""" - if self.entity_description.key not in event: - return + if ATTR_POWER_SAVING in event: + self._attr_available = not event[ATTR_POWER_SAVING] - # We received an event, so the service is online and the switch entities should - # be available. - self._attr_available = not event[ATTR_POWER_SAVING] - self._attr_is_on = event[self.entity_description.key] + if self.entity_description.key in event: + self._attr_is_on = event[self.entity_description.key] self.async_write_ha_state() diff --git a/tests/components/tractive/test_switch.py b/tests/components/tractive/test_switch.py index 0b9213bee92b4e..71ebc757cdca11 100644 --- a/tests/components/tractive/test_switch.py +++ b/tests/components/tractive/test_switch.py @@ -248,10 +248,7 @@ async def test_switch_unavailable( event = { "tracker_id": "device_id_123", - "buzzer_control": {"active": True}, - "led_control": {"active": False}, - "live_tracking": {"active": True}, - "tracker_state_reason": "POWER_SAVING", + "hardware": {"power_saving_zone_id": "zone_id_123"}, } mock_tractive_client.send_switch_event(mock_config_entry, event) await hass.async_block_till_done() @@ -260,7 +257,9 @@ async def test_switch_unavailable( assert state assert state.state == STATE_UNAVAILABLE - mock_tractive_client.send_switch_event(mock_config_entry) + event["hardware"]["power_saving_zone_id"] = None + + mock_tractive_client.send_switch_event(mock_config_entry, event) await hass.async_block_till_done() state = hass.states.get(entity_id) From 4c34dcd560b9d7326b9b2362c73239ca8a009562 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 7 Apr 2026 16:00:06 +0200 Subject: [PATCH 39/69] Bump securetar to 2026.4.0 (#167600) --- homeassistant/components/backup/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index 0c1db47c05f7da..ccc63073515601 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -8,6 +8,6 @@ "integration_type": "service", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["cronsim==2.7", "securetar==2026.2.0"], + "requirements": ["cronsim==2.7", "securetar==2026.4.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3894922105e426..0b9e70ee3d8e13 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -63,7 +63,7 @@ python-slugify==8.0.4 PyTurboJPEG==1.8.0 PyYAML==6.0.3 requests==2.33.1 -securetar==2026.2.0 +securetar==2026.4.0 SQLAlchemy==2.0.41 standard-aifc==3.13.0 standard-telnetlib==3.13.0 diff --git a/pyproject.toml b/pyproject.toml index f6b2b03371774d..ef323a8121f02e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,7 @@ dependencies = [ "python-slugify==8.0.4", "PyYAML==6.0.3", "requests==2.33.1", - "securetar==2026.2.0", + "securetar==2026.4.0", "SQLAlchemy==2.0.41", "standard-aifc==3.13.0", "standard-telnetlib==3.13.0", diff --git a/requirements.txt b/requirements.txt index 0527a296113749..55c8375f517681 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,7 +47,7 @@ python-slugify==8.0.4 PyTurboJPEG==1.8.0 PyYAML==6.0.3 requests==2.33.1 -securetar==2026.2.0 +securetar==2026.4.0 SQLAlchemy==2.0.41 standard-aifc==3.13.0 standard-telnetlib==3.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 640c948b7538ba..29a698f200bbf2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2892,7 +2892,7 @@ screenlogicpy==0.10.2 scsgate==0.1.0 # homeassistant.components.backup -securetar==2026.2.0 +securetar==2026.4.0 # homeassistant.components.sendgrid sendgrid==6.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 15919207ea1635..af5ad6e61f1bd0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2452,7 +2452,7 @@ satel-integra==1.0.0 screenlogicpy==0.10.2 # homeassistant.components.backup -securetar==2026.2.0 +securetar==2026.4.0 # homeassistant.components.emulated_kasa # homeassistant.components.sense From a422611ada204955df95087d7733d8cb7454e4f7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 7 Apr 2026 18:40:06 +0200 Subject: [PATCH 40/69] Fix securetar size calculation when encrypting backup (#167602) --- homeassistant/components/backup/util.py | 5 ++++- tests/components/backup/test_util.py | 12 ++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index d93290d675cba1..fe060521668770 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -22,6 +22,7 @@ SecureTarFile, SecureTarReadError, SecureTarRootKeyContext, + get_archive_max_ciphertext_size, ) from homeassistant.core import HomeAssistant @@ -431,7 +432,9 @@ def __init__( def size(self) -> int: """Return the maximum size of the decrypted or encrypted backup.""" - return self._backup.size + self._num_tar_files() * tarfile.RECORDSIZE + return get_archive_max_ciphertext_size( # type: ignore[no-any-return] + self._backup.size, SECURETAR_CREATE_VERSION, self._num_tar_files() + ) def _num_tar_files(self) -> int: """Return the number of inner tar files.""" diff --git a/tests/components/backup/test_util.py b/tests/components/backup/test_util.py index 83b7a6a794408e..2345747091a6cf 100644 --- a/tests/components/backup/test_util.py +++ b/tests/components/backup/test_util.py @@ -282,14 +282,14 @@ def test_validate_password_no_homeassistant(caplog: pytest.LogCaptureFixture) -> AddonInfo(name="Core 1", slug="core1", version="1.0.0"), AddonInfo(name="Core 2", slug="core2", version="1.0.0"), ], - 40960, # 4 x 10240 byte of padding + 51200, # 5 x 10240 byte of padding "test_backups/c0cb53bd.tar.decrypted", ), ( [ AddonInfo(name="Core 1", slug="core1", version="1.0.0"), ], - 30720, # 3 x 10240 byte of padding + 40960, # 4 x 10240 byte of padding "test_backups/c0cb53bd.tar.decrypted_skip_core2", ), ], @@ -460,14 +460,14 @@ async def open_backup() -> AsyncIterator[bytes]: AddonInfo(name="Core 1", slug="core1", version="1.0.0"), AddonInfo(name="Core 2", slug="core2", version="1.0.0"), ], - 40960, # 4 x 10240 byte of padding + 51200, # 5 x 10240 byte of padding "test_backups/c0cb53bd.tar.encrypted_v3", ), ( [ AddonInfo(name="Core 1", slug="core1", version="1.0.0"), ], - 30720, # 3 x 10240 byte of padding + 40960, # 4 x 10240 byte of padding "test_backups/c0cb53bd.tar.encrypted_v3_skip_core2", ), ], @@ -674,8 +674,8 @@ async def read_stream(stream: AsyncIterator[bytes]) -> bytes: # Expect the output length to match the stored encrypted backup file, with # additional padding. encrypted_backup_data = encrypted_backup_path.read_bytes() - # 4 x 10240 byte of padding - assert len(encrypted_output1) == len(encrypted_backup_data) + 40960 + # 5 x 10240 byte of padding + assert len(encrypted_output1) == len(encrypted_backup_data) + 51200 assert encrypted_output1[: len(encrypted_backup_data)] != encrypted_backup_data From f79285f9ab55c1014ea632315c396f0a1eba00ac Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 7 Apr 2026 18:41:28 +0200 Subject: [PATCH 41/69] Bump holidays to 0.94 (#167604) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index a3771b354cce2f..03f30da74a1866 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.93", "babel==2.15.0"] + "requirements": ["holidays==0.94", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index ce93a7e823b292..84918b2bad45e4 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.93"] + "requirements": ["holidays==0.94"] } diff --git a/requirements_all.txt b/requirements_all.txt index 29a698f200bbf2..85795d5a9e5be5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1226,7 +1226,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.93 +holidays==0.94 # homeassistant.components.frontend home-assistant-frontend==20260325.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index af5ad6e61f1bd0..8abdc77e80549b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1090,7 +1090,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.93 +holidays==0.94 # homeassistant.components.frontend home-assistant-frontend==20260325.6 From 394670e33f1f3252ed8132abdf03ede7abd1cb67 Mon Sep 17 00:00:00 2001 From: Tom Date: Wed, 8 Apr 2026 13:22:25 +0200 Subject: [PATCH 42/69] Fix ProxmoxVE migration causing reauthentication (#167624) --- .../components/proxmoxve/__init__.py | 8 ++++++ tests/components/proxmoxve/test_init.py | 27 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 6512b1761cd928..3e680f212a2de5 100644 --- a/homeassistant/components/proxmoxve/__init__.py +++ b/homeassistant/components/proxmoxve/__init__.py @@ -189,6 +189,14 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ProxmoxConfigEntry) -> # Migration for additional configuration options added to support API tokens if entry.version < 3: data = dict(entry.data) + # If CONF_REALM wasn't there yet, extract from username + if CONF_REALM not in data: + data[CONF_REALM] = DEFAULT_REALM + if "@" in data.get(CONF_USERNAME, ""): + username, realm = data[CONF_USERNAME].split("@", 1) + data[CONF_USERNAME] = username + data[CONF_REALM] = realm.lower() + realm = data[CONF_REALM].lower() # If the realm is one of the base providers, set the provider to match the realm. diff --git a/tests/components/proxmoxve/test_init.py b/tests/components/proxmoxve/test_init.py index 205e54f1e3345a..a3f7b21181aea3 100644 --- a/tests/components/proxmoxve/test_init.py +++ b/tests/components/proxmoxve/test_init.py @@ -281,6 +281,33 @@ async def test_migration_v2_to_v3( assert entry.data[CONF_REALM] == AUTH_PAM +async def test_migration_v2_to_v3_without_realm( + hass: HomeAssistant, +) -> None: + """Test migration from version 2 to 3.""" + entry = MockConfigEntry( + domain=DOMAIN, + version=2, + unique_id="1", + data={ + CONF_HOST: "http://test_host", + CONF_PORT: 8006, + CONF_USERNAME: "test_user@pam", + CONF_PASSWORD: "test_password", + CONF_VERIFY_SSL: True, + }, + ) + entry.add_to_hass(hass) + assert entry.version == 2 + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 3 + assert entry.data[CONF_AUTH_METHOD] == AUTH_PAM + assert entry.data[CONF_REALM] == AUTH_PAM + + async def test_new_vm_creates_entity( hass: HomeAssistant, mock_proxmox_client: MagicMock, From fb766d164bbdd444d408e2161d6282edf0f1c628 Mon Sep 17 00:00:00 2001 From: Nick Haghiri <59633028+ElCruncharino@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:12:24 -0400 Subject: [PATCH 43/69] Improve error logging for Backblaze B2 upload failures (#167721) --- .../components/backblaze_b2/backup.py | 31 +++---- tests/components/backblaze_b2/test_backup.py | 88 ++++++++++++++++++- 2 files changed, 101 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/backblaze_b2/backup.py b/homeassistant/components/backblaze_b2/backup.py index ec92a41a5dce2c..5098c96b0eeca8 100644 --- a/homeassistant/components/backblaze_b2/backup.py +++ b/homeassistant/components/backblaze_b2/backup.py @@ -101,8 +101,7 @@ async def wrapper(*args: Any, **kwargs: Any) -> T: try: return await func(*args, **kwargs) except B2Error as err: - error_msg = f"Failed during {func.__name__}" - raise BackupAgentError(error_msg) from err + raise BackupAgentError(f"Failed during {func.__name__}: {err}") from err return wrapper @@ -170,8 +169,7 @@ def _is_cache_valid(self, expiration_time: float) -> bool: async def _cleanup_failed_upload(self, filename: str) -> None: """Clean up a partially uploaded file after upload failure.""" _LOGGER.warning( - "Attempting to delete partially uploaded main backup file %s " - "due to metadata upload failure", + "Attempting to delete partially uploaded backup file %s", filename, ) try: @@ -180,11 +178,10 @@ async def _cleanup_failed_upload(self, filename: str) -> None: ) await self._hass.async_add_executor_job(uploaded_main_file_info.delete) except B2Error: - _LOGGER.debug( - "Failed to clean up partially uploaded main backup file %s. " - "Manual intervention may be required to delete it from Backblaze B2", + _LOGGER.warning( + "Failed to clean up partially uploaded backup file %s;" + " manual deletion from Backblaze B2 may be required", filename, - exc_info=True, ) else: _LOGGER.debug( @@ -256,9 +253,10 @@ async def async_upload_backup( prefixed_metadata_filename, ) - upload_successful = False + tar_uploaded = False try: await self._upload_backup_file(prefixed_tar_filename, open_stream, {}) + tar_uploaded = True _LOGGER.debug( "Main backup file upload finished for %s", prefixed_tar_filename ) @@ -270,15 +268,14 @@ async def async_upload_backup( _LOGGER.debug( "Metadata file upload finished for %s", prefixed_metadata_filename ) - upload_successful = True - finally: - if upload_successful: - _LOGGER.debug("Backup upload complete: %s", prefixed_tar_filename) - self._invalidate_caches( - backup.backup_id, prefixed_tar_filename, prefixed_metadata_filename - ) - else: + _LOGGER.debug("Backup upload complete: %s", prefixed_tar_filename) + self._invalidate_caches( + backup.backup_id, prefixed_tar_filename, prefixed_metadata_filename + ) + except B2Error: + if tar_uploaded: await self._cleanup_failed_upload(prefixed_tar_filename) + raise def _upload_metadata_file_sync( self, metadata_content: bytes, filename: str diff --git a/tests/components/backblaze_b2/test_backup.py b/tests/components/backblaze_b2/test_backup.py index 32bbd8866e895b..f076c472928216 100644 --- a/tests/components/backblaze_b2/test_backup.py +++ b/tests/components/backblaze_b2/test_backup.py @@ -510,7 +510,93 @@ async def test_upload_with_cleanup_failure( assert resp.status == 201 assert any( - "Failed to clean up partially uploaded main backup file" in msg + "Failed to clean up partially uploaded backup file" in msg + for msg in caplog.messages + ) + + +async def test_tar_upload_failure_skips_cleanup( + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that cleanup is not attempted when tar upload itself fails.""" + client = await hass_client() + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + return_value=TEST_BACKUP, + ), + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + patch.object( + BucketSimulator, + "upload_unbound_stream", + side_effect=B2Error("Connection reset"), + ), + patch.object( + BucketSimulator, + "get_file_info_by_name", + ) as mock_get_file_info, + caplog.at_level(logging.DEBUG), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + mock_get_file_info.assert_not_called() + assert not any( + "Attempting to delete partially uploaded" in msg for msg in caplog.messages + ) + assert any("Connection reset" in msg for msg in caplog.messages) + + +async def test_handle_b2_errors_logs_root_cause( + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that the actual B2 error is logged when upload fails.""" + client = await hass_client() + + with ( + patch( + "homeassistant.components.backup.manager.BackupManager.async_get_backup", + return_value=TEST_BACKUP, + ), + patch( + "homeassistant.components.backup.manager.read_backup", + return_value=TEST_BACKUP, + ), + patch("pathlib.Path.open") as mocked_open, + patch.object( + BucketSimulator, + "upload_bytes", + side_effect=B2Error("Service unavailable"), + ), + patch.object( + BucketSimulator, + "get_file_info_by_name", + return_value=Mock(delete=Mock()), + ), + caplog.at_level(logging.ERROR), + ): + mocked_open.return_value.read = Mock(side_effect=[b"test", b""]) + resp = await client.post( + f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.entry_id}", + data={"file": StringIO("test")}, + ) + + assert resp.status == 201 + assert any( + "Failed during async_upload_backup: Service unavailable" in msg for msg in caplog.messages ) From ce755f5f8f423329767280e9f736f7a98a469915 Mon Sep 17 00:00:00 2001 From: wollew Date: Thu, 9 Apr 2026 11:55:32 +0200 Subject: [PATCH 44/69] Bump pyvlx to 0.2.33 (#167764) --- homeassistant/components/velux/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index 9ebe6ff6062f7e..820442830e7050 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -14,5 +14,5 @@ "iot_class": "local_polling", "loggers": ["pyvlx"], "quality_scale": "silver", - "requirements": ["pyvlx==0.2.32"] + "requirements": ["pyvlx==0.2.33"] } diff --git a/requirements_all.txt b/requirements_all.txt index 85795d5a9e5be5..664e558b1c57fd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2739,7 +2739,7 @@ pyvesync==3.4.1 pyvizio==0.1.61 # homeassistant.components.velux -pyvlx==0.2.32 +pyvlx==0.2.33 # homeassistant.components.volumio pyvolumio==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8abdc77e80549b..5c8ade351916f0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2329,7 +2329,7 @@ pyvesync==3.4.1 pyvizio==0.1.61 # homeassistant.components.velux -pyvlx==0.2.32 +pyvlx==0.2.33 # homeassistant.components.volumio pyvolumio==0.1.5 From 500f030eaa083f6bd8976aeec86044f725524e33 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:46:34 +0200 Subject: [PATCH 45/69] Set proper state for the internet_access switches in FRITZ!Box Tools (#167767) --- homeassistant/components/fritz/coordinator.py | 9 ++++--- tests/components/fritz/test_switch.py | 26 ++++++++++++++----- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index 0cc359b318acc2..3cc797d48569f4 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -448,10 +448,13 @@ async def _async_update_hosts_info(self) -> dict[str, Device]: if not attributes.get("MACAddress"): continue + wan_access_result = None if (wan_access := attributes.get("X_AVM-DE_WANAccess")) is not None: - wan_access_result = "granted" in wan_access - else: - wan_access_result = None + # wan_access can be "granted", "denied", "unknown" or "error" + if "granted" in wan_access: + wan_access_result = True + elif "denied" in wan_access: + wan_access_result = False hosts[attributes["MACAddress"]] = Device( name=attributes["HostName"], diff --git a/tests/components/fritz/test_switch.py b/tests/components/fritz/test_switch.py index df32344ee04fcc..c2df8fbb00b139 100644 --- a/tests/components/fritz/test_switch.py +++ b/tests/components/fritz/test_switch.py @@ -328,30 +328,42 @@ async def test_switch_no_mesh_wifi_uplink( await hass.async_block_till_done(wait_background_tasks=True) -async def test_switch_device_no_wan_access( +@pytest.mark.parametrize( + ("wan_access_data", "expected_state"), + [ + (None, STATE_UNAVAILABLE), + ("unknown", STATE_UNAVAILABLE), + ("error", STATE_UNAVAILABLE), + ("granted", STATE_ON), + ("denied", STATE_OFF), + ], +) +async def test_switch_device_wan_access( hass: HomeAssistant, fc_class_mock, fh_class_mock, fs_class_mock, + wan_access_data: str | None, + expected_state: str, ) -> None: - """Test Fritz!Tools switches when device has no WAN access.""" + """Test Fritz!Tools switches have proper WAN access state.""" entity_id = "switch.printer_internet_access" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) entry.add_to_hass(hass) - attributes = [ - {k: v for k, v in host.items() if k != "X_AVM-DE_WANAccess"} - for host in MOCK_HOST_ATTRIBUTES_DATA - ] + attributes = deepcopy(MOCK_HOST_ATTRIBUTES_DATA) + for host in attributes: + host["X_AVM-DE_WANAccess"] = wan_access_data + fh_class_mock.get_hosts_attributes = MagicMock(return_value=attributes) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) assert (state := hass.states.get(entity_id)) - assert state.state == STATE_UNAVAILABLE + assert state.state == expected_state async def test_switch_device_no_ip_address( From bd904caea1b5cbfe92b618ad30c80c5ed5dfb13d Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 9 Apr 2026 14:19:46 +0200 Subject: [PATCH 46/69] Bump aiotractive to 1.0.2 (#167783) --- .../components/tractive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../tractive/test_device_tracker.py | 21 +++++++++++++++++++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tractive/manifest.json b/homeassistant/components/tractive/manifest.json index a66edb985ac45b..96abbf24adecba 100644 --- a/homeassistant/components/tractive/manifest.json +++ b/homeassistant/components/tractive/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_push", "loggers": ["aiotractive"], - "requirements": ["aiotractive==1.0.1"] + "requirements": ["aiotractive==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 664e558b1c57fd..c0125f28f6a0c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -425,7 +425,7 @@ aiotankerkoenig==0.5.1 aiotedee==0.3.0 # homeassistant.components.tractive -aiotractive==1.0.1 +aiotractive==1.0.2 # homeassistant.components.unifi aiounifi==88 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5c8ade351916f0..4ba6d8c672e964 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -410,7 +410,7 @@ aiotankerkoenig==0.5.1 aiotedee==0.3.0 # homeassistant.components.tractive -aiotractive==1.0.1 +aiotractive==1.0.2 # homeassistant.components.unifi aiounifi==88 diff --git a/tests/components/tractive/test_device_tracker.py b/tests/components/tractive/test_device_tracker.py index 6fdbc245662aec..ee30ca4a1f5b08 100644 --- a/tests/components/tractive/test_device_tracker.py +++ b/tests/components/tractive/test_device_tracker.py @@ -87,3 +87,24 @@ async def test_source_type_gps( hass.states.get("device_tracker.test_pet_tracker").attributes["source_type"] is SourceType.GPS ) + + +async def test_device_tracker_with_empty_hw_info( + hass: HomeAssistant, + mock_tractive_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the device tracker sets up correctly when hw_info is empty.""" + mock_tractive_client.tracker.return_value.hw_info = AsyncMock(return_value={}) + + with patch( + "homeassistant.components.tractive.PLATFORMS", [Platform.DEVICE_TRACKER] + ): + await init_integration(hass, mock_config_entry) + + mock_tractive_client.send_position_event(mock_config_entry) + await hass.async_block_till_done() + + state = hass.states.get("device_tracker.test_pet_tracker") + assert state is not None + assert state.attributes.get("battery_level") is None From 83da18b761be6fb9f11271f92bd18bfaf8c2a2a6 Mon Sep 17 00:00:00 2001 From: Benjamin Hudgens Date: Thu, 9 Apr 2026 13:21:14 -0500 Subject: [PATCH 47/69] Revert "Fix Ring snapshots" - #164337 (#167790) --- homeassistant/components/ring/camera.py | 21 ++++---- homeassistant/components/ring/strings.json | 3 ++ tests/components/ring/test_camera.py | 60 +++++++++++++--------- 3 files changed, 48 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 21ce0bfb2b3815..ee4ab050aca98b 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -128,8 +128,9 @@ def _handle_coordinator_update(self) -> None: self._device = self._get_coordinator_data().get_video_device( self._device.device_api_id ) + history_data = self._device.last_history - if history_data: + if history_data and self._device.has_subscription: self._last_event = history_data[0] # will call async_update to update the attributes and get the # video url from the api @@ -154,13 +155,16 @@ async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" - # For live_view cameras, get a fresh snapshot - if self.entity_description.key == "live_view": - return await self._async_get_fresh_snapshot() + if self._video_url is None: + if not self._device.has_subscription: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_subscription", + ) + return None - # For last_recording cameras, use the cached video frame key = (width, height) - if not (image := self._images.get(key)) and self._video_url is not None: + if not (image := self._images.get(key)): image = await ffmpeg.async_get_image( self.hass, self._video_url, @@ -173,11 +177,6 @@ async def async_camera_image( return image - @exception_wrap - async def _async_get_fresh_snapshot(self) -> bytes | None: - """Get a fresh snapshot from the camera.""" - return await self._device.async_get_snapshot() - async def handle_async_mjpeg_stream( self, request: web.Request ) -> web.StreamResponse | None: diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 09f36d6dd7424c..1159a8b906e690 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -151,6 +151,9 @@ "api_timeout": { "message": "Timeout communicating with Ring API" }, + "no_subscription": { + "message": "Ring Protect subscription required for snapshots" + }, "sdp_m_line_index_required": { "message": "Error negotiating stream for {device}" } diff --git a/tests/components/ring/test_camera.py b/tests/components/ring/test_camera.py index a40611ea2ce4c1..95ee0d4b5fd800 100644 --- a/tests/components/ring/test_camera.py +++ b/tests/components/ring/test_camera.py @@ -294,32 +294,10 @@ async def test_camera_image( await setup_platform(hass, Platform.CAMERA) front_camera_mock = mock_ring_devices.get_device(765432) - front_camera_mock.async_get_snapshot.return_value = SMALLEST_VALID_JPEG_BYTES state = hass.states.get("camera.front_live_view") assert state is not None - # For live_view camera, snapshot should use async_get_snapshot - image = await async_get_image(hass, "camera.front_live_view") - assert image.content == SMALLEST_VALID_JPEG_BYTES - front_camera_mock.async_get_snapshot.assert_called_once() - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_camera_last_recording_image( - hass: HomeAssistant, - mock_ring_client, - mock_ring_devices, - freezer: FrozenDateTimeFactory, -) -> None: - """Test last recording camera will return still image from video when available.""" - await setup_platform(hass, Platform.CAMERA) - - front_camera_mock = mock_ring_devices.get_device(765432) - - state = hass.states.get("camera.front_last_recording") - assert state is not None - # history not updated yet front_camera_mock.async_history.assert_not_called() front_camera_mock.async_recording_url.assert_not_called() @@ -330,23 +308,55 @@ async def test_camera_last_recording_image( ), pytest.raises(HomeAssistantError), ): - await async_get_image(hass, "camera.front_last_recording") + image = await async_get_image(hass, "camera.front_live_view") freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) # history updated so image available front_camera_mock.async_history.assert_called_once() - assert front_camera_mock.async_recording_url.call_count == 2 + front_camera_mock.async_recording_url.assert_called_once() with patch( "homeassistant.components.ring.camera.ffmpeg.async_get_image", return_value=SMALLEST_VALID_JPEG_BYTES, ): - image = await async_get_image(hass, "camera.front_last_recording") + image = await async_get_image(hass, "camera.front_live_view") assert image.content == SMALLEST_VALID_JPEG_BYTES +async def test_camera_live_view_no_subscription( + hass: HomeAssistant, + mock_ring_client, + mock_ring_devices, + freezer: FrozenDateTimeFactory, +) -> None: + """Test live view camera skips recording URL when no subscription.""" + await setup_platform(hass, Platform.CAMERA) + + front_camera_mock = mock_ring_devices.get_device(765432) + # Set device to not have subscription + front_camera_mock.has_subscription = False + + state = hass.states.get("camera.front_live_view") + assert state is not None + + # Reset mock call counts + front_camera_mock.async_recording_url.reset_mock() + + # Trigger coordinator update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # For cameras without subscription, recording URL should NOT be fetched + front_camera_mock.async_recording_url.assert_not_called() + + # Requesting an image without subscription should raise an error + with pytest.raises(HomeAssistantError): + await async_get_image(hass, "camera.front_live_view") + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_camera_stream_attributes( hass: HomeAssistant, From 818bde1d5e25599c317ec9cd8779fe52d1310cae Mon Sep 17 00:00:00 2001 From: Raj Laud <50647620+rajlaud@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:53:26 -0400 Subject: [PATCH 48/69] Fix Victron BLE false reauth triggered by unknown enum bitmask combinations (#167809) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/victron_ble/__init__.py | 32 ++++++++----- tests/components/victron_ble/fixtures.py | 14 ++++++ tests/components/victron_ble/test_sensor.py | 47 +++++++++++++++++++ 3 files changed, 80 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/victron_ble/__init__.py b/homeassistant/components/victron_ble/__init__.py index 7eff058b7b229a..7c79d7331544e1 100644 --- a/homeassistant/components/victron_ble/__init__.py +++ b/homeassistant/components/victron_ble/__init__.py @@ -19,7 +19,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant -from .const import REAUTH_AFTER_FAILURES +from .const import REAUTH_AFTER_FAILURES, VICTRON_IDENTIFIER _LOGGER = logging.getLogger(__name__) @@ -38,18 +38,24 @@ def _update( nonlocal consecutive_failures update = data.update(service_info) - # If the device type was recognized (devices dict populated) but - # only signal strength came back, decryption likely failed. - # Unsupported devices have an empty devices dict and won't trigger this. - if update.devices and len(update.entity_values) <= 1: - consecutive_failures += 1 - if consecutive_failures >= REAUTH_AFTER_FAILURES: - _LOGGER.debug( - "Triggering reauth for %s after %d consecutive failures", - address, - consecutive_failures, - ) - entry.async_start_reauth(hass) + # Only consider a reauth when the device type is recognised (devices + # populated) but the advertisement key fails the quick-check built into + # validate_advertisement_key. Using the key check instead of counting + # entity values avoids false positives: some devices legitimately return + # few (or zero) sensor values when in certain error or alarm states. + raw_data = service_info.manufacturer_data.get(VICTRON_IDENTIFIER) + if update.devices and raw_data is not None: + if not data.validate_advertisement_key(raw_data): + consecutive_failures += 1 + if consecutive_failures >= REAUTH_AFTER_FAILURES: + _LOGGER.debug( + "Triggering reauth for %s after %d consecutive failures", + address, + consecutive_failures, + ) + entry.async_start_reauth(hass) + consecutive_failures = 0 + else: consecutive_failures = 0 else: consecutive_failures = 0 diff --git a/tests/components/victron_ble/fixtures.py b/tests/components/victron_ble/fixtures.py index b8253c672df175..c6efeb4112b10d 100644 --- a/tests/components/victron_ble/fixtures.py +++ b/tests/components/victron_ble/fixtures.py @@ -187,6 +187,20 @@ source="local", ) +# Same DC/DC converter but with OffReason=0x81 (NO_INPUT_POWER|ENGINE_SHUTDOWN), +# a real bitmask combination that the current OffReason enum doesn't handle. +# The key check byte is valid so validate_advertisement_key passes, but +# parsing raises ValueError → sparse update (signal strength only). +VICTRON_DC_DC_CONVERTER_UNKNOWN_OFF_REASON_SERVICE_INFO = BluetoothServiceInfo( + name="DC/DC Converter", + address="01:02:03:04:05:08", + rssi=-60, + manufacturer_data={0x02E1: bytes.fromhex("1000c0a304121d64ca8d442b90bbde6a8cba")}, + service_data={}, + service_uuids=[], + source="local", +) + VICTRON_VEBUS_SENSORS = { "inverter_charger_device_state": "float", "inverter_charger_battery_voltage": "14.45", diff --git a/tests/components/victron_ble/test_sensor.py b/tests/components/victron_ble/test_sensor.py index a44dabb969b990..ad8565cbe07cbd 100644 --- a/tests/components/victron_ble/test_sensor.py +++ b/tests/components/victron_ble/test_sensor.py @@ -24,6 +24,7 @@ VICTRON_BATTERY_SENSE_TOKEN, VICTRON_DC_DC_CONVERTER_SERVICE_INFO, VICTRON_DC_DC_CONVERTER_TOKEN, + VICTRON_DC_DC_CONVERTER_UNKNOWN_OFF_REASON_SERVICE_INFO, VICTRON_DC_ENERGY_METER_SERVICE_INFO, VICTRON_DC_ENERGY_METER_TOKEN, VICTRON_SMART_BATTERY_PROTECT_SERVICE_INFO, @@ -208,6 +209,52 @@ async def test_reauth_triggered_only_once( assert len(flows) == 1 +@pytest.mark.usefixtures("enable_bluetooth") +async def test_reauth_not_triggered_on_unknown_enum_value( + hass: HomeAssistant, +) -> None: + """Test reauth is NOT triggered when a valid key yields a sparse update. + + Some devices report bitmask combinations for OffReason or AlarmReason that + are not in the enum (e.g. NO_INPUT_POWER|ENGINE_SHUTDOWN = 0x81 on a DC-DC + converter that stopped due to both conditions simultaneously). The parser + raises ValueError, producing a sparse update (signal strength only). + This must not be mistaken for a wrong encryption key. + + Regression test for https://github.com/home-assistant/core/issues/167105 + """ + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "address": VICTRON_DC_DC_CONVERTER_UNKNOWN_OFF_REASON_SERVICE_INFO.address, + CONF_ACCESS_TOKEN: VICTRON_DC_DC_CONVERTER_TOKEN, + }, + unique_id=VICTRON_DC_DC_CONVERTER_UNKNOWN_OFF_REASON_SERVICE_INFO.address, + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + service_info = VICTRON_DC_DC_CONVERTER_UNKNOWN_OFF_REASON_SERVICE_INFO + for idx in range(REAUTH_AFTER_FAILURES + 1): + inject_bluetooth_service_info( + hass, + BluetoothServiceInfo( + name=service_info.name, + address=service_info.address, + rssi=service_info.rssi - idx, + manufacturer_data=service_info.manufacturer_data, + service_data=service_info.service_data, + service_uuids=service_info.service_uuids, + source=service_info.source, + ), + ) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(flows) == 0 + + @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.parametrize( ("payload_hex", "expected_state"), From 887e14638b3166af1836ecc7a9145445121b1cdc Mon Sep 17 00:00:00 2001 From: Raj Laud <50647620+rajlaud@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:22:55 -0400 Subject: [PATCH 49/69] Fix Victron BLE storage errors caused by non-serializable value_fn callable in sensor entity description (#167819) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/victron_ble/sensor.py | 10 +++----- tests/components/victron_ble/test_sensor.py | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/victron_ble/sensor.py b/homeassistant/components/victron_ble/sensor.py index 18a112ab7005b0..f547cef8a3f97a 100644 --- a/homeassistant/components/victron_ble/sensor.py +++ b/homeassistant/components/victron_ble/sensor.py @@ -1,6 +1,5 @@ """Sensor platform for Victron BLE.""" -from collections.abc import Callable from dataclasses import dataclass import logging from typing import Any @@ -182,10 +181,6 @@ def error_to_state(value: float | str | None) -> str | None: class VictronBLESensorEntityDescription(SensorEntityDescription): """Describes Victron BLE sensor entity.""" - value_fn: Callable[[float | int | str | None], float | int | str | None] = ( - lambda x: x - ) - SENSOR_DESCRIPTIONS = { Keys.AC_IN_POWER: VictronBLESensorEntityDescription( @@ -258,7 +253,6 @@ class VictronBLESensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.ENUM, translation_key="charger_error", options=CHARGER_ERROR_OPTIONS, - value_fn=error_to_state, ), Keys.CONSUMED_AMPERE_HOURS: VictronBLESensorEntityDescription( key=Keys.CONSUMED_AMPERE_HOURS, @@ -538,4 +532,6 @@ def native_value(self) -> float | int | str | None: """Return the state of the sensor.""" value = self.processor.entity_data.get(self.entity_key) - return self.entity_description.value_fn(value) + if self.entity_description.key == Keys.CHARGER_ERROR: + return error_to_state(value) + return value diff --git a/tests/components/victron_ble/test_sensor.py b/tests/components/victron_ble/test_sensor.py index ad8565cbe07cbd..b987b5b3653fbe 100644 --- a/tests/components/victron_ble/test_sensor.py +++ b/tests/components/victron_ble/test_sensor.py @@ -1,16 +1,21 @@ """Test updating sensors in the victron_ble integration.""" +import json import time from home_assistant_bluetooth import BluetoothServiceInfo import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.bluetooth.passive_update_processor import ( + serialize_entity_description, +) from homeassistant.components.victron_ble.const import ( DOMAIN, REAUTH_AFTER_FAILURES, VICTRON_IDENTIFIER, ) +from homeassistant.components.victron_ble.sensor import SENSOR_DESCRIPTIONS from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -60,6 +65,26 @@ } +def test_sensor_descriptions_are_json_serializable() -> None: + """Ensure entity descriptions contain no non-JSON-serializable fields. + + The passive Bluetooth processor persists entity descriptions to storage + between HA restarts via serialize_entity_description(). Fields that are + Python callables (e.g. a value_fn lambda) cannot be serialized and cause + repeated 'Bad data' errors in the homeassistant.helpers.storage logger. + + Regression test for https://github.com/home-assistant/core/issues/167224 + """ + for key, description in SENSOR_DESCRIPTIONS.items(): + serialized = serialize_entity_description(description) + try: + json.dumps(serialized) + except TypeError as err: + raise AssertionError( + f"SENSOR_DESCRIPTIONS[{key!r}] produced a non-serializable value: {err}" + ) from err + + @pytest.mark.usefixtures("enable_bluetooth") @pytest.mark.parametrize( ( From 84490ef0bb6a7406708e36c6e2d25d6f0ab24d9a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 10 Apr 2026 12:34:05 +0200 Subject: [PATCH 50/69] Support Chess.com accounts with no name (#167824) --- .../components/chess_com/config_flow.py | 4 +++- .../components/chess_com/test_config_flow.py | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/chess_com/config_flow.py b/homeassistant/components/chess_com/config_flow.py index fea9ffd94df9ba..73d2260726f0e2 100644 --- a/homeassistant/components/chess_com/config_flow.py +++ b/homeassistant/components/chess_com/config_flow.py @@ -39,7 +39,9 @@ async def async_step_user( else: await self.async_set_unique_id(str(user.player_id)) self._abort_if_unique_id_configured() - return self.async_create_entry(title=user.name, data=user_input) + return self.async_create_entry( + title=user.name or user.username, data=user_input + ) return self.async_show_form( step_id="user", diff --git a/tests/components/chess_com/test_config_flow.py b/tests/components/chess_com/test_config_flow.py index b602b50097be85..e1f87a53118b3f 100644 --- a/tests/components/chess_com/test_config_flow.py +++ b/tests/components/chess_com/test_config_flow.py @@ -34,6 +34,27 @@ async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.usefixtures("mock_setup_entry") +async def test_flow_no_name(hass: HomeAssistant, mock_chess_client: AsyncMock) -> None: + """Test the flow with no name.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_chess_client.get_player.return_value.name = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: "joostlek"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "joostlek" + assert result["data"] == {CONF_USERNAME: "joostlek"} + assert result["result"].unique_id == "532748851" + + @pytest.mark.parametrize( ("exception", "error"), [ From 78107c478da80780a8c38cca05de2b7de17010ac Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 10 Apr 2026 09:53:51 +0200 Subject: [PATCH 51/69] Fix stale devices removal for Alexa devices (#167837) --- .../components/alexa_devices/coordinator.py | 11 ++++++- .../alexa_devices/test_coordinator.py | 32 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alexa_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py index 87299e647fe346..8988d3e13cf785 100644 --- a/homeassistant/components/alexa_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -54,7 +54,16 @@ def __init__( entry.data[CONF_PASSWORD], entry.data[CONF_LOGIN_DATA], ) - self.previous_devices: set[str] = set() + device_registry = dr.async_get(hass) + self.previous_devices: set[str] = { + identifier + for device in device_registry.devices.get_devices_for_config_entry_id( + entry.entry_id + ) + if device.entry_type != dr.DeviceEntryType.SERVICE + for identifier_domain, identifier in device.identifiers + if identifier_domain == DOMAIN + } async def _async_update_data(self) -> dict[str, AmazonDevice]: """Update device data.""" diff --git a/tests/components/alexa_devices/test_coordinator.py b/tests/components/alexa_devices/test_coordinator.py index 3e0880fcd0782f..b53734f8ceee2e 100644 --- a/tests/components/alexa_devices/test_coordinator.py +++ b/tests/components/alexa_devices/test_coordinator.py @@ -4,9 +4,11 @@ from freezegun.api import FrozenDateTimeFactory +from homeassistant.components.alexa_devices.const import DOMAIN from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import setup_integration from .const import TEST_DEVICE_1, TEST_DEVICE_1_SN, TEST_DEVICE_2, TEST_DEVICE_2_SN @@ -50,3 +52,33 @@ async def test_coordinator_stale_device( # Entity is removed assert not hass.states.get(entity_id_1) + + +async def test_coordinator_load_previous_devices_from_registry( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test coordinator preloads previous devices from registry excluding services.""" + mock_config_entry.add_to_hass(hass) + + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, TEST_DEVICE_1_SN)}, + name="Echo Test", + manufacturer="Amazon", + model="Echo Dot", + ) + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Amazon", + model="Echo Dot", + entry_type=dr.DeviceEntryType.SERVICE, + ) + + await setup_integration(hass, mock_config_entry) + coordinator = mock_config_entry.runtime_data + assert coordinator.previous_devices == {TEST_DEVICE_1_SN} From ae5bd639937d05c7e1b742d708309d7d101c9ed0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Fri, 10 Apr 2026 00:34:50 +0200 Subject: [PATCH 52/69] Fix service.yaml values for Home Connect (#167847) --- .../components/home_connect/services.py | 8 +++ .../components/home_connect/services.yaml | 33 +++++----- .../components/home_connect/strings.json | 62 +++++++++---------- .../components/home_connect/test_services.py | 48 +++++++++++++- 4 files changed, 103 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/home_connect/services.py b/homeassistant/components/home_connect/services.py index bb9783be62b03c..56dbfb608d00ed 100644 --- a/homeassistant/components/home_connect/services.py +++ b/homeassistant/components/home_connect/services.py @@ -68,8 +68,16 @@ ), OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: vol.All(int, vol.Range(min=0)), OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool, + OptionKey.LAUNDRY_CARE_COMMON_SILENT_MODE: bool, OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool, OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool, + OptionKey.LAUNDRY_CARE_WASHER_INTENSIVE_PLUS: bool, + OptionKey.LAUNDRY_CARE_WASHER_LESS_IRONING: bool, + OptionKey.LAUNDRY_CARE_WASHER_MINI_LOAD: bool, + OptionKey.LAUNDRY_CARE_WASHER_PREWASH: bool, + OptionKey.LAUNDRY_CARE_WASHER_RINSE_HOLD: bool, + OptionKey.LAUNDRY_CARE_WASHER_SOAK: bool, + OptionKey.LAUNDRY_CARE_WASHER_WATER_PLUS: bool, }.items() } diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index 2bec0dc6cf58d1..af9a2400459e3d 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -119,7 +119,7 @@ set_program_and_options: - cooking_common_program_hood_automatic - cooking_common_program_hood_venting - cooking_common_program_hood_delayed_shut_off - - cooking_oven_program_heating_mode_3_d_heating + - cooking_oven_program_heating_mode_3_d_hot_air - cooking_oven_program_heating_mode_air_fry - cooking_oven_program_heating_mode_grill_large_area - cooking_oven_program_heating_mode_grill_small_area @@ -210,6 +210,7 @@ set_program_and_options: mode: box unit_of_measurement: "%" heating_ventilation_air_conditioning_air_conditioner_option_fan_speed_mode: + example: heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_automatic required: false selector: select: @@ -222,7 +223,7 @@ set_program_and_options: collapsed: true fields: consumer_products_cleaning_robot_option_reference_map_id: - example: consumer_products_cleaning_robot_enum_type_available_maps_map1 + example: consumer_products_cleaning_robot_enum_type_available_maps_map_1 required: false selector: select: @@ -230,9 +231,9 @@ set_program_and_options: translation_key: available_maps options: - consumer_products_cleaning_robot_enum_type_available_maps_temp_map - - consumer_products_cleaning_robot_enum_type_available_maps_map1 - - consumer_products_cleaning_robot_enum_type_available_maps_map2 - - consumer_products_cleaning_robot_enum_type_available_maps_map3 + - consumer_products_cleaning_robot_enum_type_available_maps_map_1 + - consumer_products_cleaning_robot_enum_type_available_maps_map_2 + - consumer_products_cleaning_robot_enum_type_available_maps_map_3 consumer_products_cleaning_robot_option_cleaning_mode: example: consumer_products_cleaning_robot_enum_type_cleaning_modes_standard required: false @@ -310,7 +311,7 @@ set_program_and_options: - consumer_products_coffee_maker_enum_type_coffee_temperature_94_c - consumer_products_coffee_maker_enum_type_coffee_temperature_95_c - consumer_products_coffee_maker_enum_type_coffee_temperature_96_c - consumer_products_coffee_maker_option_bean_container: + consumer_products_coffee_maker_option_bean_container_selection: example: consumer_products_coffee_maker_enum_type_bean_container_selection_right required: false selector: @@ -468,8 +469,8 @@ set_program_and_options: hood_options: collapsed: true fields: - cooking_hood_option_venting_level: - example: cooking_hood_enum_type_stage_fan_stage01 + cooking_common_option_hood_venting_level: + example: cooking_hood_enum_type_stage_fan_stage_01 required: false selector: select: @@ -482,8 +483,8 @@ set_program_and_options: - cooking_hood_enum_type_stage_fan_stage_03 - cooking_hood_enum_type_stage_fan_stage_04 - cooking_hood_enum_type_stage_fan_stage_05 - cooking_hood_option_intensive_level: - example: cooking_hood_enum_type_intensive_stage_intensive_stage1 + cooking_common_option_hood_intensive_level: + example: cooking_hood_enum_type_intensive_stage_intensive_stage_1 required: false selector: select: @@ -491,8 +492,8 @@ set_program_and_options: translation_key: intensive_level options: - cooking_hood_enum_type_intensive_stage_intensive_stage_off - - cooking_hood_enum_type_intensive_stage_intensive_stage1 - - cooking_hood_enum_type_intensive_stage_intensive_stage2 + - cooking_hood_enum_type_intensive_stage_intensive_stage_1 + - cooking_hood_enum_type_intensive_stage_intensive_stage_2 oven_options: collapsed: true fields: @@ -567,7 +568,7 @@ set_program_and_options: - laundry_care_washer_enum_type_temperature_ul_hot - laundry_care_washer_enum_type_temperature_ul_extra_hot laundry_care_washer_option_spin_speed: - example: laundry_care_washer_enum_type_spin_speed_r_p_m800 + example: laundry_care_washer_enum_type_spin_speed_r_p_m_800 required: false selector: select: @@ -611,12 +612,12 @@ set_program_and_options: required: false selector: boolean: - laundry_care_washer_option_i_dos1_active: + laundry_care_washer_option_i_dos_1_active: example: false required: false selector: boolean: - laundry_care_washer_option_i_dos2_active: + laundry_care_washer_option_i_dos_2_active: example: false required: false selector: @@ -656,7 +657,7 @@ set_program_and_options: required: false selector: boolean: - laundry_care_washer_option_vario_perfect: + laundry_care_common_option_vario_perfect: example: laundry_care_common_enum_type_vario_perfect_eco_perfect required: false selector: diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index b49476407dff10..8a50dfe860c5cc 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -260,7 +260,7 @@ "cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]", "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]", "cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]", - "cooking_oven_program_heating_mode_3_d_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_heating%]", + "cooking_oven_program_heating_mode_3_d_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_hot_air%]", "cooking_oven_program_heating_mode_air_fry": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_air_fry%]", "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]", "cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]", @@ -431,7 +431,7 @@ } }, "bean_container": { - "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_container::name%]", + "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_container_selection::name%]", "state": { "consumer_products_coffee_maker_enum_type_bean_container_selection_left": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_left%]", "consumer_products_coffee_maker_enum_type_bean_container_selection_right": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_right%]" @@ -484,9 +484,9 @@ "current_map": { "name": "Current map", "state": { - "consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]", - "consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]", - "consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map_1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_1%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map_2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_2%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map_3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_3%]", "consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]" } }, @@ -557,19 +557,19 @@ } }, "intensive_level": { - "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_intensive_level::name%]", + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_common_option_hood_intensive_level::name%]", "state": { - "cooking_hood_enum_type_intensive_stage_intensive_stage1": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage1%]", - "cooking_hood_enum_type_intensive_stage_intensive_stage2": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage2%]", + "cooking_hood_enum_type_intensive_stage_intensive_stage_1": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_1%]", + "cooking_hood_enum_type_intensive_stage_intensive_stage_2": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_2%]", "cooking_hood_enum_type_intensive_stage_intensive_stage_off": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_off%]" } }, "reference_map_id": { "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_reference_map_id::name%]", "state": { - "consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]", - "consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]", - "consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map_1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_1%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map_2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_2%]", + "consumer_products_cleaning_robot_enum_type_available_maps_map_3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_3%]", "consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]" } }, @@ -620,7 +620,7 @@ "cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]", "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]", "cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]", - "cooking_oven_program_heating_mode_3_d_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_heating%]", + "cooking_oven_program_heating_mode_3_d_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_hot_air%]", "cooking_oven_program_heating_mode_air_fry": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_air_fry%]", "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]", "cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]", @@ -786,7 +786,7 @@ } }, "vario_perfect": { - "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_vario_perfect::name%]", + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_common_option_vario_perfect::name%]", "state": { "laundry_care_common_enum_type_vario_perfect_eco_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_eco_perfect%]", "laundry_care_common_enum_type_vario_perfect_off": "[%key:common::state::off%]", @@ -794,7 +794,7 @@ } }, "venting_level": { - "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_venting_level::name%]", + "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_common_option_hood_venting_level::name%]", "state": { "cooking_hood_enum_type_stage_fan_off": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_off%]", "cooking_hood_enum_type_stage_fan_stage_01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_01%]", @@ -1272,10 +1272,10 @@ "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_hygiene_plus::name%]" }, "i_dos1_active": { - "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos1_active::name%]" + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos_1_active::name%]" }, "i_dos2_active": { - "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos2_active::name%]" + "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos_2_active::name%]" }, "intensiv_zone": { "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_intensiv_zone::name%]" @@ -1458,9 +1458,9 @@ }, "available_maps": { "options": { - "consumer_products_cleaning_robot_enum_type_available_maps_map1": "Map 1", - "consumer_products_cleaning_robot_enum_type_available_maps_map2": "Map 2", - "consumer_products_cleaning_robot_enum_type_available_maps_map3": "Map 3", + "consumer_products_cleaning_robot_enum_type_available_maps_map_1": "Map 1", + "consumer_products_cleaning_robot_enum_type_available_maps_map_2": "Map 2", + "consumer_products_cleaning_robot_enum_type_available_maps_map_3": "Map 3", "consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "Temporary map" } }, @@ -1584,8 +1584,8 @@ }, "intensive_level": { "options": { - "cooking_hood_enum_type_intensive_stage_intensive_stage1": "Intensive stage 1", - "cooking_hood_enum_type_intensive_stage_intensive_stage2": "Intensive stage 2", + "cooking_hood_enum_type_intensive_stage_intensive_stage_1": "Intensive stage 1", + "cooking_hood_enum_type_intensive_stage_intensive_stage_2": "Intensive stage 2", "cooking_hood_enum_type_intensive_stage_intensive_stage_off": "Intensive stage off" } }, @@ -1629,7 +1629,7 @@ "cooking_common_program_hood_automatic": "Automatic", "cooking_common_program_hood_delayed_shut_off": "Delayed shut off", "cooking_common_program_hood_venting": "Venting", - "cooking_oven_program_heating_mode_3_d_heating": "3D heating", + "cooking_oven_program_heating_mode_3_d_hot_air": "3D hot air", "cooking_oven_program_heating_mode_air_fry": "Air fry", "cooking_oven_program_heating_mode_bottom_heating": "Bottom heating", "cooking_oven_program_heating_mode_bread_baking": "Bread baking", @@ -1892,7 +1892,7 @@ "description": "Describes the amount of coffee beans used in a coffee machine program.", "name": "Bean amount" }, - "consumer_products_coffee_maker_option_bean_container": { + "consumer_products_coffee_maker_option_bean_container_selection": { "description": "Defines the preferred bean container.", "name": "Bean container" }, @@ -1920,11 +1920,11 @@ "description": "Defines if double dispensing is enabled.", "name": "Multiple beverages" }, - "cooking_hood_option_intensive_level": { + "cooking_common_option_hood_intensive_level": { "description": "Defines the intensive setting.", "name": "Intensive level" }, - "cooking_hood_option_venting_level": { + "cooking_common_option_hood_venting_level": { "description": "Defines the required fan setting.", "name": "Venting level" }, @@ -1992,15 +1992,19 @@ "description": "Defines if the silent mode is activated.", "name": "Silent mode" }, + "laundry_care_common_option_vario_perfect": { + "description": "Defines if a cycle saves energy (Eco Perfect) or time (Speed Perfect).", + "name": "Vario perfect" + }, "laundry_care_dryer_option_drying_target": { "description": "Describes the drying target for a dryer program.", "name": "Drying target" }, - "laundry_care_washer_option_i_dos1_active": { + "laundry_care_washer_option_i_dos_1_active": { "description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 1)", "name": "i-Dos 1 Active" }, - "laundry_care_washer_option_i_dos2_active": { + "laundry_care_washer_option_i_dos_2_active": { "description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 2)", "name": "i-Dos 2 Active" }, @@ -2044,10 +2048,6 @@ "description": "Defines the temperature of the washing program.", "name": "Temperature" }, - "laundry_care_washer_option_vario_perfect": { - "description": "Defines if a cycle saves energy (Eco Perfect) or time (Speed Perfect).", - "name": "Vario perfect" - }, "laundry_care_washer_option_water_plus": { "description": "Defines if the water plus option is activated.", "name": "Water +" diff --git a/tests/components/home_connect/test_services.py b/tests/components/home_connect/test_services.py index d56a5478079e95..a5ab71f17c65c5 100644 --- a/tests/components/home_connect/test_services.py +++ b/tests/components/home_connect/test_services.py @@ -17,12 +17,19 @@ from syrupy.assertion import SnapshotAssertion from voluptuous.error import MultipleInvalid -from homeassistant.components.home_connect.const import DOMAIN +from homeassistant.components import home_connect +from homeassistant.components.home_connect.const import ( + DOMAIN, + PROGRAM_ENUM_OPTIONS, + TRANSLATION_KEYS_PROGRAMS_MAP, +) +from homeassistant.components.home_connect.services import PROGRAM_OPTIONS from homeassistant.components.home_connect.utils import bsh_key_to_translation_key from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr +from homeassistant.util.yaml import load_yaml_dict from tests.common import MockConfigEntry @@ -95,6 +102,45 @@ ] +def test_services_yaml_set_program_and_options_program_keys() -> None: + """Test that all program keys in services.yaml exist in the translation map.""" + services = load_yaml_dict(f"{home_connect.__path__[0]}/services.yaml") + yaml_programs = set( + services["set_program_and_options"]["fields"]["program"]["selector"]["select"][ + "options" + ] + ) + + assert yaml_programs <= set(TRANSLATION_KEYS_PROGRAMS_MAP.keys()) + + +def test_services_yaml_set_program_and_options_option_keys() -> None: + """Test that all program keys in services.yaml exist in the translation map.""" + services = load_yaml_dict(f"{home_connect.__path__[0]}/services.yaml") + groups = services["set_program_and_options"]["fields"] + groups.pop("device_id") + groups.pop("affects_to") + groups.pop("program") + for group in groups.values(): + for option, option_data in group["fields"].items(): + assert option in PROGRAM_ENUM_OPTIONS or option in PROGRAM_OPTIONS, ( + f"{option} is missing from both PROGRAM_ENUM_OPTIONS and PROGRAM_OPTIONS" + ) + if option in PROGRAM_ENUM_OPTIONS: + enum_values = set(PROGRAM_ENUM_OPTIONS[option][1]) + assert enum_values == set( + option_data["selector"]["select"]["options"] + ), ( + f"Options for {option} do not match between services.yaml and constants.py" + ) + assert "example" in option_data, ( + f"Example value for {option} is missing" + ) + assert option_data["example"] in enum_values, ( + f"Example value for {option} is not a valid option" + ) + + @pytest.mark.parametrize("appliance", ["Washer"], indirect=True) @pytest.mark.parametrize("service_call", SERVICE_KV_CALL_PARAMS) async def test_key_value_services( From afcc2113ced7a941e4a2aa11ecea409af2305d54 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 10 Apr 2026 09:52:19 +0200 Subject: [PATCH 53/69] Bump ZHA to 1.1.2 (#167849) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index d36b5bedf9ed44..9c745e0fe0c991 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "universal_silabs_flasher", "serialx" ], - "requirements": ["zha==1.1.1", "serialx==0.6.2"], + "requirements": ["zha==1.1.2", "serialx==0.6.2"], "usb": [ { "description": "*2652*", diff --git a/requirements_all.txt b/requirements_all.txt index c0125f28f6a0c6..2e0bd3f47e10c5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3383,7 +3383,7 @@ zeroconf==0.148.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==1.1.1 +zha==1.1.2 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4ba6d8c672e964..459942528c30b4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2865,7 +2865,7 @@ zeroconf==0.148.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==1.1.1 +zha==1.1.2 # homeassistant.components.zinvolt zinvolt==0.4.1 From d153eee822a6a314991b735ef6141c8959ffc25c Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Fri, 10 Apr 2026 09:59:53 +0200 Subject: [PATCH 54/69] Bump velbusaio to 2026.4.0 (#167868) --- homeassistant/components/velbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 237323dd481e50..eb4c90aaf83eaa 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -14,7 +14,7 @@ "velbus-protocol" ], "quality_scale": "silver", - "requirements": ["velbus-aio==2026.2.0"], + "requirements": ["velbus-aio==2026.4.0"], "usb": [ { "pid": "0B1B", diff --git a/requirements_all.txt b/requirements_all.txt index 2e0bd3f47e10c5..75edc77e9e0f05 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3222,7 +3222,7 @@ vegehub==0.1.26 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2026.2.0 +velbus-aio==2026.4.0 # homeassistant.components.venstar venstarcolortouch==0.21 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 459942528c30b4..f97a340631a0d0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2728,7 +2728,7 @@ vegehub==0.1.26 vehicle==2.2.2 # homeassistant.components.velbus -velbus-aio==2026.2.0 +velbus-aio==2026.4.0 # homeassistant.components.venstar venstarcolortouch==0.21 From 0b5f85bdb9d3b45ce83939ec05926e643fffa1f5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 10 Apr 2026 17:02:51 +0200 Subject: [PATCH 55/69] Bump zinvolt to 0.4.3 (#167908) --- homeassistant/components/zinvolt/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zinvolt/manifest.json b/homeassistant/components/zinvolt/manifest.json index 53e7b74ed004d6..a73f18e6c80b57 100644 --- a/homeassistant/components/zinvolt/manifest.json +++ b/homeassistant/components/zinvolt/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["zinvolt"], "quality_scale": "bronze", - "requirements": ["zinvolt==0.4.1"] + "requirements": ["zinvolt==0.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 75edc77e9e0f05..b1051491da6ebc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3392,7 +3392,7 @@ zhong-hong-hvac==1.0.13 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zinvolt -zinvolt==0.4.1 +zinvolt==0.4.3 # homeassistant.components.zoneminder zm-py==0.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f97a340631a0d0..ef005550ac5147 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2868,7 +2868,7 @@ zeversolar==0.3.2 zha==1.1.2 # homeassistant.components.zinvolt -zinvolt==0.4.1 +zinvolt==0.4.3 # homeassistant.components.zoneminder zm-py==0.5.4 From 6bcfc32d4812ab3a22d18a9910d1005eaafdcd01 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:57:05 +0200 Subject: [PATCH 56/69] Bump qbusmqttapi to 1.4.3 (#167909) --- homeassistant/components/qbus/light.py | 2 +- homeassistant/components/qbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/qbus/light.py b/homeassistant/components/qbus/light.py index 61225f112434df..81c7a3aa21ac12 100644 --- a/homeassistant/components/qbus/light.py +++ b/homeassistant/components/qbus/light.py @@ -79,7 +79,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: await self._async_publish_output_state(state) async def _handle_state_received(self, state: QbusMqttAnalogState) -> None: - percentage = round(state.read_percentage()) + percentage = round(state.read_percentage() or 0) self._set_state(percentage) def _set_state(self, percentage: int) -> None: diff --git a/homeassistant/components/qbus/manifest.json b/homeassistant/components/qbus/manifest.json index 15392f6cc97cf5..c14a46eae118c7 100644 --- a/homeassistant/components/qbus/manifest.json +++ b/homeassistant/components/qbus/manifest.json @@ -14,5 +14,5 @@ "cloudapp/QBUSMQTTGW/+/state" ], "quality_scale": "bronze", - "requirements": ["qbusmqttapi==1.4.2"] + "requirements": ["qbusmqttapi==1.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index b1051491da6ebc..1dbad95c934ac8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2784,7 +2784,7 @@ pyzerproc==0.4.8 qbittorrent-api==2024.9.67 # homeassistant.components.qbus -qbusmqttapi==1.4.2 +qbusmqttapi==1.4.3 # homeassistant.components.qingping qingping-ble==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef005550ac5147..2215fe04a774b7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2368,7 +2368,7 @@ pyzerproc==0.4.8 qbittorrent-api==2024.9.67 # homeassistant.components.qbus -qbusmqttapi==1.4.2 +qbusmqttapi==1.4.3 # homeassistant.components.qingping qingping-ble==1.1.0 From 1ae9e7c87da3f608aa0876428862542bef53de39 Mon Sep 17 00:00:00 2001 From: panosmz Date: Fri, 10 Apr 2026 17:48:11 +0300 Subject: [PATCH 57/69] Bump oasatelematics to 0.4 (#167911) --- homeassistant/components/oasa_telematics/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/oasa_telematics/manifest.json b/homeassistant/components/oasa_telematics/manifest.json index 7365081a95913f..194c481b6f1aff 100644 --- a/homeassistant/components/oasa_telematics/manifest.json +++ b/homeassistant/components/oasa_telematics/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["oasatelematics"], "quality_scale": "legacy", - "requirements": ["oasatelematics==0.3"] + "requirements": ["oasatelematics==0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1dbad95c934ac8..77f10c6a97d527 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1660,7 +1660,7 @@ numpy==2.3.2 nyt_games==0.5.0 # homeassistant.components.oasa_telematics -oasatelematics==0.3 +oasatelematics==0.4 # homeassistant.components.google oauth2client==4.1.3 From 7d6eaf40a689cd1a3d8a97a6754811256263cbd7 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:24:00 +0200 Subject: [PATCH 58/69] Fix light on action for qbus integration (#167917) --- homeassistant/components/qbus/light.py | 6 +++-- tests/components/qbus/test_light.py | 35 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/qbus/light.py b/homeassistant/components/qbus/light.py index 81c7a3aa21ac12..2e43b1d444f9c4 100644 --- a/homeassistant/components/qbus/light.py +++ b/homeassistant/components/qbus/light.py @@ -79,8 +79,10 @@ async def async_turn_off(self, **kwargs: Any) -> None: await self._async_publish_output_state(state) async def _handle_state_received(self, state: QbusMqttAnalogState) -> None: - percentage = round(state.read_percentage() or 0) - self._set_state(percentage) + percentage = state.read_percentage() + + if percentage is not None: + self._set_state(round(percentage)) def _set_state(self, percentage: int) -> None: self._attr_is_on = percentage > 0 diff --git a/tests/components/qbus/test_light.py b/tests/components/qbus/test_light.py index 2db2c622289c67..093bb658ade242 100644 --- a/tests/components/qbus/test_light.py +++ b/tests/components/qbus/test_light.py @@ -20,6 +20,7 @@ _PAYLOAD_LIGHT_STATE_BRIGHTNESS = ( '{"id":"UL15","properties":{"value":' + str(_BRIGHTNESS_PCT) + '},"type":"state"}' ) +_PAYLOAD_LIGHT_STATE_EVENT = '{"id":"UL15","action":"on","type":"event"}' _PAYLOAD_LIGHT_STATE_OFF = '{"id":"UL15","properties":{"value":0},"type":"state"}' _PAYLOAD_LIGHT_SET_STATE_ON = '{"id": "UL15", "type": "action", "action": "on"}' @@ -104,3 +105,37 @@ async def test_light( await hass.async_block_till_done() assert hass.states.get(_LIGHT_ENTITY_ID).state == STATE_OFF + + +async def test_light_ignore_missing_percentage( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_integration: None, +) -> None: + """Test ignoring events without percentage.""" + + # Switch ON + mqtt_mock.reset_mock() + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: _LIGHT_ENTITY_ID}, + blocking=True, + ) + + # Simulate response + async_fire_mqtt_message(hass, _TOPIC_LIGHT_STATE, _PAYLOAD_LIGHT_STATE_ON) + await hass.async_block_till_done() + + entity = hass.states.get(_LIGHT_ENTITY_ID) + brightness = entity.attributes.get(ATTR_BRIGHTNESS) + assert entity.state == STATE_ON + assert brightness > 0 + + # Simulate additional event response + async_fire_mqtt_message(hass, _TOPIC_LIGHT_STATE, _PAYLOAD_LIGHT_STATE_EVENT) + await hass.async_block_till_done() + + entity = hass.states.get(_LIGHT_ENTITY_ID) + assert entity.state == STATE_ON + assert entity.attributes.get(ATTR_BRIGHTNESS) == brightness From a331cb71998b40262699f2a341474bc9d1bbf667 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Fri, 10 Apr 2026 10:21:45 -0600 Subject: [PATCH 59/69] Bump pylitterbot to 2025.2.1 (#167921) --- homeassistant/components/litterrobot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index 2ed1e72704e45a..f217da5b801a95 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -16,5 +16,5 @@ "iot_class": "cloud_push", "loggers": ["pylitterbot"], "quality_scale": "platinum", - "requirements": ["pylitterbot==2025.2.0"] + "requirements": ["pylitterbot==2025.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 77f10c6a97d527..9c0cf0cf331fdb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2257,7 +2257,7 @@ pyliebherrhomeapi==0.4.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2025.2.0 +pylitterbot==2025.2.1 # homeassistant.components.lutron_caseta pylutron-caseta==0.27.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2215fe04a774b7..7053b6fa3d8372 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1934,7 +1934,7 @@ pyliebherrhomeapi==0.4.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2025.2.0 +pylitterbot==2025.2.1 # homeassistant.components.lutron_caseta pylutron-caseta==0.27.0 From 624fab064a219a498296025b26d998d6e46637c7 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 10 Apr 2026 18:46:06 +0200 Subject: [PATCH 60/69] Update frontend to 20260325.7 (#167922) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 284e9f4b77fa4a..e9ec83fd8e412d 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260325.6"] + "requirements": ["home-assistant-frontend==20260325.7"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0b9e70ee3d8e13..f4a288fc7098b8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==5.11.1 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20260325.6 +home-assistant-frontend==20260325.7 home-assistant-intents==2026.3.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 9c0cf0cf331fdb..1541737cfe5cb1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1229,7 +1229,7 @@ hole==0.9.0 holidays==0.94 # homeassistant.components.frontend -home-assistant-frontend==20260325.6 +home-assistant-frontend==20260325.7 # homeassistant.components.conversation home-assistant-intents==2026.3.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7053b6fa3d8372..86cf126239b56f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1093,7 +1093,7 @@ hole==0.9.0 holidays==0.94 # homeassistant.components.frontend -home-assistant-frontend==20260325.6 +home-assistant-frontend==20260325.7 # homeassistant.components.conversation home-assistant-intents==2026.3.24 From a948799a6ec2dede8bf62a14ac2a6d35131ad3cd Mon Sep 17 00:00:00 2001 From: On Freund Date: Fri, 10 Apr 2026 12:47:51 -0400 Subject: [PATCH 61/69] Bump pyrisco to 0.6.8 (#167924) --- homeassistant/components/risco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/risco/manifest.json b/homeassistant/components/risco/manifest.json index 43d471172d61ae..75fa1261e34f1f 100644 --- a/homeassistant/components/risco/manifest.json +++ b/homeassistant/components/risco/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/risco", "iot_class": "local_push", "loggers": ["pyrisco"], - "requirements": ["pyrisco==0.6.7"] + "requirements": ["pyrisco==0.6.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1541737cfe5cb1..8028b0ddb430ee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2436,7 +2436,7 @@ pyrecswitch==1.0.2 pyrepetierng==0.1.0 # homeassistant.components.risco -pyrisco==0.6.7 +pyrisco==0.6.8 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 86cf126239b56f..ceb01cb9574754 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2083,7 +2083,7 @@ pyrainbird==6.1.1 pyrate-limiter==4.1.0 # homeassistant.components.risco -pyrisco==0.6.7 +pyrisco==0.6.8 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.7 From 05463cde998c1a64832382a51487bd8811aedd3c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 10 Apr 2026 20:41:40 +0000 Subject: [PATCH 62/69] Bump version to 2026.4.2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index a6bda88d7cc1da..e9fef6a7a4f90a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -17,7 +17,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "1" +PATCH_VERSION: Final = "2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2) diff --git a/pyproject.toml b/pyproject.toml index ef323a8121f02e..4ddd1e4bfc26ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.4.1" +version = "2026.4.2" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From fcd6c6e335b5be7dc02afb14053d72c1dbeefcc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Fri, 10 Apr 2026 09:57:32 +0200 Subject: [PATCH 63/69] Improve Tibber price coordinator (#166175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Hjelseth Høyer Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/tibber/__init__.py | 17 +++++- .../components/tibber/coordinator.py | 60 +++++++++++++------ homeassistant/components/tibber/sensor.py | 11 ++-- homeassistant/components/tibber/services.py | 49 ++++++++++++++- homeassistant/components/tibber/strings.json | 9 +++ tests/components/tibber/conftest.py | 2 + tests/components/tibber/test_services.py | 40 ++++++++++++- 7 files changed, 159 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 0596a5a2dc07ca..2fa987782a9a0e 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -23,7 +23,11 @@ from homeassistant.util import dt as dt_util, ssl as ssl_util from .const import AUTH_IMPLEMENTATION, DATA_HASS_CONFIG, DOMAIN, TibberConfigEntry -from .coordinator import TibberDataAPICoordinator +from .coordinator import ( + TibberDataAPICoordinator, + TibberDataCoordinator, + TibberPriceCoordinator, +) from .services import async_setup_services PLATFORMS = [Platform.BINARY_SENSOR, Platform.NOTIFY, Platform.SENSOR] @@ -39,6 +43,8 @@ class TibberRuntimeData: session: OAuth2Session data_api_coordinator: TibberDataAPICoordinator | None = field(default=None) + data_coordinator: TibberDataCoordinator | None = field(default=None) + price_coordinator: TibberPriceCoordinator | None = field(default=None) _client: tibber.Tibber | None = None async def async_get_client(self, hass: HomeAssistant) -> tibber.Tibber: @@ -124,6 +130,15 @@ async def _close(event: Event) -> None: except tibber.FatalHttpExceptionError as err: raise ConfigEntryNotReady("Fatal HTTP error from Tibber API") from err + if tibber_connection.get_homes(only_active=True): + price_coordinator = TibberPriceCoordinator(hass, entry) + await price_coordinator.async_config_entry_first_refresh() + entry.runtime_data.price_coordinator = price_coordinator + + data_coordinator = TibberDataCoordinator(hass, entry, tibber_connection) + await data_coordinator.async_config_entry_first_refresh() + entry.runtime_data.data_coordinator = data_coordinator + coordinator = TibberDataAPICoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data.data_api_coordinator = coordinator diff --git a/homeassistant/components/tibber/coordinator.py b/homeassistant/components/tibber/coordinator.py index 75a76326146149..1edb5e932692f5 100644 --- a/homeassistant/components/tibber/coordinator.py +++ b/homeassistant/components/tibber/coordinator.py @@ -5,6 +5,7 @@ import asyncio from datetime import datetime, timedelta import logging +import random from typing import TYPE_CHECKING, TypedDict, cast from aiohttp.client_exceptions import ClientError @@ -271,9 +272,10 @@ def __init__( name=f"{DOMAIN} price", update_interval=timedelta(minutes=1), ) + self._tomorrow_price_poll_threshold_seconds = random.uniform(0, 3600 * 10) - def _seconds_until_next_15_minute(self) -> float: - """Return seconds until the next 15-minute boundary (0, 15, 30, 45) in UTC.""" + def _time_until_next_15_minute(self) -> timedelta: + """Return time until the next 15-minute boundary (0, 15, 30, 45) in UTC.""" now = dt_util.utcnow() next_minute = ((now.minute // 15) + 1) * 15 if next_minute >= 60: @@ -284,7 +286,7 @@ def _seconds_until_next_15_minute(self) -> float: next_run = now.replace( minute=next_minute, second=0, microsecond=0, tzinfo=dt_util.UTC ) - return (next_run - now).total_seconds() + return next_run - now async def _async_update_data(self) -> dict[str, TibberHomeData]: """Update data via API and return per-home data for sensors.""" @@ -292,22 +294,44 @@ async def _async_update_data(self) -> dict[str, TibberHomeData]: self.hass ) active_homes = tibber_connection.get_homes(only_active=True) - try: - await asyncio.gather( - tibber_connection.fetch_consumption_data_active_homes(), - tibber_connection.fetch_production_data_active_homes(), - ) - now = dt_util.now() - homes_to_update = [ - home - for home in active_homes - if ( - (last_data_timestamp := home.last_data_timestamp) is None - or (last_data_timestamp - now).total_seconds() < 11 * 3600 - ) - ] + now = dt_util.now() + today_start = dt_util.start_of_local_day(now) + today_end = today_start + timedelta(days=1) + tomorrow_start = today_end + tomorrow_end = tomorrow_start + timedelta(days=1) + + def _has_prices_today(home: tibber.TibberHome) -> bool: + """Return True if the home has any prices today.""" + for start in home.price_total: + start_dt = dt_util.as_local(datetime.fromisoformat(str(start))) + if today_start <= start_dt < today_end: + return True + return False + + def _has_prices_tomorrow(home: tibber.TibberHome) -> bool: + """Return True if the home has any prices tomorrow.""" + for start in home.price_total: + start_dt = dt_util.as_local(datetime.fromisoformat(str(start))) + if tomorrow_start <= start_dt < tomorrow_end: + return True + return False + + def _needs_update(home: tibber.TibberHome) -> bool: + """Return True if the home needs to be updated.""" + if not _has_prices_today(home): + return True + if _has_prices_tomorrow(home): + return False + if (today_end - now).total_seconds() < ( + self._tomorrow_price_poll_threshold_seconds + ): + return True + return False + + homes_to_update = [home for home in active_homes if _needs_update(home)] + try: if homes_to_update: await asyncio.gather( *(home.update_info_and_price_info() for home in homes_to_update) @@ -319,7 +343,7 @@ async def _async_update_data(self) -> dict[str, TibberHomeData]: result = {home.home_id: _build_home_data(home) for home in active_homes} - self.update_interval = timedelta(seconds=self._seconds_until_next_15_minute()) + self.update_interval = self._time_until_next_15_minute() return result diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 008e3abef28c11..39accbaf9bb9aa 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -609,8 +609,8 @@ async def _async_setup_graphql_sensors( entity_registry = er.async_get(hass) - coordinator: TibberDataCoordinator | None = None - price_coordinator: TibberPriceCoordinator | None = None + coordinator = entry.runtime_data.data_coordinator + price_coordinator = entry.runtime_data.price_coordinator entities: list[TibberSensor] = [] for home in tibber_connection.get_homes(only_active=False): try: @@ -626,12 +626,9 @@ async def _async_setup_graphql_sensors( _LOGGER.error("Error connecting to Tibber home: %s ", err) raise PlatformNotReady from err - if home.has_active_subscription: - if price_coordinator is None: - price_coordinator = TibberPriceCoordinator(hass, entry) + if price_coordinator is not None and home.has_active_subscription: entities.append(TibberSensorElPrice(price_coordinator, home)) - if coordinator is None: - coordinator = TibberDataCoordinator(hass, entry, tibber_connection) + if coordinator is not None and home.has_active_subscription: entities.extend( TibberDataSensor(home, coordinator, entity_description) for entity_description in SENSORS diff --git a/homeassistant/components/tibber/services.py b/homeassistant/components/tibber/services.py index 099739e4478d85..68009824c6d0a7 100644 --- a/homeassistant/components/tibber/services.py +++ b/homeassistant/components/tibber/services.py @@ -6,6 +6,8 @@ from datetime import datetime from typing import TYPE_CHECKING, Any, Final +import aiohttp +import tibber import voluptuous as vol from homeassistant.core import ( @@ -15,7 +17,7 @@ SupportsResponse, callback, ) -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.util import dt as dt_util from .const import DOMAIN @@ -52,7 +54,52 @@ async def __get_prices(call: ServiceCall) -> ServiceResponse: tibber_prices: dict[str, Any] = {} + now = dt_util.now() + today_start = dt_util.start_of_local_day(now) + today_end = today_start + dt.timedelta(days=1) + tomorrow_end = today_start + dt.timedelta(days=2) + + def _has_valid_prices(home: tibber.TibberHome) -> bool: + """Return True if the home has valid prices.""" + for price_start in home.price_total: + start_dt = dt_util.as_local(datetime.fromisoformat(str(price_start))) + + if now.hour >= 13: + if today_end <= start_dt < tomorrow_end: + return True + elif today_start <= start_dt < today_end: + return True + return False + for tibber_home in tibber_connection.get_homes(only_active=True): + if not _has_valid_prices(tibber_home): + try: + await tibber_home.update_info_and_price_info() + except TimeoutError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="get_prices_timeout", + ) from err + except tibber.InvalidLoginError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="get_prices_invalid_login", + ) from err + except ( + tibber.RetryableHttpExceptionError, + tibber.FatalHttpExceptionError, + ) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="get_prices_communication_failed", + translation_placeholders={"detail": str(err.status)}, + ) from err + except aiohttp.ClientError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="get_prices_communication_failed", + translation_placeholders={"detail": str(err)}, + ) from err home_nickname = tibber_home.name price_data = [ diff --git a/homeassistant/components/tibber/strings.json b/homeassistant/components/tibber/strings.json index d07f295785ed44..c175f2fe96265f 100644 --- a/homeassistant/components/tibber/strings.json +++ b/homeassistant/components/tibber/strings.json @@ -235,6 +235,15 @@ "data_api_reauth_required": { "message": "Reconnect Tibber so Home Assistant can enable the new Tibber Data API features." }, + "get_prices_communication_failed": { + "message": "Could not fetch energy prices from Tibber ({detail})" + }, + "get_prices_invalid_login": { + "message": "Could not authenticate with Tibber while fetching prices" + }, + "get_prices_timeout": { + "message": "Timeout fetching energy prices from Tibber" + }, "invalid_date": { "message": "Invalid datetime provided {date}" }, diff --git a/tests/components/tibber/conftest.py b/tests/components/tibber/conftest.py index befc3b68c87943..b6943067d0cbc0 100644 --- a/tests/components/tibber/conftest.py +++ b/tests/components/tibber/conftest.py @@ -183,6 +183,8 @@ def tibber_mock() -> AsyncGenerator[MagicMock]: tibber_mock.send_notification = AsyncMock() tibber_mock.rt_disconnect = AsyncMock() tibber_mock.get_homes = MagicMock(return_value=[]) + tibber_mock.fetch_consumption_data_active_homes = AsyncMock(return_value=None) + tibber_mock.fetch_production_data_active_homes = AsyncMock(return_value=None) tibber_mock.set_access_token = AsyncMock() data_api_mock = MagicMock() diff --git a/tests/components/tibber/test_services.py b/tests/components/tibber/test_services.py index 9c9fb86f91717f..ca8254cf728d75 100644 --- a/tests/components/tibber/test_services.py +++ b/tests/components/tibber/test_services.py @@ -1,15 +1,17 @@ """Test service for Tibber integration.""" import datetime as dt -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock +import aiohttp from freezegun.api import FrozenDateTimeFactory import pytest +import tibber from homeassistant.components.tibber.const import DOMAIN from homeassistant.components.tibber.services import PRICE_SERVICE_NAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError START_TIME = dt.datetime.fromtimestamp(1615766400).replace(tzinfo=dt.UTC) @@ -262,3 +264,37 @@ async def test_get_prices_invalid_input( blocking=True, return_response=True, ) + + +@pytest.mark.parametrize( + "exception", + [ + pytest.param(TimeoutError(), id="timeout"), + pytest.param(tibber.InvalidLoginError(401), id="invalid_login"), + pytest.param(tibber.RetryableHttpExceptionError(503), id="retryable_http"), + pytest.param(tibber.FatalHttpExceptionError(500), id="fatal_http"), + pytest.param(aiohttp.ClientError("connection failed"), id="client_error"), + ], +) +async def test_get_prices_refresh_raises_handled_exception( + mock_tibber_setup: MagicMock, + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + exception: Exception, +) -> None: + """When price refresh fails with handled exceptions, raise HomeAssistantError.""" + freezer.move_to(START_TIME) + mock_home = MagicMock() + mock_home.name = "home" + mock_home.price_total = {} + mock_home.update_info_and_price_info = AsyncMock(side_effect=exception) + mock_tibber_setup.get_homes.return_value = [mock_home] + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + PRICE_SERVICE_NAME, + {}, + blocking=True, + return_response=True, + ) From fde103cdfdcb1715188741670f1b75de89a6b61d Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 10 Apr 2026 22:50:36 +0200 Subject: [PATCH 64/69] Fix tibber price sensor first state update (#167938) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/tibber/sensor.py | 8 ++++++-- tests/components/tibber/test_sensor.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 39accbaf9bb9aa..0ac0114a1c88ac 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -769,9 +769,15 @@ def __init__( self._model = "Price Sensor" self._device_name = self._home_name + self._update_attributes() @callback def _handle_coordinator_update(self) -> None: + self._update_attributes() + super()._handle_coordinator_update() + + @callback + def _update_attributes(self) -> None: """Handle updated data from the coordinator.""" data = self.coordinator.data if not data or ( @@ -779,7 +785,6 @@ def _handle_coordinator_update(self) -> None: or (current_price := home_data.get("current_price")) is None ): self._attr_available = False - self.async_write_ha_state() return self._attr_native_unit_of_measurement = home_data.get( @@ -801,7 +806,6 @@ def _handle_coordinator_update(self) -> None: "estimated_annual_consumption" ] self._attr_available = True - self.async_write_ha_state() class TibberDataSensor(TibberSensor, CoordinatorEntity[TibberDataCoordinator]): diff --git a/tests/components/tibber/test_sensor.py b/tests/components/tibber/test_sensor.py index fa8c84821b82e4..e29464287512b4 100644 --- a/tests/components/tibber/test_sensor.py +++ b/tests/components/tibber/test_sensor.py @@ -82,6 +82,21 @@ async def test_price_sensor_state_unit_and_attributes( entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, home.home_id) assert entity_id is not None + state = hass.states.get(entity_id) + assert state is not None + assert float(state.state) == 1.25 + assert state.attributes["unit_of_measurement"] == "NOK/kWh" + assert state.attributes["app_nickname"] == "Home" + assert state.attributes["grid_company"] == "GridCo" + assert state.attributes["estimated_annual_consumption"] == 12000 + assert state.attributes["intraday_price_ranking"] == 0.4 + assert state.attributes["max_price"] == 1.8 + assert state.attributes["avg_price"] == 1.2 + assert state.attributes["min_price"] == 0.8 + assert state.attributes["off_peak_1"] == 0.9 + assert state.attributes["peak"] == 1.7 + assert state.attributes["off_peak_2"] == 1.0 + await async_update_entity(hass, entity_id) await hass.async_block_till_done() From cdce98faaf5f98efcf272d48e4093671c21767a6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 11 Apr 2026 10:00:37 +0200 Subject: [PATCH 65/69] Update cryptography to 46.0.7 (#167960) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f4a288fc7098b8..22de9fad9e1632 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ cached-ipaddress==1.0.1 certifi>=2021.5.30 ciso8601==2.3.3 cronsim==2.7 -cryptography==46.0.6 +cryptography==46.0.7 dbus-fast==3.1.2 file-read-backwards==2.0.0 fnv-hash-fast==2.0.0 diff --git a/pyproject.toml b/pyproject.toml index 4ddd1e4bfc26ed..71c3a7d72f39e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ dependencies = [ "lru-dict==1.3.0", "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. - "cryptography==46.0.6", + "cryptography==46.0.7", "Pillow==12.1.1", "propcache==0.4.1", "pyOpenSSL==26.0.0", diff --git a/requirements.txt b/requirements.txt index 55c8375f517681..e779bc45542af4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ bcrypt==5.0.0 certifi>=2021.5.30 ciso8601==2.3.3 cronsim==2.7 -cryptography==46.0.6 +cryptography==46.0.7 fnv-hash-fast==2.0.0 ha-ffmpeg==3.2.2 hass-nabucasa==2.2.0 From f2df848e3f40d3df11f7091f0ce0a5c496cd9967 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 1 Apr 2026 15:56:04 +0200 Subject: [PATCH 66/69] Fix spelling of "Shut down" button label in `proxmoxve` (#167059) --- .../components/proxmoxve/strings.json | 2 +- .../proxmoxve/snapshots/test_button.ambr | 42 +++++++++---------- tests/components/proxmoxve/test_button.py | 6 +-- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/proxmoxve/strings.json b/homeassistant/components/proxmoxve/strings.json index 695d2ef2d9d8b5..56c9a79781a632 100644 --- a/homeassistant/components/proxmoxve/strings.json +++ b/homeassistant/components/proxmoxve/strings.json @@ -141,7 +141,7 @@ "name": "Reset" }, "shutdown": { - "name": "Shutdown" + "name": "Shut down" }, "snapshot_create": { "name": "Create snapshot" diff --git a/tests/components/proxmoxve/snapshots/test_button.ambr b/tests/components/proxmoxve/snapshots/test_button.ambr index 0b3e2c53238d49..14a3745a56fb2b 100644 --- a/tests/components/proxmoxve/snapshots/test_button.ambr +++ b/tests/components/proxmoxve/snapshots/test_button.ambr @@ -452,7 +452,7 @@ 'state': 'unknown', }) # --- -# name: test_all_button_entities[button.pve1_shutdown-entry] +# name: test_all_button_entities[button.pve1_shut_down-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -466,7 +466,7 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': , - 'entity_id': 'button.pve1_shutdown', + 'entity_id': 'button.pve1_shut_down', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -474,12 +474,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Shutdown', + 'object_id_base': 'Shut down', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Shutdown', + 'original_name': 'Shut down', 'platform': 'proxmoxve', 'previous_unique_id': None, 'suggested_object_id': None, @@ -489,13 +489,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_button_entities[button.pve1_shutdown-state] +# name: test_all_button_entities[button.pve1_shut_down-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'pve1 Shutdown', + 'friendly_name': 'pve1 Shut down', }), 'context': , - 'entity_id': 'button.pve1_shutdown', + 'entity_id': 'button.pve1_shut_down', 'last_changed': , 'last_reported': , 'last_updated': , @@ -853,7 +853,7 @@ 'state': 'unknown', }) # --- -# name: test_all_button_entities[button.vm_db_shutdown-entry] +# name: test_all_button_entities[button.vm_db_shut_down-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -867,7 +867,7 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': , - 'entity_id': 'button.vm_db_shutdown', + 'entity_id': 'button.vm_db_shut_down', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -875,12 +875,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Shutdown', + 'object_id_base': 'Shut down', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Shutdown', + 'original_name': 'Shut down', 'platform': 'proxmoxve', 'previous_unique_id': None, 'suggested_object_id': None, @@ -890,13 +890,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_button_entities[button.vm_db_shutdown-state] +# name: test_all_button_entities[button.vm_db_shut_down-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'vm-db Shutdown', + 'friendly_name': 'vm-db Shut down', }), 'context': , - 'entity_id': 'button.vm_db_shutdown', + 'entity_id': 'button.vm_db_shut_down', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1204,7 +1204,7 @@ 'state': 'unknown', }) # --- -# name: test_all_button_entities[button.vm_web_shutdown-entry] +# name: test_all_button_entities[button.vm_web_shut_down-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1218,7 +1218,7 @@ 'disabled_by': None, 'domain': 'button', 'entity_category': , - 'entity_id': 'button.vm_web_shutdown', + 'entity_id': 'button.vm_web_shut_down', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1226,12 +1226,12 @@ 'labels': set({ }), 'name': None, - 'object_id_base': 'Shutdown', + 'object_id_base': 'Shut down', 'options': dict({ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Shutdown', + 'original_name': 'Shut down', 'platform': 'proxmoxve', 'previous_unique_id': None, 'suggested_object_id': None, @@ -1241,13 +1241,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_button_entities[button.vm_web_shutdown-state] +# name: test_all_button_entities[button.vm_web_shut_down-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'vm-web Shutdown', + 'friendly_name': 'vm-web Shut down', }), 'context': , - 'entity_id': 'button.vm_web_shutdown', + 'entity_id': 'button.vm_web_shut_down', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/proxmoxve/test_button.py b/tests/components/proxmoxve/test_button.py index eb91c17ab47b59..60c0bbf89c58ff 100644 --- a/tests/components/proxmoxve/test_button.py +++ b/tests/components/proxmoxve/test_button.py @@ -50,7 +50,7 @@ async def test_all_button_entities( ("entity_id", "command"), [ ("button.pve1_restart", "reboot"), - ("button.pve1_shutdown", "shutdown"), + ("button.pve1_shut_down", "shutdown"), ], ) async def test_node_buttons( @@ -116,7 +116,7 @@ async def test_node_all_actions_buttons( ("button.vm_web_restart", 100, "reboot"), ("button.vm_web_hibernate", 100, "hibernate"), ("button.vm_web_reset", 100, "reset"), - ("button.vm_web_shutdown", 100, "shutdown"), + ("button.vm_web_shut_down", 100, "shutdown"), ], ) async def test_vm_buttons( @@ -230,7 +230,7 @@ async def test_container_buttons( ("button.pve1_restart", AuthenticationError("auth failed")), ("button.pve1_restart", SSLError("ssl error")), ("button.pve1_restart", ConnectTimeout("timeout")), - ("button.pve1_shutdown", ResourceException(500, "error", {})), + ("button.pve1_shut_down", ResourceException(500, "error", {})), ], ) async def test_node_buttons_exceptions( From e4e9c22016d0834c658541322053316c003d9111 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 11 Apr 2026 04:04:25 -0700 Subject: [PATCH 67/69] Bump opower to 0.18.1 (#167967) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index eaec3a5ed8954b..b28ba65606cf93 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["opower"], "quality_scale": "platinum", - "requirements": ["opower==0.18.0"] + "requirements": ["opower==0.18.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8028b0ddb430ee..bf3a1b9f724105 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1729,7 +1729,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.18.0 +opower==0.18.1 # homeassistant.components.oralb oralb-ble==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ceb01cb9574754..3f519d321a7edd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1509,7 +1509,7 @@ openrgb-python==0.3.6 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.18.0 +opower==0.18.1 # homeassistant.components.oralb oralb-ble==1.1.0 From f7c5a51f462d319648ae19cc029b216db92996b5 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sat, 11 Apr 2026 16:21:16 +0200 Subject: [PATCH 68/69] Portainer fix fetching swarm stacks (#167979) --- .../components/portainer/coordinator.py | 23 +++++++++++++++++-- .../portainer/fixtures/docker_info.json | 4 +++- tests/components/portainer/test_init.py | 15 ++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index a9f6a23a822892..73f1577de12d8c 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -168,15 +168,34 @@ async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]: docker_version, docker_info, docker_system_df, - stacks, ) = await asyncio.gather( self.portainer.get_containers(endpoint.id), self.portainer.docker_version(endpoint.id), self.portainer.docker_info(endpoint.id), self.portainer.docker_system_df(endpoint.id), - self.portainer.get_stacks(endpoint.id), ) + stack_requests = [self.portainer.get_stacks(endpoint_id=endpoint.id)] + swarm_id = ( + docker_info.swarm.cluster.get("ID") + if docker_info.swarm + and docker_info.swarm.control_available + and docker_info.swarm.cluster + else None + ) + if swarm_id: + stack_requests.append( + self.portainer.get_stacks( + endpoint_id=endpoint.id, swarm_id=swarm_id + ) + ) + + stacks = [ + stack + for result in await asyncio.gather(*stack_requests) + for stack in result + ] + prev_endpoint = self.data.get(endpoint.id) if self.data else None container_map: dict[str, PortainerContainerData] = {} stack_map: dict[str, PortainerStackData] = { diff --git a/tests/components/portainer/fixtures/docker_info.json b/tests/components/portainer/fixtures/docker_info.json index 53e7297e207c4d..ee6ceb34f9efa2 100644 --- a/tests/components/portainer/fixtures/docker_info.json +++ b/tests/components/portainer/fixtures/docker_info.json @@ -76,7 +76,9 @@ "RemoteManagers": [], "Nodes": 4, "Managers": 3, - "Cluster": {} + "Cluster": { + "ID": "swarm-cluster-id" + } }, "LiveRestoreEnabled": false, "Isolation": "default", diff --git a/tests/components/portainer/test_init.py b/tests/components/portainer/test_init.py index 174994620ad726..d4b8de417cf431 100644 --- a/tests/components/portainer/test_init.py +++ b/tests/components/portainer/test_init.py @@ -363,6 +363,21 @@ async def test_new_container_callback( ) > len(entities) +async def test_swarm_stacks_fetched_by_swarm_id( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that on a Swarm manager get_stacks is called with both endpoint_id and swarm_id.""" + await setup_integration(hass, mock_config_entry) + + calls = mock_portainer_client.get_stacks.call_args_list + # Expect exactly two calls: one by endpoint_id, one by swarm_id + assert len(calls) == 2 + assert calls[0].kwargs == {"endpoint_id": 1} + assert calls[1].kwargs == {"endpoint_id": 1, "swarm_id": "swarm-cluster-id"} + + async def test_new_stack_callback( hass: HomeAssistant, mock_portainer_client: AsyncMock, From 190ee49e3ac67549e7afebf165608f53c58e0d8e Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Sat, 11 Apr 2026 18:45:56 +0200 Subject: [PATCH 69/69] Bump python-bsblan to version 5.1.4 (#167987) --- homeassistant/components/bsblan/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index 97423b009c445c..6da7ab41aeae12 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["bsblan"], "quality_scale": "silver", - "requirements": ["python-bsblan==5.1.3"], + "requirements": ["python-bsblan==5.1.4"], "zeroconf": [ { "name": "bsb-lan*", diff --git a/requirements_all.txt b/requirements_all.txt index bf3a1b9f724105..04b99c18d13ade 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2557,7 +2557,7 @@ python-awair==0.2.5 python-blockchain-api==0.0.2 # homeassistant.components.bsblan -python-bsblan==5.1.3 +python-bsblan==5.1.4 # homeassistant.components.citybikes python-citybikes==0.3.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3f519d321a7edd..12d29862f4f966 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2186,7 +2186,7 @@ python-MotionMount==2.3.0 python-awair==0.2.5 # homeassistant.components.bsblan -python-bsblan==5.1.3 +python-bsblan==5.1.4 # homeassistant.components.ecobee python-ecobee-api==0.3.2