Files
Home-Assistant/custom_components/maintenance_supporter/export.py
T
2026-07-08 10:43:39 -04:00

233 lines
8.9 KiB
Python

"""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"),
"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 [],
"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)