Files
Home-Assistant/custom_components/maintenance_supporter/export.py
T
2026-07-14 23:57:03 -04:00

283 lines
12 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
carried too (as the OLD task ids); the importer remaps them onto the fresh
task ids so a doc's task links survive a backup/restore.
"""
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 [],
"task_ids": d.get("task_ids") or [],
"part_ids": d.get("part_ids") 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 [],
"task_ids": d.get("task_ids") or [],
"part_ids": d.get("part_ids") 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),
# Provenance + lifecycle a backup must preserve: created_at is the
# next_due fallback anchor when last_performed is None (dropping it
# silently shifts due dates on restore), and the archived_* pair
# keeps a retired task retired — without them an archived task comes
# back ACTIVE after a restore.
"created_at": tdata.get("created_at"),
"archived_at": tdata.get("archived_at"),
"archived_reason": tdata.get("archived_reason"),
"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"),
# Spare parts: consumption links + the auto-buy-task marker.
"consumes_parts": tdata.get("consumes_parts"),
"part_ref": tdata.get("part_ref"),
"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,
# Spare parts: full static definition + the tracked stock (dynamic,
# read from the Store) so a backup/restore keeps the shelf state.
"parts": [
{**part, "stock": store.get_part_stock(part["id"]) if store is not None else None}
for part in (entry.data.get("parts") or {}).values()
],
}
def object_entries(hass: HomeAssistant, entry_ids: set[str] | None = None) -> list[ConfigEntry]:
"""The maintenance OBJECT entries (never the global hub), optionally
narrowed to a selection. Shared by every exporter so JSON/YAML/CSV apply
the same selective-export filter. ``entry_ids=None`` means all objects."""
return [
entry
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.unique_id != GLOBAL_UNIQUE_ID and (entry_ids is None or entry.entry_id in entry_ids)
]
def build_export_data(
hass: HomeAssistant,
include_history: bool = True,
entry_ids: set[str] | None = None,
) -> 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. ``entry_ids`` narrows the export to a
selection of objects (None = all).
"""
entries = object_entries(hass, entry_ids)
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)