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