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
@@ -12,14 +12,12 @@ from homeassistant.components import websocket_api
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import issue_registry as ir
from homeassistant.util import dt as dt_util
from ..const import (
BATTERY_FLEET_TASK_FLAG,
CONF_OBJECT,
CONF_TASKS,
DEFAULT_WARNING_DAYS,
DOMAIN,
FLAT_SCHEDULE_TYPES,
MAX_ASSIGNEE_POOL,
@@ -28,7 +26,6 @@ from ..const import (
MAX_DATE_LENGTH,
MAX_ENTITY_SLUG_LENGTH,
MAX_ICON_LENGTH,
MAX_ID_LENGTH,
MAX_LABEL_LENGTH,
MAX_LABELS,
MAX_META_LENGTH,
@@ -47,6 +44,7 @@ from ..const import (
from ..helpers.aggregate import get_store
from ..helpers.dates import INTERVAL_UNITS
from ..helpers.entry_tasks import write_task
from ..helpers.global_options import get_default_warning_days
from ..helpers.notify_icons import is_valid_icon
from ..helpers.permissions import require_write
from ..helpers.sanitize import strip_task_runtime_state
@@ -68,6 +66,7 @@ from . import (
ID_FIELD,
_load_object_entry,
_load_object_task,
_merge_global_options,
_parse_iso_date,
cleanup_group_refs,
)
@@ -135,6 +134,50 @@ def _normalize_nfc_tag(
warnings.append(nfc_warn)
return nfc_val
def _refuse_archived_object(connection: websocket_api.ActiveConnection, msg: dict[str, Any], entry: ConfigEntry) -> bool:
"""Send ``archived`` and return True for an archived object.
``task/move`` refused an archived target, but ``task/create`` and
``task/duplicate`` added tasks to one — tasks nobody sees (the object is
hidden) that still notified (bug audit 2026-09-26).
"""
if not (entry.data.get(CONF_OBJECT) or {}).get("archived_at"):
return False
connection.send_error(msg["id"], "archived", "An archived object cannot get new tasks")
return True
def _valid_due_date(connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> bool:
"""False (after ``invalid_format``) when ``msg["due_date"]`` is set but not
an ISO date; canonicalises it in place. The schema only capped the length,
so any string became a one-time task's due date — which then never came
due (bug audit 2026-09-26)."""
if msg.get("due_date") is None:
return True
parsed = _parse_iso_date(connection, msg["id"], msg["due_date"], field="due_date", code="invalid_format")
if parsed is None:
return False
msg["due_date"] = parsed.isoformat()
return True
async def _responsible_user_missing(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], *, current: Any = None
) -> bool:
"""Send ``invalid_user`` and return True when ``responsible_user_id`` names
no user. ``user/assign`` checked it, create/update stored any id — the
task then showed an unknown assignee and per-user notifications went
nowhere (bug audit 2026-09-26). Empty / None mean "unassigned"; the
``current`` (stored) value passes unchecked — an older client re-sends it
on every save and must not be refused for someone else's deletion."""
user_id = msg.get("responsible_user_id")
if not user_id or user_id == current or await hass.auth.async_get_user(user_id) is not None:
return False
connection.send_error(msg["id"], "invalid_user", "User not found")
return True
# ws_update_task: wire key -> storage key. Almost all are identity; the one
# rename is deliberate and load-bearing: the WS message envelope reserves
# "type" for command routing ({"type": "maintenance_supporter/task/update"}),
@@ -227,91 +270,114 @@ def _apply_phase_fields(
task_data.pop("phase_sequence", None)
# DRY audit 2026-09-26 B: the ONE validator per task field. task/create and
# task/update carried ~37 verbatim copies of these; both schemas are built
# from this map now — update wraps every field in a default-less
# vol.Optional (omitted = unchanged), create requires _TASK_CREATE_REQUIRED
# and fills _TASK_CREATE_DEFAULTS. Wire-keyed (``task_type`` = the task's
# type; TASK_UPDATE_FIELD_MAP translates to storage keys).
_TASK_FIELDS: dict[str, Any] = {
"name": vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
"task_type": vol.All(str, vol.Length(max=MAX_TYPE_LENGTH)),
"enabled": bool,
"schedule_type": vol.All(str, vol.Length(max=MAX_TYPE_LENGTH)),
"interval_days": vol.Any(vol.All(int, vol.Range(min=INTERVAL_DAYS_RANGE[0], max=INTERVAL_DAYS_RANGE[1])), None),
"interval_unit": vol.In(INTERVAL_UNITS),
"due_date": vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
"interval_anchor": vol.In(INTERVAL_ANCHORS),
# Nested recurrence (calendar kinds: weekdays / nth_weekday / day_of_month).
# Validated/canonicalized in the handler via Schedule.from_dict.
"schedule": vol.Any(dict, None),
# No create default: an omitted value takes the integration-wide
# setting (get_default_warning_days), not the constant 7 (BR-A4).
"warning_days": vol.All(int, vol.Range(min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1])),
"earliest_completion_days": vol.Any(
vol.All(int, vol.Range(min=EARLIEST_COMPLETION_RANGE[0], max=EARLIEST_COMPLETION_RANGE[1])), None
),
"last_performed": vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
"trigger_config": vol.Any(dict, None),
"notes": vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None),
"documentation_url": vol.Any(vol.All(str, vol.Length(max=MAX_URL_LENGTH)), None),
"responsible_user_id": vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None),
"assignee_pool": vol.Any(vol.All([vol.All(str, vol.Length(max=MAX_META_LENGTH))], vol.Length(max=MAX_ASSIGNEE_POOL)), None),
"required_completion_fields": vol.Any([vol.In(REQUIRABLE_COMPLETION_FIELDS)], None),
"rotation_strategy": vol.Any(vol.In(ROTATION_STRATEGY_VALUES), None),
"entity_slug": vol.Any(vol.All(str, vol.Length(max=MAX_ENTITY_SLUG_LENGTH)), None),
"custom_icon": vol.Any(vol.All(str, vol.Length(max=MAX_ICON_LENGTH)), None),
"nfc_tag_id": vol.Any(vol.All(str, vol.Length(max=MAX_NFC_TAG_LENGTH)), None),
# Proof of presence (#139 family): completion only via NFC/QR scan.
"require_tag_scan": vol.Any(bool, None),
# #150: per-task skip lock — false hides Skip in the UIs and the
# coordinator refuses (WS + voice), so automations cannot skip either.
"allow_skip": vol.Any(bool, None),
# #173: per-task notification mute — false = no reminders for this
# task (status changes, repeats, lead-time, bundles); the dashboard
# and entities still show it.
"notify_enabled": vol.Any(bool, None),
# #185: per-task push-notification icon ("mdi:…"); null/"" = the
# maintenance type's default. Shape-checked by _validate_notify_icon.
"notify_icon": vol.Any(vol.All(str, vol.Length(max=MAX_NOTIFY_ICON_LENGTH)), None),
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...).
"reading_unit": vol.Any(vol.All(str, vol.Length(max=MAX_READING_UNIT_LENGTH)), None),
# #161 phase 2: reading slots [{id?, name, unit?}] — shape-validated
# by helpers/reading_slots.sanitize_reading_slots at both write paths.
"readings": vol.Any(list, None),
# Spare parts consumed on completion: [{part_id, quantity}].
"consumes_parts": vol.Any(list, None),
# Task phases (#139): cyclic content rotation on one cadence.
# Shape-validated in helpers/phases.py at both write paths.
"phases": vol.Any(dict, None),
"phase_sequence": vol.Any(list, None),
"priority": vol.In(TASK_PRIORITIES),
"checklist": vol.Any(
vol.All([vol.All(str, vol.Length(max=MAX_CHECKLIST_ITEM_LENGTH))], vol.Length(max=MAX_CHECKLIST_ITEMS)), None
),
"labels": vol.Any(vol.All([vol.All(str, vol.Length(max=MAX_LABEL_LENGTH))], vol.Length(max=MAX_LABELS)), None),
# D#183: todo.* entity ids the due task is mirrored into ([] / None = off).
"mirror_todo_entities": vol.Any(
vol.All([vol.All(str, vol.Match(MIRROR_TODO_ENTITY_PATTERN))], vol.Length(max=MAX_MIRROR_TODO_LISTS)), None
),
# HH:MM strict (00–23 : 00–59). None clears the time → midnight semantic.
"schedule_time": vol.Any(vol.All(str, vol.Match(r"^([01]\d|2[0-3]):[0-5]\d$")), None),
# v1.3.0: per-task on_complete_action + quick_complete_defaults.
# Both kept loose at the schema level (vol.Any(dict, None)); strict
# field-by-field validation lives in helpers/sanitize.py so the
# config-flow path (which doesn't go through this schema) gets
# identical validation behaviour.
"on_complete_action": vol.Any(dict, None),
"quick_complete_defaults": vol.Any(dict, None),
}
#: The only field task/create requires.
_TASK_CREATE_REQUIRED: frozenset[str] = frozenset({"name"})
#: task/create's values for omitted fields (task/update has none — an omitted
#: field there means "leave unchanged").
_TASK_CREATE_DEFAULTS: dict[str, Any] = {
"task_type": "custom",
"schedule_type": "time_based",
"interval_unit": "days",
"interval_anchor": "completion",
"enabled": True,
}
def _create_marker(key: str) -> vol.Marker:
"""The task/create schema marker for one ``_TASK_FIELDS`` key."""
if key in _TASK_CREATE_REQUIRED:
return vol.Required(key)
if key in _TASK_CREATE_DEFAULTS:
return vol.Optional(key, default=_TASK_CREATE_DEFAULTS[key])
return vol.Optional(key)
# Hoisted as a module constant so the schema/field-map parity tripwire
# (tests/test_task_schema_parity.py, drift audit 2026-08) can introspect it.
_TASK_CREATE_SCHEMA: dict[Any, Any] = {
vol.Required("type"): "maintenance_supporter/task/create",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("name"): vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
vol.Optional("task_type", default="custom"): vol.All(str, vol.Length(max=MAX_TYPE_LENGTH)),
vol.Optional("schedule_type", default="time_based"): vol.All(str, vol.Length(max=MAX_TYPE_LENGTH)),
vol.Optional("interval_days"): vol.Any(
vol.All(int, vol.Range(min=INTERVAL_DAYS_RANGE[0], max=INTERVAL_DAYS_RANGE[1])), None
),
vol.Optional("interval_unit", default="days"): vol.In(INTERVAL_UNITS),
vol.Optional("due_date"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
vol.Optional("interval_anchor", default="completion"): vol.In(INTERVAL_ANCHORS),
# Nested recurrence (calendar kinds: weekdays / nth_weekday / day_of_month).
# Validated/canonicalized in the handler via Schedule.from_dict.
vol.Optional("schedule"): vol.Any(dict, None),
vol.Optional("warning_days", default=DEFAULT_WARNING_DAYS): vol.All(
int, vol.Range(min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1])
),
vol.Optional("earliest_completion_days"): vol.Any(
vol.All(int, vol.Range(min=EARLIEST_COMPLETION_RANGE[0], max=EARLIEST_COMPLETION_RANGE[1])), None
),
vol.Optional("last_performed"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
vol.Optional("trigger_config"): vol.Any(dict, None),
vol.Optional("notes"): vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None),
vol.Optional("documentation_url"): vol.Any(vol.All(str, vol.Length(max=MAX_URL_LENGTH)), None),
vol.Optional("responsible_user_id"): vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None),
vol.Optional("assignee_pool"): vol.Any(
vol.All([vol.All(str, vol.Length(max=MAX_META_LENGTH))], vol.Length(max=MAX_ASSIGNEE_POOL)), None
),
vol.Optional("required_completion_fields"): vol.Any([vol.In(REQUIRABLE_COMPLETION_FIELDS)], None),
vol.Optional("rotation_strategy"): vol.Any(vol.In(ROTATION_STRATEGY_VALUES), None),
vol.Optional("entity_slug"): vol.Any(vol.All(str, vol.Length(max=MAX_ENTITY_SLUG_LENGTH)), None),
vol.Optional("custom_icon"): vol.Any(vol.All(str, vol.Length(max=MAX_ICON_LENGTH)), None),
vol.Optional("nfc_tag_id"): vol.Any(vol.All(str, vol.Length(max=MAX_NFC_TAG_LENGTH)), None),
# Proof of presence (#139 family): completion only via NFC/QR scan.
vol.Optional("require_tag_scan"): vol.Any(bool, None),
# #150: per-task skip lock — false hides Skip in the UIs and the
# coordinator refuses (WS + voice), so automations cannot skip either.
vol.Optional("allow_skip"): vol.Any(bool, None),
# #173: per-task notification mute — false = no reminders for this
# task (status changes, repeats, lead-time, bundles); the dashboard
# and entities still show it.
vol.Optional("notify_enabled"): vol.Any(bool, None),
# #185: per-task push-notification icon ("mdi:…"); null/"" = the
# maintenance type's default. Shape-checked by _validate_notify_icon.
vol.Optional("notify_icon"): vol.Any(vol.All(str, vol.Length(max=MAX_NOTIFY_ICON_LENGTH)), None),
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...).
vol.Optional("reading_unit"): vol.Any(vol.All(str, vol.Length(max=MAX_READING_UNIT_LENGTH)), None),
# #161 phase 2: reading slots [{id?, name, unit?}] — shape-validated
# by helpers/reading_slots.sanitize_reading_slots at both write paths.
vol.Optional("readings"): vol.Any(list, None),
# Spare parts consumed on completion: [{part_id, quantity}].
vol.Optional("consumes_parts"): vol.Any(list, None),
# Task phases (#139): cyclic content rotation on one cadence.
# Shape-validated in helpers/phases.py at both write paths.
vol.Optional("phases"): vol.Any(dict, None),
vol.Optional("phase_sequence"): vol.Any(list, None),
vol.Optional("priority"): vol.In(TASK_PRIORITIES),
vol.Optional("checklist"): vol.Any(
vol.All([vol.All(str, vol.Length(max=MAX_CHECKLIST_ITEM_LENGTH))], vol.Length(max=MAX_CHECKLIST_ITEMS)), None
),
vol.Optional("labels"): vol.Any(
vol.All([vol.All(str, vol.Length(max=MAX_LABEL_LENGTH))], vol.Length(max=MAX_LABELS)), None
),
# D#183: todo.* entity ids the due task is mirrored into ([] / None = off).
vol.Optional("mirror_todo_entities"): vol.Any(
vol.All([vol.All(str, vol.Match(MIRROR_TODO_ENTITY_PATTERN))], vol.Length(max=MAX_MIRROR_TODO_LISTS)), None
),
# HH:MM strict (00–23 : 00–59). None clears the time → midnight semantic.
vol.Optional("schedule_time"): vol.Any(
vol.All(str, vol.Match(r"^([01]\d|2[0-3]):[0-5]\d$")),
None,
),
# v1.3.0: per-task on_complete_action + quick_complete_defaults.
# Both kept loose at the schema level (vol.Any(dict, None)); strict
# field-by-field validation lives in helpers/sanitize.py so the
# config-flow path (which doesn't go through this schema) gets
# identical validation behaviour.
vol.Optional("on_complete_action"): vol.Any(dict, None),
vol.Optional("quick_complete_defaults"): vol.Any(dict, None),
vol.Optional("enabled", default=True): bool,
vol.Optional("dry_run", default=False): bool,
}
_TASK_CREATE_SCHEMA: dict[Any, Any] = {
vol.Required("type"): "maintenance_supporter/task/create",
vol.Required("entry_id"): ID_FIELD,
**{_create_marker(key): validator for key, validator in _TASK_FIELDS.items()},
vol.Optional("dry_run", default=False): bool,
}
@websocket_api.websocket_command(_TASK_CREATE_SCHEMA)
@@ -323,8 +389,12 @@ async def ws_create_task(
msg: dict[str, Any],
) -> None:
"""Add a new task to an existing maintenance object."""
# The only await before the entry read — done first so nothing below
# works on a snapshot taken before it.
if await _responsible_user_missing(hass, connection, msg):
return
entry = _load_object_entry(hass, connection, msg)
if entry is None:
if entry is None or _refuse_archived_object(connection, msg, entry):
return
task_id = uuid4().hex
@@ -332,6 +402,8 @@ async def ws_create_task(
if not name:
connection.send_error(msg["id"], "invalid_input", "Name must not be empty")
return
if not _valid_due_date(connection, msg):
return
task_data: dict[str, Any] = {
"id": task_id,
@@ -339,7 +411,7 @@ async def ws_create_task(
"name": name,
"type": msg.get("task_type", "custom"),
"enabled": msg.get("enabled", True),
"warning_days": msg.get("warning_days", DEFAULT_WARNING_DAYS),
"warning_days": msg["warning_days"] if msg.get("warning_days") is not None else get_default_warning_days(hass),
# Anchor for next_due fallback when last_performed is None (issue #30).
# Use HA's timezone-aware "today" to match next_due computation.
"created_at": dt_util.now().date().isoformat(),
@@ -378,7 +450,9 @@ async def ws_create_task(
if msg.get("interval_anchor", "completion") != "completion":
task_data["interval_anchor"] = msg["interval_anchor"]
if msg.get("last_performed") is not None:
lp_date = _parse_iso_date(connection, msg["id"], msg["last_performed"], field="last_performed", code="invalid_format")
lp_date = _parse_iso_date(
connection, msg["id"], msg["last_performed"], field="last_performed", code="invalid_format", not_future=True
)
if lp_date is None:
return
initial_last_performed = msg["last_performed"]
@@ -507,9 +581,10 @@ async def ws_create_task(
task_data["on_complete_action"] = msg["on_complete_action"]
if msg.get("quick_complete_defaults"):
task_data["quick_complete_defaults"] = msg["quick_complete_defaults"]
from ..helpers.sanitize import cap_action_field, cap_quick_complete_defaults_field
from ..helpers.sanitize import cap_action_field, cap_quick_complete_defaults_field, stamp_action_owner
cap_action_field(task_data)
stamp_action_owner(task_data, connection.user.id if connection.user else None)
cap_quick_complete_defaults_field(task_data)
# Dry-run mode: validate only, do not persist
@@ -540,81 +615,13 @@ async def ws_create_task(
# Hoisted as a module constant so the schema/field-map parity tripwire
# (tests/test_task_schema_parity.py, drift audit 2026-08) can introspect it.
_TASK_UPDATE_SCHEMA: dict[Any, Any] = {
vol.Required("type"): "maintenance_supporter/task/update",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Optional("name"): vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
vol.Optional("task_type"): vol.All(str, vol.Length(max=MAX_TYPE_LENGTH)),
vol.Optional("enabled"): bool,
vol.Optional("schedule_type"): vol.All(str, vol.Length(max=MAX_TYPE_LENGTH)),
vol.Optional("interval_days"): vol.Any(
vol.All(int, vol.Range(min=INTERVAL_DAYS_RANGE[0], max=INTERVAL_DAYS_RANGE[1])), None
),
vol.Optional("interval_unit"): vol.In(INTERVAL_UNITS),
vol.Optional("due_date"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
vol.Optional("interval_anchor"): vol.In(INTERVAL_ANCHORS),
# Nested recurrence (calendar kinds); see create schema.
vol.Optional("schedule"): vol.Any(dict, None),
vol.Optional("warning_days"): vol.All(int, vol.Range(min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1])),
vol.Optional("earliest_completion_days"): vol.Any(
vol.All(int, vol.Range(min=EARLIEST_COMPLETION_RANGE[0], max=EARLIEST_COMPLETION_RANGE[1])), None
),
vol.Optional("last_performed"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
vol.Optional("trigger_config"): vol.Any(dict, None),
vol.Optional("notes"): vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None),
vol.Optional("documentation_url"): vol.Any(vol.All(str, vol.Length(max=MAX_URL_LENGTH)), None),
vol.Optional("responsible_user_id"): vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None),
vol.Optional("assignee_pool"): vol.Any(
vol.All([vol.All(str, vol.Length(max=MAX_META_LENGTH))], vol.Length(max=MAX_ASSIGNEE_POOL)), None
),
vol.Optional("required_completion_fields"): vol.Any([vol.In(REQUIRABLE_COMPLETION_FIELDS)], None),
vol.Optional("rotation_strategy"): vol.Any(vol.In(ROTATION_STRATEGY_VALUES), None),
vol.Optional("entity_slug"): vol.Any(vol.All(str, vol.Length(max=MAX_ENTITY_SLUG_LENGTH)), None),
vol.Optional("custom_icon"): vol.Any(vol.All(str, vol.Length(max=MAX_ICON_LENGTH)), None),
vol.Optional("nfc_tag_id"): vol.Any(vol.All(str, vol.Length(max=MAX_NFC_TAG_LENGTH)), None),
# Proof of presence (#139 family): completion only via NFC/QR scan.
vol.Optional("require_tag_scan"): vol.Any(bool, None),
# #150: per-task skip lock — false hides Skip in the UIs and the
# coordinator refuses (WS + voice), so automations cannot skip either.
vol.Optional("allow_skip"): vol.Any(bool, None),
# #173: per-task notification mute — false = no reminders for this
# task (status changes, repeats, lead-time, bundles); the dashboard
# and entities still show it.
vol.Optional("notify_enabled"): vol.Any(bool, None),
# #185: per-task push-notification icon ("mdi:…"); null/"" = the
# maintenance type's default. Shape-checked by _validate_notify_icon.
vol.Optional("notify_icon"): vol.Any(vol.All(str, vol.Length(max=MAX_NOTIFY_ICON_LENGTH)), None),
# v2.20 (#83): unit for `reading`-type tasks ("kWh", "m³", ...).
vol.Optional("reading_unit"): vol.Any(vol.All(str, vol.Length(max=MAX_READING_UNIT_LENGTH)), None),
# #161 phase 2: reading slots [{id?, name, unit?}] — shape-validated
# by helpers/reading_slots.sanitize_reading_slots at both write paths.
vol.Optional("readings"): vol.Any(list, None),
# Spare parts consumed on completion: [{part_id, quantity}].
vol.Optional("consumes_parts"): vol.Any(list, None),
# Task phases (#139): cyclic content rotation on one cadence.
# Shape-validated in helpers/phases.py at both write paths.
vol.Optional("phases"): vol.Any(dict, None),
vol.Optional("phase_sequence"): vol.Any(list, None),
vol.Optional("priority"): vol.In(TASK_PRIORITIES),
vol.Optional("checklist"): vol.Any(
vol.All([vol.All(str, vol.Length(max=MAX_CHECKLIST_ITEM_LENGTH))], vol.Length(max=MAX_CHECKLIST_ITEMS)), None
),
vol.Optional("labels"): vol.Any(
vol.All([vol.All(str, vol.Length(max=MAX_LABEL_LENGTH))], vol.Length(max=MAX_LABELS)), None
),
# D#183: see create schema.
vol.Optional("mirror_todo_entities"): vol.Any(
vol.All([vol.All(str, vol.Match(MIRROR_TODO_ENTITY_PATTERN))], vol.Length(max=MAX_MIRROR_TODO_LISTS)), None
),
vol.Optional("schedule_time"): vol.Any(
vol.All(str, vol.Match(r"^([01]\d|2[0-3]):[0-5]\d$")),
None,
),
# v1.3.0: same loose schema as create. Sanitize layer enforces shape.
vol.Optional("on_complete_action"): vol.Any(dict, None),
vol.Optional("quick_complete_defaults"): vol.Any(dict, None),
}
# Every _TASK_FIELDS key optional without a default (DRY audit 2026-09-26 B).
_TASK_UPDATE_SCHEMA: dict[Any, Any] = {
vol.Required("type"): "maintenance_supporter/task/update",
vol.Required("entry_id"): ID_FIELD,
vol.Required("task_id"): ID_FIELD,
**{vol.Optional(key): validator for key, validator in _TASK_FIELDS.items()},
}
@websocket_api.websocket_command(_TASK_UPDATE_SCHEMA)
@@ -626,6 +633,11 @@ async def ws_update_task(
msg: dict[str, Any],
) -> None:
"""Update an existing task."""
# First, before the task is read for the edit: the user lookup is an await.
peek = hass.config_entries.async_get_entry(msg["entry_id"])
current = ((peek.data.get(CONF_TASKS) or {}).get(msg["task_id"]) or {}).get("responsible_user_id") if peek else None
if await _responsible_user_missing(hass, connection, msg, current=current):
return
ctx = _load_object_task(hass, connection, msg)
if ctx is None:
return
@@ -674,10 +686,15 @@ async def ws_update_task(
if "nfc_tag_id" in msg:
_normalize_nfc_tag(hass, msg, tc_warnings, exclude_task_id=task_id)
if not _valid_due_date(connection, msg):
return
# Validate last_performed date format if provided
if (
msg.get("last_performed") is not None
and _parse_iso_date(connection, msg["id"], msg["last_performed"], field="last_performed", code="invalid_format")
and _parse_iso_date(
connection, msg["id"], msg["last_performed"], field="last_performed", code="invalid_format", not_future=True
)
is None
):
return
@@ -738,6 +755,11 @@ async def ws_update_task(
# D#183: an empty mirror list means "off" — drop the key, don't store [].
if "mirror_todo_entities" in msg and not msg["mirror_todo_entities"]:
task.pop("mirror_todo_entities", None)
# No rotation is ABSENCE: the dialog sends null on every save, and the
# stored None came back as the options flow's select default, failing
# its validation — the task form could not be saved (bug audit 2026-09-26).
if "rotation_strategy" in msg and not msg["rotation_strategy"]:
task.pop("rotation_strategy", None)
# The loop above copies values verbatim, which is wrong for part links:
# `task/create` validates them and `task/update` did not, so an edit could
@@ -834,9 +856,14 @@ async def ws_update_task(
sanitize_assignee_pool,
sanitize_labels,
seed_rotation_assignee,
settle_action_owner,
)
# A changed action is re-stamped with the saving user; an unchanged one
# (the panel dialog sends it back on every save) keeps the owner it was
# stored with — an operator editing the notes must not re-author it.
cap_action_field(task)
settle_action_owner(task, stored_task.get("on_complete_action"), connection.user.id if connection.user else None)
cap_quick_complete_defaults_field(task)
if "labels" in task:
task["labels"] = sanitize_labels(task["labels"])
@@ -882,8 +909,8 @@ async def ws_update_task(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/delete",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
vol.Required("task_id"): ID_FIELD,
}
)
@require_write
@@ -911,6 +938,8 @@ async def async_delete_task(
hass: HomeAssistant,
entry: ConfigEntry,
task_id: str,
*,
removed_state: dict[str, Any] | None = None,
) -> bool:
"""Remove a task and all its side-state from an object entry.
@@ -919,13 +948,17 @@ async def async_delete_task(
/ entity-registry / group-ref / repair-issue cleanup, but NOT the entry
reload and NOT any WS reply — the caller reloads (once, even for a batch) and
replies. Returns False when ``task_id`` isn't in the entry.
``removed_state`` (task/move): filled with the task's Store state as it
was at the moment of removal — the mirror cleanup before it awaits, and a
completion landing there was lost from a snapshot taken earlier (bug
audit 2026-09-26).
"""
new_data = dict(entry.data)
new_tasks = dict(new_data.get(CONF_TASKS, {}))
if task_id not in new_tasks:
return False
old_trigger_config = new_tasks[task_id].get("trigger_config")
# Adopted problem-sensor task? Preserve its notes + one-time setup
# (responsible user, priority, labels, part link) for a later re-adopt
# (no-op for everything else).
@@ -948,6 +981,8 @@ async def async_delete_task(
mirror = hass.data.get(DOMAIN, {}).get("todo_mirror")
if mirror is not None and hasattr(mirror, "async_forget_task"):
await mirror.async_forget_task(store, task_id)
if removed_state is not None:
removed_state.update(deepcopy(store.get_task_state(task_id)))
store.remove_task(task_id)
await store.async_save()
@@ -980,30 +1015,20 @@ async def async_delete_task(
# persistent and task-id keyed — without this, deleted ids accumulate
# there forever and confuse the vacation preview UI.
from ..const import CONF_VACATION_EXEMPT_TASK_IDS
from ..helpers.global_options import get_global_entry
from ..helpers.global_options import get_global_entry, get_global_options
ge = get_global_entry(hass)
if ge is not None:
exempt = ge.options.get(CONF_VACATION_EXEMPT_TASK_IDS) or []
exempt = get_global_options(hass).get(CONF_VACATION_EXEMPT_TASK_IDS) or []
if isinstance(exempt, list) and task_id in exempt:
hass.config_entries.async_update_entry(
ge,
options={
**dict(ge.options),
CONF_VACATION_EXEMPT_TASK_IDS: [t for t in exempt if t != task_id],
},
)
_merge_global_options(hass, ge, {CONF_VACATION_EXEMPT_TASK_IDS: [t for t in exempt if t != task_id]})
# Clean up any repair issues referencing this task
if old_trigger_config:
from ..entity.triggers import normalize_entity_ids
# Every repair issue about this task — not only its missing-trigger
# ones: a stale completion-action target outlived the task in Settings →
# Repairs with nothing left to fix (bug audit 2026-09-26, BR-A8).
from ..helpers.issues import async_purge_task_issues
for eid in normalize_entity_ids(old_trigger_config):
ir.async_delete_issue(
hass,
DOMAIN,
f"missing_trigger_{entry.entry_id}_{task_id}_{eid}",
)
async_purge_task_issues(hass, entry.entry_id, task_id)
return True
@@ -1011,8 +1036,8 @@ async def async_delete_task(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/duplicate",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
vol.Required("task_id"): ID_FIELD,
}
)
@require_write
@@ -1035,6 +1060,17 @@ async def ws_duplicate_task(
if ctx is None:
return
entry, _rd, source = ctx
if _refuse_archived_object(connection, msg, entry):
return
# Like task/move: a buy reminder belongs to its part's reconciler (a copy
# carrying part_ref was a second reminder for the same low episode) and
# the fleet task IS the battery fleet — a copy carrying the flag was a
# second fleet task (bug audit 2026-09-27).
from ..helpers.parts import PART_REF_FIELD
if source.get(PART_REF_FIELD) or source.get(BATTERY_FLEET_TASK_FLAG):
connection.send_error(msg["id"], "invalid_input", "A spare-part buy task or the battery fleet task cannot be duplicated")
return
new_task = deepcopy(dict(source))
new_task["id"] = uuid4().hex