393 files

This commit is contained in:
Home Assistant Version Control
2026-09-27 17:16:54 +00:00
parent b05b7a897e
commit 39d97ac2db
404 changed files with 54006 additions and 11034 deletions
@@ -26,8 +26,6 @@ from ..const import (
CONF_OBJECT_MODEL,
CONF_TASKS,
DOMAIN,
MAX_CHECKLIST_ITEM_LENGTH,
MAX_CHECKLIST_ITEMS,
MAX_ENTITY_SLUG_LENGTH,
MAX_ID_LENGTH,
MAX_IMPORT_PAYLOAD_BYTES,
@@ -37,6 +35,8 @@ from ..const import (
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,
@@ -45,7 +45,7 @@ from ..helpers.qr_generator import (
generate_qr_svg_data_uri,
)
from ..websocket.tasks import _check_nfc_tag_duplicate, _validate_trigger_config
from . import _get_object_entries, _load_object_entry, _load_object_task
from . import ID_FIELD, _get_object_entries, _load_object_entry, _load_object_task, _merge_global_options
_LOGGER = logging.getLogger(__name__)
@@ -89,10 +89,24 @@ def _sanitize_history(history: Any) -> list[dict[str, Any]]:
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)
@@ -102,6 +116,15 @@ def _sanitize_history(history: Any) -> list[dict[str, Any]]:
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
@@ -111,9 +134,237 @@ def _sanitize_history(history: Any) -> list[dict[str, Any]]:
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]],
@@ -252,36 +503,51 @@ async def ws_get_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.
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": tt.interval_days,
"interval_days": task_interval(tt, profile.country, profile.region),
"warning_days": tt.warning_days,
}
for tt in t.tasks
# 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
@@ -296,7 +562,7 @@ async def ws_get_templates(
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"): [vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
vol.Optional("entry_ids"): [ID_FIELD],
}
)
@websocket_api.require_admin
@@ -325,7 +591,7 @@ async def ws_export_data(
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/csv/export",
vol.Optional("entry_ids"): [vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
vol.Optional("entry_ids"): [ID_FIELD],
}
)
@websocket_api.require_admin
@@ -346,7 +612,7 @@ async def ws_export_csv(
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/objects/csv",
vol.Optional("entry_ids"): [vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
vol.Optional("entry_ids"): [ID_FIELD],
}
)
@websocket_api.async_response
@@ -400,9 +666,15 @@ async def ws_import_csv(
created = []
errors: list[dict[str, str]] = []
for idx, obj_data in enumerate(objects):
# 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] = []
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:
@@ -425,6 +697,7 @@ async def ws_import_csv(
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", ""),
@@ -436,6 +709,7 @@ async def ws_import_csv(
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,
@@ -512,7 +786,6 @@ def _apply_settings_import(hass: HomeAssistant, raw: dict[str, Any]) -> list[str
"""
from ..const import (
CONF_GROUPS,
CONF_NOTIFY_SERVICE,
CONF_SAVED_FILTER_VIEWS,
CONF_VACATION_BUFFER_DAYS,
CONF_VACATION_ENABLED,
@@ -521,21 +794,35 @@ def _apply_settings_import(hass: HomeAssistant, raw: dict[str, Any]) -> list[str
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 MAX_SAVED_VIEWS, sanitize_view
from ..helpers.saved_views import sanitize_view
from ..helpers.settings_registry import ALLOWED_SETTING_KEYS
from .dashboard import sanitize_settings_input
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}
filtered, notify_error = sanitize_settings_input(scalars)
if notify_error:
filtered.pop(CONF_NOTIFY_SERVICE, None)
# 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):
@@ -568,10 +855,25 @@ def _apply_settings_import(hass: HomeAssistant, raw: dict[str, Any]) -> list[str
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)
if isinstance(val, str) and parse_iso_date(val) is not None:
filtered[key] = val
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)
@@ -581,9 +883,7 @@ def _apply_settings_import(hass: HomeAssistant, raw: dict[str, Any]) -> list[str
if not filtered:
return []
merged = dict(entry.options or entry.data)
merged.update(filtered)
hass.config_entries.async_update_entry(entry, options=merged)
_merge_global_options(hass, entry, filtered)
_LOGGER.info("Settings import applied %d key(s)", len(filtered))
return sorted(filtered)
@@ -602,6 +902,8 @@ async def ws_import_json(
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")
@@ -639,7 +941,10 @@ async def ws_import_json(
created = []
errors: list[dict[str, str]] = []
for idx, obj_entry in enumerate(objects):
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.
@@ -669,17 +974,14 @@ async def ws_import_json(
# 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 / parent hierarchy — same-instance restores
# keep them valid; stale ids degrade gracefully at read time.
# 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"),
"parent_entry_id": obj_data.get("parent_entry_id"),
# 2.20: seasonal pause round-trips (a paused pool restored in
# winter stays paused); replace-flow lineage ids are the same
# instance-specific story as parent_entry_id above.
# winter stays paused).
"paused_at": _iso_marker(obj_data.get("paused_at")),
"paused_until": _iso_marker(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"),
# 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).
@@ -690,6 +992,12 @@ async def ws_import_json(
"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
@@ -858,73 +1166,38 @@ async def ws_import_json(
):
link["part_id"] = part_id_map[link["part_id"]]
# Remap part links to the regenerated part ids; drop dangling ones.
links = task_data.get("consumes_parts")
if isinstance(links, list):
remapped = []
for link in links:
if not isinstance(link, dict):
continue
foreign = str(link.get("entry_id") or "").strip()
if foreign:
# A link to another object's pool (#111). Import mints
# new entry ids, so the reference only means anything
# if that object is present in THIS instance — keep it
# then, drop it otherwise rather than restore a link
# that points nowhere.
if hass.config_entries.async_get_entry(foreign) is not None:
remapped.append(dict(link))
elif link.get("part_id") in part_id_map:
remapped.append(
{"part_id": part_id_map[link["part_id"]], "quantity": link.get("quantity", 1)}
)
if remapped:
task_data["consumes_parts"] = remapped
else:
task_data.pop("consumes_parts", None)
elif links is not None:
# 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)
# Task phases (#139): sanitize like the live WS write, remap each
# phase's part links to the regenerated ids (same rules as the
# task-level links above), 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)
for pdef in defs.values():
plinks = pdef.get("consumes_parts")
if not isinstance(plinks, list):
continue
kept = []
for link in plinks:
if not isinstance(link, dict):
continue
foreign = str(link.get("entry_id") or "").strip()
if foreign:
if hass.config_entries.async_get_entry(foreign) is not None:
kept.append(dict(link))
elif link.get("part_id") in part_id_map:
kept.append(
{"part_id": part_id_map[link["part_id"]], "quantity": link.get("quantity", 1)}
)
if kept:
pdef["consumes_parts"] = kept
else:
pdef.pop("consumes_parts", None)
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))
# 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")
@@ -933,6 +1206,9 @@ async def ws_import_json(
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)
@@ -941,39 +1217,12 @@ async def ws_import_json(
from ..helpers.sanitize import seed_rotation_assignee
seed_rotation_assignee(task_data)
# Sanitize checklist: only keep string items within length budget,
# cap total items. Drops malformed entries silently rather than
# rejecting the whole import — same forgiving model as the other
# fields above.
cl = task_data.get("checklist")
if cl is not None:
if not isinstance(cl, list):
task_data.pop("checklist", None)
else:
cleaned = [item.strip() for item in cl if isinstance(item, str) and len(item) <= MAX_CHECKLIST_ITEM_LENGTH]
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)
# D#183: mirror targets — todo.* ids only, deduped, capped; an
# empty result drops the key (same rules as the WS write paths).
if task_data.get("mirror_todo_entities") is not None:
from ..helpers.sanitize import sanitize_mirror_todo_entities
mirrors = sanitize_mirror_todo_entities(task_data["mirror_todo_entities"])
if mirrors:
task_data["mirror_todo_entities"] = mirrors
else:
task_data.pop("mirror_todo_entities", None)
# 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).
@@ -1100,6 +1349,7 @@ async def ws_import_json(
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,
@@ -1130,6 +1380,7 @@ async def ws_import_json(
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,
@@ -1146,8 +1397,8 @@ async def ws_import_json(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/qr/generate",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Optional("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
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)),
@@ -1170,9 +1421,10 @@ async def ws_generate_qr(
entry, _rd, task = ctx
task_name = task.get("name", "")
else:
entry = _load_object_entry(hass, connection, msg)
if entry is None:
obj_entry = _load_object_entry(hass, connection, msg)
if obj_entry is None:
return
entry = obj_entry
obj_data = entry.data.get(CONF_OBJECT, {})
@@ -1235,11 +1487,11 @@ def _cached_qr_svg(url: str, icon: str | None) -> str:
{
vol.Required("type"): "maintenance_supporter/qr/batch_generate",
vol.Optional("entry_ids"): vol.All(
[vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
[ID_FIELD],
vol.Length(max=1000),
),
vol.Optional("task_ids"): vol.All(
[vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
[ID_FIELD],
vol.Length(max=2000),
),
vol.Required("actions"): vol.All(