393 files

This commit is contained in:
Home Assistant Version Control
2026-09-27 17:16:54 +00:00
parent b05b7a897e
commit 39d97ac2db
404 changed files with 54006 additions and 11034 deletions
@@ -5,7 +5,7 @@ from __future__ import annotations
import logging
import math
from collections.abc import Callable, Mapping
from typing import Any
from typing import Any, cast
import voluptuous as vol
from homeassistant.components import websocket_api
@@ -43,6 +43,8 @@ from ..const import (
CONF_DEFAULT_WARNING_DAYS,
CONF_DELETE_ARCHIVED_ONEOFF_DAYS,
CONF_DISABLED_TEMPLATE_IDS,
CONF_HOME_REGION,
CONF_HOME_TYPE,
CONF_INSTALL_ASSIST_SENTENCES,
CONF_MAX_NOTIFICATIONS_PER_DAY,
CONF_MEMBER_DISPLAY,
@@ -91,6 +93,7 @@ from ..const import (
from ..helpers.aggregate import compute_status_counts
from ..helpers.global_options import get_global_options
from ..helpers.notify_targets import build_notify_targets
from ..helpers.region import is_region_setting
from ..helpers.settings_registry import (
ALLOWED_SETTING_KEYS,
FLOAT_RANGES,
@@ -113,7 +116,10 @@ _LOGGER = logging.getLogger(__name__)
# Keys accepted by global/update + their range/cap tables are derived from the
# single settings registry (helpers/settings_registry) so they can't drift from
# each other or from the options-flow selectors that share the same specs.
_ALLOWED_SETTING_KEYS = ALLOWED_SETTING_KEYS
# Every value is a SettingSpec.py_type — a plain class, the isinstance()
# target below; the registry's ``type | vol.Any`` value annotation is wider
# than what it actually holds.
_ALLOWED_SETTING_KEYS = cast(dict[str, type], ALLOWED_SETTING_KEYS)
def _opt(options: Mapping[str, Any], key: str) -> Any:
@@ -142,10 +148,20 @@ def _battery_lifetime_catalog(hass: HomeAssistant) -> list[dict[str, Any]]:
from ..helpers.battery_lifetime import lifetime_catalog
names: dict[str, str] = {}
dev_reg = dr.async_get(hass)
# HA 2026.9 deprecates mapping-style access on DeviceRegistry.devices
# (.values() logs a deprecation warning for custom integrations and
# stops working in 2027.9) and iterates ENTRIES; 2026.8 and older
# iterate ids and need .values(). Probe the collection itself, same as
# repairs.py.
registry_devices = dr.async_get(hass).devices
all_devices: list[Any] = list(
cast(Any, registry_devices).values() if isinstance(registry_devices, Mapping) else registry_devices
)
for bat in read_batteries(hass):
if bat.model_key and bat.model_key not in names:
for device in dev_reg.devices.values():
for device in all_devices:
if getattr(device, "parent_device_id", None) is not None:
continue # 2026.9 sub-device: never the appliance, and its make/model reads are deprecated
manufacturer = str(getattr(device, "manufacturer", None) or "")
model = str(getattr(device, "model", None) or getattr(device, "model_id", None) or "")
key = f"{manufacturer.strip().lower()}|{model.strip().lower()}"
@@ -154,6 +170,9 @@ def _battery_lifetime_catalog(hass: HomeAssistant) -> list[dict[str, Any]]:
break
return lifetime_catalog(hass, list(discover_battery_types(hass)), model_names=names)
except Exception: # noqa: BLE001 - a settings read must never fail on the fleet
# Logged, not swallowed: an HA API change here once went unnoticed
# because the table simply came back shorter.
_LOGGER.warning("Battery lifetime table could not be built", exc_info=True)
return []
@@ -209,6 +228,10 @@ def _build_full_settings(
"member_display": _opt(options, CONF_MEMBER_DISPLAY),
# v2.21: template-gallery curation (ids hidden from the pickers).
"disabled_template_ids": _opt(options, CONF_DISABLED_TEMPLATE_IDS),
# v2.93: home profile dwelling type (auto / house / apartment).
"home_type": _opt(options, CONF_HOME_TYPE),
# 2.94: the state / province / region the templates follow (auto = located).
"home_region": _opt(options, CONF_HOME_REGION),
# v2.10.0: archive automation thresholds (panel Settings → Archive).
# oneoff_days: auto-archive a completed one-off after N days (0 = off).
# delete_archived_oneoff_days: auto-delete an auto-archived one-off N
@@ -658,8 +681,7 @@ async def ws_get_budget_status(
"""Return current budget status (monthly/yearly spent vs budget)."""
from ..helpers.budget import compute_spend
global_entry = _get_global_entry(hass)
global_options: Mapping[str, Any] = (global_entry.options or global_entry.data) if global_entry else {}
global_options = get_global_options(hass)
monthly_budget = float(global_options.get(CONF_BUDGET_MONTHLY, 0))
yearly_budget = float(global_options.get(CONF_BUDGET_YEARLY, 0))
@@ -691,6 +713,16 @@ async def ws_get_budget_status(
# ---------------------------------------------------------------------------
def settings_error_field(error: str) -> str:
"""The setting a ``sanitize_settings_input`` error code refers to — the
notify-service codes come from ``validate_notify_service``, the others are
the sanitizer's own early returns."""
return {
"invalid_shopping_list_entity": CONF_SHOPPING_LIST_ENTITY,
"invalid_search_template": CONF_PART_SEARCH_URL_TEMPLATE,
}.get(error, CONF_NOTIFY_SERVICE)
def sanitize_settings_input(settings_input: dict[str, Any]) -> tuple[dict[str, Any], str | None]:
"""Filter + validate a flat settings dict against the registry.
@@ -746,8 +778,12 @@ def sanitize_settings_input(settings_input: dict[str, Any]) -> tuple[dict[str, A
# v1.4.0 (#44): enum-validate notification_title_style. Anything outside
# the known set is dropped silently so a bogus value can't get into the
# ConfigEntry options.
from ..const import NOTIFICATION_TITLE_STYLES, NOTIFY_COMPLETED_MODES
from ..const import HOME_TYPES, NOTIFICATION_TITLE_STYLES, NOTIFY_COMPLETED_MODES
if CONF_HOME_TYPE in filtered and filtered[CONF_HOME_TYPE] not in HOME_TYPES:
del filtered[CONF_HOME_TYPE]
if CONF_HOME_REGION in filtered and not is_region_setting(filtered[CONF_HOME_REGION]):
del filtered[CONF_HOME_REGION]
if CONF_NOTIFY_COMPLETED in filtered and filtered[CONF_NOTIFY_COMPLETED] not in NOTIFY_COMPLETED_MODES:
filtered[CONF_NOTIFY_COMPLETED] = "off"
if CONF_NOTIFICATION_TITLE_STYLE in filtered and filtered[CONF_NOTIFICATION_TITLE_STYLE] not in NOTIFICATION_TITLE_STYLES: