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