393 files
This commit is contained in:
@@ -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 {})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user