Files
HomeAssistantVS/custom_components/maintenance_supporter/websocket/objects.py
T
2026-07-08 10:43:39 -04:00

930 lines
34 KiB
Python

"""WebSocket handlers for object CRUD operations."""
from __future__ import annotations
from typing import Any
from uuid import uuid4
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.util import dt as dt_util
from ..const import (
ARCHIVE_REASON_OBJECT,
CONF_OBJECT,
CONF_OBJECT_AREA,
CONF_OBJECT_DOCUMENTATION_URL,
CONF_OBJECT_INSTALLATION_DATE,
CONF_OBJECT_MANUFACTURER,
CONF_OBJECT_MODEL,
CONF_OBJECT_NAME,
CONF_OBJECT_NOTES,
CONF_OBJECT_SERIAL_NUMBER,
CONF_OBJECT_WARRANTY_EXPIRY,
CONF_TASKS,
DOMAIN,
GLOBAL_UNIQUE_ID,
MAX_DATE_LENGTH,
MAX_ENTITY_ID_LENGTH,
MAX_ID_LENGTH,
MAX_META_LENGTH,
MAX_NAME_LENGTH,
MAX_TEXT_LENGTH,
MAX_URL_LENGTH,
)
from ..helpers.permissions import require_write
from ..helpers.sanitize import cap_object_fields
from . import (
_build_object_response,
_get_object_entries,
_get_runtime_data,
_load_object_entry,
cleanup_group_refs,
)
from .tasks import ( # v1.4.0 (#43): reuse the existing URL safety check
_is_recurring_schedule,
_is_safe_url,
)
# The optional object string fields are identical between object/create and
# object/update — define them once so the two schemas can't drift. The caps
# mirror helpers.sanitize._OBJECT_STR_LIMITS (a tripwire test enforces parity),
# which is also applied on persist via cap_object_fields as a safety net.
_OBJECT_STR_FIELD_SCHEMA: dict[Any, Any] = {
vol.Optional("area_id"): vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None),
vol.Optional("manufacturer"): vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None),
vol.Optional("model"): vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None),
vol.Optional("serial_number"): vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None),
vol.Optional("installation_date"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
vol.Optional("warranty_expiry"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None), # (#67)
# v1.4.0 (#43): per-object link to PDF manual / vendor page
vol.Optional("documentation_url"): vol.Any(vol.All(str, vol.Length(max=MAX_URL_LENGTH)), None),
# v1.4.10 (#46): free-form notes (part numbers, procedures, etc.)
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),
}
def _validate_device_link(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
*,
self_entry_id: str | None,
) -> bool:
"""Validate ha_device_id / parent_entry_id; sends the WS error itself.
The parent chain is walked upwards so an A->B->A cycle (which would make
the via_device hierarchy unresolvable) is rejected at write time.
"""
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:
connection.send_error(msg["id"], "invalid_device", f"No HA device {device_id!r}")
return False
if parent_id := msg.get("parent_entry_id"):
parent = hass.config_entries.async_get_entry(parent_id)
if parent is None or parent.domain != DOMAIN or parent.unique_id == GLOBAL_UNIQUE_ID:
connection.send_error(msg["id"], "invalid_parent", f"No maintenance object {parent_id!r}")
return False
if self_entry_id is not None:
cursor: str | None = parent_id
for _ in range(20):
if cursor == self_entry_id:
connection.send_error(
msg["id"],
"invalid_parent",
"Parent chain would form a cycle",
)
return False
cur = hass.config_entries.async_get_entry(cursor) if cursor else None
cursor = (cur.data.get(CONF_OBJECT, {}) or {}).get("parent_entry_id") if cur else None
if not cursor:
break
return True
@websocket_api.websocket_command({vol.Required("type"): "maintenance_supporter/objects"})
@websocket_api.async_response
async def ws_get_objects(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return all maintenance objects with tasks and computed status."""
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))
connection.send_result(msg["id"], {"objects": result})
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
}
)
@websocket_api.async_response
async def ws_get_object(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return a single object with full task details including history."""
entry_id = msg["entry_id"]
entry = hass.config_entries.async_get_entry(entry_id)
if entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_ID:
connection.send_error(msg["id"], "not_found", "Object not found")
return
rd = _get_runtime_data(hass, entry_id)
coord_data = rd.coordinator.data if rd and rd.coordinator else None
connection.send_result(msg["id"], _build_object_response(hass, entry, coord_data))
async def async_create_object(
hass: HomeAssistant,
*,
name: str,
area_id: str | None = None,
manufacturer: str | None = None,
model: str | None = None,
serial_number: str | None = None,
installation_date: str | None = None,
warranty_expiry: str | None = None,
documentation_url: str | None = None,
notes: str | None = None,
ha_device_id: str | None = None,
parent_entry_id: str | None = None,
) -> str:
"""Create a maintenance object (config entry) and return its entry_id.
Shared creation primitive for the ``object/create`` WS command and the
``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.
"""
data = {
CONF_OBJECT: {
"id": uuid4().hex,
CONF_OBJECT_NAME: name.strip(),
CONF_OBJECT_AREA: area_id,
CONF_OBJECT_MANUFACTURER: (manufacturer or "").strip() or None,
CONF_OBJECT_MODEL: (model or "").strip() or None,
CONF_OBJECT_SERIAL_NUMBER: (serial_number or "").strip() or None,
CONF_OBJECT_INSTALLATION_DATE: installation_date,
CONF_OBJECT_WARRANTY_EXPIRY: warranty_expiry,
CONF_OBJECT_DOCUMENTATION_URL: (documentation_url or "").strip() or None,
CONF_OBJECT_NOTES: (notes.strip() if isinstance(notes, str) and notes.strip() else None),
"ha_device_id": ha_device_id,
"parent_entry_id": parent_entry_id,
"task_ids": [],
},
CONF_TASKS: {},
}
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "websocket"}, data=data)
if result["type"] != "create_entry":
raise ValueError(f"Failed to create object: {result.get('reason', 'unknown')}")
return result["result"].entry_id
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object/create",
vol.Required("name"): vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
**_OBJECT_STR_FIELD_SCHEMA,
vol.Optional("dry_run", default=False): bool,
}
)
@require_write
@websocket_api.async_response
async def ws_create_object(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Create a new maintenance object via config flow."""
name = msg["name"].strip()
if not name:
connection.send_error(msg["id"], "invalid_input", "Name must not be empty")
return
manufacturer = (msg.get("manufacturer") or "").strip() or None
model = (msg.get("model") or "").strip() or None
serial_number = (msg.get("serial_number") or "").strip() or None
# Validate installation_date format if provided
installation_date = msg.get("installation_date")
if installation_date:
from datetime import date as date_cls
try:
date_cls.fromisoformat(installation_date)
except ValueError:
connection.send_error(msg["id"], "invalid_date", "Invalid installation_date format (expected YYYY-MM-DD)")
return
# (#67): validate warranty_expiry format if provided
warranty_expiry = msg.get("warranty_expiry")
if warranty_expiry:
from datetime import date as date_cls
try:
date_cls.fromisoformat(warranty_expiry)
except ValueError:
connection.send_error(msg["id"], "invalid_date", "Invalid warranty_expiry format (expected YYYY-MM-DD)")
return
# v1.4.0 (#43): documentation_url
documentation_url = (msg.get("documentation_url") or "").strip() or None
if documentation_url and not _is_safe_url(documentation_url):
connection.send_error(msg["id"], "invalid_url", "Only http/https URLs are allowed")
return
# v1.4.10 (#46): notes (free-form, may contain newlines)
notes_raw = msg.get("notes")
notes = notes_raw.strip() if isinstance(notes_raw, str) and notes_raw.strip() else None
# 2.19: device link / parent hierarchy
if not _validate_device_link(hass, connection, msg, self_entry_id=None):
return
# Dry-run mode: validate only, do not persist
if msg.get("dry_run"):
connection.send_result(msg["id"], {"valid": True, "entry_id": None})
return
try:
entry_id = await async_create_object(
hass,
name=name,
area_id=msg.get("area_id"),
manufacturer=manufacturer,
model=model,
serial_number=serial_number,
installation_date=installation_date,
warranty_expiry=warranty_expiry,
documentation_url=documentation_url,
notes=notes,
ha_device_id=msg.get("ha_device_id"),
parent_entry_id=msg.get("parent_entry_id"),
)
except ValueError as err:
connection.send_error(msg["id"], "create_failed", str(err))
return
connection.send_result(msg["id"], {"entry_id": entry_id})
@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.Optional("name"): vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
**_OBJECT_STR_FIELD_SCHEMA,
}
)
@require_write
@websocket_api.async_response
async def ws_update_object(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Update an existing maintenance object."""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
# Strip and validate name if provided
if "name" in msg:
msg["name"] = msg["name"].strip()
if not msg["name"]:
connection.send_error(msg["id"], "invalid_input", "Name must not be empty")
return
# Strip manufacturer/model/serial_number
if msg.get("manufacturer"):
msg["manufacturer"] = msg["manufacturer"].strip() or None
if msg.get("model"):
msg["model"] = msg["model"].strip() or None
if msg.get("serial_number"):
msg["serial_number"] = msg["serial_number"].strip() or None
# Validate installation_date format if provided
if msg.get("installation_date"):
from datetime import date as date_cls
try:
date_cls.fromisoformat(msg["installation_date"])
except ValueError:
connection.send_error(msg["id"], "invalid_date", "Invalid installation_date format (expected YYYY-MM-DD)")
return
# (#67): validate warranty_expiry format if provided
if msg.get("warranty_expiry"):
from datetime import date as date_cls
try:
date_cls.fromisoformat(msg["warranty_expiry"])
except ValueError:
connection.send_error(msg["id"], "invalid_date", "Invalid warranty_expiry format (expected YYYY-MM-DD)")
return
# v1.4.0 (#43): documentation_url
if "documentation_url" in msg:
if msg["documentation_url"] is not None:
stripped = (msg["documentation_url"] or "").strip()
msg["documentation_url"] = stripped or None
if msg["documentation_url"] and not _is_safe_url(msg["documentation_url"]):
connection.send_error(msg["id"], "invalid_url", "Only http/https URLs are allowed")
return
# v1.4.10 (#46): notes — strip but keep newlines, empty -> None
if "notes" in msg:
if msg["notes"] is not None:
stripped = msg["notes"].strip()
msg["notes"] = stripped or None
# 2.19: device link / parent hierarchy
if not _validate_device_link(hass, connection, msg, self_entry_id=entry.entry_id):
return
new_data = dict(entry.data)
obj = dict(new_data.get(CONF_OBJECT, {}))
if "name" in msg:
# Per-task unique_ids embed the object's name slug — migrate the
# entity registry on rename or the next reload orphans every entity.
from ..helpers.entity_rename import migrate_object_unique_ids
migrate_object_unique_ids(hass, entry, obj.get(CONF_OBJECT_NAME), msg["name"])
obj[CONF_OBJECT_NAME] = msg["name"]
if "area_id" in msg:
obj[CONF_OBJECT_AREA] = msg["area_id"]
if "manufacturer" in msg:
obj[CONF_OBJECT_MANUFACTURER] = msg["manufacturer"]
if "model" in msg:
obj[CONF_OBJECT_MODEL] = msg["model"]
if "serial_number" in msg:
obj[CONF_OBJECT_SERIAL_NUMBER] = msg["serial_number"]
if "installation_date" in msg:
obj[CONF_OBJECT_INSTALLATION_DATE] = msg["installation_date"]
if "warranty_expiry" in msg:
obj[CONF_OBJECT_WARRANTY_EXPIRY] = msg["warranty_expiry"]
if "documentation_url" in msg:
obj[CONF_OBJECT_DOCUMENTATION_URL] = msg["documentation_url"]
if "notes" in msg:
obj[CONF_OBJECT_NOTES] = msg["notes"]
# 2.19: entity->device attachment only changes on entity re-add, so a
# changed link/parent needs an entry reload (scheduled below).
device_link_changed = False
for key in ("ha_device_id", "parent_entry_id"):
if key in msg and msg[key] != obj.get(key):
obj[key] = msg[key]
device_link_changed = True
# Safety net: cap user strings on persist so this write path matches the
# create path (which caps via the config flow) and tasks (cap_task_fields).
cap_object_fields(obj)
new_data[CONF_OBJECT] = obj
title = obj.get(CONF_OBJECT_NAME, entry.title)
hass.config_entries.async_update_entry(entry, data=new_data, title=title)
if device_link_changed:
hass.config_entries.async_schedule_reload(entry.entry_id)
connection.send_result(msg["id"], {"success": True})
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object/delete",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
}
)
@require_write
@websocket_api.async_response
async def ws_delete_object(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Delete a maintenance object and all its tasks."""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
await hass.config_entries.async_remove(entry.entry_id)
cleanup_group_refs(hass, entry_id=entry.entry_id)
connection.send_result(msg["id"], {"success": True})
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object/duplicate",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
}
)
@require_write
@websocket_api.async_response
async def ws_duplicate_object(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Clone an object and all its tasks as a fresh, un-started copy.
A new config entry named "… (copy)" carries over the object's details and
every task's configuration, but nothing device- or history-specific: the
serial number is dropped, and each task starts clean (no history /
last_performed, its own new id, and no unique entity_slug / NFC tag). Ideal
for fleets of near-identical assets (hotel rooms, identical pumps).
"""
from copy import deepcopy
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
src_obj = entry.data.get(CONF_OBJECT, {})
new_obj = deepcopy(dict(src_obj))
new_obj["id"] = uuid4().hex
base_name = str(src_obj.get(CONF_OBJECT_NAME, "")).strip() or "Object"
new_obj[CONF_OBJECT_NAME] = f"{base_name} (copy)"[:MAX_NAME_LENGTH]
# Serial number identifies one physical unit — never duplicate it.
new_obj[CONF_OBJECT_SERIAL_NUMBER] = None
new_obj["task_ids"] = []
new_obj.pop("archived_at", None)
new_tasks: dict[str, Any] = {}
for src_task in entry.data.get(CONF_TASKS, {}).values():
task = deepcopy(dict(src_task))
task_id = uuid4().hex
task["id"] = task_id
task["object_id"] = new_obj["id"]
for key in (
"entity_slug",
"nfc_tag_id",
"history",
"last_performed",
"last_planned_due",
"adaptive_config",
"archived_at",
"archived_reason",
):
task.pop(key, None)
if isinstance(task.get("trigger_config"), dict):
task["trigger_config"].pop("_trigger_state", None)
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},
)
if result["type"] != "create_entry":
connection.send_error(msg["id"], "duplicate_failed", result.get("reason", "unknown"))
return
connection.send_result(msg["id"], {"entry_id": result["result"].entry_id})
@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.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).
vol.Optional("language"): vol.All(str, vol.Length(max=10)),
}
)
@require_write
@websocket_api.async_response
async def ws_create_from_template(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Create an object (with its tasks) from a predefined template.
Surfaces the config-flow template gallery in the panel: builds the object +
its tasks from the template and routes them through the same websocket flow
step used elsewhere (cap + normalize + create).
"""
from uuid import uuid4
from ..helpers.i18n import normalize_language
from ..templates import get_template_by_id, localize_template_text
template = get_template_by_id(msg["template_id"])
if template is None:
connection.send_error(msg["id"], "not_found", "Template not found")
return
lang = (msg.get("language") or normalize_language(hass))[:2].lower()
default_name = localize_template_text(template.name, lang) or template.name
name = (msg.get("name") or default_name).strip() or default_name
object_id = uuid4().hex
new_obj: dict[str, Any] = {
"id": object_id,
CONF_OBJECT_NAME: name[:MAX_NAME_LENGTH],
"task_ids": [],
}
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)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": "websocket"},
data={CONF_OBJECT: new_obj, CONF_TASKS: new_tasks},
)
if result["type"] != "create_entry":
connection.send_error(msg["id"], "create_failed", result.get("reason", "unknown"))
return
connection.send_result(msg["id"], {"entry_id": result["result"].entry_id})
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object/archive",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
}
)
@require_write
@websocket_api.async_response
async def ws_archive_object(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Archive an object and cascade to its active tasks.
Each currently-active task is archived with reason OBJECT, so a later object
unarchive restores exactly those. A task already archived (manually/auto)
keeps its own reason and is left untouched by the cascade.
"""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
obj = dict(entry.data.get(CONF_OBJECT, {}))
if obj.get("archived_at") is not None:
connection.send_error(msg["id"], "already_archived", "Object already archived")
return
now_iso = dt_util.now().isoformat()
new_data = _archived_entry_data(entry.data, now_iso)
hass.config_entries.async_update_entry(entry, data=new_data)
# Reload so the object's tasks' triggers tear down and entities go inert.
await hass.config_entries.async_reload(entry.entry_id)
connection.send_result(msg["id"], {"success": True, "archived_at": now_iso})
def _archived_entry_data(entry_data: Any, now_iso: str) -> 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).
"""
obj = dict(entry_data.get(CONF_OBJECT, {}))
obj["archived_at"] = now_iso
# Archiving supersedes a seasonal pause — don't leave both markers.
obj.pop("paused_at", None)
obj.pop("paused_until", None)
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
td["archived_at"] = now_iso
td["archived_reason"] = ARCHIVE_REASON_OBJECT
new_tasks[tid] = td
new_data = dict(entry_data)
new_data[CONF_OBJECT] = obj
new_data[CONF_TASKS] = new_tasks
return new_data
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object/unarchive",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
}
)
@require_write
@websocket_api.async_response
async def ws_unarchive_object(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Unarchive an object and un-cascade the tasks it had archived.
Only tasks archived BY this object (reason OBJECT) are restored; recurring
ones get a fresh cycle (D2). Tasks archived manually or auto-archived keep
their archived state — they were retired independently.
"""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
obj = dict(entry.data.get(CONF_OBJECT, {}))
if obj.get("archived_at") is None:
connection.send_error(msg["id"], "not_archived", "Object is not archived")
return
obj.pop("archived_at", None)
rd = _get_runtime_data(hass, entry.entry_id)
store = getattr(rd, "store", None) if rd else None
today_iso = dt_util.now().date().isoformat()
tasks_data = dict(entry.data.get(CONF_TASKS, {}))
new_tasks: dict[str, Any] = {}
for tid, td in tasks_data.items():
td = dict(td)
if td.get("archived_reason") == ARCHIVE_REASON_OBJECT:
td.pop("archived_at", None)
td.pop("archived_reason", None)
# Fresh cycle for recurring tasks (D2); last_performed is dynamic →
# Store when present, else the static dict (legacy).
if _is_recurring_schedule(td):
if store is not None:
store.set_last_performed(tid, today_iso)
state = store._ensure_task(tid)
state.pop("last_planned_due", None)
else:
td["last_performed"] = today_iso
td.pop("last_planned_due", None)
new_tasks[tid] = td
new_data = dict(entry.data)
new_data[CONF_OBJECT] = obj
new_data[CONF_TASKS] = new_tasks
hass.config_entries.async_update_entry(entry, data=new_data)
if store is not None:
await store.async_save()
await hass.config_entries.async_reload(entry.entry_id)
connection.send_result(msg["id"], {"success": True})
@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.Optional("until"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
}
)
@require_write
@websocket_api.async_response
async def ws_pause_object(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Seasonally pause an object (journey N3).
Tasks stay visible but read status ``paused``: schedules freeze, triggers
tear down, nothing notifies. ``until`` (ISO date, optional) auto-resumes
on that day via the coordinator; without it the pause holds until an
explicit ``object/resume``.
"""
from datetime import date as date_cls
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
obj = dict(entry.data.get(CONF_OBJECT, {}))
if obj.get("archived_at") is not None:
connection.send_error(msg["id"], "archived", "An archived object cannot be paused")
return
if obj.get("paused_at") is not None:
connection.send_error(msg["id"], "already_paused", "Object already paused")
return
until = msg.get("until")
if until:
try:
until_date = date_cls.fromisoformat(until)
except ValueError:
connection.send_error(msg["id"], "invalid_date", "Invalid until format (expected YYYY-MM-DD)")
return
if until_date <= dt_util.now().date():
connection.send_error(msg["id"], "invalid_date", "until must be a future date")
return
now_iso = dt_util.now().isoformat()
obj["paused_at"] = now_iso
obj["paused_until"] = until or None
new_data = dict(entry.data)
new_data[CONF_OBJECT] = obj
hass.config_entries.async_update_entry(entry, data=new_data)
# Reload so triggers tear down and every entity repaints as paused.
await hass.config_entries.async_reload(entry.entry_id)
connection.send_result(
msg["id"],
{"success": True, "paused_at": now_iso, "paused_until": until or None},
)
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object/resume",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
}
)
@require_write
@websocket_api.async_response
async def ws_resume_object(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""End a seasonal pause: schedules re-anchor to a fresh cycle from today.
Same core as the coordinator's ``paused_until`` auto-resume — the pool
pump comes back with a clean slate, not five months overdue.
"""
from ..helpers.pause import build_resumed_entry_data
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
obj = entry.data.get(CONF_OBJECT, {})
if obj.get("paused_at") is None:
connection.send_error(msg["id"], "not_paused", "Object is not paused")
return
rd = _get_runtime_data(hass, entry.entry_id)
store = getattr(rd, "store", None) if rd else None
new_data = build_resumed_entry_data(dict(entry.data), store, dt_util.now().date().isoformat())
hass.config_entries.async_update_entry(entry, data=new_data)
if store is not None:
await store.async_save()
await hass.config_entries.async_reload(entry.entry_id)
connection.send_result(msg["id"], {"success": True})
@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.Optional("name"): vol.Any(vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)), None),
}
)
@require_write
@websocket_api.async_response
async def ws_replace_object(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Replace a worn-out object with a successor (journey N1).
The predecessor is archived in place — its full history, costs and
documents remain browsable, marked with ``replaced_by_entry_id``. The
successor starts as a pre-filled fresh unit: same task configuration
(fresh ids, no history), the documents carried over (manuals usually
outlive the individual machine; blobs are refcounted, not copied), the
installation date set to today, and serial number / warranty cleared —
those belong to the specific old unit.
"""
from copy import deepcopy
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
src_obj = entry.data.get(CONF_OBJECT, {})
if src_obj.get("archived_at") is not None:
connection.send_error(msg["id"], "archived", "An archived object cannot be replaced")
return
name = (msg.get("name") or "").strip() or str(src_obj.get(CONF_OBJECT_NAME, "")).strip() or "Object"
new_obj = deepcopy(dict(src_obj))
new_obj["id"] = uuid4().hex
new_obj[CONF_OBJECT_NAME] = name[:MAX_NAME_LENGTH]
# Unit-specific identity does not transfer to the new machine.
new_obj[CONF_OBJECT_SERIAL_NUMBER] = None
new_obj[CONF_OBJECT_WARRANTY_EXPIRY] = None
new_obj[CONF_OBJECT_INSTALLATION_DATE] = dt_util.now().date().isoformat()
new_obj["task_ids"] = []
for key in ("archived_at", "paused_at", "paused_until", "replaced_by_entry_id"):
new_obj.pop(key, None)
new_obj["predecessor_entry_id"] = entry.entry_id
new_tasks: dict[str, Any] = {}
for src_task in entry.data.get(CONF_TASKS, {}).values():
task = deepcopy(dict(src_task))
task_id = uuid4().hex
task["id"] = task_id
task["object_id"] = new_obj["id"]
for key in (
"entity_slug",
"nfc_tag_id",
"history",
"last_performed",
"last_planned_due",
"adaptive_config",
"archived_at",
"archived_reason",
):
task.pop(key, None)
if isinstance(task.get("trigger_config"), dict):
task["trigger_config"].pop("_trigger_state", None)
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},
)
if result["type"] != "create_entry":
connection.send_error(msg["id"], "replace_failed", result.get("reason", "unknown"))
return
new_entry_id: str = result["result"].entry_id
# Carry the document library over — manuals outlive the machine. Blob
# refcounts increase; nothing is copied on disk.
from .. import DOCUMENT_STORE_KEY
from . import object_id_for_entry
doc_store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
if doc_store is not None:
src_docs = doc_store.for_object(object_id_for_entry(entry))
if src_docs:
await doc_store.async_import_documents(new_obj["id"], src_docs)
# Retire the predecessor (archive cascade) with the successor pointer.
now_iso = dt_util.now().isoformat()
retired = _archived_entry_data(entry.data, now_iso)
retired_obj = dict(retired[CONF_OBJECT])
retired_obj["replaced_by_entry_id"] = new_entry_id
retired[CONF_OBJECT] = retired_obj
hass.config_entries.async_update_entry(entry, data=retired)
await hass.config_entries.async_reload(entry.entry_id)
connection.send_result(msg["id"], {"entry_id": new_entry_id})
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/entity/attributes",
vol.Required("entity_id"): vol.All(str, vol.Length(max=MAX_ENTITY_ID_LENGTH)),
}
)
@callback
def ws_entity_attributes(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return relevant attributes for an entity, combining domain mapping with live state.
Used by the frontend trigger setup to show a dropdown of suitable attributes
instead of a free text field.
"""
from ..helpers.entity_attributes import get_entity_attributes
result = get_entity_attributes(hass, msg["entity_id"])
connection.send_result(msg["id"], result)