182 files

This commit is contained in:
Home Assistant Version Control
2026-08-17 12:11:35 +00:00
parent 7dddf6bb13
commit ece15a1c1b
183 changed files with 11457 additions and 3092 deletions
@@ -19,6 +19,7 @@ from ..const import (
DEFAULT_WARNING_DAYS,
DOMAIN,
GLOBAL_UNIQUE_ID,
task_unique_id,
)
from ..helpers.aggregate import get_object_entries, get_runtime_data
@@ -38,22 +39,10 @@ _get_runtime_data = get_runtime_data
def _get_merged_tasks(entry: ConfigEntry) -> dict[str, Any]:
"""Return merged task data (static ConfigEntry + dynamic Store) for an entry."""
tasks_data = entry.data.get(CONF_TASKS, {})
rd = getattr(entry, "runtime_data", None)
store = getattr(rd, "store", None) if rd else None
if store is not None:
merged = store.merge_all_tasks(tasks_data)
# #73: overlay the in-cycle checklist ticks HERE rather than via the
# merge whitelist — merged dicts feed MaintenanceTask.from_dict all
# over the coordinator, and this field is presentation state the model
# never needs.
for tid, td in merged.items():
progress = store.get_task_state(tid).get("checklist_progress")
if progress:
td["checklist_progress"] = progress
return merged
return tasks_data
"""Return merged task data (thin re-export of helpers.aggregate.merged_tasks)."""
from ..helpers.aggregate import merged_tasks
return merged_tasks(entry)
# How many recent history entries ride in the LIST payload. Chosen to cover
@@ -181,7 +170,7 @@ def _build_task_summary(
er.async_get(hass).async_get_entity_id(
"sensor",
"maintenance_supporter",
f"maintenance_supporter_{object_slug}_{task_id}",
task_unique_id(object_slug, task_id),
)
if object_slug
else None
@@ -190,7 +179,7 @@ def _build_task_summary(
er.async_get(hass).async_get_entity_id(
"binary_sensor",
"maintenance_supporter",
f"maintenance_supporter_{object_slug}_{task_id}_overdue",
task_unique_id(object_slug, task_id, "overdue"),
)
if object_slug
else None
@@ -412,6 +401,30 @@ def _get_global_entry(hass: HomeAssistant) -> ConfigEntry | None:
return get_global_entry(hass)
def _load_global_options(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> tuple[ConfigEntry, dict[str, Any]] | None:
"""Resolve the global entry and a mutable copy of its options, or send the
standard not-found error and return None.
The load → ``dict(options or data)`` → mutate → write-back triad was
copied across groups/vacation/dashboard/saved-views handlers, each with a
slightly different error message for the identical condition.
"""
global_entry = _get_global_entry(hass)
if global_entry is None:
connection.send_error(msg["id"], "not_found", "Global config entry not found")
return None
return global_entry, dict(global_entry.options or global_entry.data)
def _save_global_options(hass: HomeAssistant, entry: ConfigEntry, options: dict[str, Any]) -> None:
"""Write back a mutated global-options dict."""
hass.config_entries.async_update_entry(entry, options=options)
def _load_object_entry(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
@@ -556,6 +569,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
ws_export_csv,
ws_export_data,
ws_export_objects_csv,
ws_export_settings,
ws_generate_qr,
ws_get_templates,
ws_import_csv,
@@ -658,6 +672,7 @@ def async_register_commands(hass: HomeAssistant) -> None:
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)
websocket_api.async_register_command(hass, ws_export_settings)
websocket_api.async_register_command(hass, ws_get_budget_status)
websocket_api.async_register_command(hass, ws_schedule_preview)
websocket_api.async_register_command(hass, ws_export_csv)
@@ -6,6 +6,7 @@ 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
from ..const import (
@@ -21,6 +22,32 @@ from ..helpers.task_fields import INTERVAL_DAYS_RANGE
from . import _get_merged_tasks, _get_runtime_data, _load_object_entry
async def _persist_adaptive_config(
hass: HomeAssistant,
entry: ConfigEntry,
task_id: str,
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)
store.async_delay_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)
if rd and rd.coordinator:
await rd.coordinator.async_refresh_now()
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/task/analyze_interval",
@@ -176,24 +203,7 @@ async def ws_seasonal_overrides(
else:
adaptive_config.pop("seasonal_overrides", None)
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)
store.async_delay_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)
# Refresh coordinator
if rd and rd.coordinator:
await rd.coordinator.async_refresh_now()
await _persist_adaptive_config(hass, entry, task_id, adaptive_config)
connection.send_result(msg["id"], {"success": True, "overrides": validated})
@@ -246,24 +256,7 @@ async def ws_set_environmental_entity(
adaptive_config.pop("environmental_entity", None)
adaptive_config.pop("environmental_attribute", None)
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)
store.async_delay_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)
# Refresh coordinator
if rd and rd.coordinator:
await rd.coordinator.async_refresh_now()
await _persist_adaptive_config(hass, entry, task_id, adaptive_config)
connection.send_result(
msg["id"],
@@ -333,21 +326,6 @@ async def ws_set_adaptive(
base = read_legacy_fields(tasks_data[task_id])["interval_days"]
adaptive_config["base_interval"] = base if base is not None else 30
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)
store.async_delay_save()
else:
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()
await _persist_adaptive_config(hass, entry, task_id, adaptive_config)
connection.send_result(msg["id"], {"success": True, "adaptive_config": adaptive_config})
@@ -92,6 +92,8 @@ from . import (
_get_global_entry,
_get_object_entries,
_get_runtime_data,
_load_global_options,
_save_global_options,
)
_LOGGER = logging.getLogger(__name__)
@@ -551,30 +553,13 @@ async def ws_get_budget_status(
# ---------------------------------------------------------------------------
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/global/update",
vol.Required("settings"): dict,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def ws_update_global_settings(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Update global settings.
def sanitize_settings_input(settings_input: dict[str, Any]) -> tuple[dict[str, Any], str | None]:
"""Filter + validate a flat settings dict against the registry.
Accepts a flat dict of setting keys to update. Unknown keys are
silently ignored. Returns the full updated settings.
"""
global_entry = _get_global_entry(hass)
if global_entry is None:
connection.send_error(msg["id"], "not_found", "Global config entry not found")
return
settings_input: dict[str, Any] = msg["settings"]
Returns ``(filtered, notify_error)`` — the validated subset plus the
error code when ``notify_service`` was present but invalid. Shared by
``global/update`` and the settings-export import path so the two
surfaces cannot drift."""
# Filter to allowed keys and validate types
filtered: dict[str, Any] = {}
@@ -692,24 +677,56 @@ async def ws_update_global_settings(
tids.append(v)
filtered[CONF_DISABLED_TEMPLATE_IDS] = tids
if not filtered:
connection.send_error(msg["id"], "invalid_input", "No valid setting keys provided")
return
# Validate notify_service if provided
if CONF_NOTIFY_SERVICE in filtered:
from ..config_flow_options_global import validate_notify_service
normalized, error = validate_notify_service(filtered[CONF_NOTIFY_SERVICE])
if error:
connection.send_error(msg["id"], error, f"Invalid notify service: {error}")
return
return filtered, error
filtered[CONF_NOTIFY_SERVICE] = normalized
return filtered, None
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/global/update",
vol.Required("settings"): dict,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def ws_update_global_settings(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Update global settings.
Accepts a flat dict of setting keys to update. Unknown keys are
silently ignored. Returns the full updated settings.
"""
ctx = _load_global_options(hass, connection, msg)
if ctx is None:
return
global_entry, current_options = ctx
settings_input: dict[str, Any] = msg["settings"]
filtered, notify_error = sanitize_settings_input(settings_input)
if notify_error:
connection.send_error(msg["id"], notify_error, f"Invalid notify service: {notify_error}")
return
if not filtered:
connection.send_error(msg["id"], "invalid_input", "No valid setting keys provided")
return
# Merge with existing options
merged = dict(global_entry.options or global_entry.data)
merged = current_options
merged.update(filtered)
hass.config_entries.async_update_entry(global_entry, options=merged)
_save_global_options(hass, global_entry, merged)
_LOGGER.debug("Global settings updated via WS: %s", list(filtered.keys()))
@@ -751,12 +768,10 @@ async def ws_test_notification(
send_test_notification,
)
global_entry = _get_global_entry(hass)
if global_entry is None:
connection.send_error(msg["id"], "not_found", "Global config entry not found")
ctx = _load_global_options(hass, connection, msg)
if ctx is None:
return
options = dict(global_entry.options or global_entry.data)
_global_entry, options = ctx
result_key = await send_test_notification(hass, options, user_id=msg.get("user_id"))
connection.send_result(
msg["id"],
@@ -23,13 +23,12 @@ from homeassistant.core import HomeAssistant
from ..const import (
CONF_OBJECT,
DOMAIN,
GLOBAL_UNIQUE_ID,
MAX_ID_LENGTH,
MAX_NAME_LENGTH,
MAX_URL_LENGTH,
)
from ..helpers.permissions import require_write
from . import _load_object_entry, object_id_for_entry
from . import _get_object_entries, _load_object_entry, object_id_for_entry
from .tasks import _is_safe_url
if TYPE_CHECKING:
@@ -218,9 +217,7 @@ async def ws_documents_search(
# object id -> (entry_id, name), so hits carry a human-readable location.
obj_map: dict[str, tuple[str, str]] = {}
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
continue
for entry in _get_object_entries(hass):
obj = entry.data.get(CONF_OBJECT, {})
oid = obj.get("id")
if isinstance(oid, str) and oid:
@@ -17,7 +17,7 @@ from ..const import (
MAX_TEXT_LENGTH,
)
from ..helpers.permissions import require_write
from . import _get_global_entry
from . import _get_global_entry, _load_global_options, _save_global_options
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/groups"})
@@ -66,10 +66,10 @@ async def ws_create_group(
"""Create a new maintenance group."""
from ..const import CONF_GROUPS
global_entry = _get_global_entry(hass)
if global_entry is None:
connection.send_error(msg["id"], "not_found", "Global config not found")
ctx = _load_global_options(hass, connection, msg)
if ctx is None:
return
global_entry, options = ctx
name = msg["name"].strip()
if not name:
@@ -77,7 +77,6 @@ async def ws_create_group(
return
group_id = uuid4().hex
options = dict(global_entry.options or global_entry.data)
groups = dict(options.get(CONF_GROUPS, {}))
groups[group_id] = {
"name": name,
@@ -85,7 +84,7 @@ async def ws_create_group(
"task_refs": msg.get("task_refs", []),
}
options[CONF_GROUPS] = groups
hass.config_entries.async_update_entry(global_entry, options=options)
_save_global_options(hass, global_entry, options)
connection.send_result(msg["id"], {"group_id": group_id})
@@ -117,12 +116,11 @@ async def ws_update_group(
"""Update an existing maintenance group."""
from ..const import CONF_GROUPS
global_entry = _get_global_entry(hass)
if global_entry is None:
connection.send_error(msg["id"], "not_found", "Global config not found")
ctx = _load_global_options(hass, connection, msg)
if ctx is None:
return
global_entry, options = ctx
options = dict(global_entry.options or global_entry.data)
groups = dict(options.get(CONF_GROUPS, {}))
group_id = msg["group_id"]
@@ -140,7 +138,7 @@ async def ws_update_group(
groups[group_id] = group
options[CONF_GROUPS] = groups
hass.config_entries.async_update_entry(global_entry, options=options)
_save_global_options(hass, global_entry, options)
connection.send_result(msg["id"], {"success": True})
@@ -161,12 +159,11 @@ async def ws_delete_group(
"""Delete a maintenance group."""
from ..const import CONF_GROUPS
global_entry = _get_global_entry(hass)
if global_entry is None:
connection.send_error(msg["id"], "not_found", "Global config not found")
ctx = _load_global_options(hass, connection, msg)
if ctx is None:
return
global_entry, options = ctx
options = dict(global_entry.options or global_entry.data)
groups = dict(options.get(CONF_GROUPS, {}))
group_id = msg["group_id"]
@@ -176,6 +173,6 @@ async def ws_delete_group(
del groups[group_id]
options[CONF_GROUPS] = groups
hass.config_entries.async_update_entry(global_entry, options=options)
_save_global_options(hass, global_entry, options)
connection.send_result(msg["id"], {"success": True})
@@ -20,7 +20,6 @@ from ..const import (
CONF_OBJECT_NAME,
CONF_TASKS,
DOMAIN,
GLOBAL_UNIQUE_ID,
MAX_CHECKLIST_ITEM_LENGTH,
MAX_CHECKLIST_ITEMS,
MAX_ID_LENGTH,
@@ -35,6 +34,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
_LOGGER = logging.getLogger(__name__)
@@ -341,6 +341,129 @@ def _parse_structured(raw: str) -> Any:
return loaded
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/settings/export",
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def ws_export_settings(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Export the global entry's settings as JSON.
The objects export deliberately excludes the global scope (groups, saved
views, vacation, notification/budget settings, feature toggles) — this is
its second half. Import goes through the regular json/import command,
which recognizes the ``global_settings`` section.
"""
from ..export import build_settings_export
connection.send_result(
msg["id"],
{"format": "json", "data": json_mod.dumps(build_settings_export(hass), indent=2)},
)
def _apply_settings_import(hass: HomeAssistant, raw: dict[str, Any]) -> list[str]:
"""Apply an imported ``global_settings`` payload; returns the applied keys.
Scalar settings run through the SAME validation as the ``global/update``
WS command (``sanitize_settings_input``); an invalid notify_service is
dropped rather than failing the import. The structured sections reuse
their own sanitizers: saved views via ``sanitize_view``, groups shape-
checked here, vacation dates validated like ``vacation/update``. Group
task_refs and vacation exempt ids may point at objects of the SOURCE
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,
CONF_SAVED_FILTER_VIEWS,
CONF_VACATION_BUFFER_DAYS,
CONF_VACATION_ENABLED,
CONF_VACATION_END,
CONF_VACATION_EXEMPT_TASK_IDS,
CONF_VACATION_START,
MAX_GROUP_TASK_REFS,
MAX_NAME_LENGTH,
)
from ..export import _NON_PORTABLE_SETTINGS
from ..helpers.global_options import get_global_entry
from ..helpers.saved_views import MAX_SAVED_VIEWS, sanitize_view
from ..helpers.settings_registry import ALLOWED_SETTING_KEYS
from .dashboard import sanitize_settings_input
entry = get_global_entry(hass)
if entry is None or not isinstance(raw, dict):
return []
scalars = {k: v for k, v in raw.items() if k in ALLOWED_SETTING_KEYS and k not in _NON_PORTABLE_SETTINGS}
filtered, notify_error = sanitize_settings_input(scalars)
if notify_error:
filtered.pop(CONF_NOTIFY_SERVICE, None)
groups_in = raw.get(CONF_GROUPS)
if isinstance(groups_in, dict):
groups: dict[str, dict[str, Any]] = {}
for gid, g in groups_in.items():
if not isinstance(g, dict) or not str(g.get("name") or "").strip():
continue
refs = [
{"entry_id": str(r["entry_id"]), "task_id": str(r["task_id"])}
for r in (g.get("task_refs") or [])
if isinstance(r, dict) and r.get("entry_id") and r.get("task_id")
][:MAX_GROUP_TASK_REFS]
groups[str(gid)] = {
"name": str(g["name"]).strip()[:MAX_NAME_LENGTH],
"description": str(g.get("description") or "")[:MAX_NAME_LENGTH],
"task_refs": refs,
}
if groups:
filtered[CONF_GROUPS] = groups
views_in = raw.get(CONF_SAVED_FILTER_VIEWS)
if isinstance(views_in, list):
views = []
for v in views_in[:MAX_SAVED_VIEWS]:
clean = sanitize_view(v, view_id=str(v.get("id")) if isinstance(v, dict) and v.get("id") else None)
if clean is not None:
views.append(clean)
if views:
filtered[CONF_SAVED_FILTER_VIEWS] = views
if isinstance(raw.get(CONF_VACATION_ENABLED), bool):
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
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]
filtered[CONF_VACATION_EXEMPT_TASK_IDS] = cleaned
if not filtered:
return []
merged = dict(entry.options or entry.data)
merged.update(filtered)
hass.config_entries.async_update_entry(entry, options=merged)
_LOGGER.info("Settings import applied %d key(s)", len(filtered))
return sorted(filtered)
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/json/import",
@@ -366,11 +489,18 @@ async def ws_import_json(
connection.send_error(msg["id"], "invalid_format", "Content is not valid JSON or YAML")
return
if not isinstance(data, dict) or "objects" not in data:
has_settings = isinstance(data, dict) and isinstance(data.get("global_settings"), dict)
if not isinstance(data, dict) or ("objects" not in data and not has_settings):
connection.send_error(msg["id"], "invalid_format", "JSON must contain an 'objects' array")
return
objects = data["objects"]
# A settings export (see export.build_settings_export) may travel alone or
# alongside an objects payload — apply it first either way.
settings_applied: list[str] = []
if has_settings:
settings_applied = _apply_settings_import(hass, data["global_settings"])
objects = data.get("objects", [])
if not isinstance(objects, list):
connection.send_error(msg["id"], "invalid_format", "'objects' must be an array")
return
@@ -379,7 +509,7 @@ async def ws_import_json(
connection.send_error(msg["id"], "too_many", "JSON contains more than 1000 objects")
return
if not objects:
if not objects and not settings_applied:
connection.send_error(msg["id"], "empty", "No objects found in JSON")
return
@@ -536,6 +666,18 @@ async def ws_import_json(
if val is not None:
task_data[key] = val
# In-cycle checklist ticks: keyed by item TEXT so they survive the
# id regeneration; keys are filtered against the imported checklist
# exactly like the live checklist_progress WS write. Rides
# entry.data until the fresh entry's first setup migrates it into
# the Store (split-only field — storage._SPLIT_ONLY_TASK_FIELDS).
raw_progress = task_entry.get("checklist_progress")
if isinstance(raw_progress, dict):
items = set(task_data.get("checklist") or [])
progress = {k: bool(v) for k, v in raw_progress.items() if isinstance(k, str) and k in items}
if progress:
task_data["checklist_progress"] = progress
# #130: history entries carry used_parts, and since they are
# editable (stock reconciled by delta), the part ids must follow
# the regenerated ones. Own-part ids remap via part_id_map; links
@@ -702,6 +844,8 @@ async def ws_import_json(
"total": len(objects),
"created": len(created),
}
if settings_applied:
resp["settings_applied"] = settings_applied
if errors:
resp["errors"] = errors
connection.send_result(msg["id"], resp)
@@ -724,10 +868,8 @@ async def ws_generate_qr(
msg: dict[str, Any],
) -> None:
"""Generate a QR code for a maintenance object or task."""
entry_id = msg["entry_id"]
entry = hass.config_entries.async_get_entry(entry_id)
if entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_ID:
connection.send_error(msg["id"], "not_found", "Object not found")
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
obj_data = entry.data.get(CONF_OBJECT, {})
@@ -747,7 +889,7 @@ async def ws_generate_qr(
try:
url = build_qr_url(
hass,
entry_id,
entry.entry_id,
task_id=task_id,
action=action,
base_url_override=base_url,
@@ -828,7 +970,7 @@ async def ws_batch_generate_qr(
filters mean "all" at that level.
"""
# Resolve target entries (always exclude the global config entry).
all_entries = [entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.unique_id != GLOBAL_UNIQUE_ID]
all_entries = _get_object_entries(hass)
entry_filter = msg.get("entry_ids")
if entry_filter:
wanted = set(entry_filter)
@@ -35,7 +35,7 @@ from ..const import (
)
from ..helpers.pause import reanchor_recurring_task
from ..helpers.permissions import require_write
from ..helpers.sanitize import cap_object_fields
from ..helpers.sanitize import cap_object_fields, strip_task_runtime_state
from . import (
_build_object_response,
_get_object_entries,
@@ -169,13 +169,11 @@ async def ws_get_object(
msg: dict[str, Any],
) -> None:
"""Return a single object with full task details including history."""
entry_id = msg["entry_id"]
entry = hass.config_entries.async_get_entry(entry_id)
if entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_ID:
connection.send_error(msg["id"], "not_found", "Object not found")
entry = _load_object_entry(hass, connection, msg)
if entry is None:
return
rd = _get_runtime_data(hass, entry_id)
rd = _get_runtime_data(hass, entry.entry_id)
coord_data = rd.coordinator.data if rd and rd.coordinator else None
connection.send_result(msg["id"], _build_object_response(hass, entry, coord_data))
@@ -499,19 +497,7 @@ async def ws_duplicate_object(
task_id = uuid4().hex
task["id"] = task_id
task["object_id"] = new_obj["id"]
for key in (
"entity_slug",
"nfc_tag_id",
"history",
"last_performed",
"last_planned_due",
"adaptive_config",
"archived_at",
"archived_reason",
):
task.pop(key, None)
if isinstance(task.get("trigger_config"), dict):
task["trigger_config"].pop("_trigger_state", None)
strip_task_runtime_state(task)
new_tasks[task_id] = task
new_obj["task_ids"].append(task_id)
@@ -568,8 +554,7 @@ async def ws_create_from_template(
# flow's duplicate detection (object name, case-insensitive).
existing_names = {
str(e.data.get(CONF_OBJECT, {}).get(CONF_OBJECT_NAME, "")).strip().lower()
for e in hass.config_entries.async_entries(DOMAIN)
if e.unique_id != GLOBAL_UNIQUE_ID
for e in _get_object_entries(hass)
}
if name.strip().lower() in existing_names:
base = name[: MAX_NAME_LENGTH - 4]
@@ -912,19 +897,7 @@ async def ws_replace_object(
task_id = uuid4().hex
task["id"] = task_id
task["object_id"] = new_obj["id"]
for key in (
"entity_slug",
"nfc_tag_id",
"history",
"last_performed",
"last_planned_due",
"adaptive_config",
"archived_at",
"archived_reason",
):
task.pop(key, None)
if isinstance(task.get("trigger_config"), dict):
task["trigger_config"].pop("_trigger_state", None)
strip_task_runtime_state(task)
links = task.get("consumes_parts")
if isinstance(links, list):
remapped = []
@@ -9,6 +9,7 @@ from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from ..const import CONF_PARTS, MAX_ID_LENGTH
from ..helpers.aggregate import object_name
from ..helpers.parts import (
MAX_PART_STOCK,
MAX_PARTS_PER_OBJECT,
@@ -266,12 +267,11 @@ async def ws_parts_overview(
every consuming task — the object's own tasks and pooled #111 links from
other objects. Read-only; the per-object CRUD stays on part/*.
"""
from ..const import CONF_OBJECT, DOMAIN, GLOBAL_UNIQUE_ID
from ..helpers.parts import part_is_low
from . import _get_merged_tasks
from . import _get_merged_tasks, _get_object_entries
entries = [e for e in hass.config_entries.async_entries(DOMAIN) if e.unique_id != GLOBAL_UNIQUE_ID]
names = {e.entry_id: (e.data.get(CONF_OBJECT) or {}).get("name") or e.title for e in entries}
entries = _get_object_entries(hass)
names = {e.entry_id: object_name(e) for e in entries}
# (owner_entry_id, part_id) -> consuming task links, own AND pooled.
consumers: dict[tuple[str, str], list[dict[str, Any]]] = {}
@@ -145,7 +145,16 @@ async def ws_adopt_problem_sensors(
[{"part_id": sel["part_id"], "quantity": 1}] if sel.get("part_id") else stashed.get("consumes_parts") or []
)
if raw_links:
links = sanitize_consumes_parts(raw_links, set(entry.data.get(CONF_PARTS) or {}))
# foreign_part_ids keeps pooled #111 cross-object links — the
# CRUD paths pass it, this copy had forgotten it (a re-adopted
# task silently lost its pooled part link).
from . import foreign_part_resolver
links = sanitize_consumes_parts(
raw_links,
set(entry.data.get(CONF_PARTS) or {}),
foreign_part_ids=foreign_part_resolver(hass),
)
if links:
task_data["consumes_parts"] = links
await async_persist_task(hass, entry, task_data)
@@ -73,7 +73,7 @@ async def ws_save_saved_view(
connection.send_error(msg["id"], "too_many_views", "The saved-views limit has been reached")
return
except LookupError:
connection.send_error(msg["id"], "not_found", "Global entry not found")
connection.send_error(msg["id"], "not_found", "Global config entry not found")
return
connection.send_result(msg["id"], {"views": views, "saved_id": saved_id})
@@ -96,6 +96,6 @@ async def ws_delete_saved_view(
try:
_persist(hass, views)
except LookupError:
connection.send_error(msg["id"], "not_found", "Global entry not found")
connection.send_error(msg["id"], "not_found", "Global config entry not found")
return
connection.send_result(msg["id"], {"views": views})
@@ -148,12 +148,13 @@ async def ws_complete_task(
# automatic consumes_parts path is untouched.
used_parts = msg.get("used_parts")
if used_parts is not None:
from ..const import CONF_PARTS
from ..helpers.parts import sanitize_consumes_parts
from . import foreign_part_resolver
used_parts = sanitize_consumes_parts(
used_parts,
set(_entry.data.get("parts") or {}),
set(_entry.data.get(CONF_PARTS) or {}),
foreign_part_ids=foreign_part_resolver(hass),
)
@@ -420,6 +421,11 @@ async def ws_checklist_progress(
rd, entry = ctx
task_items = set(entry.data[CONF_TASKS][msg["task_id"]].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.
if rd.store is None:
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.
@@ -22,7 +22,6 @@ from ..const import (
DEFAULT_WARNING_DAYS,
DOMAIN,
FLAT_SCHEDULE_TYPES,
GLOBAL_UNIQUE_ID,
MAX_ASSIGNEE_POOL,
MAX_CHECKLIST_ITEM_LENGTH,
MAX_CHECKLIST_ITEMS,
@@ -42,6 +41,7 @@ from ..const import (
)
from ..helpers.dates import INTERVAL_UNITS
from ..helpers.permissions import require_write
from ..helpers.sanitize import strip_task_runtime_state
from ..helpers.schedule import (
FLAT_RECURRENCE_KEYS,
KIND_INTERVAL,
@@ -719,10 +719,10 @@ async def async_delete_task(
# persistent and task-id keyed — without this, deleted ids accumulate
# there forever and confuse the vacation preview UI.
from ..const import CONF_VACATION_EXEMPT_TASK_IDS
from ..helpers.global_options import get_global_entry
for ge in hass.config_entries.async_entries(DOMAIN):
if ge.unique_id != GLOBAL_UNIQUE_ID:
continue
ge = get_global_entry(hass)
if ge is not None:
exempt = ge.options.get(CONF_VACATION_EXEMPT_TASK_IDS) or []
if isinstance(exempt, list) and task_id in exempt:
hass.config_entries.async_update_entry(
@@ -732,7 +732,6 @@ async def async_delete_task(
CONF_VACATION_EXEMPT_TASK_IDS: [t for t in exempt if t != task_id],
},
)
break
# Clean up any repair issues referencing this task
if old_trigger_config:
@@ -786,10 +785,7 @@ async def ws_duplicate_task(
new_task["name"] = f"{base_name} (copy)"[:MAX_NAME_LENGTH]
new_task["created_at"] = dt_util.now().date().isoformat()
# Never carry over per-task-unique keys or any stray dynamic state.
for key in ("entity_slug", "nfc_tag_id", "history", "last_performed", "last_planned_due", "adaptive_config"):
new_task.pop(key, None)
if isinstance(new_task.get("trigger_config"), dict):
new_task["trigger_config"].pop("_trigger_state", None)
strip_task_runtime_state(new_task)
await async_persist_task(hass, entry, new_task)
@@ -156,11 +156,9 @@ async def ws_unarchive_task(
# so an imported due_override can't out-rank it in merge_task_data.
clear_cycle_modifiers(td)
new_data = dict(entry.data)
new_tasks = dict(new_data.get(CONF_TASKS, {}))
new_tasks[task_id] = td
new_data[CONF_TASKS] = new_tasks
hass.config_entries.async_update_entry(entry, data=new_data)
from ..helpers.entry_tasks import write_task
write_task(hass, entry, task_id, td)
await hass.config_entries.async_reload(entry.entry_id)
@@ -108,11 +108,9 @@ 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.
new_data = dict(entry.data)
new_tasks = dict(new_data.get(CONF_TASKS, {}))
new_tasks[task_id] = task
new_data[CONF_TASKS] = new_tasks
hass.config_entries.async_update_entry(entry, data=new_data)
from ..helpers.entry_tasks import write_task
write_task(hass, entry, task_id, task)
# Refresh coordinator
rd = _get_runtime_data(hass, entry.entry_id)
@@ -12,7 +12,6 @@ from homeassistant.util import dt as dt_util
from ..const import (
CONF_OBJECT,
CONF_TASKS,
CONF_VACATION_BUFFER_DAYS,
CONF_VACATION_ENABLED,
CONF_VACATION_END,
@@ -24,7 +23,7 @@ from ..const import (
)
from ..helpers.schedule import read_legacy_fields
from ..helpers.vacation import compute_preview, get_vacation_state
from . import _get_global_entry, _get_object_entries
from . import _get_merged_tasks, _get_object_entries, _load_global_options, _save_global_options
def _state_payload(hass: HomeAssistant) -> dict[str, Any]:
@@ -64,12 +63,10 @@ async def ws_vacation_update(
msg: dict[str, Any],
) -> None:
"""Patch vacation config on the global entry. Partial updates allowed."""
global_entry = _get_global_entry(hass)
if global_entry is None:
connection.send_error(msg["id"], "not_found", "Global entry not found")
ctx = _load_global_options(hass, connection, msg)
if ctx is None:
return
options = dict(global_entry.options or global_entry.data)
global_entry, options = ctx
if "enabled" in msg:
options[CONF_VACATION_ENABLED] = bool(msg["enabled"])
@@ -126,7 +123,7 @@ async def ws_vacation_update(
break
options[CONF_VACATION_EXEMPT_TASK_IDS] = cleaned
hass.config_entries.async_update_entry(global_entry, options=options)
_save_global_options(hass, global_entry, options)
connection.send_result(msg["id"], _state_payload(hass))
@@ -157,12 +154,8 @@ async def ws_vacation_preview(
for entry in _get_object_entries(hass):
obj_data = entry.data.get(CONF_OBJECT, {})
obj_name = obj_data.get("name", "")
tasks_data = entry.data.get(CONF_TASKS, {})
# Merge dynamic store fields (last_performed, etc.) when available.
rd = getattr(entry, "runtime_data", None)
store = getattr(rd, "store", None) if rd else None
merged: dict[str, dict[str, Any]] = store.merge_all_tasks(tasks_data) if store is not None else dict(tasks_data)
merged = _get_merged_tasks(entry)
for task_id, task_data in merged.items():
sched = read_legacy_fields(task_data)
@@ -203,12 +196,11 @@ async def ws_vacation_end_now(
msg: dict[str, Any],
) -> None:
"""Disable vacation mode immediately, preserve the date config for reuse."""
global_entry = _get_global_entry(hass)
if global_entry is None:
connection.send_error(msg["id"], "not_found", "Global entry not found")
ctx = _load_global_options(hass, connection, msg)
if ctx is None:
return
global_entry, options = ctx
options = dict(global_entry.options or global_entry.data)
options[CONF_VACATION_ENABLED] = False
# Optionally clamp end-date to today so the historical record reflects when
# the user actually returned. Use HA's configured timezone — the user's
@@ -222,7 +214,7 @@ async def ws_vacation_end_now(
except (TypeError, ValueError):
pass
hass.config_entries.async_update_entry(global_entry, options=options)
_save_global_options(hass, global_entry, options)
connection.send_result(msg["id"], _state_payload(hass))