From 09750872b5fa92d07580f8c0de13b413a4670da2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 24 Sep 2025 23:55:32 +0200 Subject: [PATCH 1/6] Bump version to 2025.11.0dev0 (#152915) --- .github/workflows/ci.yaml | 2 +- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 77c5d02bc56aa1..3cad6a4e532415 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,7 +40,7 @@ env: CACHE_VERSION: 8 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1 - HA_SHORT_VERSION: "2025.10" + HA_SHORT_VERSION: "2025.11" DEFAULT_PYTHON: "3.13" ALL_PYTHON_VERSIONS: "['3.13']" # 10.3 is the oldest supported version diff --git a/homeassistant/const.py b/homeassistant/const.py index 3b9702b972ee6b..02daeadf011251 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 -MINOR_VERSION: Final = 10 +MINOR_VERSION: Final = 11 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/pyproject.toml b/pyproject.toml index 366482ec7fc344..ae1d8fa5c10d3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.10.0.dev0" +version = "2025.11.0.dev0" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From ae7bc7fb1b19944754a752c613d55dc6c131370a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Sep 2025 19:16:48 -0500 Subject: [PATCH 2/6] Bump aioesphomeapi to 41.9.4 (#152923) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 39ff0bc184c876..674ced0bf9c66e 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==41.9.3", + "aioesphomeapi==41.9.4", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.3.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 7d3421674a5244..6feb2fe6840cd1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==41.9.3 +aioesphomeapi==41.9.4 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bec1bb02bf2254..249f309297cb40 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,7 +235,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==41.9.3 +aioesphomeapi==41.9.4 # homeassistant.components.flo aioflo==2021.11.0 From 0b0f8c5829a1a3b76dec8695e828c3d5cd389ccc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 24 Sep 2025 22:15:29 -0400 Subject: [PATCH 3/6] Remove some more domains from common controls (#152927) --- homeassistant/components/usage_prediction/common_control.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/usage_prediction/common_control.py b/homeassistant/components/usage_prediction/common_control.py index 995d3c5a559c18..9d86b5f276669e 100644 --- a/homeassistant/components/usage_prediction/common_control.py +++ b/homeassistant/components/usage_prediction/common_control.py @@ -38,13 +38,11 @@ Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.BUTTON, - Platform.CALENDAR, Platform.CAMERA, Platform.CLIMATE, Platform.COVER, Platform.FAN, Platform.HUMIDIFIER, - Platform.IMAGE, Platform.LAWN_MOWER, Platform.LIGHT, Platform.LOCK, @@ -55,7 +53,6 @@ Platform.SENSOR, Platform.SIREN, Platform.SWITCH, - Platform.TEXT, Platform.VACUUM, Platform.VALVE, Platform.WATER_HEATER, From 9cd3ab853dde5cc311f4c92a38a62fb5ab267c97 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 25 Sep 2025 04:18:06 +0200 Subject: [PATCH 4/6] Add block Spook < 4.0.0 as breaking Home Assistant (#152930) --- homeassistant/loader.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 07c4a9345737ec..fc10223a182fc7 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -121,6 +121,9 @@ class BlockedIntegration: "variable": BlockedIntegration( AwesomeVersion("3.4.4"), "prevents recorder from working" ), + # Added in 2025.10.0 because of + # https://github.com/frenck/spook/issues/1066 + "spook": BlockedIntegration(AwesomeVersion("4.0.0"), "breaks the template engine"), } DATA_COMPONENTS: HassKey[dict[str, ModuleType | ComponentProtocol]] = HassKey( From 7c8ad9d535b713b1120a25b871aba79b12aeb05c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Sep 2025 21:27:40 -0500 Subject: [PATCH 5/6] Fix ESPHome reauth not being triggered on incorrect password (#152911) --- .../components/esphome/config_flow.py | 10 ++++- homeassistant/components/esphome/manager.py | 10 +++++ tests/components/esphome/test_config_flow.py | 38 ++++++++++++++++++- tests/components/esphome/test_dashboard.py | 4 +- tests/components/esphome/test_manager.py | 31 +++++++++++++++ 5 files changed, 89 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index e1aedb90b3cb0b..6197716f617e1d 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -57,6 +57,7 @@ ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key" ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk" +ERROR_INVALID_PASSWORD_AUTH = "invalid_auth" _LOGGER = logging.getLogger(__name__) ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" @@ -137,6 +138,11 @@ async def async_step_reauth( self._password = "" return await self._async_authenticate_or_add() + if error == ERROR_INVALID_PASSWORD_AUTH or ( + error is None and self._device_info and self._device_info.uses_password + ): + return await self.async_step_authenticate() + if error is None and entry_data.get(CONF_NOISE_PSK): # Device was configured with encryption but now connects without it. # Check if it's the same device before offering to remove encryption. @@ -690,13 +696,15 @@ async def _fetch_device_info( cli = APIClient( host, port or DEFAULT_PORT, - "", + self._password or "", zeroconf_instance=zeroconf_instance, noise_psk=noise_psk, ) try: await cli.connect() self._device_info = await cli.device_info() + except InvalidAuthAPIError: + return ERROR_INVALID_PASSWORD_AUTH except RequiresEncryptionAPIError: return ERROR_REQUIRES_ENCRYPTION_KEY except InvalidEncryptionKeyAPIError as ex: diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index a14eb3f5a164f4..c3db4c3e9e8ecd 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -372,6 +372,9 @@ async def on_connect(self) -> None: """Subscribe to states and list entities on successful API login.""" try: await self._on_connect() + except InvalidAuthAPIError as err: + _LOGGER.warning("Authentication failed for %s: %s", self.host, err) + await self._start_reauth_and_disconnect() except APIConnectionError as err: _LOGGER.warning( "Error getting setting up connection for %s: %s", self.host, err @@ -641,7 +644,14 @@ async def on_connect_error(self, err: Exception) -> None: if self.reconnect_logic: await self.reconnect_logic.stop() return + await self._start_reauth_and_disconnect() + + async def _start_reauth_and_disconnect(self) -> None: + """Start reauth flow and stop reconnection attempts.""" self.entry.async_start_reauth(self.hass) + await self.cli.disconnect() + if self.reconnect_logic: + await self.reconnect_logic.stop() async def _handle_dynamic_encryption_key( self, device_info: EsphomeDeviceInfo diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index f3bb1c77e408f0..27d585bea6f369 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -1184,6 +1184,42 @@ async def test_reauth_attempt_to_change_mac_aborts( } +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reauth_password_changed( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reauth when password has changed.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: "old_password"}, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + mock_client.connect.side_effect = InvalidAuthAPIError("Invalid password") + + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authenticate" + assert result["description_placeholders"] == { + "name": "Mock Title", + } + + mock_client.connect.side_effect = None + mock_client.connect.return_value = None + mock_client.device_info.return_value = DeviceInfo( + uses_password=True, name="test", mac_address="11:22:33:44:55:aa" + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "new_password"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data[CONF_PASSWORD] == "new_password" + + @pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf") async def test_reauth_fixed_via_dashboard( hass: HomeAssistant, @@ -1239,7 +1275,7 @@ async def test_reauth_fixed_via_dashboard_add_encryption_remove_password( ) -> None: """Test reauth fixed automatically via dashboard with password removed.""" mock_client.device_info.side_effect = ( - InvalidAuthAPIError, + InvalidEncryptionKeyAPIError("Wrong key", "test"), DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:aa"), ) diff --git a/tests/components/esphome/test_dashboard.py b/tests/components/esphome/test_dashboard.py index 340a10a86d160b..36542b2bd09803 100644 --- a/tests/components/esphome/test_dashboard.py +++ b/tests/components/esphome/test_dashboard.py @@ -3,7 +3,7 @@ from typing import Any from unittest.mock import patch -from aioesphomeapi import APIClient, DeviceInfo, InvalidAuthAPIError +from aioesphomeapi import APIClient, DeviceInfo, InvalidEncryptionKeyAPIError import pytest from homeassistant.components.esphome import CONF_NOISE_PSK, DOMAIN, dashboard @@ -194,7 +194,7 @@ async def test_new_dashboard_fix_reauth( ) -> None: """Test config entries waiting for reauth are triggered.""" mock_client.device_info.side_effect = ( - InvalidAuthAPIError, + InvalidEncryptionKeyAPIError("Wrong key", "test"), DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:AA"), ) diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 86dfb6e9ea3f7c..319d70b4e42652 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -1455,6 +1455,37 @@ async def test_no_reauth_wrong_mac( ) +async def test_auth_error_during_on_connect_triggers_reauth( + hass: HomeAssistant, + mock_client: APIClient, +) -> None: + """Test that InvalidAuthAPIError during on_connect triggers reauth.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="11:22:33:44:55:aa", + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "wrong_password", + }, + ) + entry.add_to_hass(hass) + + mock_client.device_info_and_list_entities = AsyncMock( + side_effect=InvalidAuthAPIError("Invalid password!") + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress(DOMAIN) + assert len(flows) == 1 + assert flows[0]["context"]["source"] == "reauth" + assert flows[0]["context"]["entry_id"] == entry.entry_id + assert mock_client.disconnect.call_count >= 1 + + async def test_entry_missing_unique_id( hass: HomeAssistant, mock_client: APIClient, From 91e13d447a0aca0cdea90d8a824cd5b4537acac8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 24 Sep 2025 23:09:54 -0400 Subject: [PATCH 6/6] Prevent common control calling async methods from thread (#152931) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../usage_prediction/common_control.py | 112 +++++++++--------- .../usage_prediction/test_common_control.py | 61 +++++++--- 2 files changed, 101 insertions(+), 72 deletions(-) diff --git a/homeassistant/components/usage_prediction/common_control.py b/homeassistant/components/usage_prediction/common_control.py index 9d86b5f276669e..69f2164fc763b3 100644 --- a/homeassistant/components/usage_prediction/common_control.py +++ b/homeassistant/components/usage_prediction/common_control.py @@ -3,13 +3,14 @@ from __future__ import annotations from collections import Counter -from collections.abc import Callable +from collections.abc import Callable, Sequence from datetime import datetime, timedelta from functools import cache import logging from typing import Any, Literal, cast from sqlalchemy import select +from sqlalchemy.engine.row import Row from sqlalchemy.orm import Session from homeassistant.components.recorder import get_instance @@ -90,61 +91,32 @@ async def async_predict_common_control( Args: hass: Home Assistant instance user_id: User ID to filter events by. - - Returns: - Dictionary with time categories as keys and lists of most common entity IDs as values """ # Get the recorder instance to ensure it's ready recorder = get_instance(hass) ent_reg = er.async_get(hass) # Execute the database operation in the recorder's executor - return await recorder.async_add_executor_job( + data = await recorder.async_add_executor_job( _fetch_with_session, hass, _fetch_and_process_data, ent_reg, user_id ) - - -def _fetch_and_process_data( - session: Session, ent_reg: er.EntityRegistry, user_id: str -) -> EntityUsagePredictions: - """Fetch and process service call events from the database.""" # Prepare a dictionary to track results results: dict[str, Counter[str]] = { time_cat: Counter() for time_cat in TIME_CATEGORIES } + allowed_entities = set(hass.states.async_entity_ids(ALLOWED_DOMAINS)) + hidden_entities: set[str] = set() + # Keep track of contexts that we processed so that we will only process # the first service call in a context, and not subsequent calls. context_processed: set[bytes] = set() - thirty_days_ago_ts = (dt_util.utcnow() - timedelta(days=30)).timestamp() - user_id_bytes = uuid_hex_to_bytes_or_none(user_id) - if not user_id_bytes: - raise ValueError("Invalid user_id format") - - # Build the main query for events with their data - query = ( - select( - Events.context_id_bin, - Events.time_fired_ts, - EventData.shared_data, - ) - .select_from(Events) - .outerjoin(EventData, Events.data_id == EventData.data_id) - .outerjoin(EventTypes, Events.event_type_id == EventTypes.event_type_id) - .where(Events.time_fired_ts >= thirty_days_ago_ts) - .where(Events.context_user_id_bin == user_id_bytes) - .where(EventTypes.event_type == "call_service") - .order_by(Events.time_fired_ts) - ) - # Execute the query context_id: bytes time_fired_ts: float shared_data: str | None local_time_zone = dt_util.get_default_time_zone() - for context_id, time_fired_ts, shared_data in ( - session.connection().execute(query).all() - ): + for context_id, time_fired_ts, shared_data in data: # Skip if we have already processed an event that was part of this context if context_id in context_processed: continue @@ -153,7 +125,7 @@ def _fetch_and_process_data( context_processed.add(context_id) # Parse the event data - if not shared_data: + if not time_fired_ts or not shared_data: continue try: @@ -187,27 +159,26 @@ def _fetch_and_process_data( if not isinstance(entity_ids, list): entity_ids = [entity_ids] - # Filter out entity IDs that are not in allowed domains - entity_ids = [ - entity_id - for entity_id in entity_ids - if entity_id.split(".")[0] in ALLOWED_DOMAINS - and ((entry := ent_reg.async_get(entity_id)) is None or not entry.hidden) - ] + # Convert to local time for time category determination + period = time_category( + datetime.fromtimestamp(time_fired_ts, local_time_zone).hour + ) + period_results = results[period] - if not entity_ids: - continue + # Count entity usage + for entity_id in entity_ids: + if entity_id not in allowed_entities or entity_id in hidden_entities: + continue - # Convert timestamp to datetime and determine time category - if time_fired_ts: - # Convert to local time for time category determination - period = time_category( - datetime.fromtimestamp(time_fired_ts, local_time_zone).hour - ) + if ( + entity_id not in period_results + and (entry := ent_reg.async_get(entity_id)) + and entry.hidden + ): + hidden_entities.add(entity_id) + continue - # Count entity usage - for entity_id in entity_ids: - results[period][entity_id] += 1 + period_results[entity_id] += 1 return EntityUsagePredictions( morning=[ @@ -226,11 +197,40 @@ def _fetch_and_process_data( ) +def _fetch_and_process_data( + session: Session, ent_reg: er.EntityRegistry, user_id: str +) -> Sequence[Row[tuple[bytes | None, float | None, str | None]]]: + """Fetch and process service call events from the database.""" + thirty_days_ago_ts = (dt_util.utcnow() - timedelta(days=30)).timestamp() + user_id_bytes = uuid_hex_to_bytes_or_none(user_id) + if not user_id_bytes: + raise ValueError("Invalid user_id format") + + # Build the main query for events with their data + query = ( + select( + Events.context_id_bin, + Events.time_fired_ts, + EventData.shared_data, + ) + .select_from(Events) + .outerjoin(EventData, Events.data_id == EventData.data_id) + .outerjoin(EventTypes, Events.event_type_id == EventTypes.event_type_id) + .where(Events.time_fired_ts >= thirty_days_ago_ts) + .where(Events.context_user_id_bin == user_id_bytes) + .where(EventTypes.event_type == "call_service") + .order_by(Events.time_fired_ts) + ) + return session.connection().execute(query).all() + + def _fetch_with_session( hass: HomeAssistant, - fetch_func: Callable[[Session], EntityUsagePredictions], + fetch_func: Callable[ + [Session], Sequence[Row[tuple[bytes | None, float | None, str | None]]] + ], *args: object, -) -> EntityUsagePredictions: +) -> Sequence[Row[tuple[bytes | None, float | None, str | None]]]: """Execute a fetch function with a database session.""" with session_scope(hass=hass, read_only=True) as session: return fetch_func(session, *args) diff --git a/tests/components/usage_prediction/test_common_control.py b/tests/components/usage_prediction/test_common_control.py index de6db025472270..090d9ddf7ffc2e 100644 --- a/tests/components/usage_prediction/test_common_control.py +++ b/tests/components/usage_prediction/test_common_control.py @@ -62,9 +62,15 @@ async def test_with_service_calls(hass: HomeAssistant) -> None: """Test function with actual service call events in database.""" user_id = str(uuid.uuid4()) + hass.states.async_set("light.living_room", "off") + hass.states.async_set("light.kitchen", "off") + hass.states.async_set("climate.thermostat", "off") + hass.states.async_set("light.bedroom", "off") + hass.states.async_set("lock.front_door", "locked") + # Create service call events at different times of day # Morning events - use separate service calls to get around context deduplication - with freeze_time("2023-07-01 07:00:00+00:00"): # Morning + with freeze_time("2023-07-01 07:00:00"): # Morning hass.bus.async_fire( EVENT_CALL_SERVICE, { @@ -77,7 +83,7 @@ async def test_with_service_calls(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Afternoon events - with freeze_time("2023-07-01 14:00:00+00:00"): # Afternoon + with freeze_time("2023-07-01 14:00:00"): # Afternoon hass.bus.async_fire( EVENT_CALL_SERVICE, { @@ -90,7 +96,7 @@ async def test_with_service_calls(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Evening events - with freeze_time("2023-07-01 19:00:00+00:00"): # Evening + with freeze_time("2023-07-01 19:00:00"): # Evening hass.bus.async_fire( EVENT_CALL_SERVICE, { @@ -103,7 +109,7 @@ async def test_with_service_calls(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Night events - with freeze_time("2023-07-01 23:00:00+00:00"): # Night + with freeze_time("2023-07-01 23:00:00"): # Night hass.bus.async_fire( EVENT_CALL_SERVICE, { @@ -119,7 +125,7 @@ async def test_with_service_calls(hass: HomeAssistant) -> None: await async_wait_recording_done(hass) # Get predictions - make sure we're still in a reasonable timeframe - with freeze_time("2023-07-02 10:00:00+00:00"): # Next day, so events are recent + with freeze_time("2023-07-02 10:00:00"): # Next day, so events are recent results = await async_predict_common_control(hass, user_id) # Verify results contain the expected entities in the correct time periods @@ -151,7 +157,12 @@ async def test_multiple_entities_in_one_call(hass: HomeAssistant) -> None: suggested_object_id="kitchen", ) - with freeze_time("2023-07-01 10:00:00+00:00"): # Morning + hass.states.async_set("light.living_room", "off") + hass.states.async_set("light.kitchen", "off") + hass.states.async_set("light.hallway", "off") + hass.states.async_set("not_allowed.domain", "off") + + with freeze_time("2023-07-01 10:00:00"): # Morning hass.bus.async_fire( EVENT_CALL_SERVICE, { @@ -163,6 +174,7 @@ async def test_multiple_entities_in_one_call(hass: HomeAssistant) -> None: "light.kitchen", "light.hallway", "not_allowed.domain", + "light.not_in_state_machine", ] }, }, @@ -172,7 +184,7 @@ async def test_multiple_entities_in_one_call(hass: HomeAssistant) -> None: await async_wait_recording_done(hass) - with freeze_time("2023-07-02 10:00:00+00:00"): # Next day, so events are recent + with freeze_time("2023-07-02 10:00:00"): # Next day, so events are recent results = await async_predict_common_control(hass, user_id) # Two lights should be counted (10:00 UTC = 02:00 local = night) @@ -189,7 +201,10 @@ async def test_context_deduplication(hass: HomeAssistant) -> None: user_id = str(uuid.uuid4()) context = Context(user_id=user_id) - with freeze_time("2023-07-01 10:00:00+00:00"): # Morning + hass.states.async_set("light.living_room", "off") + hass.states.async_set("switch.coffee_maker", "off") + + with freeze_time("2023-07-01 10:00:00"): # Morning # Fire multiple events with the same context hass.bus.async_fire( EVENT_CALL_SERVICE, @@ -215,7 +230,7 @@ async def test_context_deduplication(hass: HomeAssistant) -> None: await async_wait_recording_done(hass) - with freeze_time("2023-07-02 10:00:00+00:00"): # Next day, so events are recent + with freeze_time("2023-07-02 10:00:00"): # Next day, so events are recent results = await async_predict_common_control(hass, user_id) # Only the first event should be processed (10:00 UTC = 02:00 local = night) @@ -232,8 +247,11 @@ async def test_old_events_excluded(hass: HomeAssistant) -> None: """Test that events older than 30 days are excluded.""" user_id = str(uuid.uuid4()) + hass.states.async_set("light.old_event", "off") + hass.states.async_set("light.recent_event", "off") + # Create an old event (35 days ago) - with freeze_time("2023-05-27 10:00:00+00:00"): # 35 days before July 1st + with freeze_time("2023-05-27 10:00:00"): # 35 days before July 1st hass.bus.async_fire( EVENT_CALL_SERVICE, { @@ -246,7 +264,7 @@ async def test_old_events_excluded(hass: HomeAssistant) -> None: await hass.async_block_till_done() # Create a recent event (5 days ago) - with freeze_time("2023-06-26 10:00:00+00:00"): # 5 days before July 1st + with freeze_time("2023-06-26 10:00:00"): # 5 days before July 1st hass.bus.async_fire( EVENT_CALL_SERVICE, { @@ -261,7 +279,7 @@ async def test_old_events_excluded(hass: HomeAssistant) -> None: await async_wait_recording_done(hass) # Query with current time - with freeze_time("2023-07-01 10:00:00+00:00"): + with freeze_time("2023-07-01 10:00:00"): results = await async_predict_common_control(hass, user_id) # Only recent event should be included (10:00 UTC = 02:00 local = night) @@ -278,8 +296,16 @@ async def test_entities_limit(hass: HomeAssistant) -> None: """Test that only top entities are returned per time category.""" user_id = str(uuid.uuid4()) + hass.states.async_set("light.most_used", "off") + hass.states.async_set("light.second", "off") + hass.states.async_set("light.third", "off") + hass.states.async_set("light.fourth", "off") + hass.states.async_set("light.fifth", "off") + hass.states.async_set("light.sixth", "off") + hass.states.async_set("light.seventh", "off") + # Create more than 5 different entities in morning - with freeze_time("2023-07-01 08:00:00+00:00"): + with freeze_time("2023-07-01 08:00:00"): # Create entities with different frequencies entities_with_counts = [ ("light.most_used", 10), @@ -308,7 +334,7 @@ async def test_entities_limit(hass: HomeAssistant) -> None: await async_wait_recording_done(hass) with ( - freeze_time("2023-07-02 10:00:00+00:00"), + freeze_time("2023-07-02 10:00:00"), patch( "homeassistant.components.usage_prediction.common_control.RESULTS_TO_INCLUDE", 5, @@ -335,7 +361,10 @@ async def test_different_users_separated(hass: HomeAssistant) -> None: user_id_1 = str(uuid.uuid4()) user_id_2 = str(uuid.uuid4()) - with freeze_time("2023-07-01 10:00:00+00:00"): + hass.states.async_set("light.user1_light", "off") + hass.states.async_set("light.user2_light", "off") + + with freeze_time("2023-07-01 10:00:00"): # User 1 events hass.bus.async_fire( EVENT_CALL_SERVICE, @@ -363,7 +392,7 @@ async def test_different_users_separated(hass: HomeAssistant) -> None: await async_wait_recording_done(hass) # Get results for each user - with freeze_time("2023-07-02 10:00:00+00:00"): # Next day, so events are recent + with freeze_time("2023-07-02 10:00:00"): # Next day, so events are recent results_user1 = await async_predict_common_control(hass, user_id_1) results_user2 = await async_predict_common_control(hass, user_id_2)