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