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
@@ -4,8 +4,9 @@ from __future__ import annotations
import logging
import os
from collections.abc import Callable
from datetime import date
from typing import Any
from typing import Any, Final
import voluptuous as vol
from homeassistant.components import websocket_api
@@ -23,22 +24,27 @@ from ..const import (
DEFAULT_WARNING_DAYS,
DOMAIN,
MAX_ID_LENGTH,
task_unique_id,
)
from ..helpers.aggregate import get_object_entries, get_runtime_data, get_store, is_object_entry, object_name
from ..helpers.aggregate import get_object_entries, get_runtime_data, get_store, is_object_entry, object_name, task_entity_id
from ..helpers.aggregate import object_slug as aggregate_object_slug
from ..helpers.phases import current_phase_summary
_LOGGER = logging.getLogger(__name__)
#: The schema validator for every id-shaped command field (entry_id, task_id,
#: doc_id, part_id, …): a capped string. ``task/move`` shipped with bare
#: ``str`` for its three ids — tests/test_ws_schema_caps.py refuses that.
ID_FIELD = vol.All(str, vol.Length(max=MAX_ID_LENGTH))
#:
#: The three shared fields carry explicit annotations: the handler submodules
#: import them from this package while it imports the submodules back (an
#: import cycle), and mypy cannot infer a type across that cycle.
ID_FIELD: Final[vol.All] = vol.All(str, vol.Length(max=MAX_ID_LENGTH))
#: ``{slot_id: value | None}`` for a task with reading slots (#161 phase 2) —
#: the live completion records it, the history edit replaces the snapshot.
#: Wide numeric bounds: meters count high, temperatures go negative. Slot ids
#: are checked against the task in the handler (unknown -> invalid_input).
READING_VALUES_FIELD = vol.Any(
READING_VALUES_FIELD: Final[vol.Any] = vol.Any(
{str: vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-1e12, max=1e12)), None)},
None,
)
@@ -51,7 +57,7 @@ READING_VALUES_FIELD = vol.Any(
#: and the history edit's stock delta already read a missing quantity as 1
#: (parts_runtime.async_apply_history_parts_edit). One shape, one cap — the
#: edit path must not accept what completion refuses.
USED_PARTS_FIELD = vol.Any(
USED_PARTS_FIELD: Final[vol.Any] = vol.Any(
vol.All(
[
vol.Schema(
@@ -102,16 +108,6 @@ def _get_merged_tasks(entry: ConfigEntry) -> dict[str, Any]:
_HISTORY_WINDOW = int(os.environ.get("MS_HISTORY_WINDOW", "20"))
def _current_phase_summary(task_data: dict[str, Any]) -> dict[str, Any] | None:
"""{id, name, index, count} of the phase currently due, or None (#139)."""
from ..helpers.phases import current_phase
phase = current_phase(task_data)
if phase is None:
return None
return {"id": phase["id"], "name": phase["name"], "index": phase["index"], "count": phase["count"]}
def _build_task_summary(
hass: HomeAssistant,
task_id: str,
@@ -126,8 +122,6 @@ def _build_task_summary(
use HA's native entity_ids: filter pattern without re-implementing the
slugify logic.
"""
from homeassistant.helpers import entity_registry as er
from ..entity.triggers import normalize_entity_ids
from ..helpers.schedule import KIND_CALENDAR, Schedule, read_legacy_fields
@@ -227,7 +221,7 @@ def _build_task_summary(
"phases": task_data.get("phases"),
"phase_sequence": task_data.get("phase_sequence"),
"phase_cursor": task_data.get("phase_cursor", 0),
"current_phase": _current_phase_summary(task_data),
"current_phase": current_phase_summary(task_data),
"priority": task_data.get("priority") or DEFAULT_TASK_PRIORITY,
# v2.10.0 archive: archived_at is the persisted timestamp (None = active);
# `archived` is the convenience bool the frontend filters on; reason is
@@ -245,23 +239,9 @@ def _build_task_summary(
# Lookup via entity registry by unique_id so we get the actual
# registered entity_id (which can differ from the unique_id when the
# user has renamed it). None when registry lookup fails (rare).
"sensor_entity_id": (
er.async_get(hass).async_get_entity_id(
"sensor",
"maintenance_supporter",
task_unique_id(object_slug, task_id),
)
if object_slug
else None
),
"sensor_entity_id": task_entity_id(hass, object_slug, task_id) if object_slug else None,
"binary_sensor_entity_id": (
er.async_get(hass).async_get_entity_id(
"binary_sensor",
"maintenance_supporter",
task_unique_id(object_slug, task_id, "overdue"),
)
if object_slug
else None
task_entity_id(hass, object_slug, task_id, platform="binary_sensor", suffix="overdue") if object_slug else None
),
"trigger_config": trigger_config,
# Battery Fleet: marks the single aggregate task so the detail view
@@ -303,6 +283,8 @@ def _build_task_summary(
"is_done": ct.get("_is_done", False),
"days_until_due": ct.get("_days_until_due"),
"next_due": ct.get("_next_due"),
# #189: titles of the calendar events behind next_due (calendar kind).
"next_event_titles": list(ct.get("_next_event_titles") or []),
"trigger_active": ct.get("_trigger_active", False),
"trigger_current_value": ct.get("_trigger_current_value"),
"trigger_entity_state": ct.get("_trigger_entity_state", "available"),
@@ -363,12 +345,10 @@ def _build_object_response(
compact: bool = False,
) -> dict[str, Any]:
"""Build a full object response dict (compact: empty keys stripped)."""
from ..const import slugify_object_name
obj_data = entry.data.get(CONF_OBJECT, {})
tasks_data = _get_merged_tasks(entry)
ct_tasks = (coordinator_data or {}).get(CONF_TASKS, {})
object_slug = slugify_object_name(obj_data.get("name", "unknown"))
object_slug = aggregate_object_slug(obj_data)
tasks = [_build_task_summary(hass, tid, tdata, ct_tasks.get(tid), object_slug) for tid, tdata in tasks_data.items()]
@@ -442,6 +422,8 @@ def _build_object_response(
"paused": obj_data.get("paused_at") is not None,
"paused_at": obj_data.get("paused_at"),
"paused_until": obj_data.get("paused_until"),
# 2.94: the template the object was made from (None = unknown).
"template_id": obj_data.get("template_id"),
# v2.20 (N1) replace-flow lineage, both directions.
"predecessor_entry_id": obj_data.get("predecessor_entry_id"),
"replaced_by_entry_id": obj_data.get("replaced_by_entry_id"),
@@ -510,6 +492,20 @@ def _save_global_options(hass: HomeAssistant, entry: ConfigEntry, options: dict[
hass.config_entries.async_update_entry(entry, options=options)
def _merge_global_options(hass: HomeAssistant, entry: ConfigEntry, changes: dict[str, Any]) -> None:
"""Merge ``changes`` into the global entry's settings and save them.
The one read-modify-write for code that already holds the global entry
(DRY audit 2026-09-26 B — saved views, group cleanup, the vacation
exempt list on task delete/move, the settings import). The base is
``options``, else the creation ``data`` — the read rule of
helpers.global_options.get_global_options; a copy that started from
``entry.options`` alone would, on a never-saved entry, write an options
dict holding ONLY the change and hide every setting kept in ``data``.
"""
_save_global_options(hass, entry, {**(entry.options or entry.data), **changes})
def _load_object_entry(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
@@ -589,6 +585,7 @@ def _parse_iso_date(
*,
field: str,
code: str = "invalid_date",
not_future: bool = False,
) -> date | None:
"""``YYYY-MM-DD`` -> ``date``, or send ``code`` and return None.
@@ -596,12 +593,24 @@ def _parse_iso_date(
ten spellings of the same message; the error CODE is the caller's (the
task create/update pair answers ``invalid_format``, the rest
``invalid_date`` — the frontend keys on it).
``not_future``: a date after today is refused with ``invalid_date``. A
reset / last-performed date is a day something WAS done — one in the
year 9999 overflowed the schedule math inside every refresh and kept the
object in setup-retry across restarts (bug audit 2026-09-27).
"""
try:
return date.fromisoformat(value)
parsed = date.fromisoformat(value)
except (TypeError, ValueError):
connection.send_error(msg_id, code, f"{field} must be a valid date (YYYY-MM-DD)")
return None
if not_future:
from homeassistant.util import dt as dt_util
if parsed > dt_util.now().date():
connection.send_error(msg_id, "invalid_date", f"{field} must not be in the future")
return None
return parsed
async def async_commit_store(rd: Any, *, budget: bool = False) -> None:
@@ -649,8 +658,7 @@ def cleanup_group_refs(
if global_entry is None:
return
options = dict(global_entry.options or global_entry.data)
groups = options.get(CONF_GROUPS)
groups = (global_entry.options or global_entry.data).get(CONF_GROUPS)
if not groups:
return
@@ -671,8 +679,7 @@ def cleanup_group_refs(
changed = True
if changed:
options[CONF_GROUPS] = groups
hass.config_entries.async_update_entry(global_entry, options=options)
_merge_global_options(hass, global_entry, {CONF_GROUPS: groups})
# ---------------------------------------------------------------------------
@@ -724,6 +731,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
from .documents import (
ws_documents_add_link,
ws_documents_delete,
ws_documents_discard_upload,
ws_documents_list,
ws_documents_search,
ws_documents_storage,
@@ -739,6 +747,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
from .integration_setups import (
ws_adopt_integration_setups,
ws_discover_integration_setups,
ws_preview_integration_setup,
)
from .io import (
ws_batch_generate_qr,
@@ -777,6 +786,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
from .problem_sensors import (
ws_adopt_problem_sensors,
ws_discover_problem_sensors,
ws_preview_problem_sensors,
)
from .reference_numbers import ws_compact_reference_numbers
from .saved_views import (
@@ -863,6 +873,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_import_csv)
websocket_api.async_register_command(hass, ws_import_json)
websocket_api.async_register_command(hass, ws_discover_problem_sensors)
websocket_api.async_register_command(hass, ws_preview_problem_sensors)
websocket_api.async_register_command(hass, ws_adopt_problem_sensors)
websocket_api.async_register_command(hass, ws_battery_fleet_overview)
websocket_api.async_register_command(hass, ws_battery_fleet_history)
@@ -875,6 +886,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_battery_fleet_set_track_self_charging)
websocket_api.async_register_command(hass, ws_battery_fleet_set_due_without_sensor)
websocket_api.async_register_command(hass, ws_discover_integration_setups)
websocket_api.async_register_command(hass, ws_preview_integration_setup)
websocket_api.async_register_command(hass, ws_adopt_integration_setups)
websocket_api.async_register_command(hass, ws_list_saved_views)
websocket_api.async_register_command(hass, ws_save_saved_view)
@@ -908,12 +920,15 @@ def async_register_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_documents_add_link)
websocket_api.async_register_command(hass, ws_documents_update)
websocket_api.async_register_command(hass, ws_documents_delete)
# Bug audit 2026-09-27 (R SEC-3): read tier — a non-writer removes the
# completion photo they uploaded that never became part of a record.
websocket_api.async_register_command(hass, ws_documents_discard_upload)
websocket_api.async_register_command(hass, ws_documents_search)
websocket_api.async_register_command(hass, ws_search)
websocket_api.async_register_command(hass, ws_compact_reference_numbers)
def foreign_part_resolver(hass):
def foreign_part_resolver(hass: HomeAssistant) -> Callable[[str], set[str] | None]:
"""Callback for ``sanitize_consumes_parts``: which parts an entry owns.
Returns None for an entry that does not exist or is not a maintenance
@@ -921,9 +936,10 @@ def foreign_part_resolver(hass):
"""
from ..const import CONF_PARTS
def _resolve(entry_id: str):
def _resolve(entry_id: str) -> set[str] | None:
entry = hass.config_entries.async_get_entry(entry_id)
if not is_object_entry(entry):
# is_object_entry() rejects None too; the explicit test narrows the type.
if entry is None or not is_object_entry(entry):
return None
return set(entry.data.get(CONF_PARTS) or {})
@@ -10,18 +10,18 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from ..const import (
ADAPTIVE_EWA_ALPHA_RANGE,
ADAPTIVE_MIN_INTERVAL_CAP_DAYS,
CONF_TASKS,
DOMAIN,
MAX_ENTITY_ID_LENGTH,
MAX_ID_LENGTH,
MAX_META_LENGTH,
)
from ..helpers.aggregate import get_runtime_data
from ..helpers.interval_analyzer import hemisphere
from ..helpers.permissions import require_write
from ..helpers.task_fields import INTERVAL_DAYS_RANGE
from . import _load_object_task, async_commit_store
from . import ID_FIELD, _load_object_task, async_commit_store
async def _persist_adaptive_config(
@@ -55,8 +55,8 @@ async def _persist_adaptive_config(
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/task/analyze_interval",
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,
}
)
@websocket_api.async_response
@@ -111,8 +111,8 @@ async def ws_analyze_interval(
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/task/apply_suggestion",
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,
vol.Required("interval"): vol.All(int, vol.Range(min=INTERVAL_DAYS_RANGE[0], max=INTERVAL_DAYS_RANGE[1])),
}
)
@@ -141,8 +141,8 @@ async def ws_apply_suggestion(
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/task/seasonal_overrides",
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,
# At most 12 month keys survive validation anyway — cap the input so a
# giant dict isn't iterated/coerced first (parity with every other list).
vol.Required("overrides"): vol.All(dict, vol.Length(max=12)),
@@ -200,8 +200,8 @@ async def ws_seasonal_overrides(
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/task/set_environmental_entity",
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,
vol.Optional("environmental_entity"): vol.Any(vol.All(str, vol.Length(max=MAX_ENTITY_ID_LENGTH)), None),
vol.Optional("environmental_attribute"): vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None),
}
@@ -256,10 +256,12 @@ async def ws_set_environmental_entity(
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/task/set_adaptive",
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,
vol.Required("enabled"): bool,
vol.Optional("ewa_alpha"): vol.All(vol.Coerce(float), vol.Range(min=0.1, max=0.9)),
vol.Optional("ewa_alpha"): vol.All(
vol.Coerce(float), vol.Range(min=ADAPTIVE_EWA_ALPHA_RANGE[0], max=ADAPTIVE_EWA_ALPHA_RANGE[1])
),
vol.Optional("min_interval_days"): vol.All(int, vol.Range(min=1, max=ADAPTIVE_MIN_INTERVAL_CAP_DAYS)),
vol.Optional("max_interval_days"): vol.All(int, vol.Range(min=1, max=INTERVAL_DAYS_RANGE[1])),
vol.Optional("seasonal_enabled"): bool,
@@ -5,7 +5,7 @@ from __future__ import annotations
import logging
import math
from collections.abc import Callable, Mapping
from typing import Any
from typing import Any, cast
import voluptuous as vol
from homeassistant.components import websocket_api
@@ -43,6 +43,8 @@ from ..const import (
CONF_DEFAULT_WARNING_DAYS,
CONF_DELETE_ARCHIVED_ONEOFF_DAYS,
CONF_DISABLED_TEMPLATE_IDS,
CONF_HOME_REGION,
CONF_HOME_TYPE,
CONF_INSTALL_ASSIST_SENTENCES,
CONF_MAX_NOTIFICATIONS_PER_DAY,
CONF_MEMBER_DISPLAY,
@@ -91,6 +93,7 @@ from ..const import (
from ..helpers.aggregate import compute_status_counts
from ..helpers.global_options import get_global_options
from ..helpers.notify_targets import build_notify_targets
from ..helpers.region import is_region_setting
from ..helpers.settings_registry import (
ALLOWED_SETTING_KEYS,
FLOAT_RANGES,
@@ -113,7 +116,10 @@ _LOGGER = logging.getLogger(__name__)
# Keys accepted by global/update + their range/cap tables are derived from the
# single settings registry (helpers/settings_registry) so they can't drift from
# each other or from the options-flow selectors that share the same specs.
_ALLOWED_SETTING_KEYS = ALLOWED_SETTING_KEYS
# Every value is a SettingSpec.py_type — a plain class, the isinstance()
# target below; the registry's ``type | vol.Any`` value annotation is wider
# than what it actually holds.
_ALLOWED_SETTING_KEYS = cast(dict[str, type], ALLOWED_SETTING_KEYS)
def _opt(options: Mapping[str, Any], key: str) -> Any:
@@ -142,10 +148,20 @@ def _battery_lifetime_catalog(hass: HomeAssistant) -> list[dict[str, Any]]:
from ..helpers.battery_lifetime import lifetime_catalog
names: dict[str, str] = {}
dev_reg = dr.async_get(hass)
# HA 2026.9 deprecates mapping-style access on DeviceRegistry.devices
# (.values() logs a deprecation warning for custom integrations and
# stops working in 2027.9) and iterates ENTRIES; 2026.8 and older
# iterate ids and need .values(). Probe the collection itself, same as
# repairs.py.
registry_devices = dr.async_get(hass).devices
all_devices: list[Any] = list(
cast(Any, registry_devices).values() if isinstance(registry_devices, Mapping) else registry_devices
)
for bat in read_batteries(hass):
if bat.model_key and bat.model_key not in names:
for device in dev_reg.devices.values():
for device in all_devices:
if getattr(device, "parent_device_id", None) is not None:
continue # 2026.9 sub-device: never the appliance, and its make/model reads are deprecated
manufacturer = str(getattr(device, "manufacturer", None) or "")
model = str(getattr(device, "model", None) or getattr(device, "model_id", None) or "")
key = f"{manufacturer.strip().lower()}|{model.strip().lower()}"
@@ -154,6 +170,9 @@ def _battery_lifetime_catalog(hass: HomeAssistant) -> list[dict[str, Any]]:
break
return lifetime_catalog(hass, list(discover_battery_types(hass)), model_names=names)
except Exception: # noqa: BLE001 - a settings read must never fail on the fleet
# Logged, not swallowed: an HA API change here once went unnoticed
# because the table simply came back shorter.
_LOGGER.warning("Battery lifetime table could not be built", exc_info=True)
return []
@@ -209,6 +228,10 @@ def _build_full_settings(
"member_display": _opt(options, CONF_MEMBER_DISPLAY),
# v2.21: template-gallery curation (ids hidden from the pickers).
"disabled_template_ids": _opt(options, CONF_DISABLED_TEMPLATE_IDS),
# v2.93: home profile dwelling type (auto / house / apartment).
"home_type": _opt(options, CONF_HOME_TYPE),
# 2.94: the state / province / region the templates follow (auto = located).
"home_region": _opt(options, CONF_HOME_REGION),
# v2.10.0: archive automation thresholds (panel Settings → Archive).
# oneoff_days: auto-archive a completed one-off after N days (0 = off).
# delete_archived_oneoff_days: auto-delete an auto-archived one-off N
@@ -658,8 +681,7 @@ async def ws_get_budget_status(
"""Return current budget status (monthly/yearly spent vs budget)."""
from ..helpers.budget import compute_spend
global_entry = _get_global_entry(hass)
global_options: Mapping[str, Any] = (global_entry.options or global_entry.data) if global_entry else {}
global_options = get_global_options(hass)
monthly_budget = float(global_options.get(CONF_BUDGET_MONTHLY, 0))
yearly_budget = float(global_options.get(CONF_BUDGET_YEARLY, 0))
@@ -691,6 +713,16 @@ async def ws_get_budget_status(
# ---------------------------------------------------------------------------
def settings_error_field(error: str) -> str:
"""The setting a ``sanitize_settings_input`` error code refers to — the
notify-service codes come from ``validate_notify_service``, the others are
the sanitizer's own early returns."""
return {
"invalid_shopping_list_entity": CONF_SHOPPING_LIST_ENTITY,
"invalid_search_template": CONF_PART_SEARCH_URL_TEMPLATE,
}.get(error, CONF_NOTIFY_SERVICE)
def sanitize_settings_input(settings_input: dict[str, Any]) -> tuple[dict[str, Any], str | None]:
"""Filter + validate a flat settings dict against the registry.
@@ -746,8 +778,12 @@ def sanitize_settings_input(settings_input: dict[str, Any]) -> tuple[dict[str, A
# v1.4.0 (#44): enum-validate notification_title_style. Anything outside
# the known set is dropped silently so a bogus value can't get into the
# ConfigEntry options.
from ..const import NOTIFICATION_TITLE_STYLES, NOTIFY_COMPLETED_MODES
from ..const import HOME_TYPES, NOTIFICATION_TITLE_STYLES, NOTIFY_COMPLETED_MODES
if CONF_HOME_TYPE in filtered and filtered[CONF_HOME_TYPE] not in HOME_TYPES:
del filtered[CONF_HOME_TYPE]
if CONF_HOME_REGION in filtered and not is_region_setting(filtered[CONF_HOME_REGION]):
del filtered[CONF_HOME_REGION]
if CONF_NOTIFY_COMPLETED in filtered and filtered[CONF_NOTIFY_COMPLETED] not in NOTIFY_COMPLETED_MODES:
filtered[CONF_NOTIFY_COMPLETED] = "off"
if CONF_NOTIFICATION_TITLE_STYLE in filtered and filtered[CONF_NOTIFICATION_TITLE_STYLE] not in NOTIFICATION_TITLE_STYLES:
@@ -23,7 +23,6 @@ from homeassistant.core import HomeAssistant
from ..const import (
CONF_OBJECT,
DOMAIN,
MAX_ID_LENGTH,
MAX_NAME_LENGTH,
MAX_TEXT_LENGTH,
MAX_URL_LENGTH,
@@ -31,7 +30,7 @@ from ..const import (
from ..helpers.aggregate import object_name
from ..helpers.permissions import require_write
from ..helpers.search_match import query_tokens, score_fields, snippet
from . import _get_object_entries, _load_object_entry, object_id_for_entry
from . import ID_FIELD, _get_object_entries, _load_object_entry, object_id_for_entry
from .tasks import _is_safe_url
if TYPE_CHECKING:
@@ -47,7 +46,7 @@ _TAGS_SCHEMA = vol.All(
vol.Length(max=_MAX_TAGS),
)
_TASK_IDS_SCHEMA = vol.All(
[vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
[ID_FIELD],
vol.Length(max=_MAX_TASK_IDS),
)
# {task_id: page} jump-to-page hints; page 0 clears, >=1 sets (PDFs, #page=N).
@@ -67,7 +66,7 @@ def _get_store(hass: HomeAssistant) -> DocumentStore:
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/documents/list",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
}
)
@websocket_api.async_response
@@ -109,7 +108,7 @@ async def ws_documents_storage(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/documents/add_link",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
vol.Required("url"): vol.All(str, vol.Length(min=1, max=MAX_URL_LENGTH)),
vol.Optional("title"): vol.Any(vol.All(str, vol.Length(max=MAX_NAME_LENGTH)), None),
vol.Optional("tags"): _TAGS_SCHEMA,
@@ -150,7 +149,7 @@ async def ws_documents_add_link(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/documents/update",
vol.Required("doc_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("doc_id"): ID_FIELD,
vol.Optional("title"): vol.All(str, vol.Length(max=MAX_NAME_LENGTH)),
vol.Optional("tags"): _TAGS_SCHEMA,
vol.Optional("task_ids"): _TASK_IDS_SCHEMA,
@@ -192,7 +191,7 @@ async def ws_documents_update(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/documents/delete",
vol.Required("doc_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("doc_id"): ID_FIELD,
}
)
@require_write
@@ -216,6 +215,51 @@ async def ws_documents_delete(
connection.send_result(msg["id"], {"success": True, "bytes_freed": freed})
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/documents/discard_upload",
vol.Required("entry_id"): ID_FIELD,
vol.Required("doc_id"): ID_FIELD,
}
)
@websocket_api.async_response
async def ws_documents_discard_upload(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Delete a completion photo that never became part of a record (read tier).
Every signed-in user may upload a completion photo, but only writers may
delete documents — so a photo a household member removed from the
dialog again, or whose completion was cancelled, stayed behind as an
orphan counting against the object's document cap (bug audit
2026-09-27, R SEC-3). This deletes a document only when it belongs to
``entry_id``'s object, is tagged exactly ``photo`` and nothing points at
it (no history entry of any task, no part, no task link) — anything else
answers ``not_found``, whether it exists or not, so the command reveals
nothing and can remove nothing that is part of the record.
"""
from ..helpers.completion_requirements import is_unattached_photo, photo_references
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
store = _get_store(hass)
doc = store.documents.get(msg["doc_id"])
references = photo_references(hass, strict=False)
if (
doc is None
or references is None
or doc.get("object_id") != object_id_for_entry(entry)
or not is_unattached_photo(doc, msg["doc_id"], references)
):
connection.send_error(msg["id"], "not_found", "Document not found")
return
await store.async_remove(msg["doc_id"])
connection.send_result(msg["id"], {"success": True})
_SEARCH_MAX_RESULTS = 50
#: Field weights for the tolerant matcher — the title is what people remember,
#: the file name and tags come next, a URL or MIME rarely.
@@ -382,10 +426,17 @@ async def ws_search(
by_digest.setdefault(digest, []).append(did)
for hit in await store.text_index.async_search(msg["query"], limit=limit * 2):
for did in by_digest.get(hit["digest"], []):
# The search awaits: a document deleted meanwhile raised a
# KeyError here and the whole search failed (bug audit
# 2026-09-26) — it simply is no hit any more.
live = store.documents.get(did)
if live is None:
doc_hits.pop(did, None)
continue
cur = doc_hits.get(did)
if cur is None:
doc_hits[did] = {
**_doc_hit(did, store.documents[did], obj_map),
**_doc_hit(did, live, obj_map),
"score": hit["score"],
"match": "content",
"page": hit["page"],
@@ -395,15 +446,16 @@ async def ws_search(
cur["page"] = hit["page"]
cur["snippet"] = hit["snippet"]
cur["score"] = max(cur["score"], hit["score"]) + min(cur["score"], hit["score"]) // 4
documents = sorted(doc_hits.values(), key=lambda d: -d["score"])[:limit]
documents = sorted((hit for did, hit in doc_hits.items() if did in store.documents), key=lambda d: -d["score"])[:limit]
# History notes across every task of every object.
history: list[dict[str, Any]] = []
for entry in _get_object_entries(hass):
oname = object_name(entry)
obj_ref = (entry.data.get(CONF_OBJECT) or {}).get("ref_no")
# Not ``obj_ref``: that name is the parsed int of the reference branch above.
object_ref = (entry.data.get(CONF_OBJECT) or {}).get("ref_no")
for tid, td in merged_tasks(entry).items():
td = {**td, "_object_ref": obj_ref}
td = {**td, "_object_ref": object_ref}
for h in td.get("history") or []:
notes = h.get("notes") if isinstance(h, dict) else None
if not isinstance(notes, str) or not notes.strip():
@@ -12,12 +12,11 @@ from homeassistant.core import HomeAssistant
from ..const import (
DOMAIN,
MAX_GROUP_TASK_REFS,
MAX_ID_LENGTH,
MAX_NAME_LENGTH,
MAX_TEXT_LENGTH,
)
from ..helpers.permissions import require_write
from . import _get_global_entry, _load_global_options, _save_global_options
from . import ID_FIELD, _get_global_entry, _load_global_options, _save_global_options
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/groups"})
@@ -35,8 +34,7 @@ async def ws_get_groups(
connection.send_result(msg["id"], {"groups": {}})
return
options = global_entry.options or global_entry.data
groups = options.get(CONF_GROUPS, {})
groups = (global_entry.options or global_entry.data).get(CONF_GROUPS, {})
connection.send_result(msg["id"], {"groups": groups})
@@ -48,8 +46,8 @@ async def ws_get_groups(
vol.Optional("task_refs", default=[]): vol.All(
[
{
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,
}
],
vol.Length(max=MAX_GROUP_TASK_REFS),
@@ -92,14 +90,14 @@ async def ws_create_group(
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/group/update",
vol.Required("group_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("group_id"): ID_FIELD,
vol.Optional("name"): vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
vol.Optional("description"): vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)),
vol.Optional("task_refs"): vol.All(
[
{
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,
}
],
vol.Length(max=MAX_GROUP_TASK_REFS),
@@ -130,7 +128,13 @@ async def ws_update_group(
group = dict(groups[group_id])
if "name" in msg:
group["name"] = msg["name"]
# Same rule as group/create: " " passed the length check and
# renamed the group to nothing (bug audit 2026-09-26).
name = msg["name"].strip()
if not name:
connection.send_error(msg["id"], "invalid_input", "Name must not be empty")
return
group["name"] = name
if "description" in msg:
group["description"] = msg["description"]
if "task_refs" in msg:
@@ -146,7 +150,7 @@ async def ws_update_group(
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/group/delete",
vol.Required("group_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("group_id"): ID_FIELD,
}
)
@require_write
@@ -3,9 +3,12 @@
``integration_setups/discover`` lists devices of catalogued integrations
(helpers/integration_signatures — every signature verified against the
integration's source) whose consumable entities can back maintenance tasks;
``integration_setups/adopt`` creates the object (or extends the existing one on
that device) with the tasks and their sensor triggers PRE-WIRED. Discovery is
read; adoption is write, mirroring problem-sensor adoption.
``integration_setups/preview`` (2.94) recomputes one device's proposals against
another target object — the dialog's before/after when the user picks a
different object; ``integration_setups/adopt`` creates the object (or extends
the existing one on that device) with the tasks and their sensor triggers
PRE-WIRED. Discovery and preview are read; adoption is write, mirroring
problem-sensor adoption.
"""
from __future__ import annotations
@@ -17,14 +20,15 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from ..const import CONF_OBJECT, CONF_TASKS, DOMAIN, MAX_ID_LENGTH, MAX_NAME_LENGTH
from ..helpers.aggregate import is_object_entry
from ..const import CONF_OBJECT, CONF_TASKS, DOMAIN, MAX_NAME_LENGTH
from ..helpers.integration_signatures import (
SIGNATURES,
build_setup_trigger,
discover_integration_setups,
)
from ..helpers.permissions import require_write
from . import ID_FIELD
from .adopt_batch import AdoptBatch
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/integration_setups/discover"})
@@ -38,11 +42,45 @@ async def ws_discover_integration_setups(
connection.send_result(msg["id"], {"setups": discover_integration_setups(hass)})
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/integration_setups/preview",
vol.Required("device_id"): ID_FIELD,
# An existing object as the target; omitted / null = a new object.
vol.Optional("entry_id"): vol.Any(ID_FIELD, None),
}
)
@websocket_api.async_response
async def ws_preview_integration_setup(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""One device's proposals judged against the chosen target (2.94): what
the target already has (``already``), what it likely has under another
name (``covered_by``) and how many tasks it holds now — the dialog's
before/after. Read-only; adopt re-runs discovery itself."""
from ..helpers.aggregate import is_object_entry
entry_id = msg.get("entry_id") or ""
if entry_id and not is_object_entry(hass.config_entries.async_get_entry(entry_id)):
connection.send_error(msg["id"], "not_found", "Target object not found")
return
setup = next(
(s for s in discover_integration_setups(hass, targets={msg["device_id"]: entry_id}) if s["device_id"] == msg["device_id"]),
None,
)
if setup is None:
connection.send_error(msg["id"], "not_found", "No suggestion for this device")
return
connection.send_result(msg["id"], setup)
_SELECTION_SCHEMA = vol.Schema(
{
vol.Required("device_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("device_id"): ID_FIELD,
# Existing target object; omit to create a fresh object for the device.
vol.Optional("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Optional("entry_id"): ID_FIELD,
vol.Optional("object_name"): vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
# Subset of suggested task names to adopt; omit = all suggested.
vol.Optional("task_names"): vol.All(
@@ -75,26 +113,21 @@ async def ws_adopt_integration_setups(
msg: dict[str, Any],
) -> None:
"""Create sensor-wired maintenance tasks for the selected suggestions."""
from ..export import object_entries
from ..helpers.i18n import normalize_language
from ..templates import localize_template_text
from .objects import async_create_object
from .tasks_persist import async_persist_task
lang = normalize_language(hass)
# Re-run discovery server-side: the wiring (entities, thresholds) comes
# from the verified catalog, never from the client.
setups = {s["device_id"]: s for s in discover_integration_setups(hass)}
tasks_created = 0
objects_created = 0
errors: list[dict[str, str]] = []
batch = AdoptBatch(hass)
for sel in msg["selections"]:
device_id = sel["device_id"]
setup = setups.get(device_id)
if setup is None:
errors.append({"device_id": device_id, "reason": "no suggestion for this device"})
batch.errors.append({"device_id": device_id, "reason": "no suggestion for this device"})
continue
wanted = set(sel.get("task_names") or [t["task_name"] for t in setup["tasks"]])
# Keyed by (task_name, direction): one integration can ship a task name
@@ -103,25 +136,18 @@ async def ws_adopt_integration_setups(
sig_by_key = {
(s.task_name, s.direction): s for s in SIGNATURES[setup["integration"]].tasks
}
created_entry_id: str | None = None
batch.begin()
try:
entry_id = sel.get("entry_id") or setup["suggested_entry_id"]
if not entry_id:
entry_id = await async_create_object(
hass,
entry_id = await batch.create_object(
name=sel.get("object_name") or setup["suggested_object_name"],
ha_device_id=device_id,
)
created_entry_id = entry_id
objects_created += 1
entry = hass.config_entries.async_get_entry(entry_id)
# Same guard as websocket._load_object_entry: the global settings
# entry is NOT a valid adoption target — async_persist_task writes
# CONF_TASKS + CONF_OBJECT["task_ids"] into whatever entry it is
# handed, so a client-supplied global entry_id would corrupt it.
if not is_object_entry(entry):
errors.append({"device_id": device_id, "reason": "target object not found"})
entry = batch.target_entry(entry_id)
if entry is None:
batch.errors.append({"device_id": device_id, "reason": "target object not found"})
continue
# #105: adopting into a user-picked existing object that isn't
@@ -165,8 +191,7 @@ async def ws_adopt_integration_setups(
baseline = baselines.get(task["task_name"])
if baseline is not None and sig.direction == "usage_delta":
trigger["trigger_baseline_value"] = float(baseline)
await async_persist_task(
hass,
await batch.persist_task(
entry,
{
"id": uuid4().hex,
@@ -180,19 +205,10 @@ async def ws_adopt_integration_setups(
"trigger_config": trigger,
},
)
tasks_created += 1
except (ValueError, KeyError) as err:
errors.append({"device_id": device_id, "reason": str(err)})
if created_entry_id is not None:
objects_created -= 1
if hass.config_entries.async_get_entry(created_entry_id) is not None:
await hass.config_entries.async_remove(created_entry_id)
# Removes an object created for this device together with the
# tasks already persisted into it — and un-counts both (the
# tasks were over-reported before the DRY audit 2026-09-26).
await batch.fail({"device_id": device_id, "reason": str(err)})
result: dict[str, Any] = {
"tasks_created": tasks_created,
"objects_created": objects_created,
"total": len(object_entries(hass)),
}
if errors:
result["errors"] = errors
connection.send_result(msg["id"], result)
connection.send_result(msg["id"], batch.result())
@@ -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(
@@ -26,7 +26,6 @@ from ..const import (
DOMAIN,
MAX_DATE_LENGTH,
MAX_ENTITY_ID_LENGTH,
MAX_ID_LENGTH,
MAX_META_LENGTH,
MAX_NAME_LENGTH,
MAX_TEXT_LENGTH,
@@ -37,6 +36,7 @@ from ..helpers.pause import reanchor_recurring_task
from ..helpers.permissions import require_write
from ..helpers.sanitize import cap_object_fields, strip_object_reference, strip_task_runtime_state
from . import (
ID_FIELD,
_build_object_response,
_get_object_entries,
_load_object_entry,
@@ -65,11 +65,59 @@ _OBJECT_STR_FIELD_SCHEMA: dict[Any, Any] = {
vol.Optional("notes"): vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None),
# 2.19: attach the object to an EXISTING HA device (entities land on its
# device page) / nest under another maintenance object (via_device).
vol.Optional("ha_device_id"): vol.Any(vol.All(str, vol.Length(max=MAX_ID_LENGTH)), None),
vol.Optional("parent_entry_id"): vol.Any(vol.All(str, vol.Length(max=MAX_ID_LENGTH)), None),
vol.Optional("ha_device_id"): vol.Any(ID_FIELD, None),
vol.Optional("parent_entry_id"): vol.Any(ID_FIELD, None),
}
def _carry_parts_shelf(src_parts: Any, *, keep_doc_links: bool) -> tuple[dict[str, str], dict[str, Any]]:
"""Copy an object's spare-part definitions with FRESH ids.
Returns ``(old→new id map, new parts)``. Shared by object/replace and
object/duplicate so a copy never shares part ids with its source.
``keep_doc_links=False`` drops a part's ``doc_id``: a duplicate does not
copy the documents, and a manual of ANOTHER object would dangle once that
object is gone.
"""
part_id_map: dict[str, str] = {}
new_parts: dict[str, Any] = {}
for src_part in (src_parts or {}).values():
if not isinstance(src_part, dict):
continue
carried = dict(src_part)
new_pid = uuid4().hex
part_id_map[str(carried.get("id"))] = new_pid
carried["id"] = new_pid
if not keep_doc_links:
carried.pop("doc_id", None)
new_parts[new_pid] = carried
return part_id_map, new_parts
def _remap_own_part_links(task: dict[str, Any], part_id_map: dict[str, str], own_entry_id: str) -> dict[str, Any]:
"""Point a copied task's links to its OWN object's parts at the fresh ids.
Task-level AND phase-level links (helpers.parts.map_part_links — replace
walked the task level only, and a phase kept consuming the retired
predecessor's part; bug audit 2026-09-27). A link into ANOTHER object's
pool (#111) is carried verbatim; an own link to a part the shelf does not
carry is dropped rather than left pointing at nothing.
"""
from ..helpers.parts import map_part_links
def _rewrite(link: dict[str, Any]) -> dict[str, Any] | None:
owner = str(link.get("entry_id") or "")
if owner and owner != own_entry_id:
return link
part_id = str(link.get("part_id") or "")
if part_id not in part_id_map:
return None
return {"part_id": part_id_map[part_id], "quantity": link.get("quantity", 1)}
remapped, _changed = map_part_links(task, _rewrite)
return remapped
def _validate_object_dates(connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> bool:
"""False (after sending ``invalid_date``) when a present installation_date
/ warranty_expiry (#67) is not ``YYYY-MM-DD`` — shared by create and update."""
@@ -167,7 +215,7 @@ async def ws_get_objects(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
}
)
@websocket_api.async_response
@@ -206,8 +254,20 @@ async def async_create_object(
``add_object`` service (DRY). Inputs are normalized here; callers do their
own validation/error reporting (the WS layer keeps its specific error
codes). Raises ValueError if the config flow does not create an entry.
The two dates are checked here too: the WS layer refuses a malformed one
up front, but the ``add_object`` service stored any string — "next
spring" as an installation date broke every age / warranty computation
that parses it (bug audit 2026-09-27). Raises ValueError.
"""
data = {
from ..helpers.dates import parse_iso_date
for field, value in (("installation_date", installation_date), ("warranty_expiry", warranty_expiry)):
if value and (not isinstance(value, str) or parse_iso_date(value) is None):
raise ValueError(f"{field} must be a valid date (YYYY-MM-DD), got {value!r}")
installation_date = installation_date or None
warranty_expiry = warranty_expiry or None
data: dict[str, Any] = {
CONF_OBJECT: {
"id": uuid4().hex,
CONF_OBJECT_NAME: name.strip(),
@@ -305,7 +365,7 @@ async def ws_create_object(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object/update",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
vol.Optional("name"): vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
**_OBJECT_STR_FIELD_SCHEMA,
}
@@ -328,6 +388,17 @@ async def ws_update_object(
if not msg["name"]:
connection.send_error(msg["id"], "invalid_input", "Name must not be empty")
return
# The same name rule as every create path (helpers.object_names): a
# rename onto another object's name went through, and the next
# create of that name — or a replace under it — failed confusingly
# (bug audit 2026-09-27). Only a CHANGED name is checked: the dialog
# re-sends the name on every save, and the successor of a replace
# legitimately shares its archived predecessor's name.
from ..helpers.object_names import name_changed_onto_taken
if name_changed_onto_taken(hass, entry, msg["name"]):
connection.send_error(msg["id"], "invalid_input", "Another object already has this name")
return
# Strip manufacturer/model/serial_number
if msg.get("manufacturer"):
@@ -409,7 +480,7 @@ async def ws_update_object(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object/delete",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
}
)
@require_write
@@ -432,7 +503,7 @@ async def ws_delete_object(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object/duplicate",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
}
)
@require_write
@@ -457,6 +528,14 @@ async def ws_duplicate_object(
return
src_obj = entry.data.get(CONF_OBJECT, {})
# There is ONE battery fleet: a copy carried the fleet flags on the object
# and its task and ran a second fleet — double triggers and notifications
# (bug audit 2026-09-27; task/duplicate refuses the fleet task alike).
from ..const import BATTERY_FLEET_OBJECT_FLAG
if src_obj.get(BATTERY_FLEET_OBJECT_FLAG):
connection.send_error(msg["id"], "invalid_input", "The battery fleet object cannot be duplicated")
return
new_obj = deepcopy(dict(src_obj))
new_obj["id"] = uuid4().hex
base_name = str(src_obj.get(CONF_OBJECT_NAME, "")).strip() or "Object"
@@ -467,20 +546,33 @@ async def ws_duplicate_object(
new_obj.pop("archived_at", None)
strip_object_reference(new_obj)
# The parts shelf travels with fresh ids (like replace) and the task
# links follow them. The copy used to carry the tasks' part links but no
# parts: every completion of the copy raised a broken-part-link repair
# (bug audit 2026-09-27). Stock does not travel — the copy is another
# unit with its own shelf, untracked until counted.
part_id_map, new_parts = _carry_parts_shelf(entry.data.get("parts"), keep_doc_links=False)
from ..helpers.parts import PART_REF_FIELD
new_tasks: dict[str, Any] = {}
for src_task in entry.data.get(CONF_TASKS, {}).values():
# Auto "buy" reminders belong to the reconciler of the SOURCE's parts.
if src_task.get(PART_REF_FIELD):
continue
task = deepcopy(dict(src_task))
task_id = uuid4().hex
task["id"] = task_id
task["object_id"] = new_obj["id"]
strip_task_runtime_state(task)
task = _remap_own_part_links(task, part_id_map, entry.entry_id)
new_tasks[task_id] = task
new_obj["task_ids"].append(task_id)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": "websocket"},
data={CONF_OBJECT: new_obj, CONF_TASKS: new_tasks},
data={CONF_OBJECT: new_obj, CONF_TASKS: new_tasks, "parts": new_parts},
)
if result["type"] != "create_entry":
connection.send_error(msg["id"], "duplicate_failed", result.get("reason", "unknown"))
@@ -491,7 +583,7 @@ async def ws_duplicate_object(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object/from_template",
vol.Required("template_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("template_id"): ID_FIELD,
vol.Optional("name"): vol.Any(vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)), None),
# v2.21.1: the caller's UI language — created object/task names are
# localized (falls back to the server language).
@@ -514,7 +606,8 @@ async def ws_create_from_template(
from uuid import uuid4
from ..helpers.i18n import normalize_language, normalize_language_code
from ..templates import get_template_by_id, localize_template_text
from ..helpers.template_usage import OBJECT_TEMPLATE_ID
from ..templates import async_build_template_tasks, get_template_by_id, localize_template_text
template = get_template_by_id(msg["template_id"])
if template is None:
@@ -526,17 +619,15 @@ async def ws_create_from_template(
name = (msg.get("name") or default_name).strip() or default_name
# Auto-number on collision: applying the same template twice (or owning
# three litter boxes) must not fail with already_configured — the second
# object becomes "Name 2", then "Name 3", … The check mirrors the config
# flow's duplicate detection (object name, case-insensitive).
existing_names = {
str(e.data.get(CONF_OBJECT, {}).get(CONF_OBJECT_NAME, "")).strip().lower()
for e in _get_object_entries(hass)
}
if name.strip().lower() in existing_names:
# object becomes "Name 2", then "Name 3", … The check is the config
# flow's own (helpers.object_names).
from ..helpers.object_names import name_taken
if name_taken(hass, name):
base = name[: MAX_NAME_LENGTH - 4]
for n in range(2, 100):
candidate = f"{base} {n}"
if candidate.strip().lower() not in existing_names:
if not name_taken(hass, candidate):
name = candidate
break
object_id = uuid4().hex
@@ -544,25 +635,12 @@ async def ws_create_from_template(
"id": object_id,
CONF_OBJECT_NAME: name[:MAX_NAME_LENGTH],
"task_ids": [],
# 2.94: the gallery marks templates in use as "already set up".
OBJECT_TEMPLATE_ID: template.id,
}
new_tasks: dict[str, Any] = {}
for tt in template.tasks:
task_id = uuid4().hex
task: dict[str, Any] = {
"id": task_id,
"object_id": object_id,
"name": localize_template_text(tt.name, lang),
"type": tt.type,
"enabled": True,
"schedule_type": tt.schedule_type,
"warning_days": tt.warning_days,
}
if tt.interval_days is not None:
task["interval_days"] = tt.interval_days
if tt.notes:
task["notes"] = localize_template_text(tt.notes, lang)
new_tasks[task_id] = task
new_obj["task_ids"].append(task_id)
# Seasons follow the home's hemisphere and climate (helpers/climate.py).
new_tasks = await async_build_template_tasks(hass, template, lang, object_id)
new_obj["task_ids"] = list(new_tasks)
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -578,7 +656,7 @@ async def ws_create_from_template(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object/archive",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
}
)
@require_write
@@ -604,7 +682,7 @@ async def ws_archive_object(
return
now_iso = dt_util.now().isoformat()
new_data = _archived_entry_data(entry.data, now_iso)
new_data = _archived_entry_data(entry.data, now_iso, keep_task_ids=_live_pool_buy_tasks(hass, entry))
hass.config_entries.async_update_entry(entry, data=new_data)
# Reload so the object's tasks' triggers tear down and entities go inert.
@@ -613,11 +691,34 @@ async def ws_archive_object(
connection.send_result(msg["id"], {"success": True, "archived_at": now_iso})
def _archived_entry_data(entry_data: Any, now_iso: str) -> dict[str, Any]:
def _live_pool_buy_tasks(hass: HomeAssistant, entry: Any) -> set[str]:
"""The object's buy reminders for parts OTHER live objects still borrow.
Archiving the owner of a shared pool (#111) retires the owner, not the
shelf — the borrowers keep consuming it, so its "Buy …" reminder must
stay live (bug audit 2026-09-27; the buy-task reconcile keeps these
parts, parts_runtime).
"""
from ..helpers.parts import PART_REF_FIELD
from ..helpers.shared_parts import borrowed_part_ids
in_use = borrowed_part_ids(hass, entry.entry_id, active_only=True)
if not in_use:
return set()
keep: set[str] = set()
for tid, td in (entry.data.get(CONF_TASKS) or {}).items():
ref = td.get(PART_REF_FIELD)
if isinstance(ref, dict) and str(ref.get("part_id") or "") in in_use:
keep.add(tid)
return keep
def _archived_entry_data(entry_data: Any, now_iso: str, *, keep_task_ids: set[str] | None = None) -> dict[str, Any]:
"""New entry data with the object archived and active tasks cascaded.
Shared by ``object/archive`` and the replace flow (which retires the
predecessor with exactly the same semantics).
predecessor with exactly the same semantics). ``keep_task_ids`` stay
active (a shared pool's buy reminders, :func:`_live_pool_buy_tasks`).
"""
obj = dict(entry_data.get(CONF_OBJECT, {}))
obj["archived_at"] = now_iso
@@ -628,7 +729,7 @@ def _archived_entry_data(entry_data: Any, now_iso: str) -> dict[str, Any]:
new_tasks: dict[str, Any] = {}
for tid, td in dict(entry_data.get(CONF_TASKS, {})).items():
td = dict(td)
if td.get("archived_at") is None: # cascade only to active tasks
if td.get("archived_at") is None and tid not in (keep_task_ids or ()): # cascade only to active tasks
td["archived_at"] = now_iso
td["archived_reason"] = ARCHIVE_REASON_OBJECT
new_tasks[tid] = td
@@ -642,7 +743,7 @@ def _archived_entry_data(entry_data: Any, now_iso: str) -> dict[str, Any]:
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object/unarchive",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
}
)
@require_write
@@ -699,7 +800,7 @@ async def ws_unarchive_object(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object/pause",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
vol.Optional("until"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
}
)
@@ -758,7 +859,7 @@ async def ws_pause_object(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object/resume",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
}
)
@require_write
@@ -798,7 +899,7 @@ async def ws_resume_object(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/object/replace",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
vol.Optional("name"): vol.Any(vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)), None),
}
)
@@ -848,14 +949,8 @@ async def ws_replace_object(
# Carry the parts shelf — the spares don't change when the machine dies.
# Fresh ids (like tasks); consumption links are remapped below and the
# tracked stock is copied into the successor's store after creation.
part_id_map: dict[str, str] = {}
new_parts: dict[str, Any] = {}
for src_part in (entry.data.get("parts") or {}).values():
carried = dict(src_part)
new_pid = uuid4().hex
part_id_map[str(carried.get("id"))] = new_pid
carried["id"] = new_pid
new_parts[new_pid] = carried
# Part manuals travel with the documents (part_id_map below).
part_id_map, new_parts = _carry_parts_shelf(entry.data.get("parts"), keep_doc_links=True)
new_tasks: dict[str, Any] = {}
for src_task in entry.data.get(CONF_TASKS, {}).values():
@@ -868,25 +963,11 @@ async def ws_replace_object(
task["id"] = task_id
task["object_id"] = new_obj["id"]
strip_task_runtime_state(task)
links = task.get("consumes_parts")
if isinstance(links, list):
remapped = []
for link in links:
if not isinstance(link, dict):
continue
if link.get("entry_id"):
# A pool owned by ANOTHER object (#111) is untouched by
# replacing this one — carry the link across verbatim, ids
# and all, or the successor silently stops consuming it.
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["consumes_parts"] = remapped
else:
task.pop("consumes_parts", None)
# Own-shelf links (task level AND per phase) follow the fresh part
# ids; a pool owned by ANOTHER object (#111) is untouched by
# replacing this one — carried verbatim, or the successor silently
# stops consuming it.
task = _remap_own_part_links(task, part_id_map, entry.entry_id)
new_tasks[task_id] = task
new_obj["task_ids"].append(task_id)
@@ -926,6 +1007,13 @@ async def ws_replace_object(
if stock is not None:
new_store.set_part_stock(new_pid, stock)
await new_store.async_save()
# Objects BORROWING the predecessor's pool (#111) now draw on the
# successor's shelf. They kept linking to the archived predecessor —
# whose buy tasks the archive suppresses — so the pool silently split
# in two (bug audit 2026-09-27).
from ..helpers.shared_parts import relink_borrowers
relink_borrowers(hass, entry.entry_id, new_entry_id, part_id_map)
if new_entry is not None:
from ..parts_runtime import schedule_buy_task_reconcile
@@ -7,8 +7,9 @@ from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import VolDictType
from ..const import BATTERY_FLEET_OBJECT_FLAG, BATTERY_FLEET_REMOVED_PARTS, CONF_OBJECT, CONF_PARTS, MAX_ID_LENGTH
from ..const import BATTERY_FLEET_OBJECT_FLAG, BATTERY_FLEET_REMOVED_PARTS, CONF_OBJECT, CONF_PARTS
from ..helpers.aggregate import get_store, object_name
from ..helpers.parts import (
MAX_PART_STOCK,
@@ -17,7 +18,7 @@ from ..helpers.parts import (
normalize_part,
)
from ..helpers.permissions import require_write
from . import _get_runtime_data, _load_object_entry
from . import ID_FIELD, _get_runtime_data, _load_object_entry
def _parts_of(entry: Any) -> dict[str, dict[str, Any]]:
@@ -31,7 +32,7 @@ def _persist_parts(hass: HomeAssistant, entry: Any, parts: dict[str, dict[str, A
hass.config_entries.async_update_entry(entry, data=new_data)
_PART_FIELDS_SCHEMA = {
_PART_FIELDS_SCHEMA: VolDictType = {
vol.Required("name"): str,
vol.Optional("mpn"): vol.Any(str, None),
vol.Optional("gtin"): vol.Any(str, None),
@@ -44,7 +45,7 @@ _PART_FIELDS_SCHEMA = {
vol.Optional("reorder_threshold"): vol.Any(int, None),
vol.Optional("restock_quantity"): vol.Any(int, float, None),
vol.Optional("auto_buy_task"): bool,
vol.Optional("doc_id"): vol.Any(vol.All(str, vol.Length(max=MAX_ID_LENGTH)), None),
vol.Optional("doc_id"): vol.Any(ID_FIELD, None),
# Initial / edited stock travels WITH the definition for dialog simplicity,
# but is stored in the per-entry Store (dynamic), not entry.data.
vol.Optional("stock"): vol.Any(int, float, None),
@@ -54,7 +55,7 @@ _PART_FIELDS_SCHEMA = {
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/part/create",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
**_PART_FIELDS_SCHEMA,
}
)
@@ -96,8 +97,8 @@ async def ws_create_part(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/part/update",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("part_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
vol.Required("part_id"): ID_FIELD,
**_PART_FIELDS_SCHEMA,
}
)
@@ -145,14 +146,15 @@ async def ws_update_part(
connection.send_result(msg["id"], {"success": True})
_PART_FIELD_KEYS = {k.schema for k in _PART_FIELDS_SCHEMA if str(k.schema) != "stock"}
# str() of a voluptuous marker is its key name (Marker.__str__ -> str(schema)).
_PART_FIELD_KEYS = {str(k) for k in _PART_FIELDS_SCHEMA if str(k) != "stock"}
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/part/delete",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("part_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
vol.Required("part_id"): ID_FIELD,
}
)
@require_write
@@ -228,8 +230,8 @@ async def ws_delete_part(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/part/restock",
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("part_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("entry_id"): ID_FIELD,
vol.Required("part_id"): ID_FIELD,
# Either a relative delta (may be negative for corrections) or an
# absolute count — exactly one.
vol.Optional("delta"): vol.All(vol.Any(int, float), vol.Coerce(float), vol.Range(min=-MAX_PART_STOCK, max=MAX_PART_STOCK)),
@@ -15,14 +15,16 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from ..const import CONF_OBJECT, DOMAIN, MAX_ENTITY_ID_LENGTH, MAX_ID_LENGTH, MAX_NAME_LENGTH
from ..helpers.aggregate import is_object_entry
from ..const import CONF_OBJECT, DOMAIN, MAX_ENTITY_ID_LENGTH, MAX_NAME_LENGTH, TRIGGER_FIELD_RANGES
from ..helpers.permissions import require_write
from ..helpers.problem_sensors import (
build_problem_task,
discover_problem_sensors,
pop_stashed_config,
sensor_covered_by,
)
from . import ID_FIELD
from .adopt_batch import AdoptBatch
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/problem_sensors/discover"})
@@ -36,24 +38,72 @@ async def ws_discover_problem_sensors(
connection.send_result(msg["id"], {"sensors": discover_problem_sensors(hass)})
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/problem_sensors/preview",
vol.Required("selections"): vol.All(
[
vol.Schema(
{
vol.Required("entity_id"): vol.All(str, vol.Length(max=MAX_ENTITY_ID_LENGTH)),
vol.Required("name"): vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
vol.Optional("entry_id"): vol.Any(ID_FIELD, None),
}
)
],
vol.Length(max=100),
),
}
)
@websocket_api.async_response
async def ws_preview_problem_sensors(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Judge each sensor against the object the dialog now sends it to
(2.94): ``{covered: {entity_id: {task_id, name, reason} | null}}`` — the
task that probably already watches this problem under another name.
Read-only; a new object (no ``entry_id``) has nothing to cover."""
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from ..helpers.aggregate import is_object_entry
ent_reg, dev_reg = er.async_get(hass), dr.async_get(hass)
covered: dict[str, Any] = {}
for sel in msg["selections"]:
entry = hass.config_entries.async_get_entry(sel["entry_id"]) if sel.get("entry_id") else None
if entry is not None and not is_object_entry(entry):
entry = None
ent = ent_reg.async_get(sel["entity_id"])
device = dev_reg.async_get(ent.device_id) if ent and ent.device_id else None
device_name = (device.name_by_user or device.name or "") if device else ""
covered[sel["entity_id"]] = sensor_covered_by(hass, sel["entity_id"], sel["name"], entry, device_name)
connection.send_result(msg["id"], {"covered": covered})
# The trigger hold-time bounds the flows and the WS validator use too.
_FOR_MINUTES = TRIGGER_FIELD_RANGES["trigger_for_minutes"]
_SELECTION_SCHEMA = vol.Schema(
{
vol.Required("entity_id"): vol.All(str, vol.Length(max=MAX_ENTITY_ID_LENGTH)),
vol.Required("name"): vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
# Existing target object; omit to create a fresh object for this device.
vol.Optional("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Optional("entry_id"): ID_FIELD,
vol.Optional("object_name"): vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
vol.Optional("device_id"): vol.Any(vol.All(str, vol.Length(max=MAX_ID_LENGTH)), None),
vol.Optional("device_id"): vol.Any(ID_FIELD, None),
# Spare part to link as consumes_parts on the adopted task (discovery's
# suggested_part_id) — completing the task then consumes/restocks it.
vol.Optional("part_id"): vol.Any(vol.All(str, vol.Length(max=MAX_ID_LENGTH)), None),
vol.Optional("part_id"): vol.Any(ID_FIELD, None),
# Responsible HA user for the created task (the adopt dialog offers one
# picker applied to every selection). Wins over a stashed value.
vol.Optional("responsible_user_id"): vol.Any(vol.All(str, vol.Length(max=MAX_ID_LENGTH)), None),
vol.Optional("responsible_user_id"): vol.Any(ID_FIELD, None),
# #136: minutes the problem state must HOLD before the task triggers
# (one dialog field applied to every selection). 0/omitted = trigger
# on the first flicker, the pre-#136 behaviour.
vol.Optional("for_minutes"): vol.Any(vol.All(int, vol.Range(min=0, max=1440)), None),
vol.Optional("for_minutes"): vol.Any(vol.All(int, vol.Range(min=_FOR_MINUTES[0], max=_FOR_MINUTES[1])), None),
}
)
@@ -81,13 +131,7 @@ async def ws_adopt_problem_sensors(
sensors are one thing"); the dialog resolves an existing object's name to
its ``entry_id`` before sending, so the name match stays within the batch.
"""
from ..export import object_entries
from ..websocket.objects import async_create_object
from ..websocket.tasks_persist import async_persist_task
selections = msg["selections"]
tasks_created = 0
objects_created = 0
batch = AdoptBatch(hass)
# Created tasks, in order — the dialog links "configure now" to the first.
created: list[dict[str, str]] = []
# Reuse an object created earlier in THIS batch for the same device, so two
@@ -96,40 +140,45 @@ async def ws_adopt_problem_sensors(
# #188: ...and for the same object NAME (case-insensitive) — the way the
# dialog lets the user say which sensors belong together.
name_to_entry: dict[str, str] = {}
errors: list[dict[str, str]] = []
for sel in selections:
for sel in msg["selections"]:
entity_id = sel["entity_id"]
entry_id = sel.get("entry_id")
device_id = sel.get("device_id")
created_entry_id: str | None = None # object created in THIS iteration
group_name = str(sel.get("object_name") or "").strip().casefold()
batch.begin()
try:
if not entry_id and device_id and device_id in device_to_entry:
entry_id = device_to_entry[device_id]
if not entry_id and group_name and group_name in name_to_entry:
entry_id = name_to_entry[group_name]
if not entry_id:
entry_id = await async_create_object(
hass,
entry_id = await batch.create_object(
name=sel.get("object_name") or sel["name"],
ha_device_id=device_id or None,
)
created_entry_id = entry_id
objects_created += 1
if device_id:
device_to_entry[device_id] = entry_id
if group_name:
name_to_entry[group_name] = entry_id
entry = hass.config_entries.async_get_entry(entry_id)
# Same guard as websocket._load_object_entry: the global settings
# entry is NOT a valid adoption target — async_persist_task writes
# CONF_TASKS + CONF_OBJECT["task_ids"] into whatever entry it is
# handed, so a client-supplied global entry_id would corrupt it.
if not is_object_entry(entry):
errors.append({"entity_id": entity_id, "reason": "target object not found"})
entry = batch.target_entry(entry_id)
if entry is None:
batch.errors.append({"entity_id": entity_id, "reason": "target object not found"})
continue
# 2.94: the sensor goes to THE object its device most likely is
# (the dialog's default) and that object has no device yet —
# link it, so the next discovery finds it by device, as the
# suggested setups do. Any other pick stays unlinked.
if device_id and not (entry.data.get(CONF_OBJECT) or {}).get("ha_device_id"):
from ..helpers.adopt_match import candidate_object
match = candidate_object(hass, device_id)
if match is not None and match["entry_id"] == entry.entry_id:
new_data = dict(entry.data)
new_data[CONF_OBJECT] = {**new_data.get(CONF_OBJECT, {}), "ha_device_id": device_id}
hass.config_entries.async_update_entry(entry, data=new_data)
entry = hass.config_entries.async_get_entry(entry.entry_id) or entry
task = build_problem_task(entity_id, sel["name"], for_minutes=sel.get("for_minutes") or 0)
task_data = {
@@ -174,29 +223,16 @@ async def ws_adopt_problem_sensors(
)
if links:
task_data["consumes_parts"] = links
await async_persist_task(hass, entry, task_data)
tasks_created += 1
await batch.persist_task(entry, task_data)
created.append({"entry_id": entry_id, "task_id": task_data["id"], "name": task_data["name"]})
except (ValueError, KeyError) as err:
errors.append({"entity_id": entity_id, "reason": str(err)})
# Roll back an object created in THIS iteration whose task failed —
# never leave an empty, task-less orphan object behind (and undo the
# count + device-reuse pointer so a later selection re-creates it).
if created_entry_id is not None:
objects_created -= 1
# device/name reuse pointers so a later selection re-creates it).
if await batch.fail({"entity_id": entity_id, "reason": str(err)}):
if device_id:
device_to_entry.pop(device_id, None)
if group_name:
name_to_entry.pop(group_name, None)
if hass.config_entries.async_get_entry(created_entry_id) is not None:
await hass.config_entries.async_remove(created_entry_id)
result: dict[str, Any] = {
"tasks_created": tasks_created,
"objects_created": objects_created,
"created": created,
"total": len(object_entries(hass)),
}
if errors:
result["errors"] = errors
connection.send_result(msg["id"], result)
connection.send_result(msg["id"], batch.result(created=created))
@@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant
from ..const import CONF_SAVED_FILTER_VIEWS, DOMAIN, MAX_ID_LENGTH, MAX_VIEW_NAME_LENGTH
from ..helpers.permissions import require_write
from ..helpers.saved_views import list_saved_views, remove_view, sanitize_view, upsert_view
from . import _get_global_entry
from . import ID_FIELD, _get_global_entry, _merge_global_options
def _persist(hass: HomeAssistant, views: list[dict[str, Any]]) -> None:
@@ -25,9 +25,7 @@ def _persist(hass: HomeAssistant, views: list[dict[str, Any]]) -> None:
global_entry = _get_global_entry(hass)
if global_entry is None:
raise LookupError("global_entry_missing")
options = dict(global_entry.options or global_entry.data)
options[CONF_SAVED_FILTER_VIEWS] = views
hass.config_entries.async_update_entry(global_entry, options=options)
_merge_global_options(hass, global_entry, {CONF_SAVED_FILTER_VIEWS: views})
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/views/list"})
@@ -46,7 +44,7 @@ async def ws_list_saved_views(
vol.Required("type"): f"{DOMAIN}/views/save",
# Omit view_id to create; include it to update in place. (Not "id" — that
# key is the WebSocket message id the framework owns.)
vol.Optional("view_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Optional("view_id"): ID_FIELD,
vol.Required("name"): vol.All(str, vol.Length(min=1, max=MAX_VIEW_NAME_LENGTH)),
vol.Optional("filters"): dict,
}
@@ -17,13 +17,13 @@ from ..const import (
MAX_COST,
MAX_DATE_LENGTH,
MAX_DURATION_MINUTES,
MAX_ID_LENGTH,
MAX_TEXT_LENGTH,
MAX_TIMESTAMP_LENGTH,
)
from ..helpers.completion_photos import MAX_COMPLETION_PHOTOS, normalize_photo_doc_ids
from ..models.maintenance_task import MaintenanceTask
from . import (
ID_FIELD,
READING_VALUES_FIELD,
USED_PARTS_FIELD,
_load_object_task,
@@ -66,8 +66,8 @@ def _refuse_too_early(connection: websocket_api.ActiveConnection, msg: dict[str,
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/complete",
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,
vol.Optional("notes"): vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None),
vol.Optional("cost"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=0, max=MAX_COST)), None),
vol.Optional("duration"): vol.Any(vol.All(vol.Coerce(int), vol.Range(min=0, max=MAX_DURATION_MINUTES)), None),
@@ -93,10 +93,10 @@ def _refuse_too_early(connection: websocket_api.ActiveConnection, msg: dict[str,
# images (via the document upload endpoint, tagged "photo"). The
# scalar form is what pre-2.75 clients send — merged into the list.
vol.Optional("photo_doc_ids"): vol.Any(
vol.All([vol.All(str, vol.Length(max=MAX_ID_LENGTH))], vol.Length(max=MAX_COMPLETION_PHOTOS)),
vol.All([ID_FIELD], vol.Length(max=MAX_COMPLETION_PHOTOS)),
None,
),
vol.Optional("photo_doc_id"): vol.Any(vol.All(str, vol.Length(max=MAX_ID_LENGTH)), None),
vol.Optional("photo_doc_id"): vol.Any(ID_FIELD, None),
# Meter readings (v2.20, #83): the recorded value for `reading` tasks.
# Wide numeric bounds — meters count high, temperatures go negative.
vol.Optional("reading_value"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-1e12, max=1e12)), None),
@@ -177,6 +177,17 @@ async def ws_complete_task(
connection.send_error(msg["id"], "invalid_input", str(err))
return
# Only this object's uploaded files count as completion photos — a
# foreign doc id satisfied a required photo and got linked (and later
# re-homed by task/move). This command is the one completion surface
# that carries photos (bug audit 2026-09-26).
from ..helpers.completion_requirements import own_photo_doc_ids
from . import object_id_for_entry
photo_doc_ids = own_photo_doc_ids(
hass, object_id_for_entry(_entry), normalize_photo_doc_ids(msg.get("photo_doc_ids"), msg.get("photo_doc_id"))
)
try:
await rd.coordinator.complete_maintenance(
source="panel",
@@ -186,7 +197,7 @@ async def ws_complete_task(
duration=msg.get("duration"),
checklist_state=msg.get("checklist_state"),
feedback=msg.get("feedback"),
photo_doc_ids=normalize_photo_doc_ids(msg.get("photo_doc_ids"), msg.get("photo_doc_id")) or None,
photo_doc_ids=photo_doc_ids or None,
reading_value=msg.get("reading_value"),
reading_values=reading_values,
restock_quantity=msg.get("restock_quantity"),
@@ -219,8 +230,8 @@ async def ws_complete_task(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/quick_complete",
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,
}
)
@websocket_api.async_response
@@ -273,8 +284,8 @@ async def ws_quick_complete_task(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/skip",
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,
vol.Optional("reason"): vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None),
# Record the skipped cycle as MISSED (was due, never done) rather than a
# deliberate skip — clearer history + compliance views.
@@ -310,8 +321,8 @@ async def ws_skip_task(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/reset",
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,
vol.Optional("date"): vol.Any(vol.All(str, vol.Length(max=MAX_DATE_LENGTH)), None),
}
)
@@ -329,7 +340,10 @@ async def ws_reset_task(
reset_date = None
if msg.get("date"):
reset_date = _parse_iso_date(connection, msg["id"], msg["date"], field="date")
# A reset marks a day the work WAS done — a future one (read tier:
# any household member) overflowed the schedule math and took the
# object down (bug audit 2026-09-27).
reset_date = _parse_iso_date(connection, msg["id"], msg["date"], field="date", not_future=True)
if reset_date is None:
return
@@ -348,8 +362,8 @@ async def ws_reset_task(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/set_phase",
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,
# Index into phase_sequence — which cycle step is due NEXT.
vol.Required("cursor"): vol.All(int, vol.Range(min=0, max=100)),
}
@@ -387,8 +401,8 @@ async def ws_set_task_phase(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/postpone",
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,
vol.Required("until"): vol.All(str, vol.Length(max=MAX_DATE_LENGTH)),
}
)
@@ -407,6 +421,15 @@ async def ws_postpone_task(
until = _parse_iso_date(connection, msg["id"], msg["until"], field="until")
if until is None:
return
# Bug audit 2026-09-27: an override in year 9999 overflowed the calendar
# entity's "next day" — a postpone reaches at most one maximum interval out.
from datetime import timedelta
from ..const import MAX_INTERVAL_DAYS
if until > dt_util.now().date() + timedelta(days=MAX_INTERVAL_DAYS):
connection.send_error(msg["id"], "invalid_date", f"until must be within {MAX_INTERVAL_DAYS} days")
return
try:
await rd.coordinator.async_postpone_task(msg["task_id"], until)
@@ -419,8 +442,8 @@ async def ws_postpone_task(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/snooze",
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,
}
)
@websocket_api.async_response
@@ -435,7 +458,7 @@ async def ws_snooze_task(
due-soon/overdue/triggered reminders for ``snooze_duration_hours`` — it does
not change the task's schedule or state.
"""
from .. import DOMAIN, NOTIFICATION_MANAGER_KEY
from ..const import DOMAIN, NOTIFICATION_MANAGER_KEY
if _load_object_task(hass, connection, msg) is None:
return
@@ -451,8 +474,8 @@ async def ws_snooze_task(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/checklist_progress",
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,
# Same shape/caps as task/complete's checklist_state.
vol.Required("checklist_state"): vol.All(
{vol.All(str, vol.Length(max=MAX_CHECKLIST_ITEM_LENGTH)): bool},
@@ -478,11 +501,16 @@ async def ws_checklist_progress(
a step IS doing the work — the same household member who may complete the
task must be able to record partial progress.
"""
ctx = _load_object_task(hass, connection, msg, need_coordinator=True)
from ..helpers.phases import effective_field
# Merged view: the phase cursor lives in the Store. A phased task (#139)
# shows the CURRENT phase's checklist; validating against the task-level
# list dropped every tick on it (bug audit 2026-09-26).
ctx = _load_object_task(hass, connection, msg, merged=True, need_coordinator=True)
if ctx is None:
return
_entry, rd, task = ctx
task_items = set(task.get("checklist") or [])
task_items = set(effective_field(task, "checklist") or [])
state = {item: bool(done) for item, done in msg["checklist_state"].items() if item in task_items}
# Progress lives ONLY in the Store (no legacy fallback) — degrade to a
# clean error instead of an AttributeError when it failed to load.
@@ -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
@@ -13,7 +13,6 @@ from ..const import (
LIFECYCLE_HISTORY_TYPES,
MAX_COST,
MAX_DURATION_MINUTES,
MAX_ID_LENGTH,
MAX_META_LENGTH,
MAX_TEXT_LENGTH,
MAX_TIMESTAMP_LENGTH,
@@ -26,6 +25,7 @@ from ..helpers.completion_photos import (
from ..helpers.permissions import require_write
from ..storage import reanchor_from_history
from . import (
ID_FIELD,
READING_VALUES_FIELD,
USED_PARTS_FIELD,
_load_object_task,
@@ -53,8 +53,8 @@ from . import (
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/history/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.Required("entry_id"): ID_FIELD,
vol.Required("task_id"): ID_FIELD,
# ISO datetime string identifying the entry being edited.
vol.Required("original_timestamp"): vol.All(str, vol.Length(max=MAX_TIMESTAMP_LENGTH)),
# Patch fields — all optional; absent fields stay unchanged.
@@ -79,7 +79,7 @@ from . import (
# themselves are never deleted here — they stay in the object's
# documents, the entry merely stops pointing at them.
vol.Optional("photo_doc_ids"): vol.Any(
vol.All([vol.All(str, vol.Length(max=MAX_ID_LENGTH))], vol.Length(max=MAX_COMPLETION_PHOTOS)),
vol.All([ID_FIELD], vol.Length(max=MAX_COMPLETION_PHOTOS)),
None,
),
}
@@ -188,43 +188,68 @@ async def ws_update_history_entry(
else:
patched.pop("reading_values", None)
# #161: completion photos on the entry. A new id is linked to the task
# like a live completion does (best-effort, after the write below); a
# removed id keeps its document and its links — only the entry forgets
# it. The legacy scalar is folded into the list the moment the entry is
# edited.
photos_to_link: list[str] = []
if "photo_doc_ids" in msg:
from ..helpers.completion_requirements import own_photo_doc_ids
from . import object_id_for_entry
old_photos = history_photo_ids(patched)
requested = normalize_photo_doc_ids(msg["photo_doc_ids"])
# A NEW photo must be a file of this object (bug audit 2026-09-26,
# same rule as task/complete); one the entry already carries stays
# even when it lives elsewhere now (a shared photo of a moved task).
own = set(own_photo_doc_ids(hass, object_id_for_entry(entry), [d for d in requested if d not in old_photos]))
new_photos = [d for d in requested if d in old_photos or d in own]
patched.pop("photo_doc_id", None)
if new_photos:
patched["photo_doc_ids"] = new_photos
else:
patched.pop("photo_doc_ids", None)
photos_to_link = [d for d in new_photos if d not in old_photos]
# Nothing above awaited, so the entry is looked up again, its CURRENT
# used_parts give the parts delta, and the patched entry is written back
# — all in one synchronous step, before any side effect. The delta used
# to be computed from the top snapshot and applied (stock moved) across
# awaits, and only then was the entry looked up again: an entry deleted
# meanwhile answered not_found AFTER the stock had moved, and a
# concurrent edit's parts were counted from a stale selection (bug audit
# 2026-09-27).
history = list(store.get_history(task_id))
target_index = next(
(i for i, h in enumerate(history) if h.get("timestamp") == msg["original_timestamp"]),
None,
)
if target_index is None:
connection.send_error(msg["id"], "not_found", f"No history entry with timestamp {msg['original_timestamp']!r}")
return
# #130: part consumption on the entry. The stock is adjusted by the
# per-part delta between the stored and the submitted selection, so
# corrections and backfills keep the shelf honest. Best-effort like the
# live completion path — a vanished part skips its stock math.
parts_touched: list[Any] = []
if "used_parts" in msg:
from ..parts_runtime import async_apply_history_parts_edit
from ..parts_runtime import apply_history_parts_edit
old_used = patched.get("used_parts") or []
old_used = history[target_index].get("used_parts") or []
# Deliberately NOT sanitize_consumes_parts here: an edited entry may
# reference a part that has since been deleted, and that link must
# stay RECORDED (stock math skips it) — dropping unknown ids would
# rewrite history. Field validation (ids, quantity range, list cap)
# is the schema's job above.
new_used = msg["used_parts"] or []
enriched = await async_apply_history_parts_edit(hass, entry, slot_task, old_used, new_used)
enriched, parts_touched = apply_history_parts_edit(hass, entry, slot_task, old_used, new_used)
if enriched:
patched["used_parts"] = enriched
else:
patched.pop("used_parts", None)
# #161: completion photos on the entry. A new id is linked to the task
# like a live completion does (best-effort); a removed id keeps its
# document and its links — only the entry forgets it. The legacy
# scalar is folded into the list the moment the entry is edited.
if "photo_doc_ids" in msg:
old_photos = history_photo_ids(patched)
new_photos = normalize_photo_doc_ids(msg["photo_doc_ids"])
patched.pop("photo_doc_id", None)
if new_photos:
patched["photo_doc_ids"] = new_photos
else:
patched.pop("photo_doc_ids", None)
if rd.coordinator:
for doc_id in new_photos:
if doc_id not in old_photos:
await rd.coordinator._link_completion_photo(doc_id, task_id)
history[target_index] = patched
store.set_history(task_id, history)
@@ -236,6 +261,15 @@ async def ws_update_history_entry(
if any(h.get("type") in LIFECYCLE_HISTORY_TYPES for h in history):
reanchor_from_history(store, task_id, history)
# The awaiting side effects run only now, for an entry that is written.
if parts_touched:
from ..parts_runtime import async_commit_parts_edit
await async_commit_parts_edit(hass, parts_touched)
if rd.coordinator:
for doc_id in photos_to_link:
await rd.coordinator._link_completion_photo(doc_id, task_id)
# Save + budget cache + immediate refresh so the UI reflects the change.
await async_commit_store(rd, budget=True)
@@ -252,8 +286,8 @@ async def ws_update_history_entry(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/history/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,
vol.Required("timestamp"): vol.All(str, vol.Length(max=MAX_TIMESTAMP_LENGTH)),
}
)
@@ -287,6 +321,29 @@ async def ws_delete_history_entry(
# until its next completion (the static config's own last_performed, if
# any, shows through the merge). A moved anchor drops a stale postpone.
reanchor_from_history(store, task_id, remaining)
_rewind_phase_cursor(store, task_id, _task, history, msg["timestamp"])
await async_commit_store(rd, budget=True)
connection.send_result(msg["id"], {"success": True, "remaining": len(remaining)})
def _rewind_phase_cursor(store: Any, task_id: str, task: dict[str, Any], history: list[dict[str, Any]], timestamp: str) -> None:
"""Deleting the LATEST completion of a phased task (#139) hands its phase
back: that completion had advanced the cursor and the anchor now rewinds
(reanchor_from_history), but the cursor stayed — the step the user just
un-did was skipped for good (bug audit 2026-09-27). Older completions and
pure backfills never moved the cursor, so they leave it alone."""
from ..helpers.phases import clamp_phase_cursor
seq = [p for p in task.get("phase_sequence") or [] if isinstance(p, str)]
lifecycle = [h for h in history if h.get("type") in LIFECYCLE_HISTORY_TYPES]
if not seq or not lifecycle:
return
latest = max(lifecycle, key=lambda h: str(h.get("timestamp") or ""))
phase_id = latest.get("phase_id")
if latest.get("timestamp") != timestamp or latest.get("type") != "completed" or phase_id not in seq:
return
# The completion advanced the cursor from ITS phase to the next one; the
# previous slot is that phase unless the sequence was edited since.
cursor = clamp_phase_cursor(store.get_task_state(task_id).get("phase_cursor", 0), len(seq))
previous = (cursor - 1) % len(seq)
store.set_phase_cursor(task_id, previous if seq[previous] == phase_id else seq.index(phase_id))
@@ -12,13 +12,13 @@ from homeassistant.util import dt as dt_util
from ..const import (
ARCHIVE_REASON_MANUAL,
CONF_TASKS,
MAX_ID_LENGTH,
)
from ..helpers.aggregate import get_coordinator_data, get_store, object_name
from ..helpers.entry_tasks import write_task
from ..helpers.pause import clear_cycle_modifiers, reanchor_recurring_task
from ..helpers.permissions import require_write
from . import (
ID_FIELD,
_build_task_summary,
_get_merged_tasks,
_get_object_entries,
@@ -42,8 +42,8 @@ def _is_recurring_schedule(task: dict[str, Any]) -> bool:
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/archive",
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
@@ -83,8 +83,8 @@ async def ws_archive_task(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/unarchive",
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
@@ -158,7 +158,7 @@ async def ws_unarchive_task(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/list",
vol.Optional("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Optional("entry_id"): ID_FIELD,
}
)
@callback
@@ -191,8 +191,8 @@ def ws_list_tasks(
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/history",
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,
}
)
@callback
@@ -13,14 +13,17 @@ from ..const import (
BATTERY_FLEET_TASK_FLAG,
CONF_OBJECT,
CONF_TASKS,
DEFAULT_WARNING_DAYS,
DOMAIN,
FLAT_SCHEDULE_TYPES,
MAX_TASKS_PER_OBJECT,
)
from ..helpers.aggregate import get_store, is_object_entry
from ..helpers.dates import parse_iso_date
from ..helpers.entry_tasks import insert_new_task
from ..helpers.global_options import get_default_warning_days
from ..helpers.sanitize import cap_task_fields
from ..helpers.schedule import (
Schedule,
normalize_task_storage,
)
@@ -29,6 +32,20 @@ from ..helpers.schedule import (
# ---------------------------------------------------------------------------
def _checked_due_date(value: Any) -> str:
"""The canonical ISO form of a service-supplied ``due_date``.
The service schemas only require a string, so "next tuesday" was stored
as a one-time task's due date — the task then never came due (bug audit
2026-09-26). Raises ValueError, which the services report as invalid
input.
"""
parsed = parse_iso_date(value) if isinstance(value, str) else None
if parsed is None:
raise ValueError(f"due_date must be a valid date (YYYY-MM-DD), got {value!r}")
return parsed.isoformat()
async def async_persist_task(
hass: HomeAssistant,
entry: ConfigEntry,
@@ -64,7 +81,7 @@ async def async_create_task_simple(
interval_days: int | None = None,
interval_unit: str = "days",
due_date: str | None = None,
warning_days: int = DEFAULT_WARNING_DAYS,
warning_days: int | None = None,
enabled: bool = True,
notes: str | None = None,
schedule: dict[str, Any] | None = None,
@@ -76,8 +93,12 @@ async def async_create_task_simple(
For the full field set (triggers, checklists, completion actions, …) use
the panel / card dialogs or the ``task/create`` WS command.
Raises ValueError if the entry_id is not a maintenance object or the name
is empty.
Raises ValueError if the entry_id is not a maintenance object, the name
is empty or the due date is not a date.
``warning_days`` defaults to the integration-wide setting like every
other create path — it was the bare constant 7 here (bug audit
2026-09-26).
Like the config-flow save handlers (see ``helpers/sanitize``), this runs
:func:`cap_task_fields` before persisting: the ``add_task`` *service*
@@ -85,31 +106,34 @@ 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 not is_object_entry(entry):
# is_object_entry() rejects None as well; the explicit test narrows the type.
if entry is None or 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:
raise ValueError("Name must not be empty")
task_id = uuid4().hex
task_data: dict[str, Any] = {
"id": uuid4().hex,
"id": task_id,
"object_id": entry.data.get(CONF_OBJECT, {}).get("id", ""),
"name": name,
"type": task_type,
"enabled": enabled,
"schedule_type": schedule_type,
"warning_days": warning_days,
"warning_days": warning_days if warning_days is not None else get_default_warning_days(hass),
"created_at": dt_util.now().date().isoformat(),
}
if schedule:
# Calendar kinds: persist the nested schedule (normalize treats it as
# authoritative over the flat fields).
task_data["schedule"] = schedule
# authoritative over the flat fields) — canonicalised like the WS
# create path does; it was stored raw (bug audit 2026-09-26).
task_data["schedule"] = Schedule.from_dict(schedule).to_dict()
if interval_days is not None:
task_data["interval_days"] = interval_days
if interval_unit and interval_unit != "days":
task_data["interval_unit"] = interval_unit
if due_date:
task_data["due_date"] = due_date
task_data["due_date"] = _checked_due_date(due_date)
if notes:
task_data["notes"] = notes
# Same sanitising as the config-flow create path, applied BEFORE the
@@ -117,7 +141,7 @@ async def async_create_task_simple(
# interval_days/warning_days is what the schedule model sees.
cap_task_fields(task_data)
await async_persist_task(hass, entry, task_data)
return task_data["id"]
return task_id
_UPDATABLE_FLAT_FIELDS = (
@@ -157,7 +181,8 @@ 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 not is_object_entry(entry):
# is_object_entry() rejects None as well; the explicit test narrows the type.
if entry is None or not is_object_entry(entry):
raise ValueError(f"No maintenance object found for entry_id {entry_id!r}")
new_data = dict(entry.data)
@@ -166,6 +191,8 @@ async def async_update_task_simple(
raise ValueError(f"No task {task_id!r} in {entry.title!r}")
task = dict(new_tasks[task_id])
if updates.get("due_date") is not None:
updates = {**updates, "due_date": _checked_due_date(updates["due_date"])}
for key in _UPDATABLE_FLAT_FIELDS:
if key in updates and updates[key] is not None:
task[key] = updates[key]
@@ -184,9 +211,23 @@ async def async_update_task_simple(
if updates.get("schedule_type") is not None:
task["schedule_type"] = updates["schedule_type"]
if updates.get("schedule"):
task["schedule"] = updates["schedule"]
task["schedule"] = Schedule.from_dict(updates["schedule"]).to_dict()
elif isinstance(task.get("schedule"), dict) and Schedule.from_dict(task["schedule"]).is_calendar_kind and (
any(updates.get(key) is not None for key in ("interval_days", "interval_unit", "due_date"))
or updates.get("schedule_type") in FLAT_SCHEDULE_TYPES
):
# A flat recurrence edit on a calendar-kind task: normalize keeps a
# calendar schedule authoritative and DROPS the flat keys, so the
# interval the caller set silently vanished. Same rule as the WS
# task/update — the flat fields rebuild the schedule (bug audit
# 2026-09-26).
task.pop("schedule", None)
cap_task_fields(task)
# The service cannot change the completion action, so the stored one keeps
# the user it runs as — dropping ``configured_by`` here made an operator's
# action run with system rights after any service edit (bug audit
# 2026-09-27).
cap_task_fields(task, keep_action_owner=True)
new_tasks[task_id] = normalize_task_storage(task)
new_data[CONF_TASKS] = new_tasks
hass.config_entries.async_update_entry(entry, data=new_data)
@@ -277,46 +318,53 @@ async def async_move_task(
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):
entry.pop("ref_no", None)
def _strip_refs(state: dict[str, Any]) -> dict[str, Any]:
state.pop("next_history_ref", None)
for entry in state.get("history") or []:
if isinstance(entry, dict):
entry.pop("ref_no", None)
return state
state = _strip_refs(deepcopy(src_store.get_task_state(task_id)))
# Group memberships: snapshot, let the delete sweep them, re-add under the target.
from ..const import CONF_GROUPS
from ..helpers.global_options import get_global_entry
from ..helpers.global_options import get_global_entry, get_global_options
from . import _merge_global_options
member_groups: list[str] = []
global_entry = get_global_entry(hass)
if global_entry is not None:
for gid, group in (dict(global_entry.options or global_entry.data).get(CONF_GROUPS) or {}).items():
if any(isinstance(r, dict) and r.get("task_id") == task_id for r in group.get("task_refs", [])):
member_groups.append(gid)
for gid, group in (get_global_options(hass).get(CONF_GROUPS) or {}).items():
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
exempt = get_global_options(hass).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]
}
# Photos linked to this task alone are re-homed with it — a doc shared
# with the object's other tasks stays where it is (link kept). Which of
# them the history uses is decided on the FINAL state below.
sole_task_docs = {did for did in doc_links if (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")
await async_delete_task(hass, source, task_id)
# The delete awaits before it drops the Store state; take the state as
# it was at that moment — a completion landing during the awaits was
# lost from the snapshot above (bug audit 2026-09-26).
final_state: dict[str, Any] = {}
await async_delete_task(hass, source, task_id, removed_state=final_state)
if final_state:
state = _strip_refs(final_state)
photo_ids: set[str] = set()
for entry in state.get("history") or []:
if isinstance(entry, dict):
photo_ids.update(history_photo_ids(entry))
rehome_ids = photo_ids & sole_task_docs
new_data = dict(target.data)
new_tasks = dict(new_data.get(CONF_TASKS, {}))
@@ -333,20 +381,18 @@ async def async_move_task(
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"])
# Both write-backs read the settings the delete leg just rewrote
# (DRY audit 2026-09-26 B: one read rule, one merge).
if vacation_exempt and global_entry is not None:
options = dict(global_entry.options)
current = options.get(CONF_VACATION_EXEMPT_TASK_IDS) or []
current = get_global_options(hass).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)
_merge_global_options(hass, global_entry, {CONF_VACATION_EXEMPT_TASK_IDS: [*current, task_id]})
if member_groups and global_entry is not None:
options = dict(global_entry.options or global_entry.data)
groups = dict(options.get(CONF_GROUPS) or {})
groups = dict(get_global_options(hass).get(CONF_GROUPS) or {})
for gid in member_groups:
group = groups.get(gid)
if group is None:
continue
groups[gid] = {**group, "task_refs": [*group.get("task_refs", []), {"entry_id": target.entry_id, "task_id": task_id}]}
options[CONF_GROUPS] = groups
hass.config_entries.async_update_entry(global_entry, options=options)
_merge_global_options(hass, global_entry, {CONF_GROUPS: groups})
@@ -13,11 +13,15 @@ from homeassistant.core import HomeAssistant
from ..const import (
CONF_TASKS,
TRIGGER_FIELD_RANGES,
TRIGGER_RUNTIME_HOURS_MAX,
UNAVAILABLE_STATES,
TriggerType,
)
from ..helpers.aggregate import object_name
from ..helpers.trigger_fallback import threshold_limits_overlap
from ..helpers.url_safety import _SAFE_URL_SCHEMES as _SAFE_URL_SCHEMES
from ..helpers.url_safety import is_safe_url
from . import (
_get_object_entries,
)
@@ -26,36 +30,8 @@ from . import (
# Validation helpers
# ---------------------------------------------------------------------------
_SAFE_URL_SCHEMES = {"http", "https"}
def _is_safe_url(url: str | None) -> bool:
"""Reject javascript:, data:, protocol-relative and other dangerous URLs.
Only http/https and genuine path-relative URLs (no host) pass. ASCII control
characters and surrounding whitespace are stripped first, since urlparse and
browsers ignore them and they can otherwise mask a "//host" or scheme-less
host (e.g. ``" //evil.com"`` or ``"\t//evil.com"``).
"""
if not url:
return True
from urllib.parse import urlparse
cleaned = "".join(ch for ch in url if ch.isprintable()).strip()
if not cleaned:
return True
# Block protocol-relative URLs like //evil.com
if cleaned.startswith("//"):
return False
try:
parsed = urlparse(cleaned)
except Exception: # noqa: BLE001 - any malformed URL is rejected as unsafe
return False
scheme = parsed.scheme.lower()
if scheme in _SAFE_URL_SCHEMES:
return True
# An empty scheme is only safe for a true path-relative URL with no host.
return scheme == "" and not parsed.netloc
# The URL rule lives in helpers.url_safety (every write path shares it).
_is_safe_url = is_safe_url
# ---------------------------------------------------------------------------
@@ -285,12 +261,10 @@ _NUMBER_FIELDS: tuple[str, ...] = (
"trigger_target_value",
"trigger_baseline_value",
)
# Whole-number fields with an inclusive range.
_INT_FIELDS: dict[str, tuple[int, int]] = {
"trigger_for_minutes": (0, 1440),
"trigger_target_changes": (1, 10_000),
}
TRIGGER_RUNTIME_HOURS_MAX = 100_000
# Whole-number fields with an inclusive range — the bounds the options-flow
# selectors use too (const.TRIGGER_FIELD_RANGES, DRY audit 2026-09-26 B).
# TRIGGER_RUNTIME_HOURS_MAX is imported from const and re-exported here.
_INT_FIELDS: dict[str, tuple[int, int]] = TRIGGER_FIELD_RANGES
# Optional fields where an explicit null means "unset" — dropped rather than
# refused, so a client clearing a field never trips the validator.
_OPTIONAL_VALUE_FIELDS: tuple[str, ...] = (
@@ -11,13 +11,13 @@ from homeassistant.core import HomeAssistant
from ..const import (
CONF_TASKS,
DOMAIN,
MAX_ID_LENGTH,
MAX_META_LENGTH,
)
from ..helpers.aggregate import get_coordinator_data, get_runtime_data, object_name
from ..helpers.entry_tasks import write_task
from ..helpers.permissions import require_write, user_may_write
from . import (
ID_FIELD,
_build_task_summary,
_get_merged_tasks,
_get_object_entries,
@@ -66,8 +66,8 @@ async def ws_list_users(
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/task/assign_user",
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,
vol.Optional("user_id"): vol.Any(vol.All(str, vol.Length(max=MAX_META_LENGTH)), None), # None = unassign
}
)
@@ -2,6 +2,7 @@
from __future__ import annotations
from datetime import timedelta
from typing import Any
import voluptuous as vol
@@ -10,21 +11,22 @@ from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from ..const import (
CONF_OBJECT,
CONF_VACATION_BUFFER_DAYS,
CONF_VACATION_ENABLED,
CONF_VACATION_END,
CONF_VACATION_EXEMPT_TASK_IDS,
CONF_VACATION_START,
DEFAULT_WARNING_DAYS,
DOMAIN,
MAX_ID_LENGTH,
MAX_INTERVAL_DAYS,
MAX_VACATION_EXEMPT_TASKS,
)
from ..helpers.aggregate import object_name
from ..helpers.dates import parse_iso_date
from ..helpers.schedule import read_legacy_fields
from ..helpers.pause import is_task_inert
from ..helpers.vacation import compute_preview, get_vacation_state
from . import _get_merged_tasks, _get_object_entries, _load_global_options, _parse_iso_date, _save_global_options
from . import ID_FIELD, _get_merged_tasks, _get_object_entries, _load_global_options, _parse_iso_date, _save_global_options
def _state_payload(hass: HomeAssistant) -> dict[str, Any]:
@@ -51,7 +53,7 @@ async def ws_vacation_state(
vol.Optional("end"): vol.Any(vol.All(str, vol.Length(max=10)), None),
vol.Optional("buffer_days"): vol.All(int, vol.Range(min=0, max=14)),
vol.Optional("exempt_task_ids"): vol.All(
[vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
[ID_FIELD],
vol.Length(max=MAX_VACATION_EXEMPT_TASKS),
),
}
@@ -72,11 +74,24 @@ async def ws_vacation_update(
if "enabled" in msg:
options[CONF_VACATION_ENABLED] = bool(msg["enabled"])
# A vacation is a stretch of days, not a lifetime: a date more than
# MAX_INTERVAL_DAYS (10 years) out is refused. 9999-12-31 plus the
# return buffer overflowed the calendar inside every object's refresh
# and took the objects down for the whole "vacation" (bug audit
# 2026-09-27; helpers.vacation now also clamps what is already stored).
latest = dt_util.now().date() + timedelta(days=MAX_INTERVAL_DAYS)
for field, key in (("start", CONF_VACATION_START), ("end", CONF_VACATION_END)):
if field not in msg:
continue
if msg[field] is not None and _parse_iso_date(connection, msg["id"], msg[field], field=field) is None:
return
if msg[field] is not None:
parsed = _parse_iso_date(connection, msg["id"], msg[field], field=field)
if parsed is None:
return
if parsed > latest:
connection.send_error(
msg["id"], "invalid_range", f"{field} must be within {MAX_INTERVAL_DAYS} days from today"
)
return
options[key] = msg[field]
# End-vs-start sanity (only when both are present after the patch; a
@@ -133,34 +148,26 @@ async def ws_vacation_preview(
connection.send_result(msg["id"], {"rows": [], "window_end": None})
return
# Build the flat task list expected by compute_preview.
# Hand the preview each task's MERGED dict (static + Store) so it builds
# the real MaintenanceTask — a hand-picked flat subset dropped
# due_override, the seasonal window, the planned anchor and one-time due
# dates (bug audit 2026-09-26, DRY BR-A1). Inert tasks (archived,
# disabled, paused object) fire nothing during the absence either, so
# they are no preview rows.
tasks: list[dict[str, Any]] = []
for entry in _get_object_entries(hass):
obj_name = object_name(entry)
# Merge dynamic store fields (last_performed, etc.) when available.
merged = _get_merged_tasks(entry)
for task_id, task_data in merged.items():
sched = read_legacy_fields(task_data)
obj = entry.data.get(CONF_OBJECT, {})
for task_id, task_data in _get_merged_tasks(entry).items():
if is_task_inert(task_data, obj):
continue
tasks.append(
{
**task_data,
"task_id": task_id,
"entry_id": entry.entry_id,
"object_name": obj_name,
"task_name": task_data.get("name", ""),
"schedule_type": sched["schedule_type"],
"interval_days": sched["interval_days"],
"interval_unit": sched["interval_unit"],
# Nested schedule so the preview can project calendar kinds.
"schedule": task_data.get("schedule"),
"warning_days": task_data.get("warning_days", DEFAULT_WARNING_DAYS),
"last_performed": task_data.get("last_performed"),
"created_at": task_data.get("created_at"),
"enabled": task_data.get("enabled", True),
# The preview's Skip button must follow the task's own
# rule — the row had no field and offered it to every
# time-based task (bug review 2026-09-04).
"allow_skip": task_data.get("allow_skip") is not False,
}
)