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