Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion homeassistant/components/esphome/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions homeassistant/components/esphome/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/esphome/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down
115 changes: 56 additions & 59 deletions homeassistant/components/usage_prediction/common_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -38,13 +39,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,
Expand All @@ -55,7 +54,6 @@
Platform.SENSOR,
Platform.SIREN,
Platform.SWITCH,
Platform.TEXT,
Platform.VACUUM,
Platform.VALVE,
Platform.WATER_HEATER,
Expand Down Expand Up @@ -93,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
Expand All @@ -156,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:
Expand Down Expand Up @@ -190,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=[
Expand All @@ -229,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)
2 changes: 1 addition & 1 deletion homeassistant/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
3 changes: 3 additions & 0 deletions homeassistant/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
2 changes: 1 addition & 1 deletion requirements_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion requirements_test_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 37 additions & 1 deletion tests/components/esphome/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
)

Expand Down
4 changes: 2 additions & 2 deletions tests/components/esphome/test_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"),
)

Expand Down
Loading
Loading