Compare commits

...

9 Commits

Author SHA1 Message Date
J. Nick Koston
eebaa42496 split out of exec 2025-09-24 21:45:59 -05:00
J. Nick Koston
7c8ad9d535 Fix ESPHome reauth not being triggered on incorrect password (#152911) 2025-09-24 22:27:40 -04:00
Franck Nijhof
9cd3ab853d Add block Spook < 4.0.0 as breaking Home Assistant (#152930) 2025-09-24 22:18:06 -04:00
Paulus Schoutsen
0b0f8c5829 Remove some more domains from common controls (#152927) 2025-09-24 22:15:29 -04:00
J. Nick Koston
ae7bc7fb1b Bump aioesphomeapi to 41.9.4 (#152923) 2025-09-24 19:16:48 -05:00
Franck Nijhof
09750872b5 Bump version to 2025.11.0dev0 (#152915) 2025-09-24 23:55:32 +02:00
Franck Nijhof
076e51017b Bump to home-assistant/wheels@2025.09.0 (#152920) 2025-09-24 23:12:20 +02:00
Simone Chemelli
95e7b00996 Update IQS to platinum for Comelit SimpleHome (#152906) 2025-09-24 22:03:31 +01:00
J. Nick Koston
ddecf1ac21 Bump aioesphomeapi to 41.9.3 to fix segfault (#152912) 2025-09-24 22:00:45 +01:00
15 changed files with 136 additions and 50 deletions

View File

@@ -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

View File

@@ -160,7 +160,7 @@ jobs:
# home-assistant/wheels doesn't support sha pinning
- name: Build wheels
uses: home-assistant/wheels@2025.07.0
uses: home-assistant/wheels@2025.09.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
@@ -221,7 +221,7 @@ jobs:
# home-assistant/wheels doesn't support sha pinning
- name: Build wheels
uses: home-assistant/wheels@2025.07.0
uses: home-assistant/wheels@2025.09.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2

View File

@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
"quality_scale": "silver",
"quality_scale": "platinum",
"requirements": ["aiocomelit==0.12.3"]
}

View File

@@ -57,6 +57,7 @@ from .manager import async_replace_device
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 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
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 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
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:

View File

@@ -372,6 +372,9 @@ class ESPHomeManager:
"""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 @@ class ESPHomeManager:
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

View File

@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==41.9.0",
"aioesphomeapi==41.9.4",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.3.0"
],

View File

@@ -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
@@ -38,13 +39,11 @@ ALLOWED_DOMAINS = {
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 +54,6 @@ ALLOWED_DOMAINS = {
Platform.SENSOR,
Platform.SIREN,
Platform.SWITCH,
Platform.TEXT,
Platform.VACUUM,
Platform.VALVE,
Platform.WATER_HEATER,
@@ -102,15 +100,9 @@ async def async_predict_common_control(
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
@@ -119,35 +111,12 @@ def _fetch_and_process_data(
# 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
@@ -229,11 +198,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)

View File

@@ -25,7 +25,7 @@ if TYPE_CHECKING:
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}"

View File

@@ -121,6 +121,9 @@ BLOCKED_CUSTOM_INTEGRATIONS: dict[str, 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(

View File

@@ -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."

2
requirements_all.txt generated
View File

@@ -247,7 +247,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==41.9.0
aioesphomeapi==41.9.4
# homeassistant.components.flo
aioflo==2021.11.0

View File

@@ -235,7 +235,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==41.9.0
aioesphomeapi==41.9.4
# homeassistant.components.flo
aioflo==2021.11.0

View File

@@ -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"),
)

View File

@@ -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"),
)

View File

@@ -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,