From 9cf95404cff5db872c9d7b60801bc0beae93f506 Mon Sep 17 00:00:00 2001 From: albaintor <118518828+albaintor@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:09:03 +0200 Subject: [PATCH 01/16] Fixed Kodi Media Browsing (#165819) --- homeassistant/components/kodi/browse_media.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/kodi/browse_media.py b/homeassistant/components/kodi/browse_media.py index aa98ca7e8be74..1106a2ea80ae8 100644 --- a/homeassistant/components/kodi/browse_media.py +++ b/homeassistant/components/kodi/browse_media.py @@ -70,7 +70,7 @@ async def build_item_response(media_library, payload, get_thumbnail_url=None): media_content_id=search_id, media_content_type=search_type, title=title, - can_play=search_type in PLAYABLE_MEDIA_TYPES and search_id, + can_play=bool(search_type in PLAYABLE_MEDIA_TYPES and search_id), can_expand=True, children=children, thumbnail=thumbnail, From 056ff957e868e3cd5f6e2bb32cd4f18bfddd229c Mon Sep 17 00:00:00 2001 From: Raj Laud <50647620+rajlaud@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:49:16 -0700 Subject: [PATCH 02/16] Fix Victron BLE false reauth on unrecognised advertisement mode bytes (#168209) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/victron_ble/__init__.py | 42 ++++---- tests/components/victron_ble/fixtures.py | 16 ++++ tests/components/victron_ble/test_sensor.py | 96 +++++++++++++++++++ 3 files changed, 138 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/victron_ble/__init__.py b/homeassistant/components/victron_ble/__init__.py index 7c79d7331544e..fd4cd0b4fd6db 100644 --- a/homeassistant/components/victron_ble/__init__.py +++ b/homeassistant/components/victron_ble/__init__.py @@ -3,9 +3,11 @@ from __future__ import annotations import logging +from struct import error as struct_error from sensor_state_data import SensorUpdate from victron_ble_ha_parser import VictronBluetoothDeviceData +from victron_ble_ha_parser.parser import detect_device_type from homeassistant.components.bluetooth import ( BluetoothScanningMode, @@ -38,25 +40,33 @@ def _update( nonlocal consecutive_failures update = data.update(service_info) - # 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. + # Only assess key validity for instant-readout advertisements + # (0x10 prefix) whose device type the parser actually recognizes. + # Unrecognized mode bytes or non-instant-readout packets are neutral: + # they say nothing about whether the encryption key is correct, so + # they must not increment or reset the failure counter. 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) + try: + is_recognizable = ( + raw_data[:1] == b"\x10" and detect_device_type(raw_data) is not None + ) + except struct_error, IndexError: + is_recognizable = False + + if is_recognizable: + 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 else: consecutive_failures = 0 diff --git a/tests/components/victron_ble/fixtures.py b/tests/components/victron_ble/fixtures.py index c6efeb4112b10..cacbeda018890 100644 --- a/tests/components/victron_ble/fixtures.py +++ b/tests/components/victron_ble/fixtures.py @@ -201,6 +201,22 @@ source="local", ) +# Same Victron manufacturer data prefix but with an unrecognized mode byte +# (0xEE at offset 4). detect_device_type returns None for this payload, +# so validate_advertisement_key would also return False. The reauth logic +# must treat this as neutral (not a key failure). +VICTRON_VEBUS_UNRECOGNIZED_MODE_SERVICE_INFO = BluetoothServiceInfo( + name="Inverter Charger", + address="01:02:03:04:05:06", + rssi=-60, + manufacturer_data={ + 0x02E1: bytes.fromhex("10038027ee1252dad26f0b8eb39162074d140df410") + }, + 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 b987b5b3653fb..4990dfa96a9fb 100644 --- a/tests/components/victron_ble/test_sensor.py +++ b/tests/components/victron_ble/test_sensor.py @@ -41,6 +41,7 @@ VICTRON_VEBUS_BAD_KEY_SERVICE_INFO, VICTRON_VEBUS_SERVICE_INFO, VICTRON_VEBUS_TOKEN, + VICTRON_VEBUS_UNRECOGNIZED_MODE_SERVICE_INFO, ) from tests.common import MockConfigEntry, snapshot_platform @@ -165,6 +166,28 @@ def _inject_bad_advertisement(hass: HomeAssistant, seq: int = 0) -> None: ) +def _inject_unrecognized_mode_advertisement(hass: HomeAssistant, seq: int = 0) -> None: + """Inject a Victron advertisement with an unrecognized mode byte. + + detect_device_type returns None for this payload so the reauth guard + must treat it as neutral (neither increment nor reset the failure counter). + """ + info = VICTRON_VEBUS_UNRECOGNIZED_MODE_SERVICE_INFO + raw = bytearray(info.manufacturer_data[VICTRON_IDENTIFIER]) + raw[-1] = seq & 0xFF + device = generate_ble_device(address=info.address, name=info.name, details={}) + adv = generate_advertisement_data( + local_name=info.name, + manufacturer_data={VICTRON_IDENTIFIER: bytes(raw)}, + service_data=info.service_data, + service_uuids=info.service_uuids, + rssi=-60, + ) + inject_advertisement_with_time_and_source_connectable( + hass, device, adv, time.monotonic(), "local", True + ) + + @pytest.mark.usefixtures("enable_bluetooth") async def test_reauth_triggered_after_consecutive_failures( hass: HomeAssistant, @@ -323,3 +346,76 @@ async def test_charger_error_state( state = hass.states.get("sensor.solar_charger_charger_error") assert state is not None assert state.state == expected_state + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_reauth_not_triggered_on_unrecognized_mode( + hass: HomeAssistant, + mock_config_entry_added_to_hass: MockConfigEntry, +) -> None: + """Test reauth is NOT triggered by advertisements with unrecognized mode bytes. + + Some Victron devices broadcast advertisements with mode bytes that + detect_device_type does not recognize (returns None). + validate_advertisement_key also returns False for these, but that does + not mean the encryption key is wrong. + + Regression test for https://github.com/home-assistant/core/issues/168019 + """ + entry = mock_config_entry_added_to_hass + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # First inject a valid advertisement so update.devices is populated + inject_bluetooth_service_info(hass, VICTRON_VEBUS_SERVICE_INFO) + await hass.async_block_till_done() + + # Now send many unrecognized-mode advertisements + for i in range(REAUTH_AFTER_FAILURES + 5): + _inject_unrecognized_mode_advertisement(hass, seq=i) + 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") +async def test_reauth_still_triggers_across_unrecognized_mode( + hass: HomeAssistant, + mock_config_entry_added_to_hass: MockConfigEntry, +) -> None: + """Test that unrecognized-mode advertisements are neutral for the failure counter. + + The sequence bad → bad → unrecognized → bad must still trigger reauth + because unrecognized advertisements should neither increment nor reset the + consecutive failure counter. + + Regression test for https://github.com/home-assistant/core/issues/168019 + """ + entry = mock_config_entry_added_to_hass + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # First inject a valid advertisement so update.devices is populated + inject_bluetooth_service_info(hass, VICTRON_VEBUS_SERVICE_INFO) + await hass.async_block_till_done() + + # bad, bad (2 failures) + _inject_bad_advertisement(hass, seq=100) + await hass.async_block_till_done() + _inject_bad_advertisement(hass, seq=101) + await hass.async_block_till_done() + + # unrecognized mode — should be neutral + _inject_unrecognized_mode_advertisement(hass, seq=50) + await hass.async_block_till_done() + + # one more bad → 3 consecutive failures → reauth + _inject_bad_advertisement(hass, seq=102) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(flows) == 1 + assert flows[0]["context"]["source"] == "reauth" From fcd6f78f3506edbf50412c78cc12a1c31432241e Mon Sep 17 00:00:00 2001 From: MohamedBarrak3 Date: Tue, 21 Apr 2026 22:26:31 +0100 Subject: [PATCH 03/16] Fix case-sensitive MIME type check in Google Generative AI TTS (#168458) --- .../helpers.py | 6 ++-- .../test_helpers.py | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 tests/components/google_generative_ai_conversation/test_helpers.py diff --git a/homeassistant/components/google_generative_ai_conversation/helpers.py b/homeassistant/components/google_generative_ai_conversation/helpers.py index 3d053aa9f1aa5..c1708f9caec62 100644 --- a/homeassistant/components/google_generative_ai_conversation/helpers.py +++ b/homeassistant/components/google_generative_ai_conversation/helpers.py @@ -49,7 +49,7 @@ def _parse_audio_mime_type(mime_type: str) -> dict[str, int]: integers if found, otherwise None. """ - if not mime_type.startswith("audio/L"): + if not mime_type.lower().startswith("audio/l"): LOGGER.warning("Received unexpected MIME type %s", mime_type) raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}") @@ -65,9 +65,9 @@ def _parse_audio_mime_type(mime_type: str) -> dict[str, int]: with suppress(ValueError, IndexError): rate_str = param.split("=", 1)[1] rate = int(rate_str) - elif param.startswith("audio/L"): + elif param.lower().startswith("audio/l"): # Keep bits_per_sample as default if conversion fails with suppress(ValueError, IndexError): - bits_per_sample = int(param.split("L", 1)[1]) + bits_per_sample = int(param.upper().split("L", 1)[1]) return {"bits_per_sample": bits_per_sample, "rate": rate} diff --git a/tests/components/google_generative_ai_conversation/test_helpers.py b/tests/components/google_generative_ai_conversation/test_helpers.py new file mode 100644 index 0000000000000..bcca396ad2a13 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/test_helpers.py @@ -0,0 +1,28 @@ +"""Tests for the Google Generative AI Conversation helpers.""" + +from __future__ import annotations + +import pytest + +from homeassistant.components.google_generative_ai_conversation.helpers import ( + _parse_audio_mime_type, +) +from homeassistant.exceptions import HomeAssistantError + + +def test_parse_audio_mime_type_uppercase() -> None: + """Test parsing uppercase MIME type audio/L16;rate=24000.""" + result = _parse_audio_mime_type("audio/L16;rate=24000") + assert result == {"bits_per_sample": 16, "rate": 24000} + + +def test_parse_audio_mime_type_lowercase() -> None: + """Test parsing lowercase MIME type audio/l16; rate=24000; channels=1.""" + result = _parse_audio_mime_type("audio/l16; rate=24000; channels=1") + assert result == {"bits_per_sample": 16, "rate": 24000} + + +def test_parse_audio_mime_type_unsupported_raises() -> None: + """Test that an unsupported MIME type raises HomeAssistantError.""" + with pytest.raises(HomeAssistantError): + _parse_audio_mime_type("video/mp4") From 7fad242ad0ac7450e43a636c2e3c46430cef1540 Mon Sep 17 00:00:00 2001 From: Khole <29937485+KJonline@users.noreply.github.com> Date: Sat, 18 Apr 2026 13:39:31 +0100 Subject: [PATCH 04/16] Hive - Bump pyhive-integration to 1.0.9 (#168489) --- homeassistant/components/hive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index a03bf9279cb8d..5ebe678e73b2c 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -10,5 +10,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["apyhiveapi"], - "requirements": ["pyhive-integration==1.0.8"] + "requirements": ["pyhive-integration==1.0.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3390f1b934855..b6527760ff227 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2155,7 +2155,7 @@ pyhaversion==22.8.0 pyheos==1.0.6 # homeassistant.components.hive -pyhive-integration==1.0.8 +pyhive-integration==1.0.9 # homeassistant.components.homematic pyhomematic==0.1.77 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 76ab5cabd4bc2..97178dbbbe977 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1847,7 +1847,7 @@ pyhaversion==22.8.0 pyheos==1.0.6 # homeassistant.components.hive -pyhive-integration==1.0.8 +pyhive-integration==1.0.9 # homeassistant.components.homematic pyhomematic==0.1.77 From f84bf991057d3700bc01b7fa8d8f0bde39dfa50a Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 19 Apr 2026 14:06:04 +0200 Subject: [PATCH 05/16] Bump aioamazondevices to 13.4.3 (#168536) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index ed17385e825e5..a6210eb432b6c 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "platinum", - "requirements": ["aioamazondevices==13.4.1"] + "requirements": ["aioamazondevices==13.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index b6527760ff227..9c652ebe8fc23 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==13.4.1 +aioamazondevices==13.4.3 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 97178dbbbe977..a686e5e895d33 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -181,7 +181,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==13.4.1 +aioamazondevices==13.4.3 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 0db50acb893bcfc8c569d7218cecc34b79381d16 Mon Sep 17 00:00:00 2001 From: Nils Ove Erstad Date: Mon, 20 Apr 2026 21:59:03 +0200 Subject: [PATCH 06/16] Fix MQTT JSON light restoring None color_mode on startup (#168608) Co-authored-by: Jan Bouwhuis --- .../components/mqtt/light/schema_json.py | 4 +- tests/components/mqtt/test_light_json.py | 57 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index b388cdebb6516..080e15c77b660 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -337,8 +337,8 @@ async def _subscribe_topics(self) -> None: self._attr_brightness = last_attributes.get( ATTR_BRIGHTNESS, self.brightness ) - self._attr_color_mode = last_attributes.get( - ATTR_COLOR_MODE, self.color_mode + self._attr_color_mode = ( + last_attributes.get(ATTR_COLOR_MODE) or self.color_mode ) self._attr_color_temp_kelvin = last_attributes.get( ATTR_COLOR_TEMP_KELVIN, self.color_temp_kelvin diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 6ea2cb3a9d4e8..0cf412ccfa3cb 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -565,6 +565,63 @@ async def test_single_color_mode_turn_on( assert state.state == STATE_ON +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_light/set", + "supported_color_modes": ["brightness"], + } + } + }, + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_light/set", + "supported_color_modes": ["color_temp"], + } + } + }, + ], +) +async def test_restore_state_with_none_color_mode( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test restoring state with a None color_mode does not break turn_on. + + Regression test: When an optimistic light was off at the time the state + was last saved, `state_attributes` stores `color_mode: None`. On restart, + the restore path would overwrite the correctly initialized color_mode with + None, causing turn_on to raise "does not report a color mode". + """ + fake_state = State( + "light.test", + STATE_OFF, + {"color_mode": None, "brightness": None}, + ) + mock_restore_cache(hass, (fake_state,)) + + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_OFF + + # 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 + ) + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("color_mode") is not None + + @pytest.mark.parametrize( "hass_config", [ From ed560f0ba79a4bde393df3636553815b38354faf Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 21 Apr 2026 01:47:32 -0700 Subject: [PATCH 07/16] Add Roborock fan speed validation and error handling (#168623) --- homeassistant/components/roborock/vacuum.py | 32 ++++++++++------ tests/components/roborock/test_vacuum.py | 42 +++++++++++---------- 2 files changed, 44 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 68259aa15d7ec..cda6c8fe781a3 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -240,14 +240,16 @@ async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: translation_domain=DOMAIN, translation_key="update_options_failed", ) - await self.send( - RoborockCommand.SET_CUSTOM_MODE, - [ - {v: k for k, v in self._status_trait.fan_speed_mapping.items()}[ - fan_speed - ] - ], - ) + code_mapping = {v: k for k, v in self._status_trait.fan_speed_mapping.items()} + if (fan_speed_code := code_mapping.get(fan_speed)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_fan_speed", + translation_placeholders={ + "fan_speed": fan_speed, + }, + ) + await self.send(RoborockCommand.SET_CUSTOM_MODE, [fan_speed_code]) async def async_set_vacuum_goto_position(self, x: int, y: int) -> None: """Send vacuum to a specific target point.""" @@ -458,9 +460,17 @@ async def async_locate(self, **kwargs: Any) -> None: async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set vacuum fan speed.""" try: - await self.coordinator.api.set_fan_speed( - SCWindMapping.from_value(fan_speed) - ) + fan_speed_code = SCWindMapping.from_value(fan_speed) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_fan_speed", + translation_placeholders={ + "fan_speed": fan_speed, + }, + ) from err + try: + await self.coordinator.api.set_fan_speed(fan_speed_code) except RoborockException as err: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 953c390b8e148..9342df57fa737 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -142,6 +142,29 @@ async def test_commands( assert vacuum_command.send.call_args == call(command, params=called_params) +@pytest.mark.parametrize( + "entity_id", + [ + ENTITY_ID, + Q7_ENTITY_ID, + Q10_ENTITY_ID, + ], +) +async def test_set_fan_speed_invalid( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + entity_id: str, +) -> None: + """Test calling set_fan_speed with an invalid mode.""" + with pytest.raises(ServiceValidationError, match="Invalid fan speed: some-mode"): + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_SET_FAN_SPEED, + {ATTR_ENTITY_ID: entity_id, "fan_speed": "some-mode"}, + blocking=True, + ) + + @pytest.mark.parametrize( ("in_cleaning_int", "in_returning_int", "expected_command"), [ @@ -880,25 +903,6 @@ async def test_q10_set_fan_speed_command( assert q10_vacuum_api.vacuum.set_fan_level.call_args[0] == (YXFanLevel.QUIET,) -async def test_q10_set_invalid_fan_speed( - hass: HomeAssistant, - setup_entry: MockConfigEntry, - q10_vacuum_api: Mock, -) -> None: - """Test that setting an invalid fan speed raises an error.""" - vacuum = hass.states.get(Q10_ENTITY_ID) - assert vacuum - - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - VACUUM_DOMAIN, - SERVICE_SET_FAN_SPEED, - {ATTR_ENTITY_ID: Q10_ENTITY_ID, "fan_speed": "invalid_speed"}, - blocking=True, - ) - assert q10_vacuum_api.vacuum.set_fan_level.call_count == 0 - - @pytest.mark.parametrize( "command", [ From 39b690b22cac076b26628a24a8abb0eabff72a8f Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Mon, 20 Apr 2026 19:02:29 +0200 Subject: [PATCH 08/16] Correct state/device class for water in gardena (#168637) --- .../components/gardena_bluetooth/sensor.py | 6 ++++-- .../gardena_bluetooth/snapshots/test_sensor.ambr | 14 ++++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index d31a00f73da33..9cb7316c7832a 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -133,7 +133,7 @@ def context(self) -> set[str]: key=FlowStatistics.overall.unique_id, translation_key="flow_statistics_overall", state_class=SensorStateClass.TOTAL_INCREASING, - device_class=SensorDeviceClass.VOLUME, + device_class=SensorDeviceClass.WATER, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfVolume.LITERS, char=FlowStatistics.overall, @@ -141,6 +141,7 @@ def context(self) -> set[str]: GardenaBluetoothSensorEntityDescription( key=FlowStatistics.current.unique_id, translation_key="flow_statistics_current", + state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.VOLUME_FLOW_RATE, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, @@ -150,7 +151,7 @@ def context(self) -> set[str]: key=FlowStatistics.resettable.unique_id, translation_key="flow_statistics_resettable", state_class=SensorStateClass.TOTAL_INCREASING, - device_class=SensorDeviceClass.VOLUME, + device_class=SensorDeviceClass.WATER, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfVolume.LITERS, char=FlowStatistics.resettable, @@ -166,6 +167,7 @@ def context(self) -> set[str]: GardenaBluetoothSensorEntityDescription( key=Spray.current_distance.unique_id, translation_key="spray_current_distance", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, char=Spray.current_distance, diff --git a/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr b/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr index 775e05fc10823..687cd63b19119 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr @@ -92,7 +92,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -127,6 +129,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mock Title Current distance', + 'state_class': , 'unit_of_measurement': '%', }), 'context': , @@ -143,7 +146,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -182,6 +187,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'volume_flow_rate', 'friendly_name': 'Mock Title Current flow', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -399,7 +405,7 @@ 'suggested_display_precision': 2, }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Overall flow', 'platform': 'gardena_bluetooth', @@ -414,7 +420,7 @@ # name: test_sensors[aqua_contour][sensor.mock_title_overall_flow-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'volume', + 'device_class': 'water', 'friendly_name': 'Mock Title Overall flow', 'state_class': , 'unit_of_measurement': , From ca4d36db1a1d27c0607ea6a811cf15eb993ec0dc Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 22 Apr 2026 09:43:22 +0200 Subject: [PATCH 09/16] Cancel and await idle_start future if the task was canceled after an IMAP connection was lost (#168662) Co-authored-by: J. Nick Koston --- homeassistant/components/imap/coordinator.py | 22 +++++++++++++++++++- tests/components/imap/test_init.py | 5 +++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 157db4da174c8..b914388e270e1 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -494,6 +494,7 @@ async def async_start(self) -> None: async def _async_wait_push_loop(self) -> None: """Wait for data push from server.""" + idle: asyncio.Future | None = None while True: try: self.number_of_messages = await self._async_fetch_number_of_messages() @@ -527,8 +528,9 @@ async def _async_wait_push_loop(self) -> None: else: self.auth_errors = 0 self.async_set_updated_data(self.number_of_messages) + try: - idle: asyncio.Future = await self.imap_client.idle_start() + idle = await self.imap_client.idle_start() await self.imap_client.wait_server_push() self.imap_client.idle_done() async with asyncio.timeout(10): @@ -543,6 +545,24 @@ async def _async_wait_push_loop(self) -> None: await self._cleanup() await asyncio.sleep(BACKOFF_TIME) + finally: + # Ensure no pending IDLE future survives + if idle is not None and not idle.done(): + idle.cancel() + _LOGGER.debug( + "Canceling IDLE wait for %s", + self.config_entry.data[CONF_SERVER], + ) + try: + await idle + except asyncio.CancelledError: + if ( + current_task := asyncio.current_task() + ) and current_task.cancelling(): + raise + except AioImapException: + pass + async def shutdown(self, *_: Any) -> None: """Close resources.""" if self._push_wait_task: diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index dc5727991c18f..55c8499ee5b4f 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -538,6 +538,7 @@ async def test_lost_connection_with_imap_push( ) -> None: """Test error handling when the connection is lost.""" # Mock an error in waiting for a pushed update + mock_imap_protocol.idle_start.return_value = asyncio.Future() mock_imap_protocol.wait_server_push.side_effect = imap_wait_server_push_exception config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) config_entry.add_to_hass(hass) @@ -550,6 +551,10 @@ async def test_lost_connection_with_imap_push( assert state is not None assert state.state == "0" + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + assert "Canceling IDLE wait for imap.server.com" in caplog.text + @pytest.mark.parametrize("imap_has_capability", [True], ids=["push"]) async def test_fetch_number_of_messages( From 838feef66056d8c2213c80ef37a846d3ff058ecd Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 22 Apr 2026 14:07:47 +0200 Subject: [PATCH 10/16] Validate local_only user property during ws auth phase (#168812) --- homeassistant/components/http/auth.py | 37 +---------- homeassistant/components/http/auth_util.py | 45 ++++++++++++++ .../components/websocket_api/auth.py | 8 +++ tests/components/websocket_api/test_auth.py | 61 +++++++++++++++++++ 4 files changed, 115 insertions(+), 36 deletions(-) create mode 100644 homeassistant/components/http/auth_util.py diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 50b3812dd7dd5..90165ef6d8fbb 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -4,7 +4,6 @@ from collections.abc import Awaitable, Callable from datetime import timedelta -from ipaddress import ip_address import logging import secrets import time @@ -24,16 +23,14 @@ from homeassistant.auth import jwt_wrapper from homeassistant.auth.const import GROUP_ID_READ_ONLY -from homeassistant.auth.models import User from homeassistant.components import websocket_api from homeassistant.const import HASSIO_USER_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.http import current_request from homeassistant.helpers.json import json_bytes -from homeassistant.helpers.network import is_cloud_connection from homeassistant.helpers.storage import Store -from homeassistant.util.network import is_local +from .auth_util import async_user_not_allowed_do_auth from .const import ( KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, @@ -99,38 +96,6 @@ def async_sign_path( return f"{url.path}?{url.query_string}" -@callback -def async_user_not_allowed_do_auth( - hass: HomeAssistant, user: User, request: Request | None = None -) -> str | None: - """Validate that user is not allowed to do auth things.""" - if not user.is_active: - return "User is not active" - - if not user.local_only: - return None - - # User is marked as local only, check if they are allowed to do auth - if request is None: - request = current_request.get() - - if not request: - return "No request available to validate local access" - - if is_cloud_connection(hass): - return "User is local only" - - try: - remote_address = ip_address(request.remote) # type: ignore[arg-type] - except ValueError: - return "Invalid remote IP" - - if is_local(remote_address): - return None - - return "User cannot authenticate remotely" - - async def async_setup_auth( # noqa: C901 hass: HomeAssistant, app: Application, diff --git a/homeassistant/components/http/auth_util.py b/homeassistant/components/http/auth_util.py new file mode 100644 index 0000000000000..18a827bbc3b1d --- /dev/null +++ b/homeassistant/components/http/auth_util.py @@ -0,0 +1,45 @@ +"""Auth utilities for the HTTP component.""" + +from __future__ import annotations + +from ipaddress import ip_address + +from aiohttp.web import Request + +from homeassistant.auth.models import User +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.http import current_request +from homeassistant.helpers.network import is_cloud_connection +from homeassistant.util.network import is_local + + +@callback +def async_user_not_allowed_do_auth( + hass: HomeAssistant, user: User, request: Request | None = None +) -> str | None: + """Validate that user is not allowed to do auth things.""" + if not user.is_active: + return "User is not active" + + if not user.local_only: + return None + + # User is marked as local only, check if they are allowed to do auth + if request is None: + request = current_request.get() + + if not request: + return "No request available to validate local access" + + if is_cloud_connection(hass): + return "User is local only" + + try: + remote_address = ip_address(request.remote) # type: ignore[arg-type] + except ValueError: + return "Invalid remote IP" + + if is_local(remote_address): + return None + + return "User cannot authenticate remotely" diff --git a/homeassistant/components/websocket_api/auth.py b/homeassistant/components/websocket_api/auth.py index b0e319bbce5ad..ae4844cd69a00 100644 --- a/homeassistant/components/websocket_api/auth.py +++ b/homeassistant/components/websocket_api/auth.py @@ -9,6 +9,7 @@ import voluptuous as vol from voluptuous.humanize import humanize_error +from homeassistant.components.http.auth_util import async_user_not_allowed_do_auth from homeassistant.components.http.ban import process_success_login, process_wrong_login from homeassistant.components.http.const import KEY_HASS_USER from homeassistant.const import __version__ @@ -97,6 +98,13 @@ async def async_handle(self, msg: JsonValueType) -> ActiveConnection: if (access_token := valid_msg.get("access_token")) and ( refresh_token := self._hass.auth.async_validate_access_token(access_token) ): + if user_access_error := async_user_not_allowed_do_auth( + self._hass, refresh_token.user, self._request + ): + await self._send_bytes_text(auth_invalid_message(user_access_error)) + await process_wrong_login(self._request) + raise Disconnect + conn = ActiveConnection( self._logger, self._hass, diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index 09ff36c4ce02b..b86bf63fc102e 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -23,6 +23,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component +from tests.test_util import mock_real_ip from tests.typing import ClientSessionGenerator @@ -151,6 +152,66 @@ async def test_auth_active_user_inactive( assert auth_msg["type"] == TYPE_AUTH_INVALID +async def test_auth_local_only_user_rejected_remote( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + hass_access_token: str, +) -> None: + """Test that a local-only user cannot authenticate from a remote IP.""" + refresh_token = hass.auth.async_validate_access_token(hass_access_token) + refresh_token.user.local_only = True + + assert await async_setup_component(hass, "websocket_api", {}) + await hass.async_block_till_done() + + set_mock_ip = mock_real_ip(hass.http.app) + set_mock_ip("198.51.100.1") + + client = await hass_client_no_auth() + + with patch( + "homeassistant.components.websocket_api.auth.process_wrong_login", + ) as mock_process_wrong_login: + async with client.ws_connect(URL) as ws: + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_REQUIRED + + await ws.send_json({"type": TYPE_AUTH, "access_token": hass_access_token}) + + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_INVALID + assert auth_msg["message"] == "User cannot authenticate remotely" + + assert mock_process_wrong_login.called + + +async def test_auth_local_only_user_allowed_local( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + hass_access_token: str, +) -> None: + """Test that a local-only user can authenticate from a local IP.""" + refresh_token = hass.auth.async_validate_access_token(hass_access_token) + refresh_token.user.local_only = True + + assert await async_setup_component(hass, "websocket_api", {}) + await hass.async_block_till_done() + + set_mock_ip = mock_real_ip(hass.http.app) + set_mock_ip("192.168.1.100") + + client = await hass_client_no_auth() + + async with client.ws_connect(URL) as ws: + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_REQUIRED + + await ws.send_json({"type": TYPE_AUTH, "access_token": hass_access_token}) + + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_OK + + async def test_auth_active_with_password_not_allow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator ) -> None: From 245b9ed4c00b229db7e6f5e6d414279840fbdd3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Fri, 24 Apr 2026 09:44:43 +0200 Subject: [PATCH 11/16] Update Tibber library, 0.37.2 (#169027) --- homeassistant/components/tibber/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 066d2cced5fe2..774f4d0ee4a0e 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.37.1"] + "requirements": ["pyTibber==0.37.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9c652ebe8fc23..6f86967af396e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1925,7 +1925,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.37.1 +pyTibber==0.37.2 # homeassistant.components.dlink pyW215==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a686e5e895d33..2497eb3f95409 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.37.1 +pyTibber==0.37.2 # homeassistant.components.dlink pyW215==0.8.0 From 19dd68b7fcb4ee1dcb8ce58f965c2a5c09b4df8a Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 24 Apr 2026 19:51:07 +0200 Subject: [PATCH 12/16] Slow down Tractive API polling to avoid 429 too many requests (#169057) Co-authored-by: Copilot --- homeassistant/components/tractive/__init__.py | 25 +++++++++++-------- tests/components/tractive/conftest.py | 4 +++ 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 87e408b2a5849..921913413b73e 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -102,13 +102,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: TractiveConfigEntry) -> tractive = TractiveClient(hass, client, creds["user_id"], entry) + trackables = [] try: - trackable_objects = await client.trackable_objects() - trackables = await asyncio.gather( - *(_generate_trackables(client, item) for item in trackable_objects) - ) + for obj in await client.trackable_objects(): + # To avoid hitting Tractive API rate limits, we add a small + # delay between requests to fetch trackable details. + await asyncio.sleep(2) + trackables.append(await _generate_trackables(client, obj)) except aiotractive.exceptions.TractiveError as error: + await client.close() raise ConfigEntryNotReady from error + except ConfigEntryNotReady: + await client.close() + raise # When the pet defined in Tractive has no tracker linked we get None as `trackable`. # So we have to remove None values from trackables list. @@ -164,12 +170,11 @@ async def _generate_trackables( tracker = client.tracker(trackable_data["device_id"]) trackable_pet = client.trackable_object(trackable_data["_id"]) - tracker_details, hw_info, pos_report, health_overview = await asyncio.gather( - tracker.details(), - tracker.hw_info(), - tracker.pos_report(), - trackable_pet.health_overview(), - ) + # Sequential fetching to prevent HTTP 429 Rate Limits + tracker_details = await tracker.details() + hw_info = await tracker.hw_info() + pos_report = await tracker.pos_report() + health_overview = await trackable_pet.health_overview() if not tracker_details.get("_id"): raise ConfigEntryNotReady( diff --git a/tests/components/tractive/conftest.py b/tests/components/tractive/conftest.py index 1a9a865c1c189..c4a03a081e7b8 100644 --- a/tests/components/tractive/conftest.py +++ b/tests/components/tractive/conftest.py @@ -89,6 +89,10 @@ def send_server_unavailable_event(hass: HomeAssistant) -> None: patch( "homeassistant.components.tractive.aiotractive.Tractive", autospec=True ) as mock_client, + patch( + "homeassistant.components.tractive.asyncio.sleep", + new_callable=AsyncMock, + ), ): client = mock_client.return_value client.authenticate.return_value = {"user_id": "12345"} From 4507f9a8d8c87e9b0d07798426353ee9cc4785f6 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 24 Apr 2026 15:14:17 +0200 Subject: [PATCH 13/16] Bump aiotractive to 1.0.3 (#169059) --- homeassistant/components/tractive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tractive/manifest.json b/homeassistant/components/tractive/manifest.json index 96abbf24adecb..200bda0d885a2 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.2"] + "requirements": ["aiotractive==1.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6f86967af396e..b9e0d07e5bb64 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.2 +aiotractive==1.0.3 # homeassistant.components.unifi aiounifi==88 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2497eb3f95409..6fe45b36bcb12 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.2 +aiotractive==1.0.3 # homeassistant.components.unifi aiounifi==88 From 9621307cb0462a34e292f227e4c98096b4786afb Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 24 Apr 2026 16:27:15 +0200 Subject: [PATCH 14/16] Validate local_only user for signed requests (#169066) --- homeassistant/components/http/auth.py | 3 + tests/components/http/test_auth.py | 85 +++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 90165ef6d8fbb..6ce5d6d56d195 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -182,6 +182,9 @@ def async_validate_signed_request(request: Request) -> bool: if refresh_token is None: return False + if async_user_not_allowed_do_auth(hass, refresh_token.user, request): + return False + request[KEY_HASS_USER] = refresh_token.user request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id return True diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 095ae8ad17a80..6997b78d8e45d 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -592,6 +592,91 @@ async def test_local_only_user_rejected( assert req.status == HTTPStatus.UNAUTHORIZED +async def test_auth_access_signed_path_with_local_only_user( + hass: HomeAssistant, + app: web.Application, + aiohttp_client: ClientSessionGenerator, + hass_access_token: str, +) -> None: + """Test access with signed url for a local-only user.""" + app.router.add_post("/", mock_handler) + app.router.add_get("/another_path", mock_handler) + await async_setup_auth(hass, app) + set_mock_ip = mock_real_ip(app) + client = await aiohttp_client(app) + + refresh_token = hass.auth.async_validate_access_token(hass_access_token) + refresh_token.user.local_only = True + + signed_path = async_sign_path( + hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id + ) + + # Local IP is allowed for local-only user + set_mock_ip("192.168.1.123") + + req = await client.head(signed_path) + assert req.status == HTTPStatus.OK + + req = await client.get(signed_path) + assert req.status == HTTPStatus.OK + data = await req.json() + assert data["user_id"] == refresh_token.user.id + + # Remote IP is rejected for local-only user + for remote_addr in EXTERNAL_ADDRESSES: + set_mock_ip(remote_addr) + signed_path = async_sign_path( + hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id + ) + + req = await client.head(signed_path) + assert req.status == HTTPStatus.UNAUTHORIZED + + req = await client.get(signed_path) + assert req.status == HTTPStatus.UNAUTHORIZED + + +async def test_auth_access_signed_path_with_inactive_user( + hass: HomeAssistant, + app: web.Application, + aiohttp_client: ClientSessionGenerator, + hass_access_token: str, +) -> None: + """Test access with signed url for an inactive user.""" + app.router.add_post("/", mock_handler) + app.router.add_get("/another_path", mock_handler) + await async_setup_auth(hass, app) + client = await aiohttp_client(app) + + refresh_token = hass.auth.async_validate_access_token(hass_access_token) + + signed_path = async_sign_path( + hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id + ) + + # Active user is allowed + req = await client.head(signed_path) + assert req.status == HTTPStatus.OK + + req = await client.get(signed_path) + assert req.status == HTTPStatus.OK + data = await req.json() + assert data["user_id"] == refresh_token.user.id + signed_path = async_sign_path( + hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id + ) + + # Inactive user is rejected + refresh_token.user.is_active = False + + req = await client.head(signed_path) + assert req.status == HTTPStatus.UNAUTHORIZED + + req = await client.get(signed_path) + assert req.status == HTTPStatus.UNAUTHORIZED + + async def test_async_user_not_allowed_do_auth( hass: HomeAssistant, app: web.Application ) -> None: From 458b5fe8bf3126e853df0ccdea3059518b4ac376 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 24 Apr 2026 19:50:11 +0200 Subject: [PATCH 15/16] Update frontend to 20260325.8 (#169076) --- 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 e9ec83fd8e412..f5ba1caabf723 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.7"] + "requirements": ["home-assistant-frontend==20260325.8"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 090cb2f94c94d..fce7f8c72c35b 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.7 +home-assistant-frontend==20260325.8 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 b9e0d07e5bb64..387249aa1b71e 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.7 +home-assistant-frontend==20260325.8 # homeassistant.components.conversation home-assistant-intents==2026.3.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6fe45b36bcb12..79db7d7fdcf6c 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.7 +home-assistant-frontend==20260325.8 # homeassistant.components.conversation home-assistant-intents==2026.3.24 From f479b0ad6a5e51687ad2d8d0ee531427bb5c7cdf Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 24 Apr 2026 18:08:48 +0000 Subject: [PATCH 16/16] Bump version to 2026.4.4 --- 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 ef7e86b366530..75bee8f442be6 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 = "3" +PATCH_VERSION: Final = "4" __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 f89c595533ba1..e6e4b4caccc09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.4.3" +version = "2026.4.4" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3."