This commit is contained in:
Home Assistant Version Control
2026-08-06 15:24:19 +00:00
parent 6aaf37b9bc
commit d5da256341
93 changed files with 5199 additions and 8790 deletions
@@ -261,8 +261,33 @@ def _build_task_summary(
}
def _build_object_response(hass: HomeAssistant, entry: ConfigEntry, coordinator_data: dict[str, Any] | None) -> dict[str, Any]:
"""Build a full object response dict."""
_EMPTY_LIST: list[Any] = []
_EMPTY_DICT: dict[str, Any] = {}
def _strip_empty(d: dict[str, Any]) -> dict[str, Any]:
"""Drop keys whose value is None, [] or {} (compact mode, perf wave 2 #3).
Measured on a 121-task instance: 52 % of the objects payload was keys
carrying one of these three empties. Only clients that OPT IN via
``compact: true`` get stripped responses — they hydrate the handful of
list/dict-typed keys back client-side (helpers/hydrate-objects.ts; the
two lists are pinned against each other by test_ws_compact_mode).
Scalars stay droppable without a table because absent and null read the
same through JS ``== null`` / ``||`` access. False, 0 and "" are kept —
they are meaningful values, not absences.
"""
return {k: v for k, v in d.items() if not (v is None or v in (_EMPTY_LIST, _EMPTY_DICT))}
def _build_object_response(
hass: HomeAssistant,
entry: ConfigEntry,
coordinator_data: dict[str, Any] | None,
*,
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, {})
@@ -311,7 +336,7 @@ def _build_object_response(hass: HomeAssistant, entry: ConfigEntry, coordinator_
}
)
return {
resp: dict[str, Any] = {
"entry_id": entry.entry_id,
"object": {
"id": obj_data.get("id", ""),
@@ -344,6 +369,12 @@ def _build_object_response(hass: HomeAssistant, entry: ConfigEntry, coordinator_
# 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"),
# Battery-fleet markers (field-completeness audit, #50 class):
# the fleet flag existed only task-level in the response while
# the OBJECT flag drives find_fleet_entry — consumers (and our
# own visual harness) had to detect the fleet via a task.
"battery_fleet": obj_data.get("battery_fleet", False),
"battery_fleet_excluded": obj_data.get("battery_fleet_excluded", []),
# (roadmap P2) count of attached documents (files + web-links) for
# the objects-table paperclip badge; computed, not persisted.
"document_count": document_count,
@@ -365,6 +396,11 @@ def _build_object_response(hass: HomeAssistant, entry: ConfigEntry, coordinator_
"tasks": tasks,
"parts": parts_payload,
}
if compact:
resp["object"] = _strip_empty(resp["object"])
resp["tasks"] = [_strip_empty(t) for t in tasks]
resp = _strip_empty(resp)
return resp
def _get_global_entry(hass: HomeAssistant) -> ConfigEntry | None:
@@ -292,6 +292,10 @@ async def ws_get_statistics(
vol.Required("type"): "maintenance_supporter/subscribe",
# 2.52 delta protocol opt-in — see the handler docstring.
vol.Optional("deltas", default=False): bool,
# Compact payloads (perf wave 2 #3): same opt-in + hydration contract
# as the `objects` read — empty keys stripped from every snapshot and
# delta this subscription ships.
vol.Optional("compact", default=False): bool,
}
)
@websocket_api.async_response
@@ -320,6 +324,7 @@ async def ws_subscribe(
import json
deltas: bool = msg.get("deltas", False)
compact: bool = msg.get("compact", False)
attached_entry_ids: set[str] = set()
unsub_callbacks: list[Callable[[], None]] = []
dirty: set[str] = set()
@@ -330,7 +335,7 @@ async def ws_subscribe(
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)
return _build_object_response(hass, entry, coord_data, compact=compact)
def _hash(resp: dict[str, Any]) -> int:
return hash(json.dumps(resp, sort_keys=True, default=str))
@@ -128,7 +128,16 @@ def _validate_device_link(
return True
@websocket_api.websocket_command({vol.Required("type"): "maintenance_supporter/objects"})
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/objects",
# Opt-in (perf wave 2 #3): strip keys whose value is None/[]/{} from
# the object + task summaries. Own panel/card pass this and hydrate
# the list/dict keys back; consumers that don't ask keep the full,
# every-field shape (#50 contract untouched).
vol.Optional("compact", default=False): bool,
}
)
@websocket_api.async_response
async def ws_get_objects(
hass: HomeAssistant,
@@ -136,12 +145,13 @@ async def ws_get_objects(
msg: dict[str, Any],
) -> None:
"""Return all maintenance objects with tasks and computed status."""
compact = bool(msg.get("compact", False))
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))
result.append(_build_object_response(hass, entry, coord_data, compact=compact))
connection.send_result(msg["id"], {"objects": result})