393 files
This commit is contained in:
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user