"""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 # 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 new_tasks: dict[str, Any] = {} for src_task in entry.data.get(CONF_TASKS, {}).values(): # Auto "buy" reminders are transient reconciler-owned state — the # successor's own reconcile recreates one if the carried part is low. if src_task.get("part_ref"): continue 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) links = task.get("consumes_parts") if isinstance(links, list): remapped = [ {"part_id": part_id_map[link["part_id"]], "quantity": link.get("quantity", 1)} for link in links if isinstance(link, dict) and link.get("part_id") in part_id_map ] if remapped: task["consumes_parts"] = remapped else: task.pop("consumes_parts", 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, "parts": new_parts}, ) 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: # part_id_map keeps a doc's spare-part links pointing at the carried # parts' fresh ids (task links intentionally drop — the successor's # tasks restart fresh). await doc_store.async_import_documents(new_obj["id"], src_docs, part_id_map=part_id_map) # Copy the tracked stock counts (dynamic store state) onto the carried # parts, then let the successor's reconcile recreate any needed reminder. if part_id_map: src_rd = getattr(entry, "runtime_data", None) src_store = getattr(src_rd, "store", None) if src_rd else None new_entry = hass.config_entries.async_get_entry(new_entry_id) new_rd = getattr(new_entry, "runtime_data", None) if new_entry else None new_store = getattr(new_rd, "store", None) if new_rd else None if src_store is not None and new_store is not None: for old_pid, new_pid in part_id_map.items(): stock = src_store.get_part_stock(old_pid) if stock is not None: new_store.set_part_stock(new_pid, stock) await new_store.async_save() if new_entry is not None: from ..parts_runtime import schedule_buy_task_reconcile schedule_buy_task_reconcile(hass, new_entry) # 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)