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
@@ -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)