278 files

This commit is contained in:
Home Assistant Version Control committed 2026-09-13 20:38:58 +00:00
1 parent d7a0a08372
commit 1b4f5f68c6
278 files changed
+36892 -7150

No files matched your search

@@ -10,20 +10,19 @@ from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from ..const import (
BATTERY_FLEET_TASK_FLAG,
CONF_OBJECT,
CONF_TASKS,
DEFAULT_WARNING_DAYS,
DOMAIN,
GLOBAL_UNIQUE_ID,
MAX_TASKS_PER_OBJECT,
)
from ..helpers.aggregate import get_store, is_object_entry
from ..helpers.entry_tasks import insert_new_task
from ..helpers.sanitize import cap_task_fields
from ..helpers.schedule import (
normalize_task_storage,
)
from . import (
_get_runtime_data,
)
# ---------------------------------------------------------------------------
# Task CRUD
@@ -45,44 +44,13 @@ async def async_persist_task(
Store dynamic state, and reloads the entry so the task's entities
(sensor / binary_sensor / buttons) are created.
"""
# Store recurrence in the canonical nested `schedule` shape (schedule-model v2).
task_data = normalize_task_storage(task_data)
task_id = task_data["id"]
# Per-object task cap — this is the single create chokepoint for BOTH the
# task/create WS command and the add_task service, so one guard covers both.
# The ValueError surfaces as a WS error / a service ValidationError at the
# callers (a runaway automation can't inflate ConfigEntry.data without bound).
existing_tasks = entry.data.get(CONF_TASKS, {})
if task_id not in existing_tasks and len(existing_tasks) >= MAX_TASKS_PER_OBJECT:
raise ValueError(f"This object already has the maximum of {MAX_TASKS_PER_OBJECT} tasks")
new_data = dict(entry.data)
new_tasks = dict(new_data.get(CONF_TASKS, {}))
new_tasks[task_id] = task_data
new_data[CONF_TASKS] = new_tasks
obj = dict(new_data.get(CONF_OBJECT, {}))
task_ids = list(obj.get("task_ids", []))
task_ids.append(task_id)
obj["task_ids"] = task_ids
new_data[CONF_OBJECT] = obj
hass.config_entries.async_update_entry(entry, data=new_data)
rd = _get_runtime_data(hass, entry.entry_id)
store = getattr(rd, "store", None) if rd else None
# The insert itself (normalise, per-object cap → ValueError, task_ids,
# ConfigEntry.data, Store init) is the sync core shared with the options
# flow; this path saves right away and reloads so the task's entities
# (sensor / binary_sensor / buttons) are created.
store = insert_new_task(hass, entry, task_data, last_performed=last_performed, history=history)
if store is not None:
store.init_task(task_id, last_performed=last_performed)
if history:
store.set_history(task_id, history)
await store.async_save()
else:
# Legacy: dynamic fields live in ConfigEntry.data
task_data["last_performed"] = last_performed
task_data["history"] = history or []
new_tasks[task_id] = task_data
new_data[CONF_TASKS] = new_tasks
hass.config_entries.async_update_entry(entry, data=new_data)
await hass.config_entries.async_reload(entry.entry_id)
@@ -117,7 +85,7 @@ async def async_create_task_simple(
reachable directly from Python, so the caps can't live only in the schema.
"""
entry = hass.config_entries.async_get_entry(entry_id)
if entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_ID:
if not is_object_entry(entry):
raise ValueError(f"No maintenance object found for entry_id {entry_id!r}")
name = (name or "").strip()
if not name:
@@ -189,7 +157,7 @@ async def async_update_task_simple(
Raises ValueError for an unknown entry/task or an empty name.
"""
entry = hass.config_entries.async_get_entry(entry_id)
if entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_ID:
if not is_object_entry(entry):
raise ValueError(f"No maintenance object found for entry_id {entry_id!r}")
new_data = dict(entry.data)
@@ -225,6 +193,27 @@ async def async_update_task_simple(
await hass.config_entries.async_reload(entry_id)
class TaskMoveRefused(ValueError):
"""``task/move`` refused up front — carries the WS error code.
Raised BEFORE the delete leg so nothing has changed when the caller sees
it (bug audit 2026-09-12): a task that is not movable, or an entry whose
Store is not loaded (the move would silently drop history / readings /
trigger state).
"""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
def _stamp_part_links(links: Any, entry_id: str) -> Any:
"""Give every own-pool part link an explicit ``entry_id`` (foreign-pool form)."""
if not isinstance(links, list):
return links
return [{**link, "entry_id": link.get("entry_id") or entry_id} if isinstance(link, dict) else link for link in links]
async def async_move_task(
hass: HomeAssistant,
source: ConfigEntry,
@@ -236,33 +225,59 @@ async def async_move_task(
What travels: the task's config (schedule, trigger, checklist, parts links,
slug, NFC tag …) and its Store state (history, last_performed, planned
due, adaptive config, phase cursor, checklist progress, trigger runtime
incl. counter baselines). Group memberships follow the task. What does
not: the reference number (``ref_no`` and the history entries' numbers —
the target object numbers it afresh on its next refresh) and document
links (documents belong to the source object; ``async_delete_task``
unlinks them). Part links keep an explicit ``entry_id`` so a link to one
of the source object's parts still resolves as a foreign-pool link.
incl. counter baselines). Group memberships, the vacation exemption and
the document links follow the task; completion photos that belong to
nothing but this task are re-homed to the target object (they would
otherwise die with the source object — bug audit 2026-09-12). What does
not travel: the reference number (``ref_no`` and the history entries'
numbers — the target object numbers it afresh on its next refresh). Part
links — task-level AND per phase — keep an explicit ``entry_id`` so a
link to one of the source object's parts still resolves as a
foreign-pool link.
Both entries are reloaded by the caller: the source drops the task's
entities, the target creates them under its own object slug.
Raises ValueError when the target is full.
Raises :class:`TaskMoveRefused` (with a WS error code) for a task that
must stay with its object / an entry whose Store is not loaded, and a
plain ValueError when the target is full.
"""
from copy import deepcopy
from ..const import MAX_TASKS_PER_OBJECT
from ..const import CONF_VACATION_EXEMPT_TASK_IDS, DOCUMENT_STORE_KEY
from ..helpers.completion_photos import history_photo_ids
from ..helpers.parts import PART_REF_FIELD
from .tasks_crud import async_delete_task
task_data = deepcopy(dict(source.data[CONF_TASKS][task_id]))
task_data.pop("ref_no", None)
links = task_data.get("consumes_parts")
if isinstance(links, list):
task_data["consumes_parts"] = [
{**link, "entry_id": link.get("entry_id") or source.entry_id} if isinstance(link, dict) else link for link in links
]
# A buy task follows its spare part and the fleet task IS the battery
# fleet — neither can live on another object (bug audit 2026-09-12).
if task_data.get(PART_REF_FIELD):
raise TaskMoveRefused("task_not_movable", "A spare-part buy task stays with its part")
if task_data.get(BATTERY_FLEET_TASK_FLAG):
raise TaskMoveRefused("task_not_movable", "The battery fleet task cannot be moved")
src_rd = _get_runtime_data(hass, source.entry_id)
src_store = getattr(src_rd, "store", None) if src_rd else None
state = deepcopy(src_store.get_task_state(task_id)) if src_store is not None else {}
# Both Stores must be loaded (entry disabled / setup-retry / mid-reload
# = no runtime_data): the config would move while history, readings and
# trigger state silently vanished (bug audit 2026-09-12).
src_store = get_store(hass, source.entry_id)
tgt_store = get_store(hass, target.entry_id)
if src_store is None or tgt_store is None:
raise TaskMoveRefused("object_not_loaded", "Both objects must be loaded to move a task")
task_data.pop("ref_no", None)
# The task now belongs to the target object — like the duplicate /
# replace paths re-stamp it (bug audit 2026-09-12).
task_data["object_id"] = (target.data.get(CONF_OBJECT) or {}).get("id", "")
task_data["consumes_parts"] = _stamp_part_links(task_data.get("consumes_parts"), source.entry_id)
if task_data["consumes_parts"] is None:
del task_data["consumes_parts"]
phases = task_data.get("phases")
if isinstance(phases, dict):
for pdef in phases.values():
if isinstance(pdef, dict) and isinstance(pdef.get("consumes_parts"), list):
pdef["consumes_parts"] = _stamp_part_links(pdef["consumes_parts"], source.entry_id)
state = deepcopy(src_store.get_task_state(task_id))
state.pop("next_history_ref", None)
for entry in state.get("history") or []:
if isinstance(entry, dict):
@@ -279,6 +294,24 @@ async def async_move_task(
if any(isinstance(r, dict) and r.get("task_id") == task_id for r in group.get("task_refs", [])):
member_groups.append(gid)
# Vacation exemption + document links: the delete leg strips both
# (task-id keyed, otherwise never pruned) — snapshot, restore after.
vacation_exempt = False
if global_entry is not None:
exempt = global_entry.options.get(CONF_VACATION_EXEMPT_TASK_IDS) or []
vacation_exempt = isinstance(exempt, list) and task_id in exempt
doc_store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
doc_links = doc_store.task_links(task_id) if doc_store is not None else {}
photo_ids: set[str] = set()
for entry in state.get("history") or []:
if isinstance(entry, dict):
photo_ids.update(history_photo_ids(entry))
# Re-home only the photos that are linked to this task alone — a doc
# shared with the object's other tasks stays where it is (link kept).
rehome_ids = {
did for did in photo_ids if did in doc_links and (doc_store.get(did) or {}).get("task_ids") == [task_id]
}
existing = target.data.get(CONF_TASKS, {})
if len(existing) >= MAX_TASKS_PER_OBJECT:
raise ValueError(f"The target object already has the maximum of {MAX_TASKS_PER_OBJECT} tasks")
@@ -294,11 +327,18 @@ async def async_move_task(
new_data[CONF_OBJECT] = obj
hass.config_entries.async_update_entry(target, data=new_data)
tgt_rd = _get_runtime_data(hass, target.entry_id)
tgt_store = getattr(tgt_rd, "store", None) if tgt_rd else None
if tgt_store is not None:
tgt_store.put_task_state(task_id, state)
await tgt_store.async_save()
tgt_store.put_task_state(task_id, state)
await tgt_store.async_save()
if doc_store is not None and doc_links:
await doc_store.async_relink_task(task_id, doc_links, rehome_doc_ids=rehome_ids, object_id=task_data["object_id"])
if vacation_exempt and global_entry is not None:
options = dict(global_entry.options)
current = options.get(CONF_VACATION_EXEMPT_TASK_IDS) or []
if task_id not in current:
options[CONF_VACATION_EXEMPT_TASK_IDS] = [*current, task_id]
hass.config_entries.async_update_entry(global_entry, options=options)
if member_groups and global_entry is not None:
options = dict(global_entry.options or global_entry.data)