393 files

This commit is contained in:
Home Assistant Version Control
2026-09-27 17:16:54 +00:00
parent b05b7a897e
commit 39d97ac2db
404 changed files with 54006 additions and 11034 deletions
@@ -26,7 +26,6 @@ from ..const import (
DOMAIN,
MAX_DATE_LENGTH,
MAX_ENTITY_ID_LENGTH,
MAX_ID_LENGTH,
MAX_META_LENGTH,
MAX_NAME_LENGTH,
MAX_TEXT_LENGTH,
@@ -37,6 +36,7 @@ from ..helpers.pause import reanchor_recurring_task
from ..helpers.permissions import require_write
from ..helpers.sanitize import cap_object_fields, strip_object_reference, strip_task_runtime_state
from . import (
ID_FIELD,
_build_object_response,
_get_object_entries,
_load_object_entry,
@@ -65,11 +65,59 @@ _OBJECT_STR_FIELD_SCHEMA: dict[Any, Any] = {
vol.Optional("notes"): vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None),
# 2.19: attach the object to an EXISTING HA device (entities land on its
# device page) / nest under another maintenance object (via_device).
vol.Optional("ha_device_id"): vol.Any(vol.All(str, vol.Length(max=MAX_ID_LENGTH)), None),
vol.Optional("parent_entry_id"): vol.Any(vol.All(str, vol.Length(max=MAX_ID_LENGTH)), None),
vol.Optional("ha_device_id"): vol.Any(ID_FIELD, None),
vol.Optional("parent_entry_id"): vol.Any(ID_FIELD, None),
}
def _carry_parts_shelf(src_parts: Any, *, keep_doc_links: bool) -> tuple[dict[str, str], dict[str, Any]]:
"""Copy an object's spare-part definitions with FRESH ids.
Returns ``(old→new id map, new parts)``. Shared by object/replace and
object/duplicate so a copy never shares part ids with its source.
``keep_doc_links=False`` drops a part's ``doc_id``: a duplicate does not
copy the documents, and a manual of ANOTHER object would dangle once that
object is gone.
"""
part_id_map: dict[str, str] = {}
new_parts: dict[str, Any] = {}
for src_part in (src_parts or {}).values():
if not isinstance(src_part, dict):
continue
carried = dict(src_part)
new_pid = uuid4().hex
part_id_map[str(carried.get("id"))] = new_pid
carried["id"] = new_pid
if not keep_doc_links:
carried.pop("doc_id", None)
new_parts[new_pid] = carried
return part_id_map, new_parts
def _remap_own_part_links(task: dict[str, Any], part_id_map: dict[str, str], own_entry_id: str) -> dict[str, Any]:
"""Point a copied task's links to its OWN object's parts at the fresh ids.
Task-level AND phase-level links (helpers.parts.map_part_links — replace
walked the task level only, and a phase kept consuming the retired
predecessor's part; bug audit 2026-09-27). A link into ANOTHER object's
pool (#111) is carried verbatim; an own link to a part the shelf does not
carry is dropped rather than left pointing at nothing.
"""
from ..helpers.parts import map_part_links
def _rewrite(link: dict[str, Any]) -> dict[str, Any] | None:
owner = str(link.get("entry_id") or "")
if owner and owner != own_entry_id:
return link
part_id = str(link.get("part_id") or "")
if part_id not in part_id_map:
return None
return {"part_id": part_id_map[part_id], "quantity": link.get("quantity", 1)}
remapped, _changed = map_part_links(task, _rewrite)
return remapped
def _validate_object_dates(connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> bool:
"""False (after sending ``invalid_date``) when a present installation_date
/ warranty_expiry (#67) is not ``YYYY-MM-DD`` — shared by create and update."""
@@ -167,7 +215,7 @@ async def ws_get_objects(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
}
)
@websocket_api.async_response
@@ -206,8 +254,20 @@ async def async_create_object(
``add_object`` service (DRY). Inputs are normalized here; callers do their
own validation/error reporting (the WS layer keeps its specific error
codes). Raises ValueError if the config flow does not create an entry.
The two dates are checked here too: the WS layer refuses a malformed one
up front, but the ``add_object`` service stored any string — "next
spring" as an installation date broke every age / warranty computation
that parses it (bug audit 2026-09-27). Raises ValueError.
"""
data = {
from ..helpers.dates import parse_iso_date
for field, value in (("installation_date", installation_date), ("warranty_expiry", warranty_expiry)):
if value and (not isinstance(value, str) or parse_iso_date(value) is None):
raise ValueError(f"{field} must be a valid date (YYYY-MM-DD), got {value!r}")
installation_date = installation_date or None
warranty_expiry = warranty_expiry or None
data: dict[str, Any] = {
CONF_OBJECT: {
"id": uuid4().hex,
CONF_OBJECT_NAME: name.strip(),
@@ -305,7 +365,7 @@ async def ws_create_object(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object/update",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
vol.Optional("name"): vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
**_OBJECT_STR_FIELD_SCHEMA,
}
@@ -328,6 +388,17 @@ async def ws_update_object(
if not msg["name"]:
connection.send_error(msg["id"], "invalid_input", "Name must not be empty")
return
# The same name rule as every create path (helpers.object_names): a
# rename onto another object's name went through, and the next
# create of that name — or a replace under it — failed confusingly
# (bug audit 2026-09-27). Only a CHANGED name is checked: the dialog
# re-sends the name on every save, and the successor of a replace
# legitimately shares its archived predecessor's name.
from ..helpers.object_names import name_changed_onto_taken
if name_changed_onto_taken(hass, entry, msg["name"]):
connection.send_error(msg["id"], "invalid_input", "Another object already has this name")
return
# Strip manufacturer/model/serial_number
if msg.get("manufacturer"):
@@ -409,7 +480,7 @@ async def ws_update_object(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object/delete",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
}
)
@require_write
@@ -432,7 +503,7 @@ async def ws_delete_object(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object/duplicate",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
}
)
@require_write
@@ -457,6 +528,14 @@ async def ws_duplicate_object(
return
src_obj = entry.data.get(CONF_OBJECT, {})
# There is ONE battery fleet: a copy carried the fleet flags on the object
# and its task and ran a second fleet — double triggers and notifications
# (bug audit 2026-09-27; task/duplicate refuses the fleet task alike).
from ..const import BATTERY_FLEET_OBJECT_FLAG
if src_obj.get(BATTERY_FLEET_OBJECT_FLAG):
connection.send_error(msg["id"], "invalid_input", "The battery fleet object cannot be duplicated")
return
new_obj = deepcopy(dict(src_obj))
new_obj["id"] = uuid4().hex
base_name = str(src_obj.get(CONF_OBJECT_NAME, "")).strip() or "Object"
@@ -467,20 +546,33 @@ async def ws_duplicate_object(
new_obj.pop("archived_at", None)
strip_object_reference(new_obj)
# The parts shelf travels with fresh ids (like replace) and the task
# links follow them. The copy used to carry the tasks' part links but no
# parts: every completion of the copy raised a broken-part-link repair
# (bug audit 2026-09-27). Stock does not travel — the copy is another
# unit with its own shelf, untracked until counted.
part_id_map, new_parts = _carry_parts_shelf(entry.data.get("parts"), keep_doc_links=False)
from ..helpers.parts import PART_REF_FIELD
new_tasks: dict[str, Any] = {}
for src_task in entry.data.get(CONF_TASKS, {}).values():
# Auto "buy" reminders belong to the reconciler of the SOURCE's parts.
if src_task.get(PART_REF_FIELD):
continue
task = deepcopy(dict(src_task))
task_id = uuid4().hex
task["id"] = task_id
task["object_id"] = new_obj["id"]
strip_task_runtime_state(task)
task = _remap_own_part_links(task, part_id_map, entry.entry_id)
new_tasks[task_id] = task
new_obj["task_ids"].append(task_id)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": "websocket"},
data={CONF_OBJECT: new_obj, CONF_TASKS: new_tasks},
data={CONF_OBJECT: new_obj, CONF_TASKS: new_tasks, "parts": new_parts},
)
if result["type"] != "create_entry":
connection.send_error(msg["id"], "duplicate_failed", result.get("reason", "unknown"))
@@ -491,7 +583,7 @@ async def ws_duplicate_object(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object/from_template",
vol.Required("template_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("template_id"): ID_FIELD,
vol.Optional("name"): vol.Any(vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)), None),
# v2.21.1: the caller's UI language — created object/task names are
# localized (falls back to the server language).
@@ -514,7 +606,8 @@ async def ws_create_from_template(
from uuid import uuid4
from ..helpers.i18n import normalize_language, normalize_language_code
from ..templates import get_template_by_id, localize_template_text
from ..helpers.template_usage import OBJECT_TEMPLATE_ID
from ..templates import async_build_template_tasks, get_template_by_id, localize_template_text
template = get_template_by_id(msg["template_id"])
if template is None:
@@ -526,17 +619,15 @@ async def ws_create_from_template(
name = (msg.get("name") or default_name).strip() or default_name
# Auto-number on collision: applying the same template twice (or owning
# three litter boxes) must not fail with already_configured — the second
# object becomes "Name 2", then "Name 3", … The check mirrors the config
# flow's duplicate detection (object name, case-insensitive).
existing_names = {
str(e.data.get(CONF_OBJECT, {}).get(CONF_OBJECT_NAME, "")).strip().lower()
for e in _get_object_entries(hass)
}
if name.strip().lower() in existing_names:
# object becomes "Name 2", then "Name 3", … The check is the config
# flow's own (helpers.object_names).
from ..helpers.object_names import name_taken
if name_taken(hass, name):
base = name[: MAX_NAME_LENGTH - 4]
for n in range(2, 100):
candidate = f"{base} {n}"
if candidate.strip().lower() not in existing_names:
if not name_taken(hass, candidate):
name = candidate
break
object_id = uuid4().hex
@@ -544,25 +635,12 @@ async def ws_create_from_template(
"id": object_id,
CONF_OBJECT_NAME: name[:MAX_NAME_LENGTH],
"task_ids": [],
# 2.94: the gallery marks templates in use as "already set up".
OBJECT_TEMPLATE_ID: template.id,
}
new_tasks: dict[str, Any] = {}
for tt in template.tasks:
task_id = uuid4().hex
task: dict[str, Any] = {
"id": task_id,
"object_id": object_id,
"name": localize_template_text(tt.name, lang),
"type": tt.type,
"enabled": True,
"schedule_type": tt.schedule_type,
"warning_days": tt.warning_days,
}
if tt.interval_days is not None:
task["interval_days"] = tt.interval_days
if tt.notes:
task["notes"] = localize_template_text(tt.notes, lang)
new_tasks[task_id] = task
new_obj["task_ids"].append(task_id)
# Seasons follow the home's hemisphere and climate (helpers/climate.py).
new_tasks = await async_build_template_tasks(hass, template, lang, object_id)
new_obj["task_ids"] = list(new_tasks)
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -578,7 +656,7 @@ async def ws_create_from_template(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object/archive",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
}
)
@require_write
@@ -604,7 +682,7 @@ async def ws_archive_object(
return
now_iso = dt_util.now().isoformat()
new_data = _archived_entry_data(entry.data, now_iso)
new_data = _archived_entry_data(entry.data, now_iso, keep_task_ids=_live_pool_buy_tasks(hass, entry))
hass.config_entries.async_update_entry(entry, data=new_data)
# Reload so the object's tasks' triggers tear down and entities go inert.
@@ -613,11 +691,34 @@ async def ws_archive_object(
connection.send_result(msg["id"], {"success": True, "archived_at": now_iso})
def _archived_entry_data(entry_data: Any, now_iso: str) -> dict[str, Any]:
def _live_pool_buy_tasks(hass: HomeAssistant, entry: Any) -> set[str]:
"""The object's buy reminders for parts OTHER live objects still borrow.
Archiving the owner of a shared pool (#111) retires the owner, not the
shelf — the borrowers keep consuming it, so its "Buy …" reminder must
stay live (bug audit 2026-09-27; the buy-task reconcile keeps these
parts, parts_runtime).
"""
from ..helpers.parts import PART_REF_FIELD
from ..helpers.shared_parts import borrowed_part_ids
in_use = borrowed_part_ids(hass, entry.entry_id, active_only=True)
if not in_use:
return set()
keep: set[str] = set()
for tid, td in (entry.data.get(CONF_TASKS) or {}).items():
ref = td.get(PART_REF_FIELD)
if isinstance(ref, dict) and str(ref.get("part_id") or "") in in_use:
keep.add(tid)
return keep
def _archived_entry_data(entry_data: Any, now_iso: str, *, keep_task_ids: set[str] | None = None) -> dict[str, Any]:
"""New entry data with the object archived and active tasks cascaded.
Shared by ``object/archive`` and the replace flow (which retires the
predecessor with exactly the same semantics).
predecessor with exactly the same semantics). ``keep_task_ids`` stay
active (a shared pool's buy reminders, :func:`_live_pool_buy_tasks`).
"""
obj = dict(entry_data.get(CONF_OBJECT, {}))
obj["archived_at"] = now_iso
@@ -628,7 +729,7 @@ def _archived_entry_data(entry_data: Any, now_iso: str) -> dict[str, Any]:
new_tasks: dict[str, Any] = {}
for tid, td in dict(entry_data.get(CONF_TASKS, {})).items():
td = dict(td)
if td.get("archived_at") is None: # cascade only to active tasks
if td.get("archived_at") is None and tid not in (keep_task_ids or ()): # cascade only to active tasks
td["archived_at"] = now_iso
td["archived_reason"] = ARCHIVE_REASON_OBJECT
new_tasks[tid] = td
@@ -642,7 +743,7 @@ def _archived_entry_data(entry_data: Any, now_iso: str) -> dict[str, Any]:
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object/unarchive",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
}
)
@require_write
@@ -699,7 +800,7 @@ async def ws_unarchive_object(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object/pause",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
vol.Optional("until"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
}
)
@@ -758,7 +859,7 @@ async def ws_pause_object(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object/resume",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
}
)
@require_write
@@ -798,7 +899,7 @@ async def ws_resume_object(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object/replace",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
vol.Optional("name"): vol.Any(vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)), None),
}
)
@@ -848,14 +949,8 @@ async def ws_replace_object(
# Carry the parts shelf — the spares don't change when the machine dies.
# Fresh ids (like tasks); consumption links are remapped below and the
# tracked stock is copied into the successor's store after creation.
part_id_map: dict[str, str] = {}
new_parts: dict[str, Any] = {}
for src_part in (entry.data.get("parts") or {}).values():
carried = dict(src_part)
new_pid = uuid4().hex
part_id_map[str(carried.get("id"))] = new_pid
carried["id"] = new_pid
new_parts[new_pid] = carried
# Part manuals travel with the documents (part_id_map below).
part_id_map, new_parts = _carry_parts_shelf(entry.data.get("parts"), keep_doc_links=True)
new_tasks: dict[str, Any] = {}
for src_task in entry.data.get(CONF_TASKS, {}).values():
@@ -868,25 +963,11 @@ async def ws_replace_object(
task["id"] = task_id
task["object_id"] = new_obj["id"]
strip_task_runtime_state(task)
links = task.get("consumes_parts")
if isinstance(links, list):
remapped = []
for link in links:
if not isinstance(link, dict):
continue
if link.get("entry_id"):
# A pool owned by ANOTHER object (#111) is untouched by
# replacing this one — carry the link across verbatim, ids
# and all, or the successor silently stops consuming it.
remapped.append(dict(link))
elif link.get("part_id") in part_id_map:
remapped.append(
{"part_id": part_id_map[link["part_id"]], "quantity": link.get("quantity", 1)}
)
if remapped:
task["consumes_parts"] = remapped
else:
task.pop("consumes_parts", None)
# Own-shelf links (task level AND per phase) follow the fresh part
# ids; a pool owned by ANOTHER object (#111) is untouched by
# replacing this one — carried verbatim, or the successor silently
# stops consuming it.
task = _remap_own_part_links(task, part_id_map, entry.entry_id)
new_tasks[task_id] = task
new_obj["task_ids"].append(task_id)
@@ -926,6 +1007,13 @@ async def ws_replace_object(
if stock is not None:
new_store.set_part_stock(new_pid, stock)
await new_store.async_save()
# Objects BORROWING the predecessor's pool (#111) now draw on the
# successor's shelf. They kept linking to the archived predecessor —
# whose buy tasks the archive suppresses — so the pool silently split
# in two (bug audit 2026-09-27).
from ..helpers.shared_parts import relink_borrowers
relink_borrowers(hass, entry.entry_id, new_entry_id, part_id_map)
if new_entry is not None:
from ..parts_runtime import schedule_buy_task_reconcile