189 files

This commit is contained in:
Home Assistant Version Control committed 2026-09-08 18:05:32 +00:00
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,