"""WebSocket handlers for export, import, CSV, QR, and templates.""" from __future__ import annotations import json as json_mod import logging import re from functools import lru_cache from typing import Any from uuid import uuid4 import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant from ..const import ( CONF_OBJECT, CONF_OBJECT_MANUFACTURER, CONF_OBJECT_MODEL, CONF_OBJECT_NAME, CONF_TASKS, DOMAIN, GLOBAL_UNIQUE_ID, MAX_CHECKLIST_ITEM_LENGTH, MAX_CHECKLIST_ITEMS, MAX_ID_LENGTH, ) from ..helpers.global_options import get_default_warning_days from ..helpers.qr_generator import ( _ACTION_ICON_MAP, build_qr_url, generate_qr_svg, generate_qr_svg_data_uri, ) from ..websocket.tasks import _check_nfc_tag_duplicate, _validate_trigger_config _LOGGER = logging.getLogger(__name__) @websocket_api.websocket_command( { vol.Required("type"): f"{DOMAIN}/templates", # v2.21.1: the caller's UI language — template/task names arrive # localized. Falls back to the server language. vol.Optional("language"): vol.All(str, vol.Length(max=10)), } ) @websocket_api.async_response async def ws_get_templates( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Return all maintenance templates. Every template is returned with a ``disabled`` flag (v2.21 gallery curation): the pickers hide disabled ones client-side, while the Settings section needs the full list to render the toggles. """ from ..helpers.i18n import normalize_language from ..templates import ( TEMPLATE_CATEGORIES, TEMPLATES, get_disabled_template_ids, localize_template_text, ) disabled = get_disabled_template_ids(hass) lang = (msg.get("language") or normalize_language(hass))[:2].lower() result = { "categories": {cat_id: {k: v for k, v in cat.items()} for cat_id, cat in TEMPLATE_CATEGORIES.items()}, "templates": [ { "id": t.id, "name": localize_template_text(t.name, lang), "category": t.category, "disabled": t.id in disabled, "tasks": [ { "name": localize_template_text(tt.name, lang), "type": tt.type, "schedule_type": tt.schedule_type, "interval_days": tt.interval_days, "warning_days": tt.warning_days, } for tt in t.tasks ], } for t in TEMPLATES ], } connection.send_result(msg["id"], result) @websocket_api.websocket_command( { vol.Required("type"): f"{DOMAIN}/export", vol.Optional("format", default="json"): vol.In(["json", "yaml"]), vol.Optional("include_history", default=True): bool, } ) @websocket_api.require_admin @websocket_api.async_response async def ws_export_data( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Export all maintenance data as JSON or YAML.""" from ..export import build_export_data, serialize_export fmt = msg.get("format", "json") include_history = msg.get("include_history", True) # Phase 1: gather data on the event loop (accesses HA APIs) data = build_export_data(hass, include_history=include_history) # Phase 2: serialize in executor (CPU-bound, no HA API calls) result = await hass.async_add_executor_job(serialize_export, data, fmt) connection.send_result(msg["id"], {"format": fmt, "data": result}) @websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/csv/export"}) @websocket_api.require_admin @websocket_api.async_response async def ws_export_csv( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Export all maintenance data as CSV.""" from ..helpers.csv_handler import export_objects_csv csv_data = export_objects_csv(hass) connection.send_result(msg["id"], {"csv": csv_data}) @websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/objects/csv"}) @websocket_api.async_response async def ws_export_objects_csv( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Export one row per maintenance object as CSV (#67). Not admin-gated: it exposes only the asset fields the panel already sends to every user via ``maintenance_supporter/objects`` (no cost/history). """ from ..helpers.csv_handler import export_object_records_csv csv_data = export_object_records_csv(hass) connection.send_result(msg["id"], {"csv": csv_data}) @websocket_api.websocket_command( { vol.Required("type"): f"{DOMAIN}/csv/import", vol.Required("csv_content"): str, } ) @websocket_api.require_admin @websocket_api.async_response async def ws_import_csv( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Import maintenance objects from CSV content.""" from ..helpers.csv_handler import import_objects_csv csv_content = msg["csv_content"] # Guard against oversized payloads (max 1MB / 1000 objects) if len(csv_content) > 1_048_576: connection.send_error(msg["id"], "too_large", "CSV content exceeds 1MB limit") return objects = import_objects_csv(csv_content, hass=hass) if len(objects) > 1000: connection.send_error(msg["id"], "too_many", "CSV contains more than 1000 objects") return if not objects: connection.send_error(msg["id"], "empty_csv", "No valid objects found in CSV") return created = [] errors: list[dict[str, str]] = [] for idx, obj_data in enumerate(objects): # Check for NFC tag duplicates in CSV-imported tasks nfc_warnings: list[str] = [] for t_data in obj_data.get("tasks", {}).values(): nfc_val = t_data.get("nfc_tag_id") if nfc_val: nfc_warn = _check_nfc_tag_duplicate(hass, nfc_val) if nfc_warn: nfc_warnings.append(nfc_warn) try: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "websocket"}, data={ CONF_OBJECT: obj_data["object"], CONF_TASKS: obj_data["tasks"], }, ) except Exception: obj_name = obj_data.get("object", {}).get("name", f"row {idx + 1}") _LOGGER.exception("CSV import failed for %s", obj_name) errors.append({"name": obj_name, "reason": "unexpected error"}) continue if result["type"] == "create_entry": entry_info: dict[str, Any] = { "entry_id": result["result"].entry_id, "name": obj_data["object"].get("name", ""), "task_count": len(obj_data["tasks"]), } if nfc_warnings: entry_info["warnings"] = nfc_warnings created.append(entry_info) else: obj_name = obj_data.get("object", {}).get("name", f"row {idx + 1}") errors.append({"name": obj_name, "reason": result.get("reason", "unknown")}) resp: dict[str, Any] = { "imported": created, "total": len(objects), "created": len(created), } if errors: resp["errors"] = errors connection.send_result(msg["id"], resp) def _parse_structured(raw: str) -> Any: """Parse JSON *or* YAML export content into a Python object. Both formats are accepted so every structured export (JSON and YAML) round-trips back through the importer. Raises ValueError if the content parses to neither a mapping nor a list. """ try: return json_mod.loads(raw) except (json_mod.JSONDecodeError, ValueError): pass import yaml # type: ignore[import-untyped] try: loaded = yaml.safe_load(raw) except yaml.YAMLError as err: raise ValueError("not valid JSON or YAML") from err # safe_load returns a bare string/scalar for non-structured text (e.g. a # CSV blob) — require an object/array so those route elsewhere cleanly. if not isinstance(loaded, (dict, list)): raise ValueError("not valid JSON or YAML") return loaded @websocket_api.websocket_command( { vol.Required("type"): f"{DOMAIN}/json/import", vol.Required("json_content"): str, } ) @websocket_api.require_admin @websocket_api.async_response async def ws_import_json( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Import maintenance objects from JSON or YAML content (from /export).""" raw = msg["json_content"] if len(raw) > 10_485_760: connection.send_error(msg["id"], "too_large", "Content exceeds 10MB limit") return try: data = _parse_structured(raw) except ValueError: connection.send_error(msg["id"], "invalid_format", "Content is not valid JSON or YAML") return if not isinstance(data, dict) or "objects" not in data: connection.send_error(msg["id"], "invalid_format", "JSON must contain an 'objects' array") return objects = data["objects"] if not isinstance(objects, list): connection.send_error(msg["id"], "invalid_format", "'objects' must be an array") return if len(objects) > 1000: connection.send_error(msg["id"], "too_many", "JSON contains more than 1000 objects") return if not objects: connection.send_error(msg["id"], "empty", "No objects found in JSON") return created = [] errors: list[dict[str, str]] = [] for idx, obj_entry in enumerate(objects): # Guard against malformed-but-schema-valid input (the schema only checks # json_content is a str): a non-dict entry / non-dict object would raise # AttributeError and escape the per-object try/except below. if not isinstance(obj_entry, dict): errors.append({"name": f"object {idx + 1}", "reason": "not an object"}) continue obj_data = obj_entry.get("object", {}) if not isinstance(obj_data, dict): errors.append({"name": f"object {idx + 1}", "reason": "invalid object data"}) continue obj_name = (obj_data.get("name") or "").strip() if not obj_name: errors.append({"name": f"object {idx + 1}", "reason": "missing name"}) continue obj_id = uuid4().hex import_obj: dict[str, Any] = { "id": obj_id, "name": obj_name, "manufacturer": obj_data.get("manufacturer"), "model": obj_data.get("model"), "serial_number": obj_data.get("serial_number"), "area_id": obj_data.get("area_id"), "installation_date": obj_data.get("installation_date"), "warranty_expiry": obj_data.get("warranty_expiry"), # Imported counterparts of the export fields above; length-capped by # cap_object_fields and the frontend only renders http(s) doc URLs. "documentation_url": obj_data.get("documentation_url"), "notes": obj_data.get("notes"), # 2.19: device link / parent hierarchy — same-instance restores # keep them valid; stale ids degrade gracefully at read time. "ha_device_id": obj_data.get("ha_device_id"), "parent_entry_id": obj_data.get("parent_entry_id"), # 2.20: seasonal pause round-trips (a paused pool restored in # winter stays paused); replace-flow lineage ids are the same # instance-specific story as parent_entry_id above. "paused_at": obj_data.get("paused_at"), "paused_until": obj_data.get("paused_until"), "predecessor_entry_id": obj_data.get("predecessor_entry_id"), "replaced_by_entry_id": obj_data.get("replaced_by_entry_id"), "task_ids": [], } import_tasks: dict[str, dict[str, Any]] = {} tasks_list = obj_entry.get("tasks", []) if not isinstance(tasks_list, list): tasks_list = [] for task_entry in tasks_list: if not isinstance(task_entry, dict): continue task_name = (task_entry.get("name") or "").strip() if not task_name: continue task_id = uuid4().hex task_data: dict[str, Any] = { "id": task_id, "object_id": obj_id, "name": task_name, "type": task_entry.get("type", "custom"), "enabled": task_entry.get("enabled", True), "schedule_type": task_entry.get("schedule_type", "time_based"), "warning_days": task_entry.get("warning_days", get_default_warning_days(hass)), "history": task_entry.get("history", []), } for key in ( "interval_days", "interval_unit", "due_date", "interval_anchor", "last_planned_due", # per-occurrence postpone (round-trips like last_planned_due) "due_override", # nested recurrence (calendar kinds) — config-flow normalize # treats it as authoritative when present. "schedule", "last_performed", "notes", "documentation_url", "custom_icon", "nfc_tag_id", "responsible_user_id", "entity_slug", "trigger_config", "adaptive_config", "checklist", "schedule_time", # v2.17+ / #83 fields — mirror the export builder so a JSON # backup round-trips them (validated/clamped just below). "priority", "labels", "earliest_completion_days", "on_complete_action", "quick_complete_defaults", "assignee_pool", "rotation_strategy", "reading_unit", ): val = task_entry.get(key) if val is not None: task_data[key] = val # Sanitize critical fields from import data iv = task_data.get("interval_days") if iv is not None and (not isinstance(iv, int) or iv < 1): task_data.pop("interval_days", None) lp = task_data.get("last_performed") if lp is not None: try: from datetime import date date.fromisoformat(lp) except (ValueError, TypeError): task_data.pop("last_performed", None) wd = task_data.get("warning_days") if not isinstance(wd, int) or wd < 0 or wd > 365: task_data["warning_days"] = get_default_warning_days(hass) # Sanitize checklist: only keep string items within length budget, # cap total items. Drops malformed entries silently rather than # rejecting the whole import — same forgiving model as the other # fields above. cl = task_data.get("checklist") if cl is not None: if not isinstance(cl, list): task_data.pop("checklist", None) else: cleaned = [item.strip() for item in cl if isinstance(item, str) and len(item) <= MAX_CHECKLIST_ITEM_LENGTH] cleaned = [c for c in cleaned if c] task_data["checklist"] = cleaned[:MAX_CHECKLIST_ITEMS] # schedule_time: strict HH:MM, otherwise drop st = task_data.get("schedule_time") if st is not None: if not isinstance(st, str) or not re.fullmatch(r"^([01]\d|2[0-3]):[0-5]\d$", st): task_data.pop("schedule_time", None) # Validate an imported trigger_config the same way the WS create/update # path does — strip unknown keys, normalize entity_ids, and drop it # entirely if invalid — so import isn't a hole around trigger validation. tc = task_data.get("trigger_config") if isinstance(tc, dict): errors, _warnings = _validate_trigger_config(hass, tc) if errors: task_data.pop("trigger_config", None) elif tc is not None: task_data.pop("trigger_config", None) import_tasks[task_id] = task_data import_obj["task_ids"].append(task_id) # Check for NFC tag duplicates across imported tasks nfc_warnings: list[str] = [] for t_data in import_tasks.values(): nfc_val = t_data.get("nfc_tag_id") if nfc_val: nfc_warn = _check_nfc_tag_duplicate(hass, nfc_val) if nfc_warn: nfc_warnings.append(nfc_warn) try: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "websocket"}, data={ CONF_OBJECT: import_obj, CONF_TASKS: import_tasks, }, ) except Exception: _LOGGER.exception("JSON import failed for %s", obj_name) errors.append({"name": obj_name, "reason": "unexpected error"}) continue if result["type"] == "create_entry": entry_info: dict[str, Any] = { "entry_id": result["result"].entry_id, "name": obj_name, "task_count": len(import_tasks), } if nfc_warnings: entry_info["warnings"] = nfc_warnings created.append(entry_info) # (roadmap P6) recreate document metadata + web-links for the object # (blobs travel via the /config backup; a JSON-only import leaves # file docs dangling, which the storage-hygiene repair issue catches). import_docs = obj_entry.get("documents") if isinstance(import_docs, list) and import_docs: from .. import DOCUMENT_STORE_KEY doc_store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY) if doc_store is not None: await doc_store.async_import_documents(obj_id, import_docs) else: errors.append({"name": obj_name, "reason": result.get("reason", "unknown")}) resp: dict[str, Any] = { "imported": created, "total": len(objects), "created": len(created), } if errors: resp["errors"] = errors connection.send_result(msg["id"], resp) @websocket_api.websocket_command( { vol.Required("type"): "maintenance_supporter/qr/generate", vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)), vol.Optional("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)), vol.Optional("action", default="view"): vol.In(["view", "complete", "quick_complete"]), vol.Optional("url_mode", default="server"): vol.In(["server", "local", "companion"]), vol.Optional("base_url"): vol.Url(), } ) @websocket_api.async_response async def ws_generate_qr( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Generate a QR code for a maintenance object or task.""" 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 obj_data = entry.data.get(CONF_OBJECT, {}) task_id = msg.get("task_id") task_name = None if task_id: tasks_data = entry.data.get(CONF_TASKS, {}) if task_id not in tasks_data: connection.send_error(msg["id"], "not_found", "Task not found") return task_name = tasks_data[task_id].get("name", "") action = msg.get("action", "view") url_mode = msg.get("url_mode", "server") base_url = msg.get("base_url") try: url = build_qr_url( hass, entry_id, task_id=task_id, action=action, base_url_override=base_url, url_mode=url_mode, ) except ValueError as err: connection.send_error(msg["id"], "no_url", str(err)) return from functools import partial icon = _ACTION_ICON_MAP.get(action) gen_fn = partial(generate_qr_svg_data_uri, url, border=2, icon=icon) svg_data_uri = await hass.async_add_executor_job(gen_fn) connection.send_result( msg["id"], { "svg_data_uri": svg_data_uri, "url": url, "label": { "object_name": obj_data.get(CONF_OBJECT_NAME, ""), "manufacturer": obj_data.get(CONF_OBJECT_MANUFACTURER, ""), "model": obj_data.get(CONF_OBJECT_MODEL, ""), "task_name": task_name, }, }, ) # Batch QR generation — used by the "Print QR codes" panel section. # # Typical household: 20-30 tasks × 2 actions = 40-60 QRs. Benchmarked at # ~40 ms each with icon embed (HIGH ECC) → 2.5 s for 60, 7 s for 200. # The raw SVG is ~32 KB each, so 200 × 32 KB = ~6 MB over the websocket; # we cap at 200 to keep the payload bounded and the print layout sane # (generous 6 QRs/A4 page = 34 pages). _MAX_BATCH_QRS = 200 # LRU cache keyed on (url, icon). Two users printing the same task twice # in a session hit this cache; so does re-running the batch after # narrowing the filter. Bounded size so long-running HA instances with # thousands of task-action combos can't grow the cache forever. @lru_cache(maxsize=512) def _cached_qr_svg(url: str, icon: str | None) -> str: return generate_qr_svg(url, border=2, icon=icon) @websocket_api.websocket_command( { vol.Required("type"): "maintenance_supporter/qr/batch_generate", vol.Optional("entry_ids"): vol.All( [vol.All(str, vol.Length(max=MAX_ID_LENGTH))], vol.Length(max=1000), ), vol.Optional("task_ids"): vol.All( [vol.All(str, vol.Length(max=MAX_ID_LENGTH))], vol.Length(max=2000), ), vol.Required("actions"): vol.All( [vol.In(["view", "complete", "skip", "quick_complete"])], vol.Length(min=1, max=4), ), vol.Optional("url_mode", default="server"): vol.In(["server", "local", "companion"]), vol.Optional("base_url"): vol.Url(), } ) @websocket_api.async_response async def ws_batch_generate_qr( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Generate multiple QR codes in one call for the print-all-QRs page. Resolves (entry × task × action) combinations and returns SVG strings ready to inline into a printable grid. Empty ``entry_ids`` / ``task_ids`` filters mean "all" at that level. """ # Resolve target entries (always exclude the global config entry). all_entries = [entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.unique_id != GLOBAL_UNIQUE_ID] entry_filter = msg.get("entry_ids") if entry_filter: wanted = set(entry_filter) entries = [e for e in all_entries if e.entry_id in wanted] else: entries = all_entries # Build the flat (entry_id, object_name, task_id, task_name) target list, # honouring the optional task_ids filter. task_filter = set(msg["task_ids"]) if msg.get("task_ids") else None targets: list[tuple[str, str, str, str]] = [] for entry in entries: obj_name = entry.data.get(CONF_OBJECT, {}).get(CONF_OBJECT_NAME, "") tasks_data = entry.data.get(CONF_TASKS, {}) for task_id, task_data in tasks_data.items(): if task_filter is not None and task_id not in task_filter: continue targets.append((entry.entry_id, obj_name, task_id, task_data.get("name", ""))) actions: list[str] = msg["actions"] total = len(targets) * len(actions) if total == 0: connection.send_result(msg["id"], {"qrs": [], "total": 0}) return if total > _MAX_BATCH_QRS: connection.send_error( msg["id"], "too_many", f"Batch would produce {total} QR codes; the per-request cap is " f"{_MAX_BATCH_QRS}. Narrow the object/task/action filter.", ) return url_mode = msg.get("url_mode", "server") base_url = msg.get("base_url") # Generate URL first (fast), then offload the SVG encoding to the executor # since it's CPU-bound (~30-40 ms/QR). Each SVG passes through the LRU # cache so re-runs after a filter change are near-instant. results: list[dict[str, Any]] = [] for entry_id, obj_name, task_id, task_name in targets: for action in actions: try: url = build_qr_url( hass, entry_id, task_id=task_id, action=action, base_url_override=base_url, url_mode=url_mode, ) except ValueError: # No HA URL configured — skip this row rather than fail the # whole batch. "server" mode is the only path that raises; # "companion" and "local" always resolve. continue icon = _ACTION_ICON_MAP.get(action) # None for "skip" (no icon) svg = await hass.async_add_executor_job(_cached_qr_svg, url, icon) results.append( { "entry_id": entry_id, "task_id": task_id, "object_name": obj_name, "task_name": task_name, "action": action, "svg": svg, } ) connection.send_result(msg["id"], {"qrs": results, "total": len(results)})