"""Export maintenance data as JSON or YAML.""" from __future__ import annotations import json import logging from pathlib import Path from typing import Any from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from .const import ( CONF_OBJECT, CONF_TASKS, DEFAULT_WARNING_DAYS, DOMAIN, GLOBAL_UNIQUE_ID, ) from .helpers.schedule import Schedule, read_legacy_fields _LOGGER = logging.getLogger(__name__) def _export_documents(doc_store: Any, object_id: str) -> list[dict[str, Any]]: """Export an object's document metadata + web-links (blobs ride the backup). File binaries are NOT in the JSON export (they live under /config and travel via the HA backup). An import without a matching backup therefore recreates file metadata pointing at a missing blob — the storage-hygiene repair issue catches those as dangling. Web-links round-trip fully. task_ids are dropped: tasks get fresh ids on import, so the links wouldn't resolve. """ out: list[dict[str, Any]] = [] for d in doc_store.for_object(object_id): if d.get("kind") == "weblink": out.append( { "kind": "weblink", "url": d.get("url"), "title": d.get("title"), "tags": d.get("tags") or [], } ) else: out.append( { "kind": "file", "hash": d.get("hash"), "title": d.get("title"), "filename": d.get("filename"), "mime": d.get("mime"), "size": d.get("size"), "tags": d.get("tags") or [], } ) return out def _build_export_object( hass: HomeAssistant, entry: ConfigEntry, coordinator_data: dict[str, Any] | None, include_history: bool, ) -> dict[str, Any]: """Build a single object's export dict.""" obj_data = entry.data.get(CONF_OBJECT, {}) # Merge static + Store dynamic data for each task rd = getattr(entry, "runtime_data", None) store = getattr(rd, "store", None) if rd else None static_tasks = entry.data.get(CONF_TASKS, {}) tasks_data = store.merge_all_tasks(static_tasks) if store is not None else static_tasks ct_tasks = (coordinator_data or {}).get(CONF_TASKS, {}) tasks = [] for tid, tdata in tasks_data.items(): ct = ct_tasks.get(tid, {}) sched = read_legacy_fields(tdata) task: dict[str, Any] = { "id": tid, "name": tdata.get("name", ""), "type": tdata.get("type", "custom"), "enabled": tdata.get("enabled", True), "schedule_type": sched["schedule_type"], "interval_days": sched["interval_days"], "interval_unit": sched["interval_unit"], "due_date": sched["due_date"], "interval_anchor": sched["interval_anchor"], # Nested recurrence — carries the calendar kinds (weekdays / # nth_weekday / day_of_month) that the flat fields above can't. "schedule": Schedule.parse(tdata).to_dict(), "last_planned_due": tdata.get("last_planned_due"), # A pending per-occurrence postpone is user intent — round-trip it # like last_planned_due so a backup/restore keeps the deferral. "due_override": tdata.get("due_override"), "warning_days": tdata.get("warning_days", DEFAULT_WARNING_DAYS), "last_performed": tdata.get("last_performed"), "notes": tdata.get("notes"), "documentation_url": tdata.get("documentation_url"), "custom_icon": tdata.get("custom_icon"), "nfc_tag_id": tdata.get("nfc_tag_id"), "responsible_user_id": tdata.get("responsible_user_id"), "entity_slug": tdata.get("entity_slug"), "adaptive_config": tdata.get("adaptive_config"), "checklist": tdata.get("checklist") or [], "schedule_time": tdata.get("schedule_time"), # v2.17+ / #83 task fields — persisted and user-facing, so a JSON # backup must restore them (same field-completeness contract as #67 # for documentation_url/notes; import mirrors these keys). "priority": tdata.get("priority", "normal"), "labels": tdata.get("labels") or [], "earliest_completion_days": tdata.get("earliest_completion_days"), "on_complete_action": tdata.get("on_complete_action"), "quick_complete_defaults": tdata.get("quick_complete_defaults"), "assignee_pool": tdata.get("assignee_pool") or [], "rotation_strategy": tdata.get("rotation_strategy"), "reading_unit": tdata.get("reading_unit"), "status": ct.get("_status", "ok"), "days_until_due": ct.get("_days_until_due"), "next_due": ct.get("_next_due"), "times_performed": ct.get("_times_performed", 0), "total_cost": ct.get("_total_cost", 0.0), "average_duration": ct.get("_average_duration"), } trigger_config = tdata.get("trigger_config") if trigger_config: task["trigger_config"] = trigger_config if include_history: task["history"] = tdata.get("history") or [] tasks.append(task) # (roadmap P6) attach document metadata + web-links. from . import DOCUMENT_STORE_KEY doc_store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY) object_id = obj_data.get("id", "") documents = _export_documents(doc_store, object_id) if doc_store is not None and object_id else [] return { "entry_id": entry.entry_id, "object": { "name": obj_data.get("name", ""), "area_id": obj_data.get("area_id"), "manufacturer": obj_data.get("manufacturer"), "model": obj_data.get("model"), "serial_number": obj_data.get("serial_number"), "installation_date": obj_data.get("installation_date"), "warranty_expiry": obj_data.get("warranty_expiry"), # Round-tripped so a JSON backup restores the full asset record # (these were added in v1.4.0/v1.4.10 but missed here until #67). "documentation_url": obj_data.get("documentation_url"), "notes": obj_data.get("notes"), # 2.19: device link / parent hierarchy. Instance-specific ids — # meaningful when restoring on the SAME instance; dangling values # on a foreign instance are harmless (device_info falls back). "ha_device_id": obj_data.get("ha_device_id"), "parent_entry_id": obj_data.get("parent_entry_id"), # 2.20: seasonal pause (a paused pool restored in winter stays # paused) + replace-flow lineage (instance-specific entry ids, # same caveat 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"), }, "tasks": tasks, "documents": documents, } def build_export_data( hass: HomeAssistant, include_history: bool = True, ) -> dict[str, Any]: """Gather all maintenance data into a plain dict. This must be called from the event loop (accesses HA APIs). The returned dict contains no HA objects and is safe to serialize in an executor thread. """ entries = [entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.unique_id != GLOBAL_UNIQUE_ID] objects = [] for entry in entries: rd = getattr(entry, "runtime_data", None) coord_data = rd.coordinator.data if rd and rd.coordinator else None objects.append(_build_export_object(hass, entry, coord_data, include_history)) return { "version": 1, "objects": objects, } def serialize_export(data: dict[str, Any], fmt: str = "json") -> str: """Serialize an export data dict to a JSON or YAML string. Pure function with no HA dependencies — safe to run in an executor. """ if fmt == "yaml": try: import yaml # type: ignore[import-untyped] # Normalize through JSON first: yaml.safe_dump rejects types the # JSON path coerces (e.g. tuples → lists), so YAML export would # crash on data JSON handles fine. Round-tripping keeps both # formats consistent and YAML-safe. normalized = json.loads(json.dumps(data, ensure_ascii=False)) return str(yaml.safe_dump(normalized, default_flow_style=False, allow_unicode=True)) except ImportError: _LOGGER.warning("PyYAML not available, falling back to JSON") return json.dumps(data, indent=2, ensure_ascii=False) return json.dumps(data, indent=2, ensure_ascii=False) def serialize_export_to_file(data: dict[str, Any], fmt: str, file_path: str) -> str: """Serialize export data and write to a file. Pure sync function — safe to run in an executor via ``hass.async_add_executor_job``. Returns: The file path written to. """ content = serialize_export(data, fmt) Path(file_path).write_text(content, encoding="utf-8") return file_path def export_maintenance_data( hass: HomeAssistant, fmt: str = "json", include_history: bool = True, ) -> str: """Export all maintenance data as a JSON or YAML string. Legacy convenience wrapper used by the WebSocket export handler. For the service handler, prefer ``build_export_data`` + ``serialize_export_to_file`` (via executor) to avoid blocking the event loop. """ data = build_export_data(hass, include_history=include_history) return serialize_export(data, fmt)