108 files

This commit is contained in:
Home Assistant Version Control
2026-08-13 23:38:20 +00:00
parent d851c2a3a5
commit 4e0539a155
108 changed files with 1977 additions and 9644 deletions
@@ -580,6 +580,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
from .parts import (
ws_create_part,
ws_delete_part,
ws_parts_overview,
ws_restock_part,
ws_update_part,
)
@@ -651,6 +652,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_create_part)
websocket_api.async_register_command(hass, ws_update_part)
websocket_api.async_register_command(hass, ws_delete_part)
websocket_api.async_register_command(hass, ws_parts_overview)
websocket_api.async_register_command(hass, ws_restock_part)
websocket_api.async_register_command(hass, ws_update_history_entry)
websocket_api.async_register_command(hass, ws_get_templates)
@@ -536,6 +536,24 @@ async def ws_import_json(
if val is not None:
task_data[key] = val
# #130: history entries carry used_parts, and since they are
# editable (stock reconciled by delta), the part ids must follow
# the regenerated ones. Own-part ids remap via part_id_map; links
# into another object's pool (entry_id set) are kept verbatim —
# if that entry doesn't exist in this instance they degrade to
# the safe recorded-only path, name preserved.
for hist_entry in task_data.get("history") or []:
used = hist_entry.get("used_parts")
if not isinstance(used, list):
continue
for link in used:
if (
isinstance(link, dict)
and not link.get("entry_id")
and link.get("part_id") in part_id_map
):
link["part_id"] = part_id_map[link["part_id"]]
# Remap part links to the regenerated part ids; drop dangling ones.
links = task_data.get("consumes_parts")
if isinstance(links, list):
@@ -251,3 +251,66 @@ async def ws_restock_part(
connection.send_error(msg["id"], "not_found", "Part not found")
return
connection.send_result(msg["id"], {"stock": new})
@websocket_api.websocket_command({vol.Required("type"): "maintenance_supporter/parts/overview"})
@websocket_api.async_response
async def ws_parts_overview(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Instance-wide parts inventory (#130).
Every part across all objects with its owner, live stock, low state and
every consuming task — the object's own tasks and pooled #111 links from
other objects. Read-only; the per-object CRUD stays on part/*.
"""
from ..const import CONF_OBJECT, DOMAIN, GLOBAL_UNIQUE_ID
from ..helpers.parts import part_is_low
from . import _get_merged_tasks
entries = [e for e in hass.config_entries.async_entries(DOMAIN) if e.unique_id != GLOBAL_UNIQUE_ID]
names = {e.entry_id: (e.data.get(CONF_OBJECT) or {}).get("name") or e.title for e in entries}
# (owner_entry_id, part_id) -> consuming task links, own AND pooled.
consumers: dict[tuple[str, str], list[dict[str, Any]]] = {}
for e in entries:
for task_id, task in _get_merged_tasks(e).items():
for link in task.get("consumes_parts") or []:
if not isinstance(link, dict) or not link.get("part_id"):
continue
owner = link.get("entry_id") or e.entry_id
consumers.setdefault((owner, link["part_id"]), []).append(
{
"entry_id": e.entry_id,
"object_name": names.get(e.entry_id),
"task_id": task_id,
"task_name": task.get("name"),
"quantity": link.get("quantity", 1),
"pooled": owner != e.entry_id,
}
)
rows: list[dict[str, Any]] = []
for e in entries:
parts = _parts_of(e)
if not parts:
continue
rd = _get_runtime_data(hass, e.entry_id)
store = getattr(rd, "store", None) if rd else None
for part_id, part in parts.items():
stock = store.get_part_stock(part_id) if store is not None else None
rows.append(
{
**part,
"part_id": part_id,
"entry_id": e.entry_id,
"object_name": names.get(e.entry_id),
"stock": stock,
"low": part_is_low(part, stock),
"consumers": consumers.get((e.entry_id, part_id), []),
}
)
rows.sort(key=lambda r: ((r.get("name") or "").casefold(), r.get("object_name") or ""))
connection.send_result(msg["id"], {"parts": rows, "count": len(rows)})
@@ -30,9 +30,10 @@ from . import (
# browser between read and write — timestamp is more stable. If multiple
# entries share a timestamp (rare), the first match is patched.
#
# Patchable fields: timestamp, notes, cost, duration, completed_by. Anything
# else (type, trigger_value, checklist_state, feedback) is intentionally
# read-only — those carry semantic meaning that shouldn't be silently rewritten.
# Patchable fields: timestamp, notes, cost, duration, completed_by and — since
# #130 — used_parts (stock reconciled by the per-part delta). Anything else
# (type, trigger_value, checklist_state, feedback) is intentionally read-only —
# those carry semantic meaning that shouldn't be silently rewritten.
#
# After the patch we recompute last_performed if the edited entry is the
# latest type=completed/reset/skipped entry — otherwise the next_due math
@@ -52,6 +53,19 @@ from . import (
vol.Optional("cost"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0, max=MAX_COST)), None),
vol.Optional("duration"): vol.Any(vol.All(vol.Coerce(int), vol.Range(min=0, max=MAX_DURATION_MINUTES)), None),
vol.Optional("completed_by"): vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None),
# #130: edit the entry's part consumption. The stock is reconciled by
# the per-part DELTA against the entry's previous used_parts; None (or
# []) clears the consumption and returns the old quantities to stock.
vol.Optional("used_parts"): vol.Any(
[
{
vol.Required("part_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Optional("quantity"): vol.All(vol.Coerce(float), vol.Range(min=0.01, max=999)),
vol.Optional("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
}
],
None,
),
}
)
@require_write
@@ -121,6 +135,23 @@ async def ws_update_history_entry(
else:
patched[field] = value
# #130: part consumption on the entry. The stock is adjusted by the
# per-part delta between the stored and the submitted selection, so
# corrections and backfills keep the shelf honest. Best-effort like the
# live completion path — a vanished part skips its stock math.
if "used_parts" in msg:
from ..parts_runtime import async_apply_history_parts_edit
from . import _get_merged_tasks
old_used = patched.get("used_parts") or []
new_used = msg["used_parts"] or []
task_data = _get_merged_tasks(entry).get(task_id) or {}
enriched = await async_apply_history_parts_edit(hass, entry, task_data, old_used, new_used)
if enriched:
patched["used_parts"] = enriched
else:
patched.pop("used_parts", None)
history[target_index] = patched
store.set_history(task_id, history)