189 files
This commit is contained in:
1 parent
79fefc0a7c
commit
303886f212
189 files changed
+21402
-3514
No files matched your search
@@ -14,9 +14,11 @@ from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..const import (
|
||||
BATTERY_FLEET_DUE_WITHOUT_SENSOR,
|
||||
BATTERY_FLEET_EXCLUDED,
|
||||
BATTERY_FLEET_INCLUDED,
|
||||
BATTERY_FLEET_OBJECT_FLAG,
|
||||
BATTERY_FLEET_REMOVED_PARTS,
|
||||
BATTERY_FLEET_TASK_FLAG,
|
||||
BATTERY_FLEET_TRACK_SELF_CHARGING,
|
||||
CONF_OBJECT,
|
||||
@@ -45,6 +47,11 @@ from . import _get_object_entries, _load_object_entry
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ref_or_none(value: Any) -> int | None:
|
||||
"""#170: a reference number / counter from a backup — positive int or nothing."""
|
||||
return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else None
|
||||
|
||||
|
||||
def _iso_marker(value: Any) -> str | None:
|
||||
"""Keep ``value`` only if it parses as an ISO date/datetime, else drop it.
|
||||
|
||||
@@ -90,10 +97,68 @@ def _sanitize_history(history: Any) -> list[dict[str, Any]]:
|
||||
cost = clean.get("cost")
|
||||
if isinstance(cost, bool) or not isinstance(cost, (int, float)) or not math.isfinite(cost) or cost < 0:
|
||||
clean.pop("cost", None)
|
||||
# Readings (#83 / #161 phase 2): same NaN/Infinity hole — a poisoned
|
||||
# value would break every delta after it. Malformed slot snapshots
|
||||
# are dropped item-wise, the completion itself is kept.
|
||||
rv = clean.get("reading_value")
|
||||
if rv is not None and (isinstance(rv, bool) or not isinstance(rv, (int, float)) or not math.isfinite(rv)):
|
||||
clean.pop("reading_value", None)
|
||||
if "reading_values" in clean:
|
||||
from ..helpers.reading_slots import history_reading_values
|
||||
|
||||
snapshot = history_reading_values(clean)
|
||||
if snapshot:
|
||||
clean["reading_values"] = snapshot
|
||||
else:
|
||||
clean.pop("reading_values", None)
|
||||
out.append(clean)
|
||||
return out
|
||||
|
||||
|
||||
def _remap_document_refs(
|
||||
import_tasks: dict[str, dict[str, Any]],
|
||||
import_parts: dict[str, dict[str, Any]],
|
||||
doc_id_map: dict[str, str],
|
||||
) -> None:
|
||||
"""Re-point document references at the freshly minted doc ids.
|
||||
|
||||
History entries carry completion photos (``photo_doc_ids``, or the
|
||||
pre-2.75 ``photo_doc_id`` scalar — folded into the list here) and spare
|
||||
parts carry a ``doc_id``. Ids the export did not carry (a hand-written
|
||||
file, a doc that vanished before the export) stay verbatim: a dangling
|
||||
reference renders as a missing picture, which is what it is.
|
||||
"""
|
||||
from ..helpers.completion_photos import history_photo_ids
|
||||
|
||||
for task_data in import_tasks.values():
|
||||
for hist_entry in task_data.get("history") or []:
|
||||
if not isinstance(hist_entry, dict):
|
||||
continue
|
||||
photos = history_photo_ids(hist_entry)
|
||||
if not photos:
|
||||
continue
|
||||
hist_entry.pop("photo_doc_id", None)
|
||||
hist_entry["photo_doc_ids"] = [doc_id_map.get(p, p) for p in photos]
|
||||
for part in import_parts.values():
|
||||
old = part.get("doc_id")
|
||||
if isinstance(old, str) and old in doc_id_map:
|
||||
part["doc_id"] = doc_id_map[old]
|
||||
|
||||
|
||||
async def _drop_imported_documents(doc_store: Any, object_id: str) -> None:
|
||||
"""Undo a pre-flow document import when the object never came to be.
|
||||
|
||||
Documents are recreated BEFORE the entry flow (their fresh ids must be
|
||||
known to remap history photos and part doc_ids); if the flow then
|
||||
fails they would linger as orphans nobody can reach. ``object_id`` is
|
||||
freshly minted per import, so every doc under it is ours to drop.
|
||||
"""
|
||||
if doc_store is None:
|
||||
return
|
||||
for orphan in list(doc_store.for_object(object_id)):
|
||||
await doc_store.async_remove(orphan["id"])
|
||||
|
||||
|
||||
def _import_fleet_identity(
|
||||
hass: HomeAssistant,
|
||||
obj_data: dict[str, Any],
|
||||
@@ -132,6 +197,15 @@ def _import_fleet_identity(
|
||||
import_obj[key] = cleaned[:FLEET_LIST_CAP]
|
||||
if obj_data.get(BATTERY_FLEET_TRACK_SELF_CHARGING) is True:
|
||||
import_obj[BATTERY_FLEET_TRACK_SELF_CHARGING] = True
|
||||
if obj_data.get(BATTERY_FLEET_DUE_WITHOUT_SENSOR) is False:
|
||||
import_obj[BATTERY_FLEET_DUE_WITHOUT_SENSOR] = False
|
||||
# Deleted type-parts stay deleted after a restore too — same id rule as
|
||||
# _keep_fleet_part_id, so nothing but ``batt_<type>`` ids get through.
|
||||
raw_removed = obj_data.get(BATTERY_FLEET_REMOVED_PARTS)
|
||||
if isinstance(raw_removed, list):
|
||||
removed = sorted({p.strip() for p in raw_removed if isinstance(p, str) and _keep_fleet_part_id(True, p.strip())})
|
||||
if removed:
|
||||
import_obj[BATTERY_FLEET_REMOVED_PARTS] = removed[:FLEET_LIST_CAP]
|
||||
return True
|
||||
|
||||
|
||||
@@ -617,6 +691,10 @@ async def ws_import_json(
|
||||
# semantics as paused_at, so it gets the same ISO validation. Its
|
||||
# tasks carry their own archived_* pair (mirrored below).
|
||||
"archived_at": _iso_marker(obj_data.get("archived_at")),
|
||||
# #170: keep the numbers a backup carries (collisions are
|
||||
# renumbered by the setup pass); bool/negative junk is dropped.
|
||||
"ref_no": _ref_or_none(obj_data.get("ref_no")),
|
||||
"next_task_ref": _ref_or_none(obj_data.get("next_task_ref")),
|
||||
"task_ids": [],
|
||||
}
|
||||
|
||||
@@ -668,6 +746,9 @@ async def ws_import_json(
|
||||
# remapped onto the freshly generated tasks (mirrors part_id_map).
|
||||
task_id_map: dict[str, str] = {}
|
||||
fleet_task_seen = False
|
||||
# Per-task import losses (an invalid trigger is dropped, not fatal) —
|
||||
# reported next to the NFC warnings instead of vanishing silently.
|
||||
task_warnings: list[str] = []
|
||||
tasks_list = obj_entry.get("tasks", [])
|
||||
if not isinstance(tasks_list, list):
|
||||
tasks_list = []
|
||||
@@ -726,12 +807,14 @@ async def ws_import_json(
|
||||
"priority",
|
||||
"labels",
|
||||
"earliest_completion_days",
|
||||
"ref_no",
|
||||
"on_complete_action",
|
||||
"quick_complete_defaults",
|
||||
"assignee_pool",
|
||||
"required_completion_fields",
|
||||
"rotation_strategy",
|
||||
"reading_unit",
|
||||
"readings",
|
||||
# spare parts (ids remapped below)
|
||||
"consumes_parts",
|
||||
"part_ref",
|
||||
@@ -878,6 +961,16 @@ async def ws_import_json(
|
||||
cleaned = [c for c in cleaned if c]
|
||||
task_data["checklist"] = cleaned[:MAX_CHECKLIST_ITEMS]
|
||||
|
||||
# #161 phase 2: reading slots — same shape rules as the WS write.
|
||||
if task_data.get("readings") is not None:
|
||||
from ..helpers.reading_slots import sanitize_reading_slots
|
||||
|
||||
slots = sanitize_reading_slots(task_data["readings"])
|
||||
if slots:
|
||||
task_data["readings"] = slots
|
||||
else:
|
||||
task_data.pop("readings", None)
|
||||
|
||||
# schedule_time: strict HH:MM, otherwise drop
|
||||
st = task_data.get("schedule_time")
|
||||
if st is not None:
|
||||
@@ -889,11 +982,23 @@ async def ws_import_json(
|
||||
# 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:
|
||||
# The export carries the live per-entity trigger state
|
||||
# (accumulated runtime hours, counter baseline, change count)
|
||||
# merged in as ``_trigger_state``. The validator strips it as
|
||||
# an unknown key, so a restore silently started every
|
||||
# sensor trigger from zero (bug review 2026-09-04). Keep it
|
||||
# aside and re-attach it: the fresh entry's first setup
|
||||
# migrates it into the Store like any other dynamic field.
|
||||
trigger_state = tc.pop("_trigger_state", None)
|
||||
tc_errors, _warnings = _validate_trigger_config(hass, tc)
|
||||
if tc_errors:
|
||||
task_data.pop("trigger_config", None)
|
||||
task_warnings.append(f"{task_name}: trigger dropped — {tc_errors[0]}")
|
||||
elif isinstance(trigger_state, dict) and trigger_state:
|
||||
tc["_trigger_state"] = trigger_state
|
||||
elif tc is not None:
|
||||
task_data.pop("trigger_config", None)
|
||||
task_warnings.append(f"{task_name}: trigger dropped — not a mapping")
|
||||
|
||||
import_tasks[task_id] = task_data
|
||||
import_obj["task_ids"].append(task_id)
|
||||
@@ -907,6 +1012,27 @@ async def ws_import_json(
|
||||
if nfc_warn:
|
||||
nfc_warnings.append(nfc_warn)
|
||||
|
||||
# (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).
|
||||
# Done BEFORE the entry is created: the docs get fresh ids, and the
|
||||
# history entries (completion photos, #161) and spare parts (doc_id)
|
||||
# that point at them by id must be re-pointed before they are
|
||||
# persisted — the export carries the old ids for exactly this.
|
||||
doc_store = None
|
||||
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:
|
||||
doc_id_map: dict[str, str] = {}
|
||||
await doc_store.async_import_documents(
|
||||
obj_id, import_docs, task_id_map=task_id_map, part_id_map=part_id_map, id_map=doc_id_map
|
||||
)
|
||||
if doc_id_map:
|
||||
_remap_document_refs(import_tasks, import_parts, doc_id_map)
|
||||
|
||||
try:
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
@@ -920,6 +1046,7 @@ async def ws_import_json(
|
||||
except Exception:
|
||||
_LOGGER.exception("JSON import failed for %s", obj_name)
|
||||
errors.append({"name": obj_name, "reason": "unexpected error"})
|
||||
await _drop_imported_documents(doc_store, obj_id)
|
||||
continue
|
||||
if result["type"] == "create_entry":
|
||||
entry_info: dict[str, Any] = {
|
||||
@@ -927,8 +1054,10 @@ async def ws_import_json(
|
||||
"name": obj_name,
|
||||
"task_count": len(import_tasks),
|
||||
}
|
||||
if nfc_warnings:
|
||||
entry_info["warnings"] = nfc_warnings
|
||||
if nfc_warnings or task_warnings:
|
||||
entry_info["warnings"] = nfc_warnings + task_warnings
|
||||
for warning in task_warnings:
|
||||
_LOGGER.warning("JSON import of %s: %s", obj_name, warning)
|
||||
created.append(entry_info)
|
||||
|
||||
# Restore tracked part stocks into the new entry's Store.
|
||||
@@ -948,21 +1077,9 @@ async def ws_import_json(
|
||||
|
||||
if new_entry is not None:
|
||||
schedule_buy_task_reconcile(hass, new_entry)
|
||||
|
||||
# (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, task_id_map=task_id_map, part_id_map=part_id_map
|
||||
)
|
||||
else:
|
||||
errors.append({"name": obj_name, "reason": result.get("reason", "unknown")})
|
||||
await _drop_imported_documents(doc_store, obj_id)
|
||||
|
||||
resp: dict[str, Any] = {
|
||||
"imported": created,
|
||||
|
||||
Reference in new issue
Block a user