329 files

This commit is contained in:
Home Assistant Version Control
2026-08-06 13:56:25 +00:00
parent 0df89406fa
commit 7afe7add1d
330 changed files with 13098 additions and 5942 deletions
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import os
from typing import Any
from homeassistant.components import websocket_api
@@ -40,10 +41,32 @@ def _get_merged_tasks(entry: ConfigEntry) -> dict[str, Any]:
rd = getattr(entry, "runtime_data", None)
store = getattr(rd, "store", None) if rd else None
if store is not None:
return store.merge_all_tasks(tasks_data)
merged = store.merge_all_tasks(tasks_data)
# #73: overlay the in-cycle checklist ticks HERE rather than via the
# merge whitelist — merged dicts feed MaintenanceTask.from_dict all
# over the coordinator, and this field is presentation state the model
# never needs.
for tid, td in merged.items():
progress = store.get_task_state(tid).get("checklist_progress")
if progress:
td["checklist_progress"] = progress
return merged
return tasks_data
# How many recent history entries ride in the LIST payload. Chosen to cover
# every non-detail consumer: the quick-actions dialog shows the last 20, the
# detail header the last 3, the calendar card averages costs (≈stable over
# 20), the strategy looks up the latest matching entry. The detail view's
# full timeline/charts fetch everything via `task/history` instead.
#
# The env override exists ONLY for the benchmark harness: setting
# MS_HISTORY_WINDOW to a huge value reproduces the pre-diet payload on the
# same build, so before/after is a measured A/B on identical data instead of
# an extrapolation. Never documented, never read anywhere else.
_HISTORY_WINDOW = int(os.environ.get("MS_HISTORY_WINDOW", "20"))
def _build_task_summary(
hass: HomeAssistant,
task_id: str,
@@ -177,8 +200,20 @@ def _build_task_summary(
"trigger_entity_info": trigger_entity_info,
"trigger_entity_infos": trigger_entity_infos,
"checklist": task_data.get("checklist", []),
# #73: in-cycle ticks ({item text: bool}, store-merged) — lets the
# detail view show progress without completing, and the complete
# dialog prefill what was already done.
"checklist_progress": task_data.get("checklist_progress", {}),
"labels": task_data.get("labels", []),
"history": task_data.get("history", []),
# Payload diet (perf, 2026-08): the LIST response carries only the
# most recent entries — at 150+ tasks the full histories dominated
# the `objects` payload (407 KB measured at just 8 entries/task) and
# every list consumer reads at most the last 20 (quick-dialog) or an
# aggregate. The task-detail view fetches the FULL history lazily
# via `task/history`; `history_count` tells it (and any badge)
# what exists beyond the window.
"history": (task_data.get("history") or [])[-_HISTORY_WINDOW:],
"history_count": len(task_data.get("history") or []),
# v1.3.0 — both fields are persisted by ws_create_task / ws_update_task
# and consumed by helpers/action_listener.py on EVENT_TASK_COMPLETED, but
# were missing from this response builder until issue #50. Without them
@@ -312,6 +347,20 @@ def _build_object_response(hass: HomeAssistant, entry: ConfigEntry, coordinator_
# (roadmap P2) count of attached documents (files + web-links) for
# the objects-table paperclip badge; computed, not persisted.
"document_count": document_count,
# An UPLOADED manual (category tag "manual") is the same affordance
# as documentation_url — expose them so the "manual" column and the
# object header can fall back instead of rendering "—" next to an
# object that plainly has its manual attached.
"manual_docs": [
{
"id": d.get("id"),
"title": d.get("title") or d.get("filename") or "",
"kind": d.get("kind"),
"url": d.get("url"),
}
for d in object_docs
if "manual" in (d.get("tags") or [])
],
},
"tasks": tasks,
"parts": parts_payload,
@@ -428,10 +477,12 @@ def async_register_commands(hass: HomeAssistant) -> None:
ws_set_environmental_entity,
)
from .battery_fleet import (
ws_battery_fleet_history,
ws_battery_fleet_mark_replaced,
ws_battery_fleet_overview,
ws_battery_fleet_set_excluded,
ws_battery_fleet_setup,
ws_battery_fleet_status,
)
from .dashboard import (
ws_get_budget_status,
@@ -505,6 +556,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
from .tags import ws_list_tags
from .tasks import (
ws_archive_task,
ws_checklist_progress,
ws_complete_task,
ws_create_task,
ws_delete_task,
@@ -515,6 +567,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
ws_reset_task,
ws_skip_task,
ws_snooze_task,
ws_task_history,
ws_unarchive_task,
ws_update_history_entry,
ws_update_task,
@@ -548,8 +601,10 @@ def async_register_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_archive_task)
websocket_api.async_register_command(hass, ws_unarchive_task)
websocket_api.async_register_command(hass, ws_list_tasks)
websocket_api.async_register_command(hass, ws_task_history)
websocket_api.async_register_command(hass, ws_complete_task)
websocket_api.async_register_command(hass, ws_quick_complete_task)
websocket_api.async_register_command(hass, ws_checklist_progress)
websocket_api.async_register_command(hass, ws_skip_task)
websocket_api.async_register_command(hass, ws_reset_task)
websocket_api.async_register_command(hass, ws_snooze_task)
@@ -571,6 +626,8 @@ def async_register_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_discover_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)
websocket_api.async_register_command(hass, ws_battery_fleet_status)
websocket_api.async_register_command(hass, ws_battery_fleet_setup)
websocket_api.async_register_command(hass, ws_battery_fleet_mark_replaced)
websocket_api.async_register_command(hass, ws_battery_fleet_set_excluded)
@@ -11,10 +11,17 @@ from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from ..const import DOMAIN, MAX_ENTITY_ID_LENGTH
from ..helpers.battery_fleet import compute_overview, fleet_excluded_entities, has_batteries, has_battery_notes
from ..helpers.battery_fleet import (
async_compute_overview,
async_level_history,
fleet_excluded_entities,
has_batteries,
has_battery_notes,
read_batteries,
)
from ..helpers.battery_fleet_setup import (
async_mark_replaced,
async_setup_battery_fleet,
@@ -28,8 +35,12 @@ from ..helpers.permissions import require_write
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/battery_fleet/overview"})
@websocket_api.async_response
async def ws_battery_fleet_overview(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> None:
"""Return the live fleet overview (low now, needs grouped, forecast)."""
ov = compute_overview(hass)
"""Return the live fleet overview (low now, needs grouped, forecast).
Goes through the ASYNC path so the ~dates use the discharge-trend
regression where the recorder data supports it (type table otherwise).
"""
ov = await async_compute_overview(hass)
fleet = find_fleet_entry(hass)
connection.send_result(
msg["id"],
@@ -71,6 +82,40 @@ async def ws_battery_fleet_overview(hass: HomeAssistant, connection: websocket_a
)
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/battery_fleet/status"})
@callback
def ws_battery_fleet_status(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> None:
"""The two booleans behind the panel's one-click-setup button — cheap.
The panel used to ask the FULL overview on every load just to decide
whether to render the button, which runs the trend machinery (one
recorder regression per healthy battery on a cold cache after every HA
restart). This answers ``available`` (any trackable battery) and
``configured`` (a fleet object exists) without reading the fleet at all.
"""
fleet = find_fleet_entry(hass)
connection.send_result(
msg["id"],
{"available": has_batteries(hass), "configured": fleet is not None},
)
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/battery_fleet/overview_history"})
@websocket_api.async_response
async def ws_battery_fleet_history(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> None:
"""Per-battery 30 d level history for the roster sparklines (read tier,
like overview — it renders the same data a user already sees as numbers).
Fetched lazily by the panel when the roster is expanded; the recorder
work behind it is 6 h-cached per entity (misses included), so repeated
opens are cheap. ``{series: {entity_id: {points: [[epoch_s, level]…],
threshold}}}`` — the threshold is the same one the trend forecast asks
about, so the frontend can draw the projection down to it.
"""
series = await async_level_history(hass, read_batteries(hass))
connection.send_result(msg["id"], {"series": series})
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/battery_fleet/setup",
@@ -11,6 +11,7 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.event import async_call_later
from ..const import (
BUDGET_CURRENCIES,
@@ -286,35 +287,128 @@ async def ws_get_statistics(
)
@websocket_api.websocket_command({vol.Required("type"): "maintenance_supporter/subscribe"})
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/subscribe",
# 2.52 delta protocol opt-in — see the handler docstring.
vol.Optional("deltas", default=False): bool,
}
)
@websocket_api.async_response
async def ws_subscribe(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Subscribe to real-time maintenance updates."""
"""Subscribe to real-time maintenance updates.
Two protocols, chosen by the subscriber:
* legacy (default): every event carries the FULL ``{objects: [...]}``
payload — exactly the pre-2.52 behaviour, kept for stale-cached
frontends that predate the delta merge.
* ``deltas: true``: events carry ``{delta: [<object response>...],
removed: [entry_id...]}`` — only entries whose coordinator fired AND
whose rebuilt response actually differs (hash suppression), so the
5-minute timer waves that change nothing send NOTHING, a real change
ships ~one object instead of the whole install, and the per-push
server build touches one entry instead of all of them.
Both protocols share the 1 s coalescing window and an immediate full
snapshot on subscribe.
"""
import json
deltas: bool = msg.get("deltas", False)
attached_entry_ids: set[str] = set()
unsub_callbacks: list[Callable[[], None]] = []
dirty: set[str] = set()
last_hash: dict[str, int] = {}
debounce_unsub: Callable[[], None] | None = None
def _build(entry: Any) -> dict[str, Any]:
rd = _get_runtime_data(hass, entry.entry_id)
coord_data = rd.coordinator.data if rd and rd.coordinator else None
return _build_object_response(hass, entry, coord_data)
def _hash(resp: dict[str, Any]) -> int:
return hash(json.dumps(resp, sort_keys=True, default=str))
@callback
def _forward_update() -> None:
"""Forward coordinator updates to the WebSocket."""
def _send_now(_now: Any = None) -> None:
"""Build and push once — full payload or suppressed per-entry delta."""
nonlocal debounce_unsub
debounce_unsub = None
entries = _get_object_entries(hass)
if not deltas:
connection.send_message(
websocket_api.event_message(msg["id"], {"objects": [_build(e) for e in entries]})
)
return
current_ids = {e.entry_id for e in entries}
removed = sorted(eid for eid in last_hash if eid not in current_ids)
for eid in removed:
last_hash.pop(eid, None)
changed: list[dict[str, Any]] = []
for entry in entries:
known = entry.entry_id in last_hash
if known and entry.entry_id not in dirty:
continue
resp = _build(entry)
h = _hash(resp)
if not known or last_hash[entry.entry_id] != h:
last_hash[entry.entry_id] = h
changed.append(resp)
dirty.clear()
if changed or removed:
connection.send_message(
websocket_api.event_message(msg["id"], {"delta": changed, "removed": removed})
)
@callback
def _send_snapshot() -> None:
"""The immediate full state a fresh subscriber renders from."""
entries = _get_object_entries(hass)
result = []
for entry in entries:
rd = _get_runtime_data(hass, entry.entry_id)
coord_data = rd.coordinator.data if rd and rd.coordinator else None
result.append(_build_object_response(hass, entry, coord_data))
resp = _build(entry)
if deltas:
last_hash[entry.entry_id] = _hash(resp)
result.append(resp)
connection.send_message(websocket_api.event_message(msg["id"], {"objects": result}))
@callback
def _forward_update(entry_id: str | None = None) -> None:
"""Coalesce coordinator updates into one push per second.
Every object entry runs its OWN coordinator on the same 5-minute
interval, and their timers cluster at boot — measured on a live
instance: a ~60-push wave of the FULL payload every 5 minutes,
72.7 MB in 5.5 idle minutes, each push a complete panel re-render
(the scroll jank users feel). One trailing send per window carries
the same final state; the payload is rebuilt at send time.
"""
nonlocal debounce_unsub
if entry_id is not None:
dirty.add(entry_id)
if debounce_unsub is None:
debounce_unsub = async_call_later(hass, 1.0, _send_now)
def _attach_entry(entry_id: str) -> None:
"""Attach a coordinator listener for a specific entry."""
if entry_id in attached_entry_ids:
return
rd = _get_runtime_data(hass, entry_id)
if rd and rd.coordinator:
unsub_callbacks.append(rd.coordinator.async_add_listener(_forward_update))
@callback
def _on_update(eid: str = entry_id) -> None:
_forward_update(eid)
unsub_callbacks.append(rd.coordinator.async_add_listener(_on_update))
attached_entry_ids.add(entry_id)
# Register listeners on all existing coordinators
@@ -326,20 +420,25 @@ async def ws_subscribe(
@callback
def _on_new_entry(entry_id: str) -> None:
_attach_entry(entry_id)
_forward_update()
_forward_update(entry_id)
unsub_callbacks.append(async_dispatcher_connect(hass, SIGNAL_NEW_OBJECT_ENTRY, _on_new_entry))
@callback
def _unsub() -> None:
nonlocal debounce_unsub
if debounce_unsub is not None:
debounce_unsub()
debounce_unsub = None
for unsub in unsub_callbacks:
unsub()
connection.subscriptions[msg["id"]] = _unsub
# Send initial data
# Send initial data IMMEDIATELY — the debounce is for update storms,
# not for the snapshot a fresh subscriber renders from.
connection.send_result(msg["id"])
_forward_update()
_send_snapshot()
@websocket_api.websocket_command(
@@ -85,9 +85,26 @@ def _validate_device_link(
if device_id := msg.get("ha_device_id"):
from homeassistant.helpers import device_registry as dr
if dr.async_get(hass).async_get(device_id) is None:
device = dr.async_get(hass).async_get(device_id)
if device is None:
connection.send_error(msg["id"], "invalid_device", f"No HA device {device_id!r}")
return False
# A device of our own domain is never a valid link target: it is this
# object's own maintenance device (or a sibling object's). The picker
# cannot exclude them, and ours carries the SAME NAME as the appliance
# — three prod objects spent months linked to their own doppelgänger
# before anyone noticed. For object hierarchy there is parent_entry_id.
from ..helpers.device_link import is_maintenance_device
if is_maintenance_device(hass, device):
connection.send_error(
msg["id"],
"self_link_device",
"That device belongs to Maintenance Supporter itself (the object's "
"maintenance twin, not the appliance) — pick the device owned by the "
"appliance's integration; it usually carries the same name",
)
return False
if parent_id := msg.get("parent_entry_id"):
parent = hass.config_entries.async_get_entry(parent_id)
@@ -8,6 +8,7 @@ production modules. ``__all__`` makes the re-exports explicit for mypy --strict.
"""
from .tasks_actions import (
ws_checklist_progress,
ws_complete_task,
ws_postpone_task,
ws_quick_complete_task,
@@ -27,6 +28,7 @@ from .tasks_lifecycle import (
_is_recurring_schedule,
ws_archive_task,
ws_list_tasks,
ws_task_history,
ws_unarchive_task,
)
from .tasks_persist import (
@@ -58,6 +60,7 @@ __all__ = [
"async_delete_task",
"async_persist_task",
"ws_archive_task",
"ws_checklist_progress",
"ws_complete_task",
"ws_create_task",
"ws_delete_task",
@@ -68,6 +71,7 @@ __all__ = [
"ws_reset_task",
"ws_skip_task",
"ws_snooze_task",
"ws_task_history",
"ws_unarchive_task",
"ws_update_history_entry",
"ws_update_task",
@@ -382,3 +382,46 @@ async def ws_snooze_task(
return
nm.snooze_task(msg["entry_id"], msg["task_id"])
connection.send_result(msg["id"], {"success": True})
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/checklist_progress",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
# Same shape/caps as task/complete's checklist_state.
vol.Required("checklist_state"): vol.All(
{vol.All(str, vol.Length(max=MAX_CHECKLIST_ITEM_LENGTH)): bool},
vol.Length(max=MAX_CHECKLIST_ITEMS),
),
}
)
@websocket_api.async_response
async def ws_checklist_progress(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""#73: persist in-cycle checklist ticks WITHOUT completing the task.
The dict REPLACES the stored progress (the client sends the full current
state — idempotent, no per-item race). Keys are the item TEXTS, so ticks
survive reordering and a renamed step drops its tick. Unknown keys (items
no longer on the checklist) are dropped on write; completing or skipping
the task clears the progress for the next cycle.
Deliberately the same tier as task/complete (plain authenticated): ticking
a step IS doing the work — the same household member who may complete the
task must be able to record partial progress.
"""
ctx = _load_task_context(hass, connection, msg)
if ctx is None:
return
rd, entry = ctx
task_items = set(entry.data[CONF_TASKS][msg["task_id"]].get("checklist") or [])
state = {item: bool(done) for item, done in msg["checklist_state"].items() if item in task_items}
rd.store.set_checklist_progress(msg["task_id"], state)
await rd.store.async_save()
# A user action must be visible immediately — never the 10 s debounce.
await rd.coordinator.async_refresh_now()
connection.send_result(msg["id"], {"success": True, "checklist_state": state})
@@ -150,9 +150,7 @@ async def ws_unarchive_task(
td.pop("archived_at", None)
td.pop("archived_reason", None)
if legacy_anchor:
reanchor_recurring_task(
task_id, store=None, today_iso=dt_util.now().date().isoformat(), task_data=td
)
reanchor_recurring_task(task_id, store=None, today_iso=dt_util.now().date().isoformat(), task_data=td)
elif recurring:
# The Store already holds the fresh anchor; scrub the static shadow too
# so an imported due_override can't out-rank it in merge_task_data.
@@ -207,3 +205,36 @@ def ws_list_tasks(
tasks.append(summary)
connection.send_result(msg["id"], {"tasks": tasks})
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/history",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
}
)
@callback
def ws_task_history(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""The FULL history of one task (read tier, like the list it backs).
Payload diet (perf, 2026-08): the list responses truncate ``history`` to
the most recent window — at 150+ tasks the full histories dominated the
``objects`` payload (906 KB measured at 40 entries/task, and the store
caps at 500). The detail view's timeline, filters and charts fetch the
complete record here, only when a task is actually opened.
"""
for entry in _get_object_entries(hass):
if entry.entry_id != msg["entry_id"]:
continue
task_data = _get_merged_tasks(entry).get(msg["task_id"])
if task_data is None:
break
history = task_data.get("history") or []
connection.send_result(msg["id"], {"history": history, "count": len(history)})
return
connection.send_error(msg["id"], "not_found", "Task not found")