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
@@ -4,8 +4,9 @@ from __future__ import annotations
import logging
import os
from collections.abc import Callable
from datetime import date
from typing import Any
from typing import Any, Final
import voluptuous as vol
from homeassistant.components import websocket_api
@@ -23,22 +24,27 @@ from ..const import (
DEFAULT_WARNING_DAYS,
DOMAIN,
MAX_ID_LENGTH,
task_unique_id,
)
from ..helpers.aggregate import get_object_entries, get_runtime_data, get_store, is_object_entry, object_name
from ..helpers.aggregate import get_object_entries, get_runtime_data, get_store, is_object_entry, object_name, task_entity_id
from ..helpers.aggregate import object_slug as aggregate_object_slug
from ..helpers.phases import current_phase_summary
_LOGGER = logging.getLogger(__name__)
#: The schema validator for every id-shaped command field (entry_id, task_id,
#: doc_id, part_id, …): a capped string. ``task/move`` shipped with bare
#: ``str`` for its three ids — tests/test_ws_schema_caps.py refuses that.
ID_FIELD = vol.All(str, vol.Length(max=MAX_ID_LENGTH))
#:
#: The three shared fields carry explicit annotations: the handler submodules
#: import them from this package while it imports the submodules back (an
#: import cycle), and mypy cannot infer a type across that cycle.
ID_FIELD: Final[vol.All] = vol.All(str, vol.Length(max=MAX_ID_LENGTH))
#: ``{slot_id: value | None}`` for a task with reading slots (#161 phase 2) —
#: the live completion records it, the history edit replaces the snapshot.
#: Wide numeric bounds: meters count high, temperatures go negative. Slot ids
#: are checked against the task in the handler (unknown -> invalid_input).
READING_VALUES_FIELD = vol.Any(
READING_VALUES_FIELD: Final[vol.Any] = vol.Any(
{str: vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-1e12, max=1e12)), None)},
None,
)
@@ -51,7 +57,7 @@ READING_VALUES_FIELD = vol.Any(
#: and the history edit's stock delta already read a missing quantity as 1
#: (parts_runtime.async_apply_history_parts_edit). One shape, one cap — the
#: edit path must not accept what completion refuses.
USED_PARTS_FIELD = vol.Any(
USED_PARTS_FIELD: Final[vol.Any] = vol.Any(
vol.All(
[
vol.Schema(
@@ -102,16 +108,6 @@ def _get_merged_tasks(entry: ConfigEntry) -> dict[str, Any]:
_HISTORY_WINDOW = int(os.environ.get("MS_HISTORY_WINDOW", "20"))
def _current_phase_summary(task_data: dict[str, Any]) -> dict[str, Any] | None:
"""{id, name, index, count} of the phase currently due, or None (#139)."""
from ..helpers.phases import current_phase
phase = current_phase(task_data)
if phase is None:
return None
return {"id": phase["id"], "name": phase["name"], "index": phase["index"], "count": phase["count"]}
def _build_task_summary(
hass: HomeAssistant,
task_id: str,
@@ -126,8 +122,6 @@ def _build_task_summary(
use HA's native entity_ids: filter pattern without re-implementing the
slugify logic.
"""
from homeassistant.helpers import entity_registry as er
from ..entity.triggers import normalize_entity_ids
from ..helpers.schedule import KIND_CALENDAR, Schedule, read_legacy_fields
@@ -227,7 +221,7 @@ def _build_task_summary(
"phases": task_data.get("phases"),
"phase_sequence": task_data.get("phase_sequence"),
"phase_cursor": task_data.get("phase_cursor", 0),
"current_phase": _current_phase_summary(task_data),
"current_phase": current_phase_summary(task_data),
"priority": task_data.get("priority") or DEFAULT_TASK_PRIORITY,
# v2.10.0 archive: archived_at is the persisted timestamp (None = active);
# `archived` is the convenience bool the frontend filters on; reason is
@@ -245,23 +239,9 @@ def _build_task_summary(
# Lookup via entity registry by unique_id so we get the actual
# registered entity_id (which can differ from the unique_id when the
# user has renamed it). None when registry lookup fails (rare).
"sensor_entity_id": (
er.async_get(hass).async_get_entity_id(
"sensor",
"maintenance_supporter",
task_unique_id(object_slug, task_id),
)
if object_slug
else None
),
"sensor_entity_id": task_entity_id(hass, object_slug, task_id) if object_slug else None,
"binary_sensor_entity_id": (
er.async_get(hass).async_get_entity_id(
"binary_sensor",
"maintenance_supporter",
task_unique_id(object_slug, task_id, "overdue"),
)
if object_slug
else None
task_entity_id(hass, object_slug, task_id, platform="binary_sensor", suffix="overdue") if object_slug else None
),
"trigger_config": trigger_config,
# Battery Fleet: marks the single aggregate task so the detail view
@@ -303,6 +283,8 @@ def _build_task_summary(
"is_done": ct.get("_is_done", False),
"days_until_due": ct.get("_days_until_due"),
"next_due": ct.get("_next_due"),
# #189: titles of the calendar events behind next_due (calendar kind).
"next_event_titles": list(ct.get("_next_event_titles") or []),
"trigger_active": ct.get("_trigger_active", False),
"trigger_current_value": ct.get("_trigger_current_value"),
"trigger_entity_state": ct.get("_trigger_entity_state", "available"),
@@ -363,12 +345,10 @@ def _build_object_response(
compact: bool = False,
) -> dict[str, Any]:
"""Build a full object response dict (compact: empty keys stripped)."""
from ..const import slugify_object_name
obj_data = entry.data.get(CONF_OBJECT, {})
tasks_data = _get_merged_tasks(entry)
ct_tasks = (coordinator_data or {}).get(CONF_TASKS, {})
object_slug = slugify_object_name(obj_data.get("name", "unknown"))
object_slug = aggregate_object_slug(obj_data)
tasks = [_build_task_summary(hass, tid, tdata, ct_tasks.get(tid), object_slug) for tid, tdata in tasks_data.items()]
@@ -442,6 +422,8 @@ def _build_object_response(
"paused": obj_data.get("paused_at") is not None,
"paused_at": obj_data.get("paused_at"),
"paused_until": obj_data.get("paused_until"),
# 2.94: the template the object was made from (None = unknown).
"template_id": obj_data.get("template_id"),
# v2.20 (N1) replace-flow lineage, both directions.
"predecessor_entry_id": obj_data.get("predecessor_entry_id"),
"replaced_by_entry_id": obj_data.get("replaced_by_entry_id"),
@@ -510,6 +492,20 @@ def _save_global_options(hass: HomeAssistant, entry: ConfigEntry, options: dict[
hass.config_entries.async_update_entry(entry, options=options)
def _merge_global_options(hass: HomeAssistant, entry: ConfigEntry, changes: dict[str, Any]) -> None:
"""Merge ``changes`` into the global entry's settings and save them.
The one read-modify-write for code that already holds the global entry
(DRY audit 2026-09-26 B — saved views, group cleanup, the vacation
exempt list on task delete/move, the settings import). The base is
``options``, else the creation ``data`` — the read rule of
helpers.global_options.get_global_options; a copy that started from
``entry.options`` alone would, on a never-saved entry, write an options
dict holding ONLY the change and hide every setting kept in ``data``.
"""
_save_global_options(hass, entry, {**(entry.options or entry.data), **changes})
def _load_object_entry(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
@@ -589,6 +585,7 @@ def _parse_iso_date(
*,
field: str,
code: str = "invalid_date",
not_future: bool = False,
) -> date | None:
"""``YYYY-MM-DD`` -> ``date``, or send ``code`` and return None.
@@ -596,12 +593,24 @@ def _parse_iso_date(
ten spellings of the same message; the error CODE is the caller's (the
task create/update pair answers ``invalid_format``, the rest
``invalid_date`` — the frontend keys on it).
``not_future``: a date after today is refused with ``invalid_date``. A
reset / last-performed date is a day something WAS done — one in the
year 9999 overflowed the schedule math inside every refresh and kept the
object in setup-retry across restarts (bug audit 2026-09-27).
"""
try:
return date.fromisoformat(value)
parsed = date.fromisoformat(value)
except (TypeError, ValueError):
connection.send_error(msg_id, code, f"{field} must be a valid date (YYYY-MM-DD)")
return None
if not_future:
from homeassistant.util import dt as dt_util
if parsed > dt_util.now().date():
connection.send_error(msg_id, "invalid_date", f"{field} must not be in the future")
return None
return parsed
async def async_commit_store(rd: Any, *, budget: bool = False) -> None:
@@ -649,8 +658,7 @@ def cleanup_group_refs(
if global_entry is None:
return
options = dict(global_entry.options or global_entry.data)
groups = options.get(CONF_GROUPS)
groups = (global_entry.options or global_entry.data).get(CONF_GROUPS)
if not groups:
return
@@ -671,8 +679,7 @@ def cleanup_group_refs(
changed = True
if changed:
options[CONF_GROUPS] = groups
hass.config_entries.async_update_entry(global_entry, options=options)
_merge_global_options(hass, global_entry, {CONF_GROUPS: groups})
# ---------------------------------------------------------------------------
@@ -724,6 +731,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
from .documents import (
ws_documents_add_link,
ws_documents_delete,
ws_documents_discard_upload,
ws_documents_list,
ws_documents_search,
ws_documents_storage,
@@ -739,6 +747,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
from .integration_setups import (
ws_adopt_integration_setups,
ws_discover_integration_setups,
ws_preview_integration_setup,
)
from .io import (
ws_batch_generate_qr,
@@ -777,6 +786,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
from .problem_sensors import (
ws_adopt_problem_sensors,
ws_discover_problem_sensors,
ws_preview_problem_sensors,
)
from .reference_numbers import ws_compact_reference_numbers
from .saved_views import (
@@ -863,6 +873,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_import_csv)
websocket_api.async_register_command(hass, ws_import_json)
websocket_api.async_register_command(hass, ws_discover_problem_sensors)
websocket_api.async_register_command(hass, ws_preview_problem_sensors)
websocket_api.async_register_command(hass, ws_adopt_problem_sensors)
websocket_api.async_register_command(hass, ws_battery_fleet_overview)
websocket_api.async_register_command(hass, ws_battery_fleet_history)
@@ -875,6 +886,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_battery_fleet_set_track_self_charging)
websocket_api.async_register_command(hass, ws_battery_fleet_set_due_without_sensor)
websocket_api.async_register_command(hass, ws_discover_integration_setups)
websocket_api.async_register_command(hass, ws_preview_integration_setup)
websocket_api.async_register_command(hass, ws_adopt_integration_setups)
websocket_api.async_register_command(hass, ws_list_saved_views)
websocket_api.async_register_command(hass, ws_save_saved_view)
@@ -908,12 +920,15 @@ def async_register_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_documents_add_link)
websocket_api.async_register_command(hass, ws_documents_update)
websocket_api.async_register_command(hass, ws_documents_delete)
# Bug audit 2026-09-27 (R SEC-3): read tier — a non-writer removes the
# completion photo they uploaded that never became part of a record.
websocket_api.async_register_command(hass, ws_documents_discard_upload)
websocket_api.async_register_command(hass, ws_documents_search)
websocket_api.async_register_command(hass, ws_search)
websocket_api.async_register_command(hass, ws_compact_reference_numbers)
def foreign_part_resolver(hass):
def foreign_part_resolver(hass: HomeAssistant) -> Callable[[str], set[str] | None]:
"""Callback for ``sanitize_consumes_parts``: which parts an entry owns.
Returns None for an entry that does not exist or is not a maintenance
@@ -921,9 +936,10 @@ def foreign_part_resolver(hass):
"""
from ..const import CONF_PARTS
def _resolve(entry_id: str):
def _resolve(entry_id: str) -> set[str] | None:
entry = hass.config_entries.async_get_entry(entry_id)
if not is_object_entry(entry):
# is_object_entry() rejects None too; the explicit test narrows the type.
if entry is None or not is_object_entry(entry):
return None
return set(entry.data.get(CONF_PARTS) or {})