Files
HomeAssistantVS/custom_components/maintenance_supporter/websocket/io.py
T

1589 lines
68 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""WebSocket handlers for export, import, CSV, QR, and templates."""
from __future__ import annotations
import json as json_mod
import logging
import re
from functools import lru_cache
from typing import Any
from uuid import uuid4
import voluptuous as vol
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,
CONF_OBJECT_MANUFACTURER,
CONF_OBJECT_MODEL,
CONF_TASKS,
DOMAIN,
MAX_ENTITY_SLUG_LENGTH,
MAX_ID_LENGTH,
MAX_IMPORT_PAYLOAD_BYTES,
MAX_JSON_IMPORT_PAYLOAD_BYTES,
MAX_VACATION_EXEMPT_TASKS,
)
from ..helpers.aggregate import get_store, object_name
from ..helpers.dates import normalize_hhmm, parse_iso_date
from ..helpers.global_options import get_default_warning_days
from ..helpers.history import finite_amount
from ..helpers.parts import map_part_links
from ..helpers.phases import clamp_phase_cursor, sanitize_phase_defs, sanitize_phase_sequence
from ..helpers.qr_generator import (
_ACTION_ICON_MAP,
build_qr_url,
generate_qr_svg,
generate_qr_svg_data_uri,
)
from ..websocket.tasks import _check_nfc_tag_duplicate, _validate_trigger_config
from . import ID_FIELD, _get_object_entries, _load_object_entry, _load_object_task, _merge_global_options
_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.
``paused_at`` is a *marker* whose mere presence means "paused"; a garbage
value imported from a hand-edited/foreign backup would otherwise freeze the
object as paused forever (and a malformed ``paused_until`` means auto-resume
never fires). Validate on import so only a real timestamp restores the state.
"""
from datetime import datetime
if not isinstance(value, str) or not value.strip():
return None
s = value.strip()
try:
datetime.fromisoformat(s.replace("Z", "+00:00"))
return s
except ValueError:
return s if parse_iso_date(s) is not None else None
def _sanitize_history(history: Any) -> list[dict[str, Any]]:
"""Scrub imported history entries: drop a non-finite/negative ``cost``.
Every live write path range-guards cost, but import copied history verbatim
and ``json.loads``/``yaml.safe_load`` both accept ``NaN``/``Infinity``. Such
a value would poison budget aggregation (a `+inf` fake "budget exceeded"
alert, or `nan` silently disabling all alerts). The completion still counts;
only the bad cost is removed.
"""
import math
if not isinstance(history, list):
return []
out: list[dict[str, Any]] = []
dropped = 0
for entry in history:
if not isinstance(entry, dict):
continue
clean = dict(entry)
# The timestamp is what every reader sorts, compares and parses as an
# ISO string: a number (epoch) or junk from a hand-edited backup made
# the next completion raise TypeError (bug audit 2026-09-27). A
# numeric epoch is converted; an entry whose moment cannot be read at
# all is dropped — junk text would also sort above every real date
# and erase the anchor. An ABSENT timestamp stays as it was (readers
# treat it as the empty string).
if clean.get("timestamp") is not None:
stamp = _history_timestamp(clean["timestamp"])
if stamp is None:
dropped += 1
continue
clean["timestamp"] = stamp
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)
# Duration: text or NaN made the object's average-duration sum raise
# on every refresh (bug audit 2026-09-26) — numeric text is kept as a
# number, anything else dropped.
if "duration" in clean:
minutes = finite_amount(clean["duration"])
if minutes is None:
clean.pop("duration", None)
else:
clean["duration"] = int(minutes) if minutes.is_integer() else minutes
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)
if dropped:
_LOGGER.warning("Import: dropped %d history entr(y/ies) without a readable timestamp", dropped)
return out
def _history_timestamp(value: Any) -> str | None:
"""An imported history ``timestamp`` as an ISO string, or None.
ISO datetime / date strings are kept verbatim (live history mixes aware
and naive values, and readers compare them as strings); an int / float
is taken as a Unix epoch (seconds, or milliseconds when that large) and
converted to UTC ISO; anything else is unreadable.
"""
from datetime import UTC, datetime
if isinstance(value, str):
text = value.strip()
if not text:
return None
try:
datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
return None
return text
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
seconds = float(value)
if seconds > 1e11: # milliseconds
seconds /= 1000
try:
return datetime.fromtimestamp(seconds, tz=UTC).isoformat()
except (OverflowError, OSError, ValueError):
return None
# ── Replace lineage + shared pools across an import (bug audit 2026-09-27) ──
#
# An import mints NEW entry ids, but objects refer to each other by entry id:
# a replacement's ``predecessor_entry_id`` (which also exempts it from the
# name rule — the successor usually keeps the old name), the retired object's
# ``replaced_by_entry_id``, a ``parent_entry_id`` and a task's part link into
# another object's pool (#111). Copied verbatim, every one of them pointed at
# the SOURCE instance's objects: a replaced pair restored onto a clean
# instance failed with "already_configured" for the active successor, and
# every shared-pool link was dropped. Objects are therefore created in
# dependency order and these ids remapped through an old→new map.
_LINEAGE_KEYS = ("predecessor_entry_id", "replaced_by_entry_id", "parent_entry_id")
def _object_dependencies(obj_entry: Any) -> set[str]:
"""Old entry ids an import payload object needs created BEFORE it: the
object it replaced and the owners of the pools its tasks draw on."""
from ..helpers.parts import iter_part_links
deps: set[str] = set()
if not isinstance(obj_entry, dict):
return deps
obj_data = obj_entry.get("object")
pred = obj_data.get("predecessor_entry_id") if isinstance(obj_data, dict) else None
if isinstance(pred, str) and pred:
deps.add(pred)
tasks = obj_entry.get("tasks")
for task in tasks if isinstance(tasks, list) else []:
if isinstance(task, dict):
deps.update(str(link["entry_id"]) for link in iter_part_links(task) if link.get("entry_id"))
deps.discard(str(obj_entry.get("entry_id") or ""))
return deps
def _import_order(objects: list[Any]) -> list[int]:
"""Indexes of ``objects``, each after the payload objects it depends on
(:func:`_object_dependencies`); otherwise the payload order. A cycle is
broken where it closes (those references then fall back per key)."""
by_old: dict[str, int] = {}
for idx, obj_entry in enumerate(objects):
old = obj_entry.get("entry_id") if isinstance(obj_entry, dict) else None
if isinstance(old, str) and old:
by_old.setdefault(old, idx)
deps = [sorted(by_old[d] for d in _object_dependencies(o) if d in by_old) for o in objects]
order: list[int] = []
done: set[int] = set()
for root in range(len(objects)):
if root in done:
continue
on_stack = {root}
stack = [(root, iter(deps[root]))]
while stack:
node, pending = stack[-1]
nxt = next(pending, None)
if nxt is None:
stack.pop()
on_stack.discard(node)
done.add(node)
order.append(node)
elif nxt not in done and nxt not in on_stack:
on_stack.add(nxt)
stack.append((nxt, iter(deps[nxt])))
return order
class _ImportLineage:
"""Old→new entry ids (and part ids) of one import run."""
def __init__(self, hass: HomeAssistant, objects: list[Any]) -> None:
self._hass = hass
self.payload_ids = {
str(o["entry_id"]) for o in objects if isinstance(o, dict) and isinstance(o.get("entry_id"), str) and o["entry_id"]
}
self.entry_ids: dict[str, str] = {}
self.part_ids: dict[str, dict[str, str]] = {}
self._pending: list[tuple[str, dict[str, str]]] = []
def _live(self, entry_id: str) -> bool:
return self._hass.config_entries.async_get_entry(entry_id) is not None
def apply(self, import_obj: dict[str, Any], obj_data: dict[str, Any]) -> dict[str, str]:
"""Set the lineage ids on a new object dict; returns the references to
a payload object that is not created yet (resolved by :meth:`finish`).
An id outside the payload is kept verbatim (a same-instance partial
restore keeps it valid; a stale one degrades gracefully at read
time). A payload id maps to the object's new entry id; until that
exists it falls back to a live object with the old id, else None.
"""
pending: dict[str, str] = {}
for key in _LINEAGE_KEYS:
old = obj_data.get(key)
if not isinstance(old, str) or not old:
import_obj[key] = None
continue
if old not in self.payload_ids:
import_obj[key] = old
continue
new = self.entry_ids.get(old)
if new is None:
pending[key] = old
new = old if self._live(old) else None
import_obj[key] = new
return pending
def created(self, old_entry_id: Any, new_entry_id: str, part_id_map: dict[str, str], pending: dict[str, str]) -> None:
if isinstance(old_entry_id, str) and old_entry_id:
self.entry_ids[old_entry_id] = new_entry_id
self.part_ids[old_entry_id] = dict(part_id_map)
if pending:
self._pending.append((new_entry_id, pending))
def link_rewriter(self, own_old_entry_id: str, part_id_map: dict[str, str]) -> Any:
"""The rewrite for one object's part links (helpers.parts.map_part_links):
own links follow the fresh part ids, a pool link to a payload object
follows that object's new ids, a link to a live object (a
same-instance import) stays, the rest is dropped rather than left
pointing nowhere."""
def _rewrite(link: dict[str, Any]) -> dict[str, Any] | None:
owner = str(link.get("entry_id") or "").strip()
part_id = str(link.get("part_id") or "")
if not owner or owner == own_old_entry_id:
if part_id not in part_id_map:
return None
return {"part_id": part_id_map[part_id], "quantity": link.get("quantity", 1)}
new_owner = self.entry_ids.get(owner)
owner_parts = self.part_ids.get(owner) or {}
if new_owner is not None and part_id in owner_parts:
return {**link, "entry_id": new_owner, "part_id": owner_parts[part_id]}
return link if self._live(owner) else None
return _rewrite
def finish(self) -> None:
"""Point the references to objects created LATER at their new ids."""
for new_entry_id, refs in self._pending:
entry = self._hass.config_entries.async_get_entry(new_entry_id)
if entry is None:
continue
obj = dict(entry.data.get(CONF_OBJECT) or {})
changed = {key: self.entry_ids[old] for key, old in refs.items() if old in self.entry_ids}
if not changed:
continue
obj.update(changed)
self._hass.config_entries.async_update_entry(entry, data={**entry.data, CONF_OBJECT: obj})
if "parent_entry_id" in changed:
# The via_device hierarchy is built when entities are added.
self._hass.config_entries.async_schedule_reload(new_entry_id)
# A retired object imported without its successor pointer (the CSV
# carries only the predecessor) gets it back from the successor.
created = set(self.entry_ids.values())
for new_entry_id in created:
successor = self._hass.config_entries.async_get_entry(new_entry_id)
pred_id = (successor.data.get(CONF_OBJECT) or {}).get("predecessor_entry_id") if successor else None
if pred_id not in created:
continue
pred = self._hass.config_entries.async_get_entry(pred_id)
pred_obj = dict((pred.data.get(CONF_OBJECT) or {}) if pred else {})
if pred is not None and not pred_obj.get("replaced_by_entry_id"):
pred_obj["replaced_by_entry_id"] = new_entry_id
self._hass.config_entries.async_update_entry(pred, data={**pred.data, CONF_OBJECT: pred_obj})
def _stamp_imported_action_owner(task_data: dict[str, Any], user_id: str | None) -> None:
"""A completion action restored from a file runs as the IMPORTING admin.
The file's ``configured_by`` is never trusted — it could name any user,
an admin included (json/import is admin-only, but the file may come from
anywhere). Stamping the admin who imported it keeps the SEC-2 model
("every action runs as a real user") instead of silently falling back to
system rights, and records who authorised it (bug audit 2026-09-27;
docs/CONFIGURATION.md "Who the action runs as").
"""
from ..helpers.sanitize import ACTION_OWNER_KEY
action = task_data.get("on_complete_action")
if not isinstance(action, dict):
return
action = {k: v for k, v in action.items() if k != ACTION_OWNER_KEY}
if user_id:
action[ACTION_OWNER_KEY] = user_id
task_data["on_complete_action"] = action
def _future_last_performed(value: Any) -> bool:
"""A last-performed date after today — dropped on import with a warning
(bug audit 2026-09-27: a year-9999 anchor overflowed the schedule math
inside every refresh and kept the object in setup-retry)."""
from homeassistant.util import dt as dt_util
parsed = parse_iso_date(value) if isinstance(value, str) else None
return parsed is not None and parsed > dt_util.now().date()
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],
import_obj: dict[str, Any],
obj_name: str,
) -> bool:
"""Restore the battery-fleet markers onto ``import_obj``; True if it is the fleet.
The exported flag is honoured only while this instance has NO fleet yet
(``find_fleet_entry`` returns the FIRST flagged entry, so a second flagged
object would silently shadow or be shadowed by the existing one). The
exclude/include lists are re-validated like the live WS writes: entity
ids only, deduped + sorted, capped at ``FLEET_LIST_CAP``.
"""
if obj_data.get(BATTERY_FLEET_OBJECT_FLAG) is not True:
return False
from homeassistant.core import valid_entity_id
from ..helpers.battery_fleet_setup import FLEET_LIST_CAP, find_fleet_entry
if find_fleet_entry(hass) is not None:
_LOGGER.warning(
"JSON import: %r is flagged as the Battery Fleet but this instance already has one — importing it as a plain object",
obj_name,
)
return False
import_obj[BATTERY_FLEET_OBJECT_FLAG] = True
for key in (BATTERY_FLEET_EXCLUDED, BATTERY_FLEET_INCLUDED):
raw_list = obj_data.get(key)
if not isinstance(raw_list, list):
continue
cleaned = sorted({e.strip() for e in raw_list if isinstance(e, str) and valid_entity_id(e.strip())})
if cleaned:
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
def _keep_fleet_part_id(is_fleet: bool, old_id: str) -> bool:
"""Whether an imported part keeps its id instead of getting a fresh uuid.
Only the fleet's deterministic type-part ids (``batt_<type>``, minted by
battery_fleet_setup._type_part) — every other part id is re-minted so an
import can never collide with or impersonate an existing part.
"""
return is_fleet and old_id.startswith("batt_") and len(old_id) <= MAX_ID_LENGTH
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/version"})
@websocket_api.async_response
async def ws_version(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> None:
"""The installed integration version (manifest).
Roadmap guard 2 — stale-bundle handshake: the panel compares this against
the version esbuild stamped into its bundle and offers a reload when a
cached old frontend is talking to a newer backend (HA's service worker
updates stale-while-revalidate, so this happens routinely after updates).
"""
from homeassistant.loader import async_get_integration
integration = await async_get_integration(hass, DOMAIN)
connection.send_result(msg["id"], {"version": integration.version})
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/templates",
# v2.21.1: the caller's UI language — template/task names arrive
# localized. Falls back to the server language.
vol.Optional("language"): vol.All(str, vol.Length(max=10)),
}
)
@websocket_api.async_response
async def ws_get_templates(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return all maintenance templates.
Every template is returned with a ``disabled`` flag (v2.21 gallery
curation): the pickers hide disabled ones client-side, while the Settings
section needs the full list to render the toggles. v2.93 adds the home
``profile`` (dwelling, climate, country — all derived locally) and per
template whether the gallery recommends it and why; 2.94 ``set_up`` —
an active object already stands for it (helpers.template_usage), so it
is not recommended again.
"""
from ..helpers.home_profile import async_home_profile
from ..helpers.i18n import normalize_language, normalize_language_code
from ..helpers.template_usage import templates_in_use
from ..templates import (
TEMPLATE_CATEGORIES,
TEMPLATES,
get_disabled_template_ids,
localize_template_text,
recommend_template,
task_interval,
template_tasks,
)
disabled = get_disabled_template_ids(hass)
profile = await async_home_profile(hass)
in_use = templates_in_use(hass)
lang = normalize_language_code(msg.get("language")) if msg.get("language") else normalize_language(hass)
result = {
"categories": {cat_id: {k: v for k, v in cat.items()} for cat_id, cat in TEMPLATE_CATEGORIES.items()},
"profile": profile.as_dict(),
"templates": [
{
"id": t.id,
"name": localize_template_text(t.name, lang),
"category": t.category,
"disabled": t.id in disabled,
**recommend_template(t, profile, set_up=t.id in in_use),
"tasks": [
{
"name": localize_template_text(tt.name, lang),
"type": tt.type,
"schedule_type": tt.schedule_type,
"interval_days": task_interval(tt, profile.country, profile.region),
"warning_days": tt.warning_days,
}
# What creating it here makes: winter-only tasks left
# out without a cold season, the country's cycle.
for tt in template_tasks(t, has_winter=profile.has_winter, country=profile.country, region=profile.region)
],
}
for t in TEMPLATES
],
}
connection.send_result(msg["id"], result)
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/export",
vol.Optional("format", default="json"): vol.In(["json", "yaml"]),
vol.Optional("include_history", default=True): bool,
# Selective export: restrict to these object entry_ids (omit = all).
vol.Optional("entry_ids"): [ID_FIELD],
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def ws_export_data(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Export all (or a selection of) maintenance data as JSON or YAML."""
from ..export import build_export_data, serialize_export
fmt = msg.get("format", "json")
include_history = msg.get("include_history", True)
entry_ids = set(msg["entry_ids"]) if msg.get("entry_ids") else None
# Phase 1: gather data on the event loop (accesses HA APIs)
data = build_export_data(hass, include_history=include_history, entry_ids=entry_ids)
# Phase 2: serialize in executor (CPU-bound, no HA API calls)
result = await hass.async_add_executor_job(serialize_export, data, fmt)
connection.send_result(msg["id"], {"format": fmt, "data": result})
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/csv/export",
vol.Optional("entry_ids"): [ID_FIELD],
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def ws_export_csv(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Export all (or a selection of) maintenance data as CSV."""
from ..helpers.csv_handler import export_objects_csv
entry_ids = set(msg["entry_ids"]) if msg.get("entry_ids") else None
csv_data = export_objects_csv(hass, entry_ids=entry_ids)
connection.send_result(msg["id"], {"csv": csv_data})
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/objects/csv",
vol.Optional("entry_ids"): [ID_FIELD],
}
)
@websocket_api.async_response
async def ws_export_objects_csv(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Export one row per maintenance object as CSV (#67), all or a selection.
Not admin-gated: it exposes only the asset fields the panel already sends
to every user via ``maintenance_supporter/objects`` (no cost/history).
"""
from ..helpers.csv_handler import export_object_records_csv
entry_ids = set(msg["entry_ids"]) if msg.get("entry_ids") else None
csv_data = export_object_records_csv(hass, entry_ids=entry_ids)
connection.send_result(msg["id"], {"csv": csv_data})
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/csv/import",
vol.Required("csv_content"): str,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def ws_import_csv(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Import maintenance objects from CSV content."""
from ..helpers.csv_handler import import_objects_csv
csv_content = msg["csv_content"]
# Guard against oversized payloads (max 1MB / 1000 objects)
if len(csv_content) > MAX_IMPORT_PAYLOAD_BYTES:
connection.send_error(msg["id"], "too_large", "CSV content exceeds 1MB limit")
return
objects = import_objects_csv(csv_content, hass=hass)
if len(objects) > 1000:
connection.send_error(msg["id"], "too_many", "CSV contains more than 1000 objects")
return
if not objects:
connection.send_error(msg["id"], "empty_csv", "No valid objects found in CSV")
return
created = []
errors: list[dict[str, str]] = []
# Rows are grouped by the source object (object_entry_id column), so a
# replaced pair of the same name stays two objects; the pair's lineage
# is remapped like the JSON import's (bug audit 2026-09-27).
lineage = _ImportLineage(hass, objects)
for idx in _import_order(objects):
obj_data = objects[idx]
pending = lineage.apply(obj_data["object"], obj_data["object"])
# Check for NFC tag duplicates in CSV-imported tasks
nfc_warnings: list[str] = list(obj_data.get("warnings") or [])
for t_data in obj_data.get("tasks", {}).values():
nfc_val = t_data.get("nfc_tag_id")
if nfc_val:
nfc_warn = _check_nfc_tag_duplicate(hass, nfc_val)
if nfc_warn:
nfc_warnings.append(nfc_warn)
try:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": "websocket"},
data={
CONF_OBJECT: obj_data["object"],
CONF_TASKS: obj_data["tasks"],
},
)
except Exception:
obj_name = obj_data.get("object", {}).get("name", f"row {idx + 1}")
_LOGGER.exception("CSV import failed for %s", obj_name)
errors.append({"name": obj_name, "reason": "unexpected error"})
continue
if result["type"] == "create_entry":
lineage.created(obj_data.get("entry_id"), result["result"].entry_id, {}, pending)
entry_info: dict[str, Any] = {
"entry_id": result["result"].entry_id,
"name": obj_data["object"].get("name", ""),
"task_count": len(obj_data["tasks"]),
}
if nfc_warnings:
entry_info["warnings"] = nfc_warnings
created.append(entry_info)
else:
obj_name = obj_data.get("object", {}).get("name", f"row {idx + 1}")
errors.append({"name": obj_name, "reason": result.get("reason", "unknown")})
lineage.finish()
resp: dict[str, Any] = {
"imported": created,
"total": len(objects),
"created": len(created),
}
if errors:
resp["errors"] = errors
connection.send_result(msg["id"], resp)
def _parse_structured(raw: str) -> Any:
"""Parse JSON *or* YAML export content into a Python object.
Both formats are accepted so every structured export (JSON and YAML)
round-trips back through the importer. Raises ValueError if the content
parses to neither a mapping nor a list.
"""
try:
return json_mod.loads(raw)
except (json_mod.JSONDecodeError, ValueError):
pass
import yaml # type: ignore[import-untyped]
try:
loaded = yaml.safe_load(raw)
except yaml.YAMLError as err:
raise ValueError("not valid JSON or YAML") from err
# safe_load returns a bare string/scalar for non-structured text (e.g. a
# CSV blob) — require an object/array so those route elsewhere cleanly.
if not isinstance(loaded, (dict, list)):
raise ValueError("not valid JSON or YAML")
return loaded
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/settings/export",
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def ws_export_settings(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Export the global entry's settings as JSON.
The objects export deliberately excludes the global scope (groups, saved
views, vacation, notification/budget settings, feature toggles) — this is
its second half. Import goes through the regular json/import command,
which recognizes the ``global_settings`` section.
"""
from ..export import build_settings_export
connection.send_result(
msg["id"],
{"format": "json", "data": json_mod.dumps(build_settings_export(hass), indent=2)},
)
def _apply_settings_import(hass: HomeAssistant, raw: dict[str, Any]) -> list[str]:
"""Apply an imported ``global_settings`` payload; returns the applied keys.
Scalar settings run through the SAME validation as the ``global/update``
WS command (``sanitize_settings_input``); an invalid notify_service is
dropped rather than failing the import. The structured sections reuse
their own sanitizers: saved views via ``sanitize_view``, groups shape-
checked here, vacation dates validated like ``vacation/update``. Group
task_refs and vacation exempt ids may point at objects of the SOURCE
instance — they are kept verbatim (same-instance restores keep them
valid; elsewhere they degrade gracefully like every stale reference).
"""
from ..const import (
CONF_GROUPS,
CONF_SAVED_FILTER_VIEWS,
CONF_VACATION_BUFFER_DAYS,
CONF_VACATION_ENABLED,
CONF_VACATION_END,
CONF_VACATION_EXEMPT_TASK_IDS,
CONF_VACATION_START,
MAX_GROUP_TASK_REFS,
MAX_NAME_LENGTH,
MAX_SAVED_VIEWS,
)
from ..export import _NON_PORTABLE_SETTINGS
from ..helpers.global_options import get_global_entry
from ..helpers.saved_views import sanitize_view
from ..helpers.settings_registry import ALLOWED_SETTING_KEYS
from .dashboard import sanitize_settings_input, settings_error_field
entry = get_global_entry(hass)
if entry is None or not isinstance(raw, dict):
return []
scalars = {k: v for k, v in raw.items() if k in ALLOWED_SETTING_KEYS and k not in _NON_PORTABLE_SETTINGS}
# The sanitizer stops at the FIRST invalid field and hands back what it
# had so far. The import used to drop only notify_service after any error
# — an invalid search template (javascript: included) or shopping list was
# saved as-is and the later checks never ran (found 2026-09-26). Drop the
# field each error names and validate the rest again.
filtered: dict[str, Any] = {}
for _ in range(len(scalars) + 1):
filtered, error = sanitize_settings_input(scalars)
if error is None:
break
bad = settings_error_field(error)
_LOGGER.warning("Settings import: %s dropped (%s)", bad, error)
if bad not in scalars:
filtered = {}
break
scalars = {k: v for k, v in scalars.items() if k != bad}
groups_in = raw.get(CONF_GROUPS)
if isinstance(groups_in, dict):
groups: dict[str, dict[str, Any]] = {}
for gid, g in groups_in.items():
if not isinstance(g, dict) or not str(g.get("name") or "").strip():
continue
refs = [
{"entry_id": str(r["entry_id"]), "task_id": str(r["task_id"])}
for r in (g.get("task_refs") or [])
if isinstance(r, dict) and r.get("entry_id") and r.get("task_id")
][:MAX_GROUP_TASK_REFS]
groups[str(gid)] = {
"name": str(g["name"]).strip()[:MAX_NAME_LENGTH],
"description": str(g.get("description") or "")[:MAX_NAME_LENGTH],
"task_refs": refs,
}
if groups:
filtered[CONF_GROUPS] = groups
views_in = raw.get(CONF_SAVED_FILTER_VIEWS)
if isinstance(views_in, list):
views = []
for v in views_in[:MAX_SAVED_VIEWS]:
clean = sanitize_view(v, view_id=str(v.get("id")) if isinstance(v, dict) and v.get("id") else None)
if clean is not None:
views.append(clean)
if views:
filtered[CONF_SAVED_FILTER_VIEWS] = views
if isinstance(raw.get(CONF_VACATION_ENABLED), bool):
filtered[CONF_VACATION_ENABLED] = raw[CONF_VACATION_ENABLED]
# Same bound as vacation/update: a date more than MAX_INTERVAL_DAYS out
# is no vacation — 9999-12-31 overflowed the calendar inside every
# object's refresh (bug audit 2026-09-27). Dropped with a warning.
from datetime import timedelta
from homeassistant.util import dt as dt_util
from ..const import MAX_INTERVAL_DAYS
latest_vacation_day = dt_util.now().date() + timedelta(days=MAX_INTERVAL_DAYS)
for key in (CONF_VACATION_START, CONF_VACATION_END):
val = raw.get(key)
parsed_day = parse_iso_date(val) if isinstance(val, str) else None
if parsed_day is None:
continue
if parsed_day > latest_vacation_day:
_LOGGER.warning("Settings import: %s %s is more than %d days away — dropped", key, val, MAX_INTERVAL_DAYS)
continue
filtered[key] = val
if isinstance(raw.get(CONF_VACATION_BUFFER_DAYS), int) and not isinstance(raw.get(CONF_VACATION_BUFFER_DAYS), bool):
filtered[CONF_VACATION_BUFFER_DAYS] = raw[CONF_VACATION_BUFFER_DAYS]
exempt = raw.get(CONF_VACATION_EXEMPT_TASK_IDS)
if isinstance(exempt, list):
cleaned = [t.strip() for t in exempt if isinstance(t, str) and t.strip()][:MAX_VACATION_EXEMPT_TASKS]
filtered[CONF_VACATION_EXEMPT_TASK_IDS] = cleaned
if not filtered:
return []
_merge_global_options(hass, entry, filtered)
_LOGGER.info("Settings import applied %d key(s)", len(filtered))
return sorted(filtered)
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/json/import",
vol.Required("json_content"): str,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def ws_import_json(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Import maintenance objects from JSON or YAML content (from /export)."""
from ..templates import KNOWN_TEMPLATE_IDS
raw = msg["json_content"]
if len(raw) > MAX_JSON_IMPORT_PAYLOAD_BYTES:
connection.send_error(msg["id"], "too_large", "Content exceeds 10MB limit")
return
try:
data = _parse_structured(raw)
except ValueError:
connection.send_error(msg["id"], "invalid_format", "Content is not valid JSON or YAML")
return
has_settings = isinstance(data, dict) and isinstance(data.get("global_settings"), dict)
if not isinstance(data, dict) or ("objects" not in data and not has_settings):
connection.send_error(msg["id"], "invalid_format", "JSON must contain an 'objects' array")
return
# A settings export (see export.build_settings_export) may travel alone or
# alongside an objects payload — apply it first either way.
settings_applied: list[str] = []
if has_settings:
settings_applied = _apply_settings_import(hass, data["global_settings"])
objects = data.get("objects", [])
if not isinstance(objects, list):
connection.send_error(msg["id"], "invalid_format", "'objects' must be an array")
return
if len(objects) > 1000:
connection.send_error(msg["id"], "too_many", "JSON contains more than 1000 objects")
return
if not objects and not settings_applied:
connection.send_error(msg["id"], "empty", "No objects found in JSON")
return
created = []
errors: list[dict[str, str]] = []
importing_user = connection.user.id if connection.user else None
lineage = _ImportLineage(hass, objects)
for idx in _import_order(objects):
obj_entry = objects[idx]
# Guard against malformed-but-schema-valid input (the schema only checks
# json_content is a str): a non-dict entry / non-dict object would raise
# AttributeError and escape the per-object try/except below.
if not isinstance(obj_entry, dict):
errors.append({"name": f"object {idx + 1}", "reason": "not an object"})
continue
obj_data = obj_entry.get("object", {})
if not isinstance(obj_data, dict):
errors.append({"name": f"object {idx + 1}", "reason": "invalid object data"})
continue
obj_name = (obj_data.get("name") or "").strip()
if not obj_name:
errors.append({"name": f"object {idx + 1}", "reason": "missing name"})
continue
obj_id = uuid4().hex
import_obj: dict[str, Any] = {
"id": obj_id,
"name": obj_name,
"manufacturer": obj_data.get("manufacturer"),
"model": obj_data.get("model"),
"serial_number": obj_data.get("serial_number"),
"area_id": obj_data.get("area_id"),
"installation_date": obj_data.get("installation_date"),
"warranty_expiry": obj_data.get("warranty_expiry"),
# Imported counterparts of the export fields above; length-capped by
# cap_object_fields and the frontend only renders http(s) doc URLs.
"documentation_url": obj_data.get("documentation_url"),
"notes": obj_data.get("notes"),
# 2.19: device link. Same-instance restores keep it valid; a
# stale id degrades gracefully at read time. The parent and the
# replace lineage are remapped just below (_ImportLineage).
"ha_device_id": obj_data.get("ha_device_id"),
# 2.20: seasonal pause round-trips (a paused pool restored in
# winter stays paused).
"paused_at": _iso_marker(obj_data.get("paused_at")),
"paused_until": _iso_marker(obj_data.get("paused_until")),
# Object-level archive marker — same presence-means-archived
# 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": [],
}
# 2.94: the source template, when the backup names one we know.
if obj_data.get("template_id") in KNOWN_TEMPLATE_IDS:
import_obj["template_id"] = obj_data["template_id"]
# parent / predecessor / replaced_by → the NEW entry ids.
lineage_pending = lineage.apply(import_obj, obj_data)
own_old_entry_id = str(obj_entry.get("entry_id") or "")
# Battery fleet identity (object flag + exclude/include lists + the
# self-charging opt-in). The fleet is ONE object by invariant
# (find_fleet_entry returns the first flagged entry), so the flag is
# only restored when this instance has no fleet yet — otherwise the
# payload imports as a plain object (fresh part ids, no task flag).
is_fleet = _import_fleet_identity(hass, obj_data, import_obj, obj_name)
# Spare parts: regenerate ids (like tasks) and remember the mapping so
# task-side links (consumes_parts / part_ref) can be rewritten below.
# Stock is dynamic Store state — collected here, written after setup.
# Fleet type-parts keep their deterministic ``batt_<type>`` ids: the
# fleet reconcile / mark-replaced paths key on them, so a re-minted
# uuid would orphan the whole battery-type ↔ part mapping.
from uuid import uuid4 as _uuid4
part_id_map: dict[str, str] = {}
import_parts: dict[str, dict[str, Any]] = {}
part_stocks: dict[str, float] = {}
parts_list = obj_entry.get("parts", [])
if isinstance(parts_list, list):
for part_entry in parts_list:
if not isinstance(part_entry, dict) or not (part_entry.get("name") or "").strip():
continue
old_id = str(part_entry.get("id") or "")
new_id = old_id if _keep_fleet_part_id(is_fleet, old_id) else _uuid4().hex
pdata = {k: v for k, v in part_entry.items() if k != "stock"}
pdata["id"] = new_id
# Drop a non-http(s) product_url — the WS write path validates it
# via _clean_url, but import copied it verbatim, so a crafted
# backup could persist a javascript: link (the panel now also
# guards the href, but keep bad data out of storage).
_purl = pdata.get("product_url")
if isinstance(_purl, str) and _purl.strip().lower().startswith(("http://", "https://")):
pdata["product_url"] = _purl.strip() # store trimmed so the render guard matches
else:
pdata.pop("product_url", None)
import_parts[new_id] = pdata
if old_id:
part_id_map[old_id] = new_id
stock = part_entry.get("stock")
if isinstance(stock, (int, float)) and not isinstance(stock, bool) and stock >= 0:
part_stocks[new_id] = stock
import_tasks: dict[str, dict[str, Any]] = {}
# old task id → new id, so document task-links (task_ids) can be
# 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 = []
for task_entry in tasks_list:
if not isinstance(task_entry, dict):
continue
task_name = (task_entry.get("name") or "").strip()
if not task_name:
continue
task_id = uuid4().hex
old_task_id = str(task_entry.get("id") or "")
if old_task_id:
task_id_map[old_task_id] = task_id
task_data: dict[str, Any] = {
"id": task_id,
"object_id": obj_id,
"name": task_name,
"type": task_entry.get("type", "custom"),
"enabled": task_entry.get("enabled", True),
"schedule_type": task_entry.get("schedule_type", "time_based"),
"warning_days": task_entry.get("warning_days", get_default_warning_days(hass)),
"history": _sanitize_history(task_entry.get("history", [])),
}
for key in (
# Provenance + lifecycle — mirror the export builder so an
# archived task stays archived and created_at (the next_due
# fallback anchor) survives the round trip.
"created_at",
"archived_at",
"archived_reason",
"interval_days",
"interval_unit",
"due_date",
"interval_anchor",
"last_planned_due",
# per-occurrence postpone (round-trips like last_planned_due)
"due_override",
# nested recurrence (calendar kinds) — config-flow normalize
# treats it as authoritative when present.
"schedule",
"last_performed",
"notes",
"documentation_url",
"custom_icon",
"nfc_tag_id",
"require_tag_scan",
"allow_skip",
"notify_enabled",
# #185: notification icon override (shape-checked below).
"notify_icon",
"responsible_user_id",
"entity_slug",
"trigger_config",
"adaptive_config",
"checklist",
"schedule_time",
# v2.17+ / #83 fields — mirror the export builder so a JSON
# backup round-trips them (validated/clamped just below).
"priority",
"labels",
# D#183: mirror targets (shape-sanitized below).
"mirror_todo_entities",
"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",
):
val = task_entry.get(key)
if val is not None:
task_data[key] = val
# The fleet's single aggregate task keeps its marker (detail view
# renders the battery section; the fleet reconcile repairs its
# trigger). Only ONE task may carry it, and only on the fleet.
if is_fleet and task_entry.get(BATTERY_FLEET_TASK_FLAG) is True and not fleet_task_seen:
task_data[BATTERY_FLEET_TASK_FLAG] = True
fleet_task_seen = True
# In-cycle checklist ticks: keyed by item TEXT so they survive the
# id regeneration; keys are filtered against the imported checklist
# exactly like the live checklist_progress WS write. Rides
# entry.data until the fresh entry's first setup migrates it into
# the Store (split-only field — storage._SPLIT_ONLY_TASK_FIELDS).
raw_progress = task_entry.get("checklist_progress")
if isinstance(raw_progress, dict):
items = set(task_data.get("checklist") or [])
progress = {k: bool(v) for k, v in raw_progress.items() if isinstance(k, str) and k in items}
if progress:
task_data["checklist_progress"] = progress
# #130: history entries carry used_parts, and since they are
# editable (stock reconciled by delta), the part ids must follow
# the regenerated ones. Own-part ids remap via part_id_map; links
# into another object's pool (entry_id set) are kept verbatim —
# if that entry doesn't exist in this instance they degrade to
# the safe recorded-only path, name preserved.
for hist_entry in task_data.get("history") or []:
used = hist_entry.get("used_parts")
if not isinstance(used, list):
continue
for link in used:
if (
isinstance(link, dict)
and not link.get("entry_id")
and link.get("part_id") in part_id_map
):
link["part_id"] = part_id_map[link["part_id"]]
# Task phases (#139): sanitize like the live WS write and clamp the
# cursor to the imported sequence. The cursor rides entry.data
# until the fresh entry's first setup migrates it into the Store
# (dynamic field), so a restore resumes mid-cycle.
raw_defs = task_entry.get("phases")
raw_seq = task_entry.get("phase_sequence")
if isinstance(raw_defs, dict) and isinstance(raw_seq, list):
defs = sanitize_phase_defs(raw_defs)
seq = sanitize_phase_sequence(raw_seq, defs)
if defs and seq:
task_data["phases"] = defs
task_data["phase_sequence"] = seq
task_data["phase_cursor"] = clamp_phase_cursor(task_entry.get("phase_cursor"), len(seq))
# Part links — task level AND per phase (helpers.parts.
# map_part_links, one rule for both): own links follow the
# regenerated part ids; a pool of another object (#111) follows
# that object's new ids when it is part of this import, stays
# when the object lives in THIS instance, and is dropped rather
# than restored pointing nowhere.
if task_data.get("consumes_parts") is not None and not isinstance(task_data["consumes_parts"], list):
task_data.pop("consumes_parts", None)
task_data, _links_changed = map_part_links(task_data, lineage.link_rewriter(own_old_entry_id, part_id_map))
ref = task_data.get("part_ref")
if isinstance(ref, dict) and ref.get("part_id") in part_id_map:
task_data["part_ref"] = {"part_id": part_id_map[ref["part_id"]]}
elif ref is not None:
task_data.pop("part_ref", None)
# A completion action runs as the importing admin — never as the
# user a file names (bug audit 2026-09-27, SEC-2).
_stamp_imported_action_owner(task_data, importing_user)
# Sanitize critical fields from import data
iv = task_data.get("interval_days")
if iv is not None and (not isinstance(iv, int) or iv < 1):
task_data.pop("interval_days", None)
lp = task_data.get("last_performed")
if lp is not None and parse_iso_date(lp) is None:
task_data.pop("last_performed", None)
elif _future_last_performed(lp):
task_data.pop("last_performed", None)
task_warnings.append(f"{task_name}: last performed date {lp} is in the future — dropped")
wd = task_data.get("warning_days")
if not isinstance(wd, int) or wd < 0 or wd > 365:
task_data["warning_days"] = get_default_warning_days(hass)
# A rotation task must carry its effective assignee (imports from
# pre-seeding exports may lack one) — same rule as create/update.
from ..helpers.sanitize import seed_rotation_assignee
seed_rotation_assignee(task_data)
# checklist (strip + truncate + cap), reading slots and to-do
# mirror targets are NOT re-sanitized here: the config flow's
# websocket step runs cap_task_fields on every imported task —
# the same code, and nothing in between reads them (DRY audit
# 2026-09-26 B). The rotation seed above stays: it looks at the
# raw strategy, which cap_task_fields would drop first.
# #185: notify_icon — same shape rule as the WS write paths; a
# malformed or empty value drops the override (type default).
if "notify_icon" in task_data:
from ..helpers.notify_icons import normalize_icon
icon = normalize_icon(task_data["notify_icon"])
if icon:
task_data["notify_icon"] = icon
else:
task_data.pop("notify_icon", None)
# schedule_time: canonical HH:MM. The options flow's TimeSelector
# stores "HH:MM:SS" and the export writes it verbatim — that used
# to be DROPPED here (strict HH:MM), so a backup lost the time.
st = task_data.get("schedule_time")
if st is not None:
normalized = normalize_hhmm(st)
if normalized is None:
task_data.pop("schedule_time", None)
else:
task_data["schedule_time"] = normalized
# entity_slug: the WS create/update paths reject anything but
# [a-z0-9_]+ (it becomes part of the entity_id); import copied the
# value verbatim. Normalise to that alphabet (HA's slugify would
# turn all-junk into "unknown"), drop it when nothing valid
# remains, and say so — a changed slug changes the entity ids
# (bug audit 2026-09-12).
raw_slug = task_data.get("entity_slug")
if raw_slug is not None:
slug = (
re.sub(r"[^a-z0-9_]+", "_", raw_slug.strip().lower()).strip("_")[:MAX_ENTITY_SLUG_LENGTH]
if isinstance(raw_slug, str)
else ""
)
if not slug:
task_data.pop("entity_slug", None)
task_warnings.append(f"{task_name}: entity_slug dropped — not [a-z0-9_]+")
elif slug != raw_slug:
task_data["entity_slug"] = slug
task_warnings.append(f"{task_name}: entity_slug normalised to {slug!r}")
# Validate an imported trigger_config the same way the WS create/update
# path does — strip unknown keys, normalize entity_ids, and drop it
# entirely if invalid — so import isn't a hole around trigger validation.
tc = task_data.get("trigger_config")
if isinstance(tc, dict):
# 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)
# Check for NFC tag duplicates across imported tasks
nfc_warnings: list[str] = []
for t_data in import_tasks.values():
nfc_val = t_data.get("nfc_tag_id")
if nfc_val:
nfc_warn = _check_nfc_tag_duplicate(hass, nfc_val)
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] = {}
# Outside the per-object try below on purpose (the docs must
# exist before the entry is created) — so a crash here used to
# abort the WHOLE import without a reply. The store skips
# malformed records itself; this backstop turns anything it
# still raises into a per-object warning (bug audit 2026-09-12).
try:
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
)
except Exception: # one object's documents must not sink the import
_LOGGER.exception("JSON import of %s: documents skipped", obj_name)
task_warnings.append("documents: skipped — malformed document records")
await _drop_imported_documents(doc_store, obj_id)
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,
context={"source": "websocket"},
data={
CONF_OBJECT: import_obj,
CONF_TASKS: import_tasks,
"parts": import_parts,
},
)
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":
lineage.created(obj_entry.get("entry_id"), result["result"].entry_id, part_id_map, lineage_pending)
entry_info: dict[str, Any] = {
"entry_id": result["result"].entry_id,
"name": obj_name,
"task_count": len(import_tasks),
}
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.
if part_stocks:
new_entry = hass.config_entries.async_get_entry(result["result"].entry_id)
store_new = get_store(hass, result["result"].entry_id)
if store_new is not None:
for pid, stock_val in part_stocks.items():
store_new.set_part_stock(pid, stock_val)
await store_new.async_save()
# Restored stocks can sit below min_stock — reconcile buy
# tasks like every other stock mutation does, or the
# shopping list stays silent until the next unrelated
# stock change (bug audit 2026-08-22).
from ..parts_runtime import schedule_buy_task_reconcile
if new_entry is not None:
schedule_buy_task_reconcile(hass, new_entry)
else:
errors.append({"name": obj_name, "reason": result.get("reason", "unknown")})
await _drop_imported_documents(doc_store, obj_id)
lineage.finish()
resp: dict[str, Any] = {
"imported": created,
"total": len(objects),
"created": len(created),
}
if settings_applied:
resp["settings_applied"] = settings_applied
if errors:
resp["errors"] = errors
connection.send_result(msg["id"], resp)
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/qr/generate",
vol.Required("entry_id"): ID_FIELD,
vol.Optional("task_id"): ID_FIELD,
vol.Optional("action", default="view"): vol.In(["view", "complete", "quick_complete"]),
vol.Optional("url_mode", default="server"): vol.In(["server", "local", "companion"]),
vol.Optional("base_url"): vol.All(vol.Url(), vol.Length(max=512)),
}
)
@websocket_api.async_response
async def ws_generate_qr(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Generate a QR code for a maintenance object or task."""
task_id = msg.get("task_id")
task_name = None
if task_id:
ctx = _load_object_task(hass, connection, msg)
if ctx is None:
return
entry, _rd, task = ctx
task_name = task.get("name", "")
else:
obj_entry = _load_object_entry(hass, connection, msg)
if obj_entry is None:
return
entry = obj_entry
obj_data = entry.data.get(CONF_OBJECT, {})
action = msg.get("action", "view")
url_mode = msg.get("url_mode", "server")
base_url = msg.get("base_url")
try:
url = build_qr_url(
hass,
entry.entry_id,
task_id=task_id,
action=action,
base_url_override=base_url,
url_mode=url_mode,
)
except ValueError as err:
connection.send_error(msg["id"], "no_url", str(err))
return
from functools import partial
icon = _ACTION_ICON_MAP.get(action)
gen_fn = partial(generate_qr_svg_data_uri, url, border=2, icon=icon)
svg_data_uri = await hass.async_add_executor_job(gen_fn)
connection.send_result(
msg["id"],
{
"svg_data_uri": svg_data_uri,
"url": url,
"label": {
"object_name": object_name(entry),
"manufacturer": obj_data.get(CONF_OBJECT_MANUFACTURER, ""),
"model": obj_data.get(CONF_OBJECT_MODEL, ""),
"task_name": task_name,
},
},
)
# Batch QR generation — used by the "Print QR codes" panel section.
#
# Typical household: 20-30 tasks × 2 actions = 40-60 QRs. Benchmarked at
# ~40 ms each with icon embed (HIGH ECC) → 2.5 s for 60, 7 s for 200.
# The raw SVG is ~32 KB each, so 200 × 32 KB = ~6 MB over the websocket;
# we cap at 200 to keep the payload bounded and the print layout sane
# (generous 6 QRs/A4 page = 34 pages).
_MAX_BATCH_QRS = 200
# LRU cache keyed on (url, icon). Two users printing the same task twice
# in a session hit this cache; so does re-running the batch after
# narrowing the filter. Bounded size so long-running HA instances with
# thousands of task-action combos can't grow the cache forever.
@lru_cache(maxsize=512)
def _cached_qr_svg(url: str, icon: str | None) -> str:
return generate_qr_svg(url, border=2, icon=icon)
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/qr/batch_generate",
vol.Optional("entry_ids"): vol.All(
[ID_FIELD],
vol.Length(max=1000),
),
vol.Optional("task_ids"): vol.All(
[ID_FIELD],
vol.Length(max=2000),
),
vol.Required("actions"): vol.All(
[vol.In(["view", "complete", "skip", "quick_complete"])],
vol.Length(min=1, max=4),
),
vol.Optional("url_mode", default="server"): vol.In(["server", "local", "companion"]),
vol.Optional("base_url"): vol.All(vol.Url(), vol.Length(max=512)),
}
)
@websocket_api.async_response
async def ws_batch_generate_qr(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Generate multiple QR codes in one call for the print-all-QRs page.
Resolves (entry × task × action) combinations and returns SVG strings
ready to inline into a printable grid. Empty ``entry_ids`` / ``task_ids``
filters mean "all" at that level.
"""
# Resolve target entries (always exclude the global config entry).
all_entries = _get_object_entries(hass)
entry_filter = msg.get("entry_ids")
if entry_filter:
wanted = set(entry_filter)
entries = [e for e in all_entries if e.entry_id in wanted]
else:
entries = all_entries
# Build the flat (entry_id, object_name, task_id, task_name) target list,
# honouring the optional task_ids filter.
task_filter = set(msg["task_ids"]) if msg.get("task_ids") else None
targets: list[tuple[str, str, str, str]] = []
for entry in entries:
obj_name = object_name(entry)
tasks_data = entry.data.get(CONF_TASKS, {})
for task_id, task_data in tasks_data.items():
if task_filter is not None and task_id not in task_filter:
continue
targets.append((entry.entry_id, obj_name, task_id, task_data.get("name", "")))
actions: list[str] = msg["actions"]
total = len(targets) * len(actions)
if total == 0:
connection.send_result(msg["id"], {"qrs": [], "total": 0})
return
if total > _MAX_BATCH_QRS:
connection.send_error(
msg["id"],
"too_many",
f"Batch would produce {total} QR codes; the per-request cap is "
f"{_MAX_BATCH_QRS}. Narrow the object/task/action filter.",
)
return
url_mode = msg.get("url_mode", "server")
base_url = msg.get("base_url")
# Generate URL first (fast), then offload the SVG encoding to the executor
# since it's CPU-bound (~30-40 ms/QR). Each SVG passes through the LRU
# cache so re-runs after a filter change are near-instant.
results: list[dict[str, Any]] = []
for entry_id, obj_name, task_id, task_name in targets:
for action in actions:
try:
url = build_qr_url(
hass,
entry_id,
task_id=task_id,
action=action,
base_url_override=base_url,
url_mode=url_mode,
)
except ValueError:
# No HA URL configured — skip this row rather than fail the
# whole batch. "server" mode is the only path that raises;
# "companion" and "local" always resolve.
continue
icon = _ACTION_ICON_MAP.get(action) # None for "skip" (no icon)
svg = await hass.async_add_executor_job(_cached_qr_svg, url, icon)
results.append(
{
"entry_id": entry_id,
"task_id": task_id,
"object_name": obj_name,
"task_name": task_name,
"action": action,
"svg": svg,
}
)
connection.send_result(msg["id"], {"qrs": results, "total": len(results)})