278 files

This commit is contained in:
Home Assistant Version Control
2026-09-13 20:38:58 +00:00
parent d7a0a08372
commit 1b4f5f68c6
278 changed files with 36892 additions and 7150 deletions
@@ -4,8 +4,10 @@ from __future__ import annotations
import logging
import os
from datetime import date
from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
@@ -20,13 +22,53 @@ from ..const import (
DEFAULT_TASK_PRIORITY,
DEFAULT_WARNING_DAYS,
DOMAIN,
GLOBAL_UNIQUE_ID,
MAX_ID_LENGTH,
task_unique_id,
)
from ..helpers.aggregate import get_object_entries, get_runtime_data
from ..helpers.aggregate import get_object_entries, get_runtime_data, get_store, is_object_entry, object_name
_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))
#: ``{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(
{str: vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-1e12, max=1e12)), None)},
None,
)
#: The parts used on one completion (#99 / #130): ``[{part_id, quantity?,
#: entry_id?}]``. ``entry_id`` — the pool may live on another object (#111);
#: the schema has to allow it or voluptuous rejects the message before the
#: handler (which validates the reference) ever runs. ``quantity`` defaults
#: to 1 on BOTH paths: the completion path always stored 1 for a bare link,
#: 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(
vol.All(
[
vol.Schema(
{
vol.Required("part_id"): ID_FIELD,
vol.Optional("entry_id"): ID_FIELD,
vol.Optional("quantity", default=1): vol.All(
vol.Any(int, float), vol.Coerce(float), vol.Range(min=0.01, max=999)
),
}
)
],
vol.Length(max=10),
),
None,
)
# ---------------------------------------------------------------------------
# Shared helpers (used by handler submodules)
@@ -342,8 +384,7 @@ def _build_object_response(
from ..helpers.i18n import normalize_language
from ..helpers.parts import part_is_low, resolve_shopping_url
rd_parts = getattr(entry, "runtime_data", None)
part_store = getattr(rd_parts, "store", None) if rd_parts else None
part_store = get_store(hass, entry.entry_id)
search_template = get_global_options(hass).get(CONF_PART_SEARCH_URL_TEMPLATE)
lang = normalize_language(hass)
parts_payload = []
@@ -362,7 +403,7 @@ def _build_object_response(
"entry_id": entry.entry_id,
"object": {
"id": obj_data.get("id", ""),
"name": obj_data.get("name", ""),
"name": object_name(entry),
"area_id": obj_data.get("area_id"),
"manufacturer": obj_data.get("manufacturer"),
"model": obj_data.get("model"),
@@ -477,12 +518,98 @@ def _load_object_entry(
additions can't forget any of the three checks.
"""
entry = hass.config_entries.async_get_entry(msg["entry_id"])
if entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_ID:
if not is_object_entry(entry):
connection.send_error(msg["id"], "not_found", not_found_message)
return None
return entry
def _load_object_task(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
*,
merged: bool = False,
need_store: bool = False,
need_coordinator: bool = False,
) -> tuple[ConfigEntry, Any, dict[str, Any]] | None:
"""Resolve ``msg["entry_id"]`` + ``msg["task_id"]`` to ``(entry, runtime_data,
task_data)``, or send the standard error and return None.
THE prologue of every per-task command — it was copied inline 17 times
(and the copies had drifted: snooze omitted the coordinator check, the
history delete never checked the task existed, apply_suggestion reported
a missing OBJECT as "Coordinator not found").
* entry missing / foreign / global -> ``not_found`` (via _load_object_entry)
* ``need_coordinator`` and none -> ``not_found`` (the code the action
handlers always sent for it)
* ``need_store`` and none -> ``not_loaded``
* task missing -> ``not_found``
``merged=True`` looks the task up in the Store-merged view (dynamic fields
overlaid); the default is the raw ``entry.data`` config dict. The runtime
data is None only when neither ``need_*`` flag is set and the entry is not
loaded — callers that set a flag may use ``rd.coordinator`` / ``rd.store``
without re-checking.
"""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return None
rd = get_runtime_data(hass, entry.entry_id)
if need_coordinator and (rd is None or rd.coordinator is None):
connection.send_error(msg["id"], "not_found", "Coordinator not found")
return None
if need_store and (rd is None or rd.store is None):
connection.send_error(msg["id"], "not_loaded", "Object not loaded")
return None
tasks = _get_merged_tasks(entry) if merged else entry.data.get(CONF_TASKS, {})
task_data = tasks.get(msg["task_id"])
if task_data is None:
connection.send_error(msg["id"], "not_found", "Task not found")
return None
return entry, rd, task_data
def _parse_iso_date(
connection: websocket_api.ActiveConnection,
msg_id: Any,
value: Any,
*,
field: str,
code: str = "invalid_date",
) -> date | None:
"""``YYYY-MM-DD`` -> ``date``, or send ``code`` and return None.
Ten handlers wrapped ``date.fromisoformat`` in their own try/except with
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).
"""
try:
return date.fromisoformat(value)
except (TypeError, ValueError):
connection.send_error(msg_id, code, f"{field} must be a valid date (YYYY-MM-DD)")
return None
async def async_commit_store(rd: Any, *, budget: bool = False) -> None:
"""Flush a user-initiated Store write and make it visible NOW.
Save (never the debounced delay-save — a restart in that window would
lose the change), optionally rebuild the coordinator's budget cache (a
history edit moves costs), then ``async_refresh_now`` — a user action
must never wait for the 10 s refresh debounce.
"""
await rd.store.async_save()
coordinator = getattr(rd, "coordinator", None)
if coordinator is None:
return
if budget:
coordinator._recalculate_budget_cache()
await coordinator.async_refresh_now()
def object_id_for_entry(entry: ConfigEntry) -> str:
"""Return the stable document key for an object entry.
@@ -650,6 +777,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
ws_checklist_progress,
ws_complete_task,
ws_create_task,
ws_delete_history_entry,
ws_delete_task,
ws_duplicate_task,
ws_list_tasks,
@@ -710,6 +838,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_parts_overview)
websocket_api.async_register_command(hass, ws_restock_part)
websocket_api.async_register_command(hass, ws_update_history_entry)
websocket_api.async_register_command(hass, ws_delete_history_entry)
websocket_api.async_register_command(hass, ws_get_templates)
websocket_api.async_register_command(hass, ws_version)
websocket_api.async_register_command(hass, ws_export_data)
@@ -775,11 +904,11 @@ def foreign_part_resolver(hass):
Returns None for an entry that does not exist or is not a maintenance
object, so a link to it is dropped rather than trusted.
"""
from ..const import CONF_PARTS, DOMAIN, GLOBAL_UNIQUE_ID
from ..const import CONF_PARTS
def _resolve(entry_id: str):
entry = hass.config_entries.async_get_entry(entry_id)
if entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_ID:
if not is_object_entry(entry):
return None
return set(entry.data.get(CONF_PARTS) or {})
@@ -17,9 +17,11 @@ from ..const import (
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 _get_merged_tasks, _get_runtime_data, _load_object_entry
from . import _load_object_task, async_commit_store
async def _persist_adaptive_config(
@@ -29,22 +31,22 @@ async def _persist_adaptive_config(
adaptive_config: dict[str, Any],
) -> None:
"""Write a task's adaptive_config (store, or legacy entry data) and refresh."""
rd = _get_runtime_data(hass, entry.entry_id)
store = getattr(rd, "store", None) if rd else None
if store is not None:
store.set_adaptive_config(task_id, adaptive_config)
rd = get_runtime_data(hass, entry.entry_id)
if rd is not None and rd.store is not None:
rd.store.set_adaptive_config(task_id, adaptive_config)
# Immediate save like every other user-initiated write: a debounced
# 60 s timer would lose the change on a restart/reload in that window.
await store.async_save()
else:
# Legacy: write to ConfigEntry.data
static_tasks = dict(entry.data.get(CONF_TASKS, {}))
task = dict(static_tasks[task_id])
task["adaptive_config"] = adaptive_config
static_tasks[task_id] = task
new_data = dict(entry.data)
new_data[CONF_TASKS] = static_tasks
hass.config_entries.async_update_entry(entry, data=new_data)
await async_commit_store(rd)
return
# Legacy: write to ConfigEntry.data
static_tasks = dict(entry.data.get(CONF_TASKS, {}))
task = dict(static_tasks[task_id])
task["adaptive_config"] = adaptive_config
static_tasks[task_id] = task
new_data = dict(entry.data)
new_data[CONF_TASKS] = static_tasks
hass.config_entries.async_update_entry(entry, data=new_data)
if rd and rd.coordinator:
await rd.coordinator.async_refresh_now()
@@ -66,23 +68,16 @@ async def ws_analyze_interval(
"""Return full interval analysis for a task (on-demand)."""
from ..helpers.interval_analyzer import IntervalAnalyzer
entry = _load_object_entry(hass, connection, msg)
if entry is None:
ctx = _load_object_task(hass, connection, msg, merged=True)
if ctx is None:
return
tasks_data = _get_merged_tasks(entry)
task_id = msg["task_id"]
if task_id not in tasks_data:
connection.send_error(msg["id"], "not_found", "Task not found")
return
task_data = tasks_data[task_id]
_entry, _rd, task_data = ctx
adaptive_config = dict(task_data.get("adaptive_config", {}))
# Inject hemisphere and current month for seasonal awareness
from homeassistant.util import dt as dt_util
adaptive_config["hemisphere"] = "south" if (hass.config.latitude or 0) < 0 else "north"
adaptive_config["hemisphere"] = hemisphere(hass)
adaptive_config["_current_month"] = dt_util.now().month
analyzer = IntervalAnalyzer()
@@ -129,16 +124,12 @@ async def ws_apply_suggestion(
msg: dict[str, Any],
) -> None:
"""Apply a suggested interval to a task."""
rd = _get_runtime_data(hass, msg["entry_id"])
if rd is None or rd.coordinator is None:
connection.send_error(msg["id"], "not_found", "Coordinator not found")
return
# Reject an unknown task_id instead of silently no-opping and returning
# success (the apply call is a no-op for a missing task).
if msg["task_id"] not in rd.coordinator.entry.data.get(CONF_TASKS, {}):
connection.send_error(msg["id"], "not_found", "Task not found")
# An unknown task_id is rejected up front instead of silently no-opping
# and returning success (the apply call is a no-op for a missing task).
ctx = _load_object_task(hass, connection, msg, need_coordinator=True)
if ctx is None:
return
_entry, rd, _task = ctx
await rd.coordinator.async_apply_suggested_interval(
task_id=msg["task_id"],
@@ -170,15 +161,11 @@ async def ws_seasonal_overrides(
Keys must be 1-12, values must be 0.1-5.0.
Pass empty dict {} to clear all overrides.
"""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
ctx = _load_object_task(hass, connection, msg, merged=True)
if ctx is None:
return
entry, _rd, task_data = ctx
task_id = msg["task_id"]
tasks_data = _get_merged_tasks(entry)
if task_id not in tasks_data:
connection.send_error(msg["id"], "not_found", "Task not found")
return
# Validate overrides
overrides = msg["overrides"]
@@ -199,7 +186,7 @@ async def ws_seasonal_overrides(
validated[month] = round(factor, 2)
# Persist overrides in adaptive_config
adaptive_config = dict(tasks_data[task_id].get("adaptive_config", {}))
adaptive_config = dict(task_data.get("adaptive_config", {}))
if validated:
adaptive_config["seasonal_overrides"] = validated
else:
@@ -232,17 +219,13 @@ async def ws_set_environmental_entity(
correlated with maintenance intervals to produce an adjustment factor.
Pass environmental_entity=null to clear the binding.
"""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
ctx = _load_object_task(hass, connection, msg, merged=True)
if ctx is None:
return
entry, _rd, task_data = ctx
task_id = msg["task_id"]
tasks_data = _get_merged_tasks(entry)
if task_id not in tasks_data:
connection.send_error(msg["id"], "not_found", "Task not found")
return
adaptive_config = dict(tasks_data[task_id].get("adaptive_config", {}))
adaptive_config = dict(task_data.get("adaptive_config", {}))
env_entity = msg.get("environmental_entity")
env_attribute = msg.get("environmental_attribute")
@@ -295,17 +278,13 @@ async def ws_set_adaptive(
endpoint above; omitted optional fields keep their stored values."""
from ..helpers.schedule import read_legacy_fields
entry = _load_object_entry(hass, connection, msg)
if entry is None:
ctx = _load_object_task(hass, connection, msg, merged=True)
if ctx is None:
return
entry, _rd, task_data = ctx
task_id = msg["task_id"]
tasks_data = _get_merged_tasks(entry)
if task_id not in tasks_data:
connection.send_error(msg["id"], "not_found", "Task not found")
return
adaptive_config = dict(tasks_data[task_id].get("adaptive_config", {}))
adaptive_config = dict(task_data.get("adaptive_config", {}))
adaptive_config["enabled"] = msg["enabled"]
for src, dst in (
("ewa_alpha", "ewa_alpha"),
@@ -325,7 +304,7 @@ async def ws_set_adaptive(
# Same base_interval seeding as the options flow's adaptive step.
if "base_interval" not in adaptive_config:
base = read_legacy_fields(tasks_data[task_id])["interval_days"]
base = read_legacy_fields(task_data)["interval_days"]
adaptive_config["base_interval"] = base if base is not None else 30
await _persist_adaptive_config(hass, entry, task_id, adaptive_config)
@@ -29,12 +29,14 @@ from ..const import (
CONF_ADVANCED_SCHEDULE_TIME,
CONF_ADVANCED_SEASONAL,
CONF_ARCHIVE_ONEOFF_DAYS,
CONF_BATTERY_LIFETIME_MONTHS,
CONF_BATTERY_LOW_PERCENT,
CONF_BUDGET_ALERT_THRESHOLD,
CONF_BUDGET_ALERTS_ENABLED,
CONF_BUDGET_CURRENCY,
CONF_BUDGET_MONTHLY,
CONF_BUDGET_YEARLY,
CONF_CURRENCY_DECIMALS,
CONF_DEFAULT_CONSUMABLE_THRESHOLD,
CONF_DEFAULT_WARNING_DAYS,
CONF_DELETE_ARCHIVED_ONEOFF_DAYS,
@@ -46,6 +48,7 @@ from ..const import (
CONF_NOTIFICATION_BUNDLING_ENABLED,
CONF_NOTIFICATION_TITLE_STYLE,
CONF_NOTIFICATIONS_ENABLED,
CONF_NOTIFY_COMPLETED,
CONF_NOTIFY_DUE_SOON_ENABLED,
CONF_NOTIFY_DUE_SOON_INTERVAL,
CONF_NOTIFY_EVENT_ONLY,
@@ -63,6 +66,7 @@ from ..const import (
CONF_QUIET_HOURS_ENABLED,
CONF_QUIET_HOURS_END,
CONF_QUIET_HOURS_START,
CONF_REF_NUMBERS_IN_LISTS,
CONF_REMINDER_LEAD_DAYS,
CONF_ROW_ACTION_NOTICE,
CONF_ROW_ACTION_STYLE,
@@ -71,17 +75,8 @@ from ..const import (
CONF_WARRANTY_REMINDER_DAYS,
CONF_WARRANTY_REMINDER_ENABLED,
CONF_WEEKLY_DIGEST_ENABLED,
DEFAULT_ARCHIVE_ONEOFF_DAYS,
DEFAULT_BATTERY_LOW_PERCENT,
DEFAULT_BUDGET_CURRENCY,
DEFAULT_CONSUMABLE_THRESHOLD,
DEFAULT_DELETE_ARCHIVED_ONEOFF_DAYS,
DEFAULT_OBJECTS_TABLE_COLUMNS,
DEFAULT_PANEL_ENABLED,
DEFAULT_ROW_ACTION_STYLE,
DEFAULT_SNOOZE_DURATION_HOURS,
DEFAULT_WARNING_DAYS,
DEFAULT_WARRANTY_REMINDER_DAYS,
DOMAIN,
GLOBAL_UNIQUE_ID,
KNOWN_OBJECT_TABLE_COLUMNS,
@@ -91,14 +86,17 @@ from ..const import (
TIME_HHMMSS_PATTERN,
)
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.settings_registry import (
ALLOWED_SETTING_KEYS,
FLOAT_RANGES,
INT_RANGES,
STR_MAX_LENGTHS,
setting_default,
)
from . import (
ID_FIELD,
_build_object_response,
_get_global_entry,
_get_object_entries,
@@ -115,11 +113,53 @@ _LOGGER = logging.getLogger(__name__)
_ALLOWED_SETTING_KEYS = ALLOWED_SETTING_KEYS
def _opt(options: Mapping[str, Any], key: str) -> Any:
"""A global option, or its registry default when unset — the one fallback
every echoed setting goes through (defaults live in settings_registry)."""
return options.get(key, setting_default(key))
def _currency_block(options: Mapping[str, Any]) -> dict[str, Any]:
"""How amounts are displayed: currency code, symbol and decimal places."""
code = str(_opt(options, CONF_BUDGET_CURRENCY))
return {
"currency": code,
"currency_symbol": BUDGET_CURRENCIES.get(code, BUDGET_CURRENCIES[DEFAULT_BUDGET_CURRENCY]),
"currency_decimals": int(_opt(options, CONF_CURRENCY_DECIMALS)),
}
def _battery_lifetime_catalog(hass: HomeAssistant) -> list[dict[str, Any]]:
"""The lifetime table as Settings shows it — fleet types first."""
try:
# Readable "Manufacturer Model" per pool key for the learned rows.
from homeassistant.helpers import device_registry as dr
from ..helpers.battery_fleet import discover_battery_types, read_batteries
from ..helpers.battery_lifetime import lifetime_catalog
names: dict[str, str] = {}
dev_reg = dr.async_get(hass)
for bat in read_batteries(hass):
if bat.model_key and bat.model_key not in names:
for device in dev_reg.devices.values():
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()}"
if key == bat.model_key:
names[bat.model_key] = " ".join(x for x in (manufacturer, model) if x)
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
return []
def _build_full_settings(
options: Mapping[str, Any],
*,
notify_targets: list[str] | None = None,
battery_notes: dict[str, Any] | None = None,
battery_lifetimes: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Build a full settings dict from global entry options.
@@ -128,112 +168,115 @@ def _build_full_settings(
``general.notify_targets`` so the panel picker uses the exact same set as
the options flow instead of recomputing it client-side.
"""
# Every fallback below is the registry default (settings_registry) via
# _opt — tests/test_settings_defaults.py pins the echo to that table.
return {
"features": {
"adaptive": options.get(CONF_ADVANCED_ADAPTIVE, False),
"predictions": options.get(CONF_ADVANCED_PREDICTIONS, False),
"seasonal": options.get(CONF_ADVANCED_SEASONAL, False),
"environmental": options.get(CONF_ADVANCED_ENVIRONMENTAL, False),
"budget": options.get(CONF_ADVANCED_BUDGET, False),
"groups": options.get(CONF_ADVANCED_GROUPS, False),
"checklists": options.get(CONF_ADVANCED_CHECKLISTS, False),
"schedule_time": options.get(CONF_ADVANCED_SCHEDULE_TIME, False),
"completion_actions": options.get(CONF_ADVANCED_COMPLETION_ACTIONS, False),
"adaptive": _opt(options, CONF_ADVANCED_ADAPTIVE),
"predictions": _opt(options, CONF_ADVANCED_PREDICTIONS),
"seasonal": _opt(options, CONF_ADVANCED_SEASONAL),
"environmental": _opt(options, CONF_ADVANCED_ENVIRONMENTAL),
"budget": _opt(options, CONF_ADVANCED_BUDGET),
"groups": _opt(options, CONF_ADVANCED_GROUPS),
"checklists": _opt(options, CONF_ADVANCED_CHECKLISTS),
"schedule_time": _opt(options, CONF_ADVANCED_SCHEDULE_TIME),
"completion_actions": _opt(options, CONF_ADVANCED_COMPLETION_ACTIONS),
},
# Top-level (not a feature toggle, not a bool): list of HA user IDs
# whose UI gets the full admin panel even though they're not HA admins.
"admin_panel_user_ids": options.get(CONF_ADMIN_PANEL_USER_IDS, []),
"admin_panel_user_ids": _opt(options, CONF_ADMIN_PANEL_USER_IDS),
# v2.8.4: master switch gating whether the allowlist actually grants
# write. Default False → operator allowlist is read-only.
"operator_write_enabled": options.get(CONF_OPERATOR_WRITE_ENABLED, False),
"operator_write_enabled": _opt(options, CONF_OPERATOR_WRITE_ENABLED),
# (#67): ordered objects-table columns for the panel All-Objects view.
"objects_table_columns": options.get(CONF_OBJECTS_TABLE_COLUMNS, DEFAULT_OBJECTS_TABLE_COLUMNS),
"objects_table_columns": _opt(options, CONF_OBJECTS_TABLE_COLUMNS),
# #169 follow-up: per-member avatar overrides (initials / palette colour).
"member_display": options.get(CONF_MEMBER_DISPLAY, {}),
"member_display": _opt(options, CONF_MEMBER_DISPLAY),
# v2.21: template-gallery curation (ids hidden from the pickers).
"disabled_template_ids": options.get(CONF_DISABLED_TEMPLATE_IDS, []),
"disabled_template_ids": _opt(options, CONF_DISABLED_TEMPLATE_IDS),
# 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
# days after archiving (0 = never; manual archives are never deleted).
"archive": {
"oneoff_days": options.get(CONF_ARCHIVE_ONEOFF_DAYS, DEFAULT_ARCHIVE_ONEOFF_DAYS),
"delete_archived_oneoff_days": options.get(
CONF_DELETE_ARCHIVED_ONEOFF_DAYS,
DEFAULT_DELETE_ARCHIVED_ONEOFF_DAYS,
),
"oneoff_days": _opt(options, CONF_ARCHIVE_ONEOFF_DAYS),
"delete_archived_oneoff_days": _opt(options, CONF_DELETE_ARCHIVED_ONEOFF_DAYS),
},
"general": {
"default_warning_days": options.get(CONF_DEFAULT_WARNING_DAYS, DEFAULT_WARNING_DAYS),
"default_warning_days": _opt(options, CONF_DEFAULT_WARNING_DAYS),
# #146: household "low" floors for discovery + the battery fleet.
"default_consumable_threshold": options.get(CONF_DEFAULT_CONSUMABLE_THRESHOLD, DEFAULT_CONSUMABLE_THRESHOLD),
"battery_low_percent": options.get(CONF_BATTERY_LOW_PERCENT, DEFAULT_BATTERY_LOW_PERCENT),
"default_consumable_threshold": _opt(options, CONF_DEFAULT_CONSUMABLE_THRESHOLD),
"battery_low_percent": _opt(options, CONF_BATTERY_LOW_PERCENT),
# D#162 follow-up: the household's per-type lifetime overrides and,
# computed by the caller, the effective lifetime catalog Settings
# renders (type, months, source: override / learned / table / default).
"battery_lifetime_months": dict(_opt(options, CONF_BATTERY_LIFETIME_MONTHS) or {}),
"battery_lifetimes": list(battery_lifetimes or []),
# Computed, never stored: what Battery Notes currently reports
# (default + up to 5 named override devices) — the Settings hint.
"battery_notes": battery_notes,
"notifications_enabled": options.get(CONF_NOTIFICATIONS_ENABLED, False),
"notify_service": options.get(CONF_NOTIFY_SERVICE, ""),
"notifications_enabled": _opt(options, CONF_NOTIFICATIONS_ENABLED),
"notify_service": _opt(options, CONF_NOTIFY_SERVICE),
# v2.67: buy-task shopping sync target ("" = off).
"shopping_list_entity": options.get(CONF_SHOPPING_LIST_ENTITY, ""),
"shopping_list_entity": _opt(options, CONF_SHOPPING_LIST_ENTITY),
# Shared pickable-target list so the panel picker can't drift from
# the options-flow dropdown (both go through build_notify_targets).
"notify_targets": notify_targets or [],
"panel_enabled": options.get(CONF_PANEL_ENABLED, DEFAULT_PANEL_ENABLED),
"panel_title": options.get(CONF_PANEL_TITLE, ""),
"panel_enabled": _opt(options, CONF_PANEL_ENABLED),
"panel_title": _opt(options, CONF_PANEL_TITLE),
# Opt-in copy of the shipped Assist sentences into
# <config>/custom_sentences/ (the only place the classic
# conversation agent reads them from).
"install_assist_sentences": options.get(CONF_INSTALL_ASSIST_SENTENCES, False),
"install_assist_sentences": _opt(options, CONF_INSTALL_ASSIST_SENTENCES),
# #145: task-row action style + the one-time "new look" notice
# (set by the 5→6 migration for existing installs, cleared by the
# panel banner).
"row_action_style": options.get(CONF_ROW_ACTION_STYLE, DEFAULT_ROW_ACTION_STYLE),
"row_action_notice_pending": options.get(CONF_ROW_ACTION_NOTICE, False),
"row_action_style": _opt(options, CONF_ROW_ACTION_STYLE),
"row_action_notice_pending": _opt(options, CONF_ROW_ACTION_NOTICE),
"ref_numbers_in_lists": bool(_opt(options, CONF_REF_NUMBERS_IN_LISTS)),
},
"notifications": {
"due_soon_enabled": options.get(CONF_NOTIFY_DUE_SOON_ENABLED, True),
"due_soon_interval_hours": options.get(CONF_NOTIFY_DUE_SOON_INTERVAL, 24),
"overdue_enabled": options.get(CONF_NOTIFY_OVERDUE_ENABLED, True),
"overdue_interval_hours": options.get(CONF_NOTIFY_OVERDUE_INTERVAL, 12),
"triggered_enabled": options.get(CONF_NOTIFY_TRIGGERED_ENABLED, True),
"triggered_interval_hours": options.get(CONF_NOTIFY_TRIGGERED_INTERVAL, 0),
"quiet_hours_enabled": options.get(CONF_QUIET_HOURS_ENABLED, True),
"quiet_hours_start": options.get(CONF_QUIET_HOURS_START, "22:00"),
"quiet_hours_end": options.get(CONF_QUIET_HOURS_END, "08:00"),
"max_per_day": options.get(CONF_MAX_NOTIFICATIONS_PER_DAY, 0),
"bundling_enabled": options.get(CONF_NOTIFICATION_BUNDLING_ENABLED, False),
"bundle_threshold": options.get(CONF_NOTIFICATION_BUNDLE_THRESHOLD, 2),
"due_soon_enabled": _opt(options, CONF_NOTIFY_DUE_SOON_ENABLED),
"due_soon_interval_hours": _opt(options, CONF_NOTIFY_DUE_SOON_INTERVAL),
"overdue_enabled": _opt(options, CONF_NOTIFY_OVERDUE_ENABLED),
"overdue_interval_hours": _opt(options, CONF_NOTIFY_OVERDUE_INTERVAL),
"triggered_enabled": _opt(options, CONF_NOTIFY_TRIGGERED_ENABLED),
"triggered_interval_hours": _opt(options, CONF_NOTIFY_TRIGGERED_INTERVAL),
"quiet_hours_enabled": _opt(options, CONF_QUIET_HOURS_ENABLED),
"quiet_hours_start": _opt(options, CONF_QUIET_HOURS_START),
"quiet_hours_end": _opt(options, CONF_QUIET_HOURS_END),
"max_per_day": _opt(options, CONF_MAX_NOTIFICATIONS_PER_DAY),
"bundling_enabled": _opt(options, CONF_NOTIFICATION_BUNDLING_ENABLED),
"bundle_threshold": _opt(options, CONF_NOTIFICATION_BUNDLE_THRESHOLD),
# v1.4.0 (#44): default keeps backwards-compatible per-status titles
"title_style": options.get(CONF_NOTIFICATION_TITLE_STYLE, "default"),
"title_style": _opt(options, CONF_NOTIFICATION_TITLE_STYLE),
# #173 follow-up: completion notifications (off | automatic | all).
"completed": _opt(options, CONF_NOTIFY_COMPLETED),
# Multiple lead-time reminders (days before due); [] = off.
"reminder_lead_days": options.get(CONF_REMINDER_LEAD_DAYS, []),
"reminder_lead_days": _opt(options, CONF_REMINDER_LEAD_DAYS),
# v2.26: notification routing — saved-view id scoping which
# tasks may notify ("" = all tasks).
"scope_view_id": options.get(CONF_NOTIFY_SCOPE_VIEW_ID, ""),
"scope_view_id": _opt(options, CONF_NOTIFY_SCOPE_VIEW_ID),
# #165: your own notification rule — event-only delivery and the
# extra-data template merged into every notify payload.
"event_only": options.get(CONF_NOTIFY_EVENT_ONLY, False),
"extra_data": options.get(CONF_NOTIFY_EXTRA_DATA, ""),
"event_only": _opt(options, CONF_NOTIFY_EVENT_ONLY),
"extra_data": _opt(options, CONF_NOTIFY_EXTRA_DATA),
},
"actions": {
"complete_enabled": options.get(CONF_ACTION_COMPLETE_ENABLED, False),
"skip_enabled": options.get(CONF_ACTION_SKIP_ENABLED, False),
"snooze_enabled": options.get(CONF_ACTION_SNOOZE_ENABLED, False),
"snooze_duration_hours": options.get(CONF_SNOOZE_DURATION_HOURS, DEFAULT_SNOOZE_DURATION_HOURS),
"weekly_digest_enabled": options.get(CONF_WEEKLY_DIGEST_ENABLED, False),
"warranty_reminder_enabled": options.get(CONF_WARRANTY_REMINDER_ENABLED, False),
"warranty_reminder_days": options.get(CONF_WARRANTY_REMINDER_DAYS, DEFAULT_WARRANTY_REMINDER_DAYS),
"complete_enabled": _opt(options, CONF_ACTION_COMPLETE_ENABLED),
"skip_enabled": _opt(options, CONF_ACTION_SKIP_ENABLED),
"snooze_enabled": _opt(options, CONF_ACTION_SNOOZE_ENABLED),
"snooze_duration_hours": _opt(options, CONF_SNOOZE_DURATION_HOURS),
"weekly_digest_enabled": _opt(options, CONF_WEEKLY_DIGEST_ENABLED),
"warranty_reminder_enabled": _opt(options, CONF_WARRANTY_REMINDER_ENABLED),
"warranty_reminder_days": _opt(options, CONF_WARRANTY_REMINDER_DAYS),
},
"budget": {
"monthly": options.get(CONF_BUDGET_MONTHLY, 0.0),
"yearly": options.get(CONF_BUDGET_YEARLY, 0.0),
"alerts_enabled": options.get(CONF_BUDGET_ALERTS_ENABLED, False),
"alert_threshold_pct": options.get(CONF_BUDGET_ALERT_THRESHOLD, 80),
"currency": options.get(CONF_BUDGET_CURRENCY, DEFAULT_BUDGET_CURRENCY),
"currency_symbol": BUDGET_CURRENCIES.get(
options.get(CONF_BUDGET_CURRENCY, DEFAULT_BUDGET_CURRENCY),
BUDGET_CURRENCIES[DEFAULT_BUDGET_CURRENCY],
),
"monthly": _opt(options, CONF_BUDGET_MONTHLY),
"yearly": _opt(options, CONF_BUDGET_YEARLY),
"alerts_enabled": _opt(options, CONF_BUDGET_ALERTS_ENABLED),
"alert_threshold_pct": _opt(options, CONF_BUDGET_ALERT_THRESHOLD),
**_currency_block(options),
},
# Vacation mode (v1.2.0). Mirror the active flag so the panel can
# decide whether to show the Vacation tab without a separate WS call.
@@ -268,7 +311,7 @@ async def ws_get_settings(
if global_entry is None:
connection.send_result(
msg["id"],
_build_full_settings({}, notify_targets=build_notify_targets(hass), battery_notes=bn),
_build_full_settings({}, notify_targets=build_notify_targets(hass), battery_notes=bn, battery_lifetimes=_battery_lifetime_catalog(hass)),
)
return
@@ -279,6 +322,7 @@ async def ws_get_settings(
options,
notify_targets=build_notify_targets(hass, current=options.get(CONF_NOTIFY_SERVICE, "")),
battery_notes=bn,
battery_lifetimes=_battery_lifetime_catalog(hass),
),
)
@@ -324,6 +368,9 @@ async def ws_get_statistics(
# of reading a non-existent entity and showing "unknown".
"ok": counts["ok"],
"total_cost": counts["total_cost"],
# Currency display for the cards, which have no budget_status call
# of their own: symbol + decimal places, one source for every amount.
"budget": _currency_block(get_global_options(hass)),
},
)
@@ -586,8 +633,7 @@ async def ws_get_budget_status(
# are the same number by construction.
monthly_spent, yearly_spent = compute_spend(hass)
currency_code = str(global_options.get(CONF_BUDGET_CURRENCY, DEFAULT_BUDGET_CURRENCY))
currency_symbol = BUDGET_CURRENCIES.get(currency_code, "€")
currency = _currency_block(global_options)
connection.send_result(
msg["id"],
@@ -597,7 +643,8 @@ async def ws_get_budget_status(
"yearly_budget": yearly_budget,
"yearly_spent": round(yearly_spent, 2),
"alert_threshold_pct": threshold_pct,
"currency_symbol": currency_symbol,
"currency_symbol": currency["currency_symbol"],
"currency_decimals": currency["currency_decimals"],
},
)
@@ -620,9 +667,19 @@ def sanitize_settings_input(settings_input: dict[str, Any]) -> tuple[dict[str, A
for key, expected_type in _ALLOWED_SETTING_KEYS.items():
if key in settings_input:
val = settings_input[key]
# bool is an int subclass in Python, so a bare isinstance() let
# `{"default_warning_days": true}` through as 1 — reject bools for
# every numeric spec (bug audit 2026-09-12).
if expected_type in (int, float) and isinstance(val, bool):
continue
# Accept int for float fields
if expected_type is float and isinstance(val, int):
val = float(val)
# The options flow's NumberSelector persists ints as floats (7.0);
# a settings export → import used to drop those keys silently.
# An INTEGRAL float is the same number — coerce it; 7.5 stays out.
if expected_type is int and isinstance(val, float) and math.isfinite(val) and val.is_integer():
val = int(val)
if isinstance(val, expected_type):
filtered[key] = val
@@ -652,8 +709,10 @@ 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
from ..const import NOTIFICATION_TITLE_STYLES, NOTIFY_COMPLETED_MODES
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:
del filtered[CONF_NOTIFICATION_TITLE_STYLE]
@@ -732,6 +791,13 @@ def sanitize_settings_input(settings_input: dict[str, Any]) -> tuple[dict[str, A
filtered[CONF_MEMBER_DISPLAY] = sanitize_member_display(filtered[CONF_MEMBER_DISPLAY])
# D#162 follow-up: battery lifetime overrides — canonical type keys, months
# within range, junk dropped (an empty map clears every override).
if CONF_BATTERY_LIFETIME_MONTHS in filtered:
from ..helpers.battery_lifetime import sanitize_lifetime_overrides
filtered[CONF_BATTERY_LIFETIME_MONTHS] = sanitize_lifetime_overrides(filtered[CONF_BATTERY_LIFETIME_MONTHS])
# v2.21: disabled_template_ids — keep only ids of templates that actually
# exist (a typo/stale id must not linger invisibly), dedupe.
if CONF_DISABLED_TEMPLATE_IDS in filtered:
@@ -813,6 +879,7 @@ async def ws_update_global_settings(
_build_full_settings(
merged,
notify_targets=build_notify_targets(hass, current=merged.get(CONF_NOTIFY_SERVICE, "")),
battery_lifetimes=_battery_lifetime_catalog(hass),
),
)
@@ -825,7 +892,7 @@ async def ws_update_global_settings(
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/global/test_notification",
vol.Optional("user_id"): vol.Any(str, None),
vol.Optional("user_id"): vol.Any(ID_FIELD, None),
}
)
@websocket_api.require_admin
@@ -25,8 +25,10 @@ from ..const import (
DOMAIN,
MAX_ID_LENGTH,
MAX_NAME_LENGTH,
MAX_TEXT_LENGTH,
MAX_URL_LENGTH,
)
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
@@ -111,6 +113,7 @@ async def ws_documents_storage(
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,
vol.Optional("description"): vol.Any(vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)), None),
}
)
@require_write
@@ -139,6 +142,7 @@ async def ws_documents_add_link(
url=url,
title=title.strip() if isinstance(title, str) and title.strip() else None,
tags=msg.get("tags"),
description=msg.get("description"),
)
connection.send_result(msg["id"], doc)
@@ -153,6 +157,8 @@ async def ws_documents_add_link(
vol.Optional("task_pages"): _TASK_PAGES_SCHEMA,
# Spare-part links (v2.26) — same shape/cap as task links.
vol.Optional("part_ids"): _TASK_IDS_SCHEMA,
# #164: a free-text description ('' clears).
vol.Optional("description"): vol.All(str, vol.Length(max=MAX_TEXT_LENGTH)),
}
)
@require_write
@@ -175,6 +181,7 @@ async def ws_documents_update(
task_ids=msg.get("task_ids"),
task_pages=msg.get("task_pages"),
part_ids=msg.get("part_ids"),
description=msg.get("description"),
)
if not ok:
connection.send_error(msg["id"], "not_found", "Document not found")
@@ -195,19 +202,24 @@ async def ws_documents_delete(
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Remove a document. Frees blob bytes only when the last reference goes."""
"""Remove a document. Frees blob bytes only when the last reference goes.
History entries' completion photos and parts' ``doc_id`` that pointed at
it are cleared too (they used to dangle)."""
from ..helpers.documents import async_forget_doc_ids
store = _get_store(hass)
if store.get(msg["doc_id"]) is None:
connection.send_error(msg["id"], "not_found", "Document not found")
return
freed = await store.async_remove(msg["doc_id"])
await async_forget_doc_ids(hass, {msg["doc_id"]})
connection.send_result(msg["id"], {"success": True, "bytes_freed": freed})
_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.
_DOC_FIELD_WEIGHTS = (("title", 3), ("filename", 2), ("url", 1), ("mime", 1))
_DOC_FIELD_WEIGHTS = (("title", 3), ("filename", 2), ("description", 2), ("url", 1), ("mime", 1))
def _object_map(hass: HomeAssistant) -> dict[str, tuple[str, str]]:
@@ -217,7 +229,7 @@ def _object_map(hass: HomeAssistant) -> dict[str, tuple[str, str]]:
obj = entry.data.get(CONF_OBJECT, {})
oid = obj.get("id")
if isinstance(oid, str) and oid:
obj_map[oid] = (entry.entry_id, obj.get("name", ""))
obj_map[oid] = (entry.entry_id, object_name(entry))
return obj_map
@@ -233,6 +245,7 @@ def _doc_hit(did: str, doc: dict[str, Any], obj_map: dict[str, tuple[str, str]])
"url": doc.get("url"),
"size": doc.get("size"),
"tags": doc.get("tags") or [],
"description": doc.get("description") or "",
}
@@ -17,7 +17,8 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from ..const import CONF_OBJECT, CONF_TASKS, DOMAIN, GLOBAL_UNIQUE_ID, MAX_ID_LENGTH, MAX_NAME_LENGTH
from ..const import CONF_OBJECT, CONF_TASKS, DOMAIN, MAX_ID_LENGTH, MAX_NAME_LENGTH
from ..helpers.aggregate import is_object_entry
from ..helpers.integration_signatures import (
SIGNATURES,
build_setup_trigger,
@@ -115,11 +116,11 @@ async def ws_adopt_integration_setups(
objects_created += 1
entry = hass.config_entries.async_get_entry(entry_id)
# Same three-part 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 entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_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"})
continue
@@ -24,15 +24,18 @@ from ..const import (
CONF_OBJECT,
CONF_OBJECT_MANUFACTURER,
CONF_OBJECT_MODEL,
CONF_OBJECT_NAME,
CONF_TASKS,
DOMAIN,
MAX_CHECKLIST_ITEM_LENGTH,
MAX_CHECKLIST_ITEMS,
MAX_ENTITY_SLUG_LENGTH,
MAX_ID_LENGTH,
MAX_IMPORT_PAYLOAD_BYTES,
MAX_JSON_IMPORT_PAYLOAD_BYTES,
MAX_VACATION_EXEMPT_TASKS,
)
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.phases import clamp_phase_cursor, sanitize_phase_defs, sanitize_phase_sequence
from ..helpers.qr_generator import (
@@ -42,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
from . import _get_object_entries, _load_object_entry, _load_object_task
_LOGGER = logging.getLogger(__name__)
@@ -60,7 +63,7 @@ def _iso_marker(value: Any) -> str | None:
object as paused forever (and a malformed ``paused_until`` means auto-resume
never fires). Validate on import so only a real timestamp restores the state.
"""
from datetime import date, datetime
from datetime import datetime
if not isinstance(value, str) or not value.strip():
return None
@@ -69,11 +72,7 @@ def _iso_marker(value: Any) -> str | None:
datetime.fromisoformat(s.replace("Z", "+00:00"))
return s
except ValueError:
try:
date.fromisoformat(s)
return s
except ValueError:
return None
return s if parse_iso_date(s) is not None else None
def _sanitize_history(history: Any) -> list[dict[str, Any]]:
@@ -511,8 +510,6 @@ def _apply_settings_import(hass: HomeAssistant, raw: dict[str, Any]) -> list[str
instance — they are kept verbatim (same-instance restores keep them
valid; elsewhere they degrade gracefully like every stale reference).
"""
from datetime import date as date_cls
from ..const import (
CONF_GROUPS,
CONF_NOTIFY_SERVICE,
@@ -573,17 +570,13 @@ def _apply_settings_import(hass: HomeAssistant, raw: dict[str, Any]) -> list[str
filtered[CONF_VACATION_ENABLED] = raw[CONF_VACATION_ENABLED]
for key in (CONF_VACATION_START, CONF_VACATION_END):
val = raw.get(key)
if isinstance(val, str):
try:
date_cls.fromisoformat(val)
except ValueError:
continue
if isinstance(val, str) and parse_iso_date(val) is not None:
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)
if isinstance(exempt, list):
cleaned = [t.strip() for t in exempt if isinstance(t, str) and t.strip()][:2000]
cleaned = [t.strip() for t in exempt if isinstance(t, str) and t.strip()][:MAX_VACATION_EXEMPT_TASKS]
filtered[CONF_VACATION_EXEMPT_TASK_IDS] = cleaned
if not filtered:
@@ -934,13 +927,8 @@ async def ws_import_json(
if iv is not None and (not isinstance(iv, int) or iv < 1):
task_data.pop("interval_days", None)
lp = task_data.get("last_performed")
if lp is not None:
try:
from datetime import date
date.fromisoformat(lp)
except (ValueError, TypeError):
task_data.pop("last_performed", None)
if lp is not None and parse_iso_date(lp) is None:
task_data.pop("last_performed", None)
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)
@@ -972,11 +960,36 @@ async def ws_import_json(
else:
task_data.pop("readings", None)
# schedule_time: strict HH:MM, otherwise drop
# schedule_time: canonical HH:MM. The options flow's TimeSelector
# stores "HH:MM:SS" and the export writes it verbatim — that used
# to be DROPPED here (strict HH:MM), so a backup lost the time.
st = task_data.get("schedule_time")
if st is not None:
if not isinstance(st, str) or not re.fullmatch(r"^([01]\d|2[0-3]):[0-5]\d$", st):
normalized = normalize_hhmm(st)
if normalized is None:
task_data.pop("schedule_time", None)
else:
task_data["schedule_time"] = normalized
# entity_slug: the WS create/update paths reject anything but
# [a-z0-9_]+ (it becomes part of the entity_id); import copied the
# value verbatim. Normalise to that alphabet (HA's slugify would
# turn all-junk into "unknown"), drop it when nothing valid
# remains, and say so — a changed slug changes the entity ids
# (bug audit 2026-09-12).
raw_slug = task_data.get("entity_slug")
if raw_slug is not None:
slug = (
re.sub(r"[^a-z0-9_]+", "_", raw_slug.strip().lower()).strip("_")[:MAX_ENTITY_SLUG_LENGTH]
if isinstance(raw_slug, str)
else ""
)
if not slug:
task_data.pop("entity_slug", None)
task_warnings.append(f"{task_name}: entity_slug dropped — not [a-z0-9_]+")
elif slug != raw_slug:
task_data["entity_slug"] = slug
task_warnings.append(f"{task_name}: entity_slug normalised to {slug!r}")
# Validate an imported trigger_config the same way the WS create/update
# path does — strip unknown keys, normalize entity_ids, and drop it
@@ -1028,9 +1041,20 @@ async def ws_import_json(
doc_store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
if doc_store is not None:
doc_id_map: dict[str, str] = {}
await doc_store.async_import_documents(
obj_id, import_docs, task_id_map=task_id_map, part_id_map=part_id_map, id_map=doc_id_map
)
# Outside the per-object try below on purpose (the docs must
# exist before the entry is created) — so a crash here used to
# abort the WHOLE import without a reply. The store skips
# malformed records itself; this backstop turns anything it
# still raises into a per-object warning (bug audit 2026-09-12).
try:
await doc_store.async_import_documents(
obj_id, import_docs, task_id_map=task_id_map, part_id_map=part_id_map, id_map=doc_id_map
)
except Exception: # one object's documents must not sink the import
_LOGGER.exception("JSON import of %s: documents skipped", obj_name)
task_warnings.append("documents: skipped — malformed document records")
await _drop_imported_documents(doc_store, obj_id)
doc_id_map = {}
if doc_id_map:
_remap_document_refs(import_tasks, import_parts, doc_id_map)
@@ -1064,8 +1088,7 @@ async def ws_import_json(
# Restore tracked part stocks into the new entry's Store.
if part_stocks:
new_entry = hass.config_entries.async_get_entry(result["result"].entry_id)
rd_new = getattr(new_entry, "runtime_data", None) if new_entry else None
store_new = getattr(rd_new, "store", None) if rd_new else None
store_new = get_store(hass, result["result"].entry_id)
if store_new is not None:
for pid, stock_val in part_stocks.items():
store_new.set_part_stock(pid, stock_val)
@@ -1111,20 +1134,21 @@ async def ws_generate_qr(
msg: dict[str, Any],
) -> None:
"""Generate a QR code for a maintenance object or task."""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
obj_data = entry.data.get(CONF_OBJECT, {})
task_id = msg.get("task_id")
task_name = None
if task_id:
tasks_data = entry.data.get(CONF_TASKS, {})
if task_id not in tasks_data:
connection.send_error(msg["id"], "not_found", "Task not found")
ctx = _load_object_task(hass, connection, msg)
if ctx is None:
return
task_name = tasks_data[task_id].get("name", "")
entry, _rd, task = ctx
task_name = task.get("name", "")
else:
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
obj_data = entry.data.get(CONF_OBJECT, {})
action = msg.get("action", "view")
url_mode = msg.get("url_mode", "server")
@@ -1153,7 +1177,7 @@ async def ws_generate_qr(
"svg_data_uri": svg_data_uri,
"url": url,
"label": {
"object_name": obj_data.get(CONF_OBJECT_NAME, ""),
"object_name": object_name(entry),
"manufacturer": obj_data.get(CONF_OBJECT_MANUFACTURER, ""),
"model": obj_data.get(CONF_OBJECT_MODEL, ""),
"task_name": task_name,
@@ -1226,7 +1250,7 @@ async def ws_batch_generate_qr(
task_filter = set(msg["task_ids"]) if msg.get("task_ids") else None
targets: list[tuple[str, str, str, str]] = []
for entry in entries:
obj_name = entry.data.get(CONF_OBJECT, {}).get(CONF_OBJECT_NAME, "")
obj_name = object_name(entry)
tasks_data = entry.data.get(CONF_TASKS, {})
for task_id, task_data in tasks_data.items():
if task_filter is not None and task_id not in task_filter:
@@ -24,7 +24,6 @@ from ..const import (
CONF_OBJECT_WARRANTY_EXPIRY,
CONF_TASKS,
DOMAIN,
GLOBAL_UNIQUE_ID,
MAX_DATE_LENGTH,
MAX_ENTITY_ID_LENGTH,
MAX_ID_LENGTH,
@@ -33,14 +32,15 @@ from ..const import (
MAX_TEXT_LENGTH,
MAX_URL_LENGTH,
)
from ..helpers.aggregate import get_coordinator_data, get_store, is_object_entry
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 (
_build_object_response,
_get_object_entries,
_get_runtime_data,
_load_object_entry,
_parse_iso_date,
cleanup_group_refs,
)
from .tasks import ( # v1.4.0 (#43): reuse the existing URL safety check
@@ -70,6 +70,15 @@ _OBJECT_STR_FIELD_SCHEMA: dict[Any, Any] = {
}
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."""
for field in ("installation_date", "warranty_expiry"):
if msg.get(field) and _parse_iso_date(connection, msg["id"], msg[field], field=field) is None:
return False
return True
def _validate_device_link(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
@@ -108,7 +117,7 @@ def _validate_device_link(
if parent_id := msg.get("parent_entry_id"):
parent = hass.config_entries.async_get_entry(parent_id)
if parent is None or parent.domain != DOMAIN or parent.unique_id == GLOBAL_UNIQUE_ID:
if not is_object_entry(parent):
connection.send_error(msg["id"], "invalid_parent", f"No maintenance object {parent_id!r}")
return False
if self_entry_id is not None:
@@ -149,8 +158,7 @@ async def ws_get_objects(
entries = _get_object_entries(hass)
result = []
for entry in entries:
rd = _get_runtime_data(hass, entry.entry_id)
coord_data = rd.coordinator.data if rd and rd.coordinator else None
coord_data = get_coordinator_data(hass, entry.entry_id)
result.append(_build_object_response(hass, entry, coord_data, compact=compact))
connection.send_result(msg["id"], {"objects": result})
@@ -173,8 +181,7 @@ async def ws_get_object(
if entry is None:
return
rd = _get_runtime_data(hass, entry.entry_id)
coord_data = rd.coordinator.data if rd and rd.coordinator else None
coord_data = get_coordinator_data(hass, entry.entry_id)
connection.send_result(msg["id"], _build_object_response(hass, entry, coord_data))
@@ -249,27 +256,11 @@ async def ws_create_object(
model = (msg.get("model") or "").strip() or None
serial_number = (msg.get("serial_number") or "").strip() or None
# Validate installation_date format if provided
# Validate installation_date / warranty_expiry (#67) format if provided
if not _validate_object_dates(connection, msg):
return
installation_date = msg.get("installation_date")
if installation_date:
from datetime import date as date_cls
try:
date_cls.fromisoformat(installation_date)
except ValueError:
connection.send_error(msg["id"], "invalid_date", "Invalid installation_date format (expected YYYY-MM-DD)")
return
# (#67): validate warranty_expiry format if provided
warranty_expiry = msg.get("warranty_expiry")
if warranty_expiry:
from datetime import date as date_cls
try:
date_cls.fromisoformat(warranty_expiry)
except ValueError:
connection.send_error(msg["id"], "invalid_date", "Invalid warranty_expiry format (expected YYYY-MM-DD)")
return
# v1.4.0 (#43): documentation_url
documentation_url = (msg.get("documentation_url") or "").strip() or None
@@ -346,25 +337,9 @@ async def ws_update_object(
if msg.get("serial_number"):
msg["serial_number"] = msg["serial_number"].strip() or None
# Validate installation_date format if provided
if msg.get("installation_date"):
from datetime import date as date_cls
try:
date_cls.fromisoformat(msg["installation_date"])
except ValueError:
connection.send_error(msg["id"], "invalid_date", "Invalid installation_date format (expected YYYY-MM-DD)")
return
# (#67): validate warranty_expiry format if provided
if msg.get("warranty_expiry"):
from datetime import date as date_cls
try:
date_cls.fromisoformat(msg["warranty_expiry"])
except ValueError:
connection.send_error(msg["id"], "invalid_date", "Invalid warranty_expiry format (expected YYYY-MM-DD)")
return
# Validate installation_date / warranty_expiry (#67) format if provided
if not _validate_object_dates(connection, msg):
return
# v1.4.0 (#43): documentation_url
if "documentation_url" in msg:
@@ -693,13 +668,11 @@ async def ws_unarchive_object(
return
obj.pop("archived_at", None)
rd = _get_runtime_data(hass, entry.entry_id)
store = getattr(rd, "store", None) if rd else None
store = get_store(hass, entry.entry_id)
today_iso = dt_util.now().date().isoformat()
tasks_data = dict(entry.data.get(CONF_TASKS, {}))
new_tasks: dict[str, Any] = {}
for tid, td in tasks_data.items():
for tid, td in entry.data.get(CONF_TASKS, {}).items():
td = dict(td)
if td.get("archived_reason") == ARCHIVE_REASON_OBJECT:
td.pop("archived_at", None)
@@ -744,8 +717,6 @@ async def ws_pause_object(
on that day via the coordinator; without it the pause holds until an
explicit ``object/resume``.
"""
from datetime import date as date_cls
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
@@ -760,10 +731,8 @@ async def ws_pause_object(
until = msg.get("until")
if until:
try:
until_date = date_cls.fromisoformat(until)
except ValueError:
connection.send_error(msg["id"], "invalid_date", "Invalid until format (expected YYYY-MM-DD)")
until_date = _parse_iso_date(connection, msg["id"], until, field="until")
if until_date is None:
return
if until_date <= dt_util.now().date():
connection.send_error(msg["id"], "invalid_date", "until must be a future date")
@@ -815,8 +784,7 @@ async def ws_resume_object(
connection.send_error(msg["id"], "not_paused", "Object is not paused")
return
rd = _get_runtime_data(hass, entry.entry_id)
store = getattr(rd, "store", None) if rd else None
store = get_store(hass, entry.entry_id)
new_data = build_resumed_entry_data(dict(entry.data), store, dt_util.now().date().isoformat())
hass.config_entries.async_update_entry(entry, data=new_data)
if store is not None:
@@ -949,11 +917,9 @@ async def ws_replace_object(
# Copy the tracked stock counts (dynamic store state) onto the carried
# parts, then let the successor's reconcile recreate any needed reminder.
if part_id_map:
src_rd = getattr(entry, "runtime_data", None)
src_store = getattr(src_rd, "store", None) if src_rd else None
src_store = get_store(hass, entry.entry_id)
new_entry = hass.config_entries.async_get_entry(new_entry_id)
new_rd = getattr(new_entry, "runtime_data", None) if new_entry else None
new_store = getattr(new_rd, "store", None) if new_rd else None
new_store = get_store(hass, new_entry_id)
if src_store is not None and new_store is not None:
for old_pid, new_pid in part_id_map.items():
stock = src_store.get_part_stock(old_pid)
@@ -9,7 +9,7 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from ..const import BATTERY_FLEET_OBJECT_FLAG, BATTERY_FLEET_REMOVED_PARTS, CONF_OBJECT, CONF_PARTS, MAX_ID_LENGTH
from ..helpers.aggregate import object_name
from ..helpers.aggregate import get_store, object_name
from ..helpers.parts import (
MAX_PART_STOCK,
MAX_PARTS_PER_OBJECT,
@@ -44,7 +44,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(str, None),
vol.Optional("doc_id"): vol.Any(vol.All(str, vol.Length(max=MAX_ID_LENGTH)), 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),
@@ -308,8 +308,7 @@ async def ws_parts_overview(
parts = _parts_of(e)
if not parts:
continue
rd = _get_runtime_data(hass, e.entry_id)
store = getattr(rd, "store", None) if rd else None
store = get_store(hass, e.entry_id)
for part_id, part in parts.items():
stock = store.get_part_stock(part_id) if store is not None else None
rows.append(
@@ -15,7 +15,8 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from ..const import CONF_OBJECT, DOMAIN, GLOBAL_UNIQUE_ID, MAX_ENTITY_ID_LENGTH, MAX_ID_LENGTH, MAX_NAME_LENGTH
from ..const import CONF_OBJECT, DOMAIN, MAX_ENTITY_ID_LENGTH, MAX_ID_LENGTH, MAX_NAME_LENGTH
from ..helpers.aggregate import is_object_entry
from ..helpers.permissions import require_write
from ..helpers.problem_sensors import (
build_problem_task,
@@ -110,11 +111,11 @@ async def ws_adopt_problem_sensors(
device_to_entry[device_id] = entry_id
entry = hass.config_entries.async_get_entry(entry_id)
# Same three-part 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 entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_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"})
continue
@@ -25,7 +25,7 @@ from .tasks_crud import (
ws_move_task,
ws_update_task,
)
from .tasks_history import ws_update_history_entry
from .tasks_history import ws_delete_history_entry, ws_update_history_entry
from .tasks_lifecycle import (
_is_recurring_schedule,
ws_archive_task,
@@ -65,6 +65,7 @@ __all__ = [
"ws_checklist_progress",
"ws_complete_task",
"ws_create_task",
"ws_delete_history_entry",
"ws_delete_task",
"ws_duplicate_task",
"ws_list_tasks",
@@ -12,7 +12,6 @@ from homeassistant.exceptions import ServiceValidationError
from homeassistant.util import dt as dt_util
from ..const import (
CONF_TASKS,
MAX_CHECKLIST_ITEM_LENGTH,
MAX_CHECKLIST_ITEMS,
MAX_COST,
@@ -20,11 +19,16 @@ from ..const import (
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 (
_get_runtime_data,
READING_VALUES_FIELD,
USED_PARTS_FIELD,
_load_object_task,
_parse_iso_date,
async_commit_store,
)
# ---------------------------------------------------------------------------
@@ -32,30 +36,6 @@ from . import (
# ---------------------------------------------------------------------------
def _load_task_context(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
*,
need_coordinator: bool = True,
) -> tuple[Any, Any] | None:
"""Resolve ``(runtime_data, entry)`` for a task action, or send the standard
not-found error and return None.
Consolidates the identical prologue the task-action handlers copied inline
(the copies had already drifted — e.g. snooze omitted the coordinator check).
"""
rd = _get_runtime_data(hass, msg["entry_id"])
if need_coordinator and (rd is None or rd.coordinator is None):
connection.send_error(msg["id"], "not_found", "Coordinator not found")
return None
entry = hass.config_entries.async_get_entry(msg["entry_id"])
if entry is None or msg["task_id"] not in entry.data.get(CONF_TASKS, {}):
connection.send_error(msg["id"], "not_found", "Task not found")
return None
return rd, entry
def _completion_blocked(rd: Any, task_id: str) -> bool:
"""True iff the task's completion window forbids completing it right now.
@@ -72,6 +52,17 @@ def _completion_blocked(rd: Any, task_id: str) -> bool:
return not MaintenanceTask.from_dict(td).can_complete_now
def _refuse_too_early(connection: websocket_api.ActiveConnection, msg: dict[str, Any], rd: Any) -> bool:
"""Send ``too_early`` and return True when the completion window forbids
completing the task now — the ONE pre-check shared by the complete and
quick-complete commands (the coordinator choke point raises the same key
for callers that skip it, e.g. the HA service)."""
if not _completion_blocked(rd, msg["task_id"]):
return False
connection.send_error(msg["id"], "too_early", "Task can only be completed closer to its due date")
return True
@websocket_api.websocket_command(
{
vol.Required("type"): "maintenance_supporter/task/complete",
@@ -94,7 +85,7 @@ def _completion_blocked(rd: Any, task_id: str) -> bool:
# #133: when the maintenance was actually performed (ISO datetime,
# naive = local). Backfills a past completion; must not be in the
# future — the coordinator validates and splits latest-vs-backfill.
vol.Optional("completed_at"): vol.Any(vol.All(str, vol.Length(max=64)), None),
vol.Optional("completed_at"): vol.Any(vol.All(str, vol.Length(max=MAX_TIMESTAMP_LENGTH)), None),
# QR deep-link fallback: the panel asserts the scan on a tag-gated task
# whose quick-complete needs the full dialog (bug audit 2026-08-29).
vol.Optional("via_tag_scan"): bool,
@@ -110,39 +101,15 @@ def _completion_blocked(rd: Any, task_id: str) -> bool:
# 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),
# #161 phase 2: {slot_id: value} for a task with reading slots; None
# skips a meter this time. Ids are checked against the task's slots
# in the handler (unknown -> invalid_input).
vol.Optional("reading_values"): vol.Any(
{str: vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-1e12, max=1e12)), None)},
None,
),
# skips a meter this time (shared shape with the history edit).
vol.Optional("reading_values"): READING_VALUES_FIELD,
# Spare parts: on an auto-created "buy" task, how many units were
# actually bought (dialog override of the part's restock_quantity).
vol.Optional("restock_quantity"): vol.Any(vol.All(vol.Any(int, float), vol.Coerce(float), vol.Range(min=0.01, max=9999)), None),
# #99: the parts actually used on THIS completion. An explicit list
# (even an empty one) REPLACES the task's automatic consumes_parts
# deduction; omitting the key keeps the automatic behaviour.
vol.Optional("used_parts"): vol.Any(
vol.All(
[
vol.Schema(
{
vol.Required("part_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
# #111: the pool may live on another object. The
# schema has to allow it or voluptuous rejects the
# completion before the handler (which already
# validates the reference) ever runs.
vol.Optional("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Optional("quantity", default=1): vol.All(
vol.Any(int, float), vol.Coerce(float), vol.Range(min=0.01, max=999)
),
}
)
],
vol.Length(max=10),
),
None,
),
vol.Optional("used_parts"): USED_PARTS_FIELD,
}
)
@websocket_api.async_response
@@ -152,10 +119,10 @@ async def ws_complete_task(
msg: dict[str, Any],
) -> None:
"""Mark a task as completed."""
ctx = _load_task_context(hass, connection, msg)
ctx = _load_object_task(hass, connection, msg, merged=True, need_coordinator=True)
if ctx is None:
return
rd, _entry = ctx
_entry, rd, slot_task = ctx
# #133: an optional backdated completion moment. Parsed here (the schema
# can only cheaply cap the length); range/future validation lives in the
@@ -176,12 +143,7 @@ async def ws_complete_task(
# history correction, not early work, so it bypasses the gate. A
# today-dated completed_at still honours it.
is_past_dated = completed_at is not None and completed_at.date() < dt_util.now().date()
if not is_past_dated and _completion_blocked(rd, msg["task_id"]):
connection.send_error(
msg["id"],
"too_early",
"Task can only be completed closer to its due date",
)
if not is_past_dated and _refuse_too_early(connection, msg, rd):
return
# #99: validate the per-completion selection against the object's parts
@@ -203,9 +165,7 @@ async def ws_complete_task(
reading_values = None
if msg.get("reading_values"):
from ..helpers.reading_slots import resolve_reading_values
from . import _get_merged_tasks
slot_task = _get_merged_tasks(_entry).get(msg["task_id"]) or {}
try:
reading_values = (
resolve_reading_values(
@@ -219,6 +179,7 @@ async def ws_complete_task(
try:
await rd.coordinator.complete_maintenance(
source="panel",
task_id=msg["task_id"],
notes=msg.get("notes"),
cost=msg.get("cost"),
@@ -269,26 +230,12 @@ async def ws_quick_complete_task(
msg: dict[str, Any],
) -> None:
"""Complete a task using its pre-configured `quick_complete_defaults`."""
rd = _get_runtime_data(hass, msg["entry_id"])
if rd is None or rd.coordinator is None:
connection.send_error(msg["id"], "not_found", "Coordinator not found")
ctx = _load_object_task(hass, connection, msg, need_coordinator=True)
if ctx is None:
return
_entry, rd, task = ctx
entry = hass.config_entries.async_get_entry(msg["entry_id"])
if entry is None:
connection.send_error(msg["id"], "not_found", "Object not found")
return
task = entry.data.get(CONF_TASKS, {}).get(msg["task_id"])
if not task:
connection.send_error(msg["id"], "not_found", "Task not found")
return
if _completion_blocked(rd, msg["task_id"]):
connection.send_error(
msg["id"],
"too_early",
"Task can only be completed closer to its due date",
)
if _refuse_too_early(connection, msg, rd):
return
defaults = task.get("quick_complete_defaults") or {}
@@ -304,6 +251,7 @@ async def ws_quick_complete_task(
try:
await rd.coordinator.complete_maintenance(
source="qr",
task_id=msg["task_id"],
notes=defaults.get("notes"),
cost=defaults.get("cost"),
@@ -340,10 +288,10 @@ async def ws_skip_task(
msg: dict[str, Any],
) -> None:
"""Skip the current maintenance cycle."""
ctx = _load_task_context(hass, connection, msg)
ctx = _load_object_task(hass, connection, msg, need_coordinator=True)
if ctx is None:
return
rd, _entry = ctx
_entry, rd, _task = ctx
try:
await rd.coordinator.skip_maintenance(
@@ -374,25 +322,26 @@ async def ws_reset_task(
msg: dict[str, Any],
) -> None:
"""Reset the last performed date."""
from datetime import date as date_cls
ctx = _load_task_context(hass, connection, msg)
ctx = _load_object_task(hass, connection, msg, need_coordinator=True)
if ctx is None:
return
rd, _entry = ctx
_entry, rd, _task = ctx
reset_date = None
if msg.get("date"):
try:
reset_date = date_cls.fromisoformat(msg["date"])
except ValueError:
connection.send_error(msg["id"], "invalid_date", "Invalid date format")
reset_date = _parse_iso_date(connection, msg["id"], msg["date"], field="date")
if reset_date is None:
return
await rd.coordinator.reset_maintenance(
task_id=msg["task_id"],
date=reset_date,
)
try:
await rd.coordinator.reset_maintenance(
task_id=msg["task_id"],
date=reset_date,
)
except ServiceValidationError as err:
# An archived / disabled / paused task keeps its own key (task_inactive).
connection.send_error(msg["id"], err.translation_key or "task_inactive", str(err))
return
connection.send_result(msg["id"], {"success": True})
@@ -417,11 +366,10 @@ async def ws_set_task_phase(
rotate the cursor themselves and there is deliberately no phase picker
in the complete dialog.
"""
ctx = _load_task_context(hass, connection, msg)
ctx = _load_object_task(hass, connection, msg, need_store=True, need_coordinator=True)
if ctx is None:
return
rd, entry = ctx
task = (entry.data.get(CONF_TASKS) or {}).get(msg["task_id"]) or {}
_entry, rd, task = ctx
sequence = task.get("phase_sequence") or []
if not (task.get("phases") and sequence):
connection.send_error(msg["id"], "no_phases", "Task has no phase cycle")
@@ -432,8 +380,7 @@ async def ws_set_task_phase(
)
return
rd.store.set_phase_cursor(msg["task_id"], msg["cursor"])
await rd.store.async_save() # user action - never rely on the 60 s debounce
await rd.coordinator.async_refresh_now()
await async_commit_store(rd)
connection.send_result(msg["id"], {"success": True})
@@ -452,20 +399,20 @@ async def ws_postpone_task(
msg: dict[str, Any],
) -> None:
"""Postpone the current occurrence to a chosen date (per-occurrence defer)."""
from datetime import date as date_cls
ctx = _load_task_context(hass, connection, msg)
ctx = _load_object_task(hass, connection, msg, need_coordinator=True)
if ctx is None:
return
rd, _entry = ctx
_entry, rd, _task = ctx
try:
until = date_cls.fromisoformat(msg["until"])
except ValueError:
connection.send_error(msg["id"], "invalid_date", "Invalid date format")
until = _parse_iso_date(connection, msg["id"], msg["until"], field="until")
if until is None:
return
await rd.coordinator.async_postpone_task(msg["task_id"], until)
try:
await rd.coordinator.async_postpone_task(msg["task_id"], until)
except ServiceValidationError as err:
connection.send_error(msg["id"], err.translation_key or "task_inactive", str(err))
return
connection.send_result(msg["id"], {"success": True})
@@ -490,7 +437,7 @@ async def ws_snooze_task(
"""
from .. import DOMAIN, NOTIFICATION_MANAGER_KEY
if _load_task_context(hass, connection, msg, need_coordinator=False) is None:
if _load_object_task(hass, connection, msg) is None:
return
nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
@@ -531,11 +478,11 @@ 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_task_context(hass, connection, msg)
ctx = _load_object_task(hass, connection, msg, need_coordinator=True)
if ctx is None:
return
rd, entry = ctx
task_items = set(entry.data[CONF_TASKS][msg["task_id"]].get("checklist") or [])
_entry, rd, task = ctx
task_items = set(task.get("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.
@@ -543,7 +490,5 @@ async def ws_checklist_progress(
connection.send_error(msg["id"], "storage_unavailable", "Task storage not loaded")
return
rd.store.set_checklist_progress(msg["task_id"], state)
await rd.store.async_save()
# A user action must be visible immediately — never the 10 s debounce.
await rd.coordinator.async_refresh_now()
await async_commit_store(rd)
connection.send_result(msg["id"], {"success": True, "checklist_state": state})
@@ -4,7 +4,6 @@ from __future__ import annotations
import re
from copy import deepcopy
from datetime import date
from typing import Any
from uuid import uuid4
@@ -42,14 +41,15 @@ from ..const import (
NOTIFICATION_MANAGER_KEY,
HistoryEntryType,
)
from ..helpers.aggregate import get_store
from ..helpers.dates import INTERVAL_UNITS
from ..helpers.entry_tasks import write_task
from ..helpers.permissions import require_write
from ..helpers.sanitize import strip_task_runtime_state
from ..helpers.schedule import (
FLAT_RECURRENCE_KEYS,
KIND_INTERVAL,
Schedule,
normalize_task_storage,
)
from ..helpers.task_fields import (
EARLIEST_COMPLETION_RANGE,
@@ -61,8 +61,10 @@ from ..helpers.task_fields import (
WARNING_DAYS_RANGE,
)
from . import (
_get_runtime_data,
ID_FIELD,
_load_object_entry,
_load_object_task,
_parse_iso_date,
cleanup_group_refs,
)
from .tasks_persist import async_persist_task
@@ -72,6 +74,41 @@ from .tasks_validation import (
_validate_trigger_config,
)
_ENTITY_SLUG_RE = re.compile(r"[a-z0-9_]+")
def _validate_entity_slug(connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> bool:
"""False (after sending ``invalid_entity_slug``) when ``msg["entity_slug"]``
is present, not None and malformed — shared by create and update."""
slug = msg.get("entity_slug")
if slug is None or _ENTITY_SLUG_RE.fullmatch(slug):
return True
connection.send_error(
msg["id"],
"invalid_entity_slug",
"entity_slug must match [a-z0-9_]+ (lowercase, digits, underscores only)",
)
return False
def _normalize_nfc_tag(
hass: HomeAssistant,
msg: dict[str, Any],
warnings: list[str],
*,
exclude_task_id: str | None = None,
) -> str | None:
"""Normalise ``msg["nfc_tag_id"]`` in place (""/whitespace -> None) and
append the duplicate-tag warning when another task already carries it.
Returns the normalised value — shared by create and update."""
nfc_val = (msg.get("nfc_tag_id") or "").strip() or None
msg["nfc_tag_id"] = nfc_val
if nfc_val:
nfc_warn = _check_nfc_tag_duplicate(hass, nfc_val, exclude_task_id=exclude_task_id)
if nfc_warn:
warnings.append(nfc_warn)
return nfc_val
# 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"}),
@@ -304,17 +341,14 @@ 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:
try:
date.fromisoformat(msg["last_performed"])
except (ValueError, TypeError):
connection.send_error(msg["id"], "invalid_format", "last_performed must be a valid date (YYYY-MM-DD)")
lp_date = _parse_iso_date(connection, msg["id"], msg["last_performed"], field="last_performed", code="invalid_format")
if lp_date is None:
return
initial_last_performed = msg["last_performed"]
# Add initial history entry so times_performed reflects the value.
# Use HA-TZ-aware midnight to keep interval_analyzer consistent.
from datetime import datetime, time
lp_date = date.fromisoformat(msg["last_performed"])
lp_dt = datetime.combine(lp_date, time.min, tzinfo=dt_util.DEFAULT_TIME_ZONE)
initial_history.append(
{
@@ -362,27 +396,16 @@ async def ws_create_task(
from ..helpers.sanitize import seed_rotation_assignee
seed_rotation_assignee(task_data)
if not _validate_entity_slug(connection, msg):
return
if msg.get("entity_slug") is not None:
slug = msg["entity_slug"]
if not re.fullmatch(r"[a-z0-9_]+", slug):
connection.send_error(
msg["id"],
"invalid_entity_slug",
"entity_slug must match [a-z0-9_]+ (lowercase, digits, underscores only)",
)
return
task_data["entity_slug"] = slug
task_data["entity_slug"] = msg["entity_slug"]
if msg.get("custom_icon") is not None:
task_data["custom_icon"] = msg["custom_icon"]
if msg.get("priority") is not None:
task_data["priority"] = msg["priority"]
if msg.get("nfc_tag_id") is not None:
nfc_val = (msg["nfc_tag_id"] or "").strip() or None # normalise ""/ whitespace → None
task_data["nfc_tag_id"] = nfc_val
if nfc_val:
nfc_warn = _check_nfc_tag_duplicate(hass, nfc_val)
if nfc_warn:
tc_warnings.append(nfc_warn)
task_data["nfc_tag_id"] = _normalize_nfc_tag(hass, msg, tc_warnings)
if msg.get("require_tag_scan") is not None:
task_data["require_tag_scan"] = bool(msg["require_tag_scan"])
# #150: stored only when False — absence means skipping is allowed.
@@ -541,17 +564,12 @@ async def ws_update_task(
msg: dict[str, Any],
) -> None:
"""Update an existing task."""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
ctx = _load_object_task(hass, connection, msg)
if ctx is None:
return
tasks_data = dict(entry.data.get(CONF_TASKS, {}))
entry, _rd, stored_task = ctx
task_id = msg["task_id"]
if task_id not in tasks_data:
connection.send_error(msg["id"], "not_found", "Task not found")
return
task = dict(tasks_data[task_id])
task = dict(stored_task)
# Strip and validate name if provided
if "name" in msg:
@@ -582,31 +600,20 @@ async def ws_update_task(
return
# Validate entity_slug if provided
if "entity_slug" in msg and msg["entity_slug"] is not None:
slug = msg["entity_slug"]
if not re.fullmatch(r"[a-z0-9_]+", slug):
connection.send_error(
msg["id"],
"invalid_entity_slug",
"entity_slug must match [a-z0-9_]+ (lowercase, digits, underscores only)",
)
return
if not _validate_entity_slug(connection, msg):
return
# Normalise empty NFC tag to None and check uniqueness
if "nfc_tag_id" in msg:
msg["nfc_tag_id"] = (msg["nfc_tag_id"] or "").strip() or None
if msg["nfc_tag_id"]:
nfc_warn = _check_nfc_tag_duplicate(hass, msg["nfc_tag_id"], exclude_task_id=task_id)
if nfc_warn:
tc_warnings.append(nfc_warn)
_normalize_nfc_tag(hass, msg, tc_warnings, exclude_task_id=task_id)
# Validate last_performed date format if provided
if "last_performed" in msg and msg["last_performed"] is not None:
try:
date.fromisoformat(msg["last_performed"])
except (ValueError, TypeError):
connection.send_error(msg["id"], "invalid_format", "last_performed must be a valid date (YYYY-MM-DD)")
return
if (
msg.get("last_performed") is not None
and _parse_iso_date(connection, msg["id"], msg["last_performed"], field="last_performed", code="invalid_format")
is None
):
return
# Validate documentation_url if provided
if "documentation_url" in msg and not _is_safe_url(msg["documentation_url"]):
@@ -617,6 +624,22 @@ async def ws_update_task(
if msg_key in msg:
task[data_key] = msg[msg_key]
# last_performed is a DYNAMIC field: the Store is its system of record
# and merge_task_data overlays the Store's value whenever the key exists
# there (every task that was ever completed / reset / created with a
# date). Writing the edit into entry.data alone was masked for exactly
# those tasks — the dialog's "last performed" edit never took effect.
# Write the Store too; the static copy above stays as the legacy /
# no-Store fallback (bug audit 2026-09-12). A MOVED anchor starts a new
# cycle, so a postpone (due_override) of the old one goes with it; the
# dialog re-sends an unchanged date on every save, which must not.
if "last_performed" in msg:
store_lp = get_store(hass, entry.entry_id)
if store_lp is not None:
new_anchor = msg["last_performed"] or None
store_lp.set_anchor(task_id, new_anchor, clear_modifiers=new_anchor != store_lp.get_last_performed(task_id))
await store_lp.async_save()
# #150: allow_skip is stored only when False (absence = allowed) — the
# verbatim copy above would persist True/None literals.
if "allow_skip" in msg:
@@ -666,15 +689,15 @@ async def ws_update_task(
_apply_phase_fields(hass, entry, task, msg.get("phases", task.get("phases")), msg.get("phase_sequence", task.get("phase_sequence")))
from ..helpers.phases import clamp_phase_cursor
rd_phase = _get_runtime_data(hass, msg["entry_id"])
if rd_phase and rd_phase.store:
store_phase = get_store(hass, entry.entry_id)
if store_phase is not None:
seq = task.get("phase_sequence") or []
if seq:
cur = rd_phase.store.get_task_state(task_id).get("phase_cursor", 0)
rd_phase.store.set_phase_cursor(task_id, clamp_phase_cursor(cur, len(seq)))
cur = store_phase.get_task_state(task_id).get("phase_cursor", 0)
store_phase.set_phase_cursor(task_id, clamp_phase_cursor(cur, len(seq)))
else:
rd_phase.store.get_task_state(task_id).pop("phase_cursor", None)
rd_phase.store.async_delay_save()
store_phase.get_task_state(task_id).pop("phase_cursor", None)
store_phase.async_delay_save()
# Recurrence resolution: an explicit nested `schedule` wins (calendar kinds
# and kind-switches). Otherwise rebuild from the flat view ONLY when a real
@@ -742,7 +765,7 @@ async def ws_update_task(
# Clear stale trigger runtime in Store only when trigger fundamentally changes
if "trigger_config" in msg:
old_tc = tasks_data.get(task_id, {}).get("trigger_config") or {}
old_tc = stored_task.get("trigger_config") or {}
new_tc = msg["trigger_config"] or {}
# An edited baseline counts as fundamental: the Store baseline wins
# over the config on restore (#102 restart fix), so without clearing
@@ -753,15 +776,14 @@ async def ws_update_task(
or old_tc.get("entity_ids") != new_tc.get("entity_ids")
or old_tc.get("trigger_baseline_value") != new_tc.get("trigger_baseline_value")
):
rd = _get_runtime_data(hass, msg["entry_id"])
if rd and rd.store:
rd.store.clear_trigger_runtime(task_id)
rd.store.async_delay_save()
store_tc = get_store(hass, entry.entry_id)
if store_tc is not None:
store_tc.clear_trigger_runtime(task_id)
store_tc.async_delay_save()
tasks_data[task_id] = normalize_task_storage(task)
new_data = dict(entry.data)
new_data[CONF_TASKS] = tasks_data
hass.config_entries.async_update_entry(entry, data=new_data)
# Patch only this task's key onto a fresh read (normalized) — never write
# back a whole-map snapshot taken before the store awaits above.
write_task(hass, entry, task_id, task)
# Reload entry to pick up changed task config (triggers, schedule, etc.)
await hass.config_entries.async_reload(entry.entry_id)
@@ -787,13 +809,12 @@ async def ws_delete_task(
msg: dict[str, Any],
) -> None:
"""Delete a task from a maintenance object."""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
ctx = _load_object_task(hass, connection, msg)
if ctx is None:
return
entry, _rd, _task = ctx
if not await async_delete_task(hass, entry, msg["task_id"]):
connection.send_error(msg["id"], "not_found", "Task not found")
return
await async_delete_task(hass, entry, msg["task_id"])
# Reload to re-create remaining entities
await hass.config_entries.async_reload(entry.entry_id)
@@ -837,8 +858,7 @@ async def async_delete_task(
hass.config_entries.async_update_entry(entry, data=new_data)
# Clean up Store
rd = _get_runtime_data(hass, entry.entry_id)
store = getattr(rd, "store", None) if rd else None
store = get_store(hass, entry.entry_id)
if store is not None:
store.remove_task(task_id)
await store.async_save()
@@ -923,14 +943,10 @@ async def ws_duplicate_task(
unique per task (entity_slug, nfc_tag_id) are dropped so the copy gets its
own auto-generated slug and no colliding tag.
"""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
source = entry.data.get(CONF_TASKS, {}).get(msg["task_id"])
if source is None:
connection.send_error(msg["id"], "not_found", "Task not found")
ctx = _load_object_task(hass, connection, msg)
if ctx is None:
return
entry, _rd, source = ctx
new_task = deepcopy(dict(source))
new_task["id"] = uuid4().hex
@@ -948,9 +964,9 @@ async def ws_duplicate_task(
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/task/move",
vol.Required("entry_id"): str,
vol.Required("task_id"): str,
vol.Required("target_entry_id"): str,
vol.Required("entry_id"): ID_FIELD,
vol.Required("task_id"): ID_FIELD,
vol.Required("target_entry_id"): ID_FIELD,
}
)
@require_write
@@ -963,12 +979,10 @@ async def ws_move_task(
"""Move a task to another object — config, history, readings and trigger
state travel with it; the task gets a new reference number and its
entities are recreated under the target object (forum #23)."""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
if msg["task_id"] not in entry.data.get(CONF_TASKS, {}):
connection.send_error(msg["id"], "not_found", "Task not found")
ctx = _load_object_task(hass, connection, msg)
if ctx is None:
return
entry, _rd, _task = ctx
target = _load_object_entry(
hass, connection, {**msg, "entry_id": msg["target_entry_id"]}, not_found_message="Target object not found"
)
@@ -980,10 +994,14 @@ async def ws_move_task(
if (target.data.get(CONF_OBJECT) or {}).get("archived_at"):
connection.send_error(msg["id"], "invalid_target", "The target object is archived")
return
from .tasks_persist import async_move_task
from .tasks_persist import TaskMoveRefused, async_move_task
try:
await async_move_task(hass, entry, target, msg["task_id"])
except TaskMoveRefused as err:
# task_not_movable / object_not_loaded — raised before anything changed.
connection.send_error(msg["id"], err.code, str(err))
return
except ValueError as err:
connection.send_error(msg["id"], "limit_reached", str(err))
return
@@ -16,6 +16,7 @@ from ..const import (
MAX_ID_LENGTH,
MAX_META_LENGTH,
MAX_TEXT_LENGTH,
MAX_TIMESTAMP_LENGTH,
)
from ..helpers.completion_photos import (
MAX_COMPLETION_PHOTOS,
@@ -23,9 +24,12 @@ from ..helpers.completion_photos import (
normalize_photo_doc_ids,
)
from ..helpers.permissions import require_write
from ..storage import reanchor_from_history
from . import (
_get_runtime_data,
_load_object_entry,
READING_VALUES_FIELD,
USED_PARTS_FIELD,
_load_object_task,
async_commit_store,
)
# v2.2.0 — edit existing history entries (Discussion #49 follow-up).
@@ -52,9 +56,9 @@ from . import (
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)),
# ISO datetime string identifying the entry being edited.
vol.Required("original_timestamp"): vol.All(str, vol.Length(max=64)),
vol.Required("original_timestamp"): vol.All(str, vol.Length(max=MAX_TIMESTAMP_LENGTH)),
# Patch fields — all optional; absent fields stay unchanged.
vol.Optional("timestamp"): vol.All(str, vol.Length(max=64)),
vol.Optional("timestamp"): vol.All(str, vol.Length(max=MAX_TIMESTAMP_LENGTH)),
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),
@@ -64,28 +68,12 @@ from . import (
# The scalar patches like the other fields; the slot map REPLACES the
# snapshot (None value = that meter unread), ids validated in the handler.
vol.Optional("reading_value"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-1e12, max=1e12)), None),
vol.Optional("reading_values"): vol.Any(
{str: vol.Any(vol.All(vol.Coerce(float), vol.Range(min=-1e12, max=1e12)), None)},
None,
),
vol.Optional("reading_values"): READING_VALUES_FIELD,
# #130: edit the entry's part consumption. The stock is reconciled by
# the per-part DELTA against the entry's previous used_parts; None (or
# []) clears the consumption and returns the old quantities to stock.
vol.Optional("used_parts"): vol.Any(
vol.All(
[
{
vol.Required("part_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Optional("quantity"): vol.All(vol.Coerce(float), vol.Range(min=0.01, max=999)),
vol.Optional("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
}
],
# Same cap as the live completion path (tasks_actions.py) —
# the edit path must not accept what completion refuses.
vol.Length(max=10),
),
None,
),
# Same shape + cap as the live completion path (websocket.USED_PARTS_FIELD).
vol.Optional("used_parts"): USED_PARTS_FIELD,
# #161: the entry's completion photos. Replaces the whole list; None
# (or []) detaches every photo from the entry. The documents
# themselves are never deleted here — they stay in the object's
@@ -104,15 +92,11 @@ async def ws_update_history_entry(
msg: dict[str, Any],
) -> None:
"""Edit fields of an existing history entry."""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
rd = _get_runtime_data(hass, entry.entry_id)
store = getattr(rd, "store", None) if rd else None
if store is None:
connection.send_error(msg["id"], "not_loaded", "Object not loaded")
ctx = _load_object_task(hass, connection, msg, merged=True, need_store=True)
if ctx is None:
return
entry, rd, slot_task = ctx
store = rd.store
task_id = msg["task_id"]
history = list(store.get_history(task_id))
@@ -163,9 +147,7 @@ async def ws_update_history_entry(
# #161 phase 2: "never both" — the scalar cannot be set on an entry that
# carries a slot snapshot, nor on a task that records slots.
if msg.get("reading_value") is not None:
from . import _get_merged_tasks
if patched.get("reading_values") or (_get_merged_tasks(entry).get(task_id) or {}).get("readings"):
if patched.get("reading_values") or slot_task.get("readings"):
connection.send_error(
msg["id"], "invalid_input", "This entry records named readings — patch reading_values instead"
)
@@ -188,9 +170,7 @@ async def ws_update_history_entry(
# stays editable); anything else is refused rather than guessed.
if "reading_values" in msg:
from ..helpers.reading_slots import history_reading_values, resolve_reading_values
from . import _get_merged_tasks
slot_task = _get_merged_tasks(entry).get(task_id) or {}
try:
new_values = resolve_reading_values(
slot_task.get("readings") or [],
@@ -214,7 +194,6 @@ async def ws_update_history_entry(
# live completion path — a vanished part skips its stock math.
if "used_parts" in msg:
from ..parts_runtime import async_apply_history_parts_edit
from . import _get_merged_tasks
old_used = patched.get("used_parts") or []
# Deliberately NOT sanitize_consumes_parts here: an edited entry may
@@ -223,8 +202,7 @@ async def ws_update_history_entry(
# rewrite history. Field validation (ids, quantity range, list cap)
# is the schema's job above.
new_used = msg["used_parts"] or []
task_data = _get_merged_tasks(entry).get(task_id) or {}
enriched = await async_apply_history_parts_edit(hass, entry, task_data, old_used, new_used)
enriched = await async_apply_history_parts_edit(hass, entry, slot_task, old_used, new_used)
if enriched:
patched["used_parts"] = enriched
else:
@@ -242,7 +220,7 @@ async def ws_update_history_entry(
patched["photo_doc_ids"] = new_photos
else:
patched.pop("photo_doc_ids", None)
if rd and rd.coordinator:
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)
@@ -250,29 +228,16 @@ async def ws_update_history_entry(
history[target_index] = patched
store.set_history(task_id, history)
# Recompute last_performed if the edited entry is the latest lifecycle
# entry. Lifecycle = anything that resets the maintenance cycle (shared
# set with the backdated-completion path, #133). Trigger /
# trigger_replaced entries don't affect last_performed.
lifecycle_entries = [h for h in history if h.get("type") in LIFECYCLE_HISTORY_TYPES]
if lifecycle_entries:
# "Latest" by timestamp — sort defensively (entries are usually
# already in append order, but a timestamp edit may have changed that).
latest = max(
lifecycle_entries,
key=lambda h: h.get("timestamp", ""),
)
latest_ts = latest.get("timestamp")
if latest_ts:
new_lp = latest_ts[:10] # YYYY-MM-DD prefix
store.set_last_performed(task_id, new_lp)
# Re-derive last_performed from the (possibly re-dated) lifecycle
# entries — the same rule the delete command applies, so a timestamp edit
# that moves the anchor also drops a stale postpone. Only the patchable
# fields change here (never an entry's type), so a task whose only
# lifecycle entry is the seed keeps its anchor.
if any(h.get("type") in LIFECYCLE_HISTORY_TYPES for h in history):
reanchor_from_history(store, task_id, history)
await store.async_save()
# Refresh coordinator + budget cache so the UI reflects the change
if rd and rd.coordinator:
rd.coordinator._recalculate_budget_cache()
await rd.coordinator.async_refresh_now()
# Save + budget cache + immediate refresh so the UI reflects the change.
await async_commit_store(rd, budget=True)
connection.send_result(
msg["id"],
@@ -282,3 +247,46 @@ async def ws_update_history_entry(
"new_timestamp": patched.get("timestamp"),
},
)
@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("timestamp"): vol.All(str, vol.Length(max=MAX_TIMESTAMP_LENGTH)),
}
)
@require_write
@websocket_api.async_response
async def ws_delete_history_entry(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Remove one history entry (#170): a completion recorded by mistake, a
skip that means nothing on paper. The entry is identified by its
timestamp (unique within a task's history); the task's last-performed
anchor is re-derived from what remains, so removing the latest completion
moves the schedule back to the previous one. Photos stay in the object's
documents and consumed parts are not restocked — a bookkeeping
correction, not an undo."""
ctx = _load_object_task(hass, connection, msg, need_store=True)
if ctx is None:
return
_entry, rd, _task = ctx
store = rd.store
task_id = msg["task_id"]
history = list(store.get_history(task_id))
remaining = [h for h in history if h.get("timestamp") != msg["timestamp"]]
if len(remaining) == len(history):
connection.send_error(msg["id"], "not_found", f"No history entry with timestamp {msg['timestamp']!r}")
return
store.set_history(task_id, remaining)
# Nothing left to anchor the cycle on → the task reads as never performed
# 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)
await async_commit_store(rd, budget=True)
connection.send_result(msg["id"], {"success": True, "remaining": len(remaining)})
@@ -11,19 +11,18 @@ from homeassistant.util import dt as dt_util
from ..const import (
ARCHIVE_REASON_MANUAL,
CONF_OBJECT,
CONF_OBJECT_NAME,
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 (
_build_task_summary,
_get_merged_tasks,
_get_object_entries,
_get_runtime_data,
_load_object_entry,
_load_object_task,
)
@@ -59,27 +58,20 @@ async def ws_archive_task(
Reason MANUAL → it is never auto-deleted and is unarchived individually
(an object-cascade unarchive leaves it alone). Works for any task type.
"""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
ctx = _load_object_task(hass, connection, msg)
if ctx is None:
return
tasks_data = dict(entry.data.get(CONF_TASKS, {}))
entry, _rd, task = ctx
task_id = msg["task_id"]
if task_id not in tasks_data:
connection.send_error(msg["id"], "not_found", "Task not found")
return
td = dict(tasks_data[task_id])
td = dict(task)
if td.get("archived_at") is not None:
connection.send_error(msg["id"], "already_archived", "Task already archived")
return
td["archived_at"] = dt_util.now().isoformat()
td["archived_reason"] = ARCHIVE_REASON_MANUAL
tasks_data[task_id] = td
new_data = dict(entry.data)
new_data[CONF_TASKS] = tasks_data
hass.config_entries.async_update_entry(entry, data=new_data)
write_task(hass, entry, task_id, td)
# Reload so a sensor task's triggers tear down (async_added_to_hass skips
# trigger setup for archived tasks) and every per-task entity recomputes inert.
@@ -108,15 +100,11 @@ async def ws_unarchive_task(
to today so ``next_due = today + interval`` rather than resurfacing as
retroactively overdue. One-off / manual tasks keep their terminal state.
"""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
ctx = _load_object_task(hass, connection, msg)
if ctx is None:
return
entry, _rd, td = ctx
task_id = msg["task_id"]
td = dict(entry.data.get(CONF_TASKS, {}).get(task_id, {}))
if not td:
connection.send_error(msg["id"], "not_found", "Task not found")
return
if td.get("archived_at") is None:
connection.send_error(msg["id"], "not_archived", "Task is not archived")
return
@@ -127,8 +115,7 @@ async def ws_unarchive_task(
# below re-reads AFTER it, so a concurrent writer landing during the disk
# write can't be reverted by a stale whole-map write (the migration-race
# class, bug audit 2026-07-11).
rd = _get_runtime_data(hass, entry.entry_id)
store = getattr(rd, "store", None) if rd else None
store = get_store(hass, entry.entry_id)
recurring = _is_recurring_schedule(td)
legacy_anchor = False
if recurring:
@@ -141,12 +128,12 @@ async def ws_unarchive_task(
else:
legacy_anchor = True
# Re-derive the task from a FRESH read and patch only its key.
fresh_tasks = entry.data.get(CONF_TASKS, {})
if task_id not in fresh_tasks:
# Re-derive the task from a FRESH read (post-await) and patch only its key.
fresh = entry.data.get(CONF_TASKS, {}).get(task_id)
if fresh is None:
connection.send_error(msg["id"], "not_found", "Task not found")
return
td = dict(fresh_tasks[task_id])
td = dict(fresh)
td.pop("archived_at", None)
td.pop("archived_reason", None)
if legacy_anchor:
@@ -156,8 +143,6 @@ async def ws_unarchive_task(
# so an imported due_override can't out-rank it in merge_task_data.
clear_cycle_modifiers(td)
from ..helpers.entry_tasks import write_task
write_task(hass, entry, task_id, td)
await hass.config_entries.async_reload(entry.entry_id)
@@ -191,15 +176,13 @@ def ws_list_tasks(
if filter_entry_id and entry.entry_id != filter_entry_id:
continue
entry_tasks = _get_merged_tasks(entry)
obj_data = entry.data.get(CONF_OBJECT, {})
rd = _get_runtime_data(hass, entry.entry_id)
coordinator_data = rd.coordinator.data if rd and rd.coordinator else None
ct_tasks = (coordinator_data or {}).get(CONF_TASKS, {})
obj_name = object_name(entry)
ct_tasks = (get_coordinator_data(hass, entry.entry_id) or {}).get(CONF_TASKS, {})
for task_id, task_data in entry_tasks.items():
summary = _build_task_summary(hass, task_id, task_data, ct_tasks.get(task_id))
summary["task_id"] = task_id
summary["entry_id"] = entry.entry_id
summary["object_name"] = obj_data.get(CONF_OBJECT_NAME, "")
summary["object_name"] = obj_name
tasks.append(summary)
connection.send_result(msg["id"], {"tasks": tasks})
@@ -226,13 +209,9 @@ def ws_task_history(
caps at 500). The detail view's timeline, filters and charts fetch the
complete record here, only when a task is actually opened.
"""
for entry in _get_object_entries(hass):
if entry.entry_id != msg["entry_id"]:
continue
task_data = _get_merged_tasks(entry).get(msg["task_id"])
if task_data is None:
break
history = task_data.get("history") or []
connection.send_result(msg["id"], {"history": history, "count": len(history)})
ctx = _load_object_task(hass, connection, msg, merged=True)
if ctx is None:
return
connection.send_error(msg["id"], "not_found", "Task not found")
_entry, _rd, task_data = ctx
history = task_data.get("history") or []
connection.send_result(msg["id"], {"history": history, "count": len(history)})
@@ -10,20 +10,19 @@ from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from ..const import (
BATTERY_FLEET_TASK_FLAG,
CONF_OBJECT,
CONF_TASKS,
DEFAULT_WARNING_DAYS,
DOMAIN,
GLOBAL_UNIQUE_ID,
MAX_TASKS_PER_OBJECT,
)
from ..helpers.aggregate import get_store, is_object_entry
from ..helpers.entry_tasks import insert_new_task
from ..helpers.sanitize import cap_task_fields
from ..helpers.schedule import (
normalize_task_storage,
)
from . import (
_get_runtime_data,
)
# ---------------------------------------------------------------------------
# Task CRUD
@@ -45,44 +44,13 @@ async def async_persist_task(
Store dynamic state, and reloads the entry so the task's entities
(sensor / binary_sensor / buttons) are created.
"""
# Store recurrence in the canonical nested `schedule` shape (schedule-model v2).
task_data = normalize_task_storage(task_data)
task_id = task_data["id"]
# Per-object task cap — this is the single create chokepoint for BOTH the
# task/create WS command and the add_task service, so one guard covers both.
# The ValueError surfaces as a WS error / a service ValidationError at the
# callers (a runaway automation can't inflate ConfigEntry.data without bound).
existing_tasks = entry.data.get(CONF_TASKS, {})
if task_id not in existing_tasks and len(existing_tasks) >= MAX_TASKS_PER_OBJECT:
raise ValueError(f"This object already has the maximum of {MAX_TASKS_PER_OBJECT} tasks")
new_data = dict(entry.data)
new_tasks = dict(new_data.get(CONF_TASKS, {}))
new_tasks[task_id] = task_data
new_data[CONF_TASKS] = new_tasks
obj = dict(new_data.get(CONF_OBJECT, {}))
task_ids = list(obj.get("task_ids", []))
task_ids.append(task_id)
obj["task_ids"] = task_ids
new_data[CONF_OBJECT] = obj
hass.config_entries.async_update_entry(entry, data=new_data)
rd = _get_runtime_data(hass, entry.entry_id)
store = getattr(rd, "store", None) if rd else None
# The insert itself (normalise, per-object cap → ValueError, task_ids,
# ConfigEntry.data, Store init) is the sync core shared with the options
# flow; this path saves right away and reloads so the task's entities
# (sensor / binary_sensor / buttons) are created.
store = insert_new_task(hass, entry, task_data, last_performed=last_performed, history=history)
if store is not None:
store.init_task(task_id, last_performed=last_performed)
if history:
store.set_history(task_id, history)
await store.async_save()
else:
# Legacy: dynamic fields live in ConfigEntry.data
task_data["last_performed"] = last_performed
task_data["history"] = history or []
new_tasks[task_id] = task_data
new_data[CONF_TASKS] = new_tasks
hass.config_entries.async_update_entry(entry, data=new_data)
await hass.config_entries.async_reload(entry.entry_id)
@@ -117,7 +85,7 @@ async def async_create_task_simple(
reachable directly from Python, so the caps can't live only in the schema.
"""
entry = hass.config_entries.async_get_entry(entry_id)
if entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_ID:
if not is_object_entry(entry):
raise ValueError(f"No maintenance object found for entry_id {entry_id!r}")
name = (name or "").strip()
if not name:
@@ -189,7 +157,7 @@ async def async_update_task_simple(
Raises ValueError for an unknown entry/task or an empty name.
"""
entry = hass.config_entries.async_get_entry(entry_id)
if entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_ID:
if not is_object_entry(entry):
raise ValueError(f"No maintenance object found for entry_id {entry_id!r}")
new_data = dict(entry.data)
@@ -225,6 +193,27 @@ async def async_update_task_simple(
await hass.config_entries.async_reload(entry_id)
class TaskMoveRefused(ValueError):
"""``task/move`` refused up front — carries the WS error code.
Raised BEFORE the delete leg so nothing has changed when the caller sees
it (bug audit 2026-09-12): a task that is not movable, or an entry whose
Store is not loaded (the move would silently drop history / readings /
trigger state).
"""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
def _stamp_part_links(links: Any, entry_id: str) -> Any:
"""Give every own-pool part link an explicit ``entry_id`` (foreign-pool form)."""
if not isinstance(links, list):
return links
return [{**link, "entry_id": link.get("entry_id") or entry_id} if isinstance(link, dict) else link for link in links]
async def async_move_task(
hass: HomeAssistant,
source: ConfigEntry,
@@ -236,33 +225,59 @@ async def async_move_task(
What travels: the task's config (schedule, trigger, checklist, parts links,
slug, NFC tag …) and its Store state (history, last_performed, planned
due, adaptive config, phase cursor, checklist progress, trigger runtime
incl. counter baselines). Group memberships follow the task. What does
not: the reference number (``ref_no`` and the history entries' numbers —
the target object numbers it afresh on its next refresh) and document
links (documents belong to the source object; ``async_delete_task``
unlinks them). Part links keep an explicit ``entry_id`` so a link to one
of the source object's parts still resolves as a foreign-pool link.
incl. counter baselines). Group memberships, the vacation exemption and
the document links follow the task; completion photos that belong to
nothing but this task are re-homed to the target object (they would
otherwise die with the source object — bug audit 2026-09-12). What does
not travel: the reference number (``ref_no`` and the history entries'
numbers — the target object numbers it afresh on its next refresh). Part
links — task-level AND per phase — keep an explicit ``entry_id`` so a
link to one of the source object's parts still resolves as a
foreign-pool link.
Both entries are reloaded by the caller: the source drops the task's
entities, the target creates them under its own object slug.
Raises ValueError when the target is full.
Raises :class:`TaskMoveRefused` (with a WS error code) for a task that
must stay with its object / an entry whose Store is not loaded, and a
plain ValueError when the target is full.
"""
from copy import deepcopy
from ..const import MAX_TASKS_PER_OBJECT
from ..const import CONF_VACATION_EXEMPT_TASK_IDS, DOCUMENT_STORE_KEY
from ..helpers.completion_photos import history_photo_ids
from ..helpers.parts import PART_REF_FIELD
from .tasks_crud import async_delete_task
task_data = deepcopy(dict(source.data[CONF_TASKS][task_id]))
task_data.pop("ref_no", None)
links = task_data.get("consumes_parts")
if isinstance(links, list):
task_data["consumes_parts"] = [
{**link, "entry_id": link.get("entry_id") or source.entry_id} if isinstance(link, dict) else link for link in links
]
# A buy task follows its spare part and the fleet task IS the battery
# fleet — neither can live on another object (bug audit 2026-09-12).
if task_data.get(PART_REF_FIELD):
raise TaskMoveRefused("task_not_movable", "A spare-part buy task stays with its part")
if task_data.get(BATTERY_FLEET_TASK_FLAG):
raise TaskMoveRefused("task_not_movable", "The battery fleet task cannot be moved")
src_rd = _get_runtime_data(hass, source.entry_id)
src_store = getattr(src_rd, "store", None) if src_rd else None
state = deepcopy(src_store.get_task_state(task_id)) if src_store is not None else {}
# Both Stores must be loaded (entry disabled / setup-retry / mid-reload
# = no runtime_data): the config would move while history, readings and
# trigger state silently vanished (bug audit 2026-09-12).
src_store = get_store(hass, source.entry_id)
tgt_store = get_store(hass, target.entry_id)
if src_store is None or tgt_store is None:
raise TaskMoveRefused("object_not_loaded", "Both objects must be loaded to move a task")
task_data.pop("ref_no", None)
# The task now belongs to the target object — like the duplicate /
# replace paths re-stamp it (bug audit 2026-09-12).
task_data["object_id"] = (target.data.get(CONF_OBJECT) or {}).get("id", "")
task_data["consumes_parts"] = _stamp_part_links(task_data.get("consumes_parts"), source.entry_id)
if task_data["consumes_parts"] is None:
del task_data["consumes_parts"]
phases = task_data.get("phases")
if isinstance(phases, dict):
for pdef in phases.values():
if isinstance(pdef, dict) and isinstance(pdef.get("consumes_parts"), list):
pdef["consumes_parts"] = _stamp_part_links(pdef["consumes_parts"], source.entry_id)
state = deepcopy(src_store.get_task_state(task_id))
state.pop("next_history_ref", None)
for entry in state.get("history") or []:
if isinstance(entry, dict):
@@ -279,6 +294,24 @@ async def async_move_task(
if any(isinstance(r, dict) and r.get("task_id") == task_id for r in group.get("task_refs", [])):
member_groups.append(gid)
# Vacation exemption + document links: the delete leg strips both
# (task-id keyed, otherwise never pruned) — snapshot, restore after.
vacation_exempt = False
if global_entry is not None:
exempt = global_entry.options.get(CONF_VACATION_EXEMPT_TASK_IDS) or []
vacation_exempt = isinstance(exempt, list) and task_id in exempt
doc_store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
doc_links = doc_store.task_links(task_id) if doc_store is not None else {}
photo_ids: set[str] = set()
for entry in state.get("history") or []:
if isinstance(entry, dict):
photo_ids.update(history_photo_ids(entry))
# Re-home only the photos that are linked to this task alone — a doc
# shared with the object's other tasks stays where it is (link kept).
rehome_ids = {
did for did in photo_ids if did in doc_links and (doc_store.get(did) or {}).get("task_ids") == [task_id]
}
existing = target.data.get(CONF_TASKS, {})
if len(existing) >= MAX_TASKS_PER_OBJECT:
raise ValueError(f"The target object already has the maximum of {MAX_TASKS_PER_OBJECT} tasks")
@@ -294,11 +327,18 @@ async def async_move_task(
new_data[CONF_OBJECT] = obj
hass.config_entries.async_update_entry(target, data=new_data)
tgt_rd = _get_runtime_data(hass, target.entry_id)
tgt_store = getattr(tgt_rd, "store", None) if tgt_rd else None
if tgt_store is not None:
tgt_store.put_task_state(task_id, state)
await tgt_store.async_save()
tgt_store.put_task_state(task_id, state)
await tgt_store.async_save()
if doc_store is not None and doc_links:
await doc_store.async_relink_task(task_id, doc_links, rehome_doc_ids=rehome_ids, object_id=task_data["object_id"])
if vacation_exempt and global_entry is not None:
options = dict(global_entry.options)
current = options.get(CONF_VACATION_EXEMPT_TASK_IDS) or []
if task_id not in current:
options[CONF_VACATION_EXEMPT_TASK_IDS] = [*current, task_id]
hass.config_entries.async_update_entry(global_entry, options=options)
if member_groups and global_entry is not None:
options = dict(global_entry.options or global_entry.data)
@@ -12,12 +12,11 @@ from typing import Any
from homeassistant.core import HomeAssistant
from ..const import (
CONF_OBJECT,
CONF_OBJECT_NAME,
CONF_TASKS,
UNAVAILABLE_STATES,
TriggerType,
)
from ..helpers.aggregate import object_name
from ..helpers.trigger_fallback import threshold_limits_overlap
from . import (
_get_object_entries,
@@ -71,7 +70,7 @@ def _check_nfc_tag_duplicate(hass: HomeAssistant, nfc_tag_id: str, exclude_task_
"""
for entry in _get_object_entries(hass):
tasks = entry.data.get(CONF_TASKS, {})
obj_name = entry.data.get(CONF_OBJECT, {}).get(CONF_OBJECT_NAME, "")
obj_name = object_name(entry)
for tid, tdata in tasks.items():
if tid == exclude_task_id:
continue
@@ -9,19 +9,19 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from ..const import (
CONF_OBJECT,
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 (
_build_task_summary,
_get_merged_tasks,
_get_object_entries,
_get_runtime_data,
_load_object_entry,
_load_object_task,
)
@@ -79,14 +79,11 @@ async def ws_assign_user(
msg: dict[str, Any],
) -> None:
"""Assign or unassign a user to a task."""
entry = _load_object_entry(hass, connection, msg)
if entry is None:
ctx = _load_object_task(hass, connection, msg)
if ctx is None:
return
entry, _rd, _task = ctx
task_id = msg["task_id"]
if task_id not in entry.data.get(CONF_TASKS, {}):
connection.send_error(msg["id"], "not_found", "Task not found")
return
user_id = msg.get("user_id")
@@ -101,11 +98,12 @@ async def ws_assign_user(
connection.send_error(msg["id"], "invalid_user", "User not found")
return
tasks_data = entry.data.get(CONF_TASKS, {})
if task_id not in tasks_data:
# Fresh read AFTER the await (see above) — the task may have vanished.
fresh = entry.data.get(CONF_TASKS, {}).get(task_id)
if fresh is None:
connection.send_error(msg["id"], "not_found", "Task not found")
return
task = dict(tasks_data[task_id])
task = dict(fresh)
if user_id is None:
# Unassign user - remove field if it exists
task.pop("responsible_user_id", None)
@@ -114,12 +112,10 @@ async def ws_assign_user(
# Patch only this task's key onto a fresh read — never write back a map
# snapshot from before an await.
from ..helpers.entry_tasks import write_task
write_task(hass, entry, task_id, task)
# Refresh coordinator
rd = _get_runtime_data(hass, entry.entry_id)
rd = get_runtime_data(hass, entry.entry_id)
if rd and rd.coordinator:
await rd.coordinator.async_refresh_now()
@@ -150,16 +146,14 @@ async def ws_tasks_by_user(
result = []
for entry in entries:
rd = _get_runtime_data(hass, entry.entry_id)
coord_data = rd.coordinator.data if rd and rd.coordinator else None
ct_tasks = (coord_data or {}).get(CONF_TASKS, {})
ct_tasks = (get_coordinator_data(hass, entry.entry_id) or {}).get(CONF_TASKS, {})
tasks_data = _get_merged_tasks(entry)
obj_data = entry.data.get(CONF_OBJECT, {})
obj_name = object_name(entry)
for tid, tdata in tasks_data.items():
if tdata.get("responsible_user_id") == user_id:
task_summary = _build_task_summary(hass, tid, tdata, ct_tasks.get(tid))
task_summary["object_name"] = obj_data.get("name", "")
task_summary["object_name"] = obj_name
task_summary["entry_id"] = entry.entry_id
result.append(task_summary)
@@ -2,7 +2,6 @@
from __future__ import annotations
from datetime import date
from typing import Any
import voluptuous as vol
@@ -11,7 +10,6 @@ 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,
@@ -20,10 +18,13 @@ from ..const import (
DEFAULT_WARNING_DAYS,
DOMAIN,
MAX_ID_LENGTH,
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.vacation import compute_preview, get_vacation_state
from . import _get_merged_tasks, _get_object_entries, _load_global_options, _save_global_options
from . import _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 +52,7 @@ async def ws_vacation_state(
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))],
vol.Length(max=2000),
vol.Length(max=MAX_VACATION_EXEMPT_TASKS),
),
}
)
@@ -71,38 +72,21 @@ async def ws_vacation_update(
if "enabled" in msg:
options[CONF_VACATION_ENABLED] = bool(msg["enabled"])
if "start" in msg:
if msg["start"] is None:
options[CONF_VACATION_START] = None
else:
try:
date.fromisoformat(msg["start"])
except (TypeError, ValueError):
connection.send_error(msg["id"], "invalid_date", "start must be YYYY-MM-DD")
return
options[CONF_VACATION_START] = msg["start"]
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
options[key] = msg[field]
if "end" in msg:
if msg["end"] is None:
options[CONF_VACATION_END] = None
else:
try:
date.fromisoformat(msg["end"])
except (TypeError, ValueError):
connection.send_error(msg["id"], "invalid_date", "end must be YYYY-MM-DD")
return
options[CONF_VACATION_END] = msg["end"]
# End-vs-start sanity (only when both are present after the patch).
sd = options.get(CONF_VACATION_START)
ed = options.get(CONF_VACATION_END)
if sd and ed:
try:
if date.fromisoformat(ed) < date.fromisoformat(sd):
connection.send_error(msg["id"], "invalid_range", "end must be on or after start")
return
except (TypeError, ValueError):
pass # Already rejected above
# End-vs-start sanity (only when both are present after the patch; a
# stored value that does not parse is skipped — the patch above already
# refused a malformed incoming one).
sd = parse_iso_date(options.get(CONF_VACATION_START))
ed = parse_iso_date(options.get(CONF_VACATION_END))
if sd is not None and ed is not None and ed < sd:
connection.send_error(msg["id"], "invalid_range", "end must be on or after start")
return
if "buffer_days" in msg:
options[CONF_VACATION_BUFFER_DAYS] = int(msg["buffer_days"])
@@ -119,7 +103,7 @@ async def ws_vacation_update(
continue
seen.add(v)
cleaned.append(v)
if len(cleaned) >= 2000:
if len(cleaned) >= MAX_VACATION_EXEMPT_TASKS:
break
options[CONF_VACATION_EXEMPT_TASK_IDS] = cleaned
@@ -152,8 +136,7 @@ async def ws_vacation_preview(
# Build the flat task list expected by compute_preview.
tasks: list[dict[str, Any]] = []
for entry in _get_object_entries(hass):
obj_data = entry.data.get(CONF_OBJECT, {})
obj_name = obj_data.get("name", "")
obj_name = object_name(entry)
# Merge dynamic store fields (last_performed, etc.) when available.
merged = _get_merged_tasks(entry)
@@ -210,13 +193,9 @@ async def ws_vacation_end_now(
# the user actually returned. Use HA's configured timezone — the user's
# "today" should match what their dashboard shows, not the server's UTC.
today = dt_util.now().date()
sd = options.get(CONF_VACATION_START)
if sd:
try:
if date.fromisoformat(sd) <= today:
options[CONF_VACATION_END] = today.isoformat()
except (TypeError, ValueError):
pass
sd = parse_iso_date(options.get(CONF_VACATION_START))
if sd is not None and sd <= today:
options[CONF_VACATION_END] = today.isoformat()
_save_global_options(hass, global_entry, options)
connection.send_result(msg["id"], _state_payload(hass))