New apps Added
This commit is contained in:
@@ -0,0 +1,690 @@
|
||||
"""WebSocket handlers for export, import, CSV, QR, and templates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json as json_mod
|
||||
import logging
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from ..const import (
|
||||
CONF_OBJECT,
|
||||
CONF_OBJECT_MANUFACTURER,
|
||||
CONF_OBJECT_MODEL,
|
||||
CONF_OBJECT_NAME,
|
||||
CONF_TASKS,
|
||||
DOMAIN,
|
||||
GLOBAL_UNIQUE_ID,
|
||||
MAX_CHECKLIST_ITEM_LENGTH,
|
||||
MAX_CHECKLIST_ITEMS,
|
||||
MAX_ID_LENGTH,
|
||||
)
|
||||
from ..helpers.global_options import get_default_warning_days
|
||||
from ..helpers.qr_generator import (
|
||||
_ACTION_ICON_MAP,
|
||||
build_qr_url,
|
||||
generate_qr_svg,
|
||||
generate_qr_svg_data_uri,
|
||||
)
|
||||
from ..websocket.tasks import _check_nfc_tag_duplicate, _validate_trigger_config
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/templates",
|
||||
# v2.21.1: the caller's UI language — template/task names arrive
|
||||
# localized. Falls back to the server language.
|
||||
vol.Optional("language"): vol.All(str, vol.Length(max=10)),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_get_templates(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return all maintenance templates.
|
||||
|
||||
Every template is returned with a ``disabled`` flag (v2.21 gallery
|
||||
curation): the pickers hide disabled ones client-side, while the Settings
|
||||
section needs the full list to render the toggles.
|
||||
"""
|
||||
from ..helpers.i18n import normalize_language
|
||||
from ..templates import (
|
||||
TEMPLATE_CATEGORIES,
|
||||
TEMPLATES,
|
||||
get_disabled_template_ids,
|
||||
localize_template_text,
|
||||
)
|
||||
|
||||
disabled = get_disabled_template_ids(hass)
|
||||
lang = (msg.get("language") or normalize_language(hass))[:2].lower()
|
||||
|
||||
result = {
|
||||
"categories": {cat_id: {k: v for k, v in cat.items()} for cat_id, cat in TEMPLATE_CATEGORIES.items()},
|
||||
"templates": [
|
||||
{
|
||||
"id": t.id,
|
||||
"name": localize_template_text(t.name, lang),
|
||||
"category": t.category,
|
||||
"disabled": t.id in disabled,
|
||||
"tasks": [
|
||||
{
|
||||
"name": localize_template_text(tt.name, lang),
|
||||
"type": tt.type,
|
||||
"schedule_type": tt.schedule_type,
|
||||
"interval_days": tt.interval_days,
|
||||
"warning_days": tt.warning_days,
|
||||
}
|
||||
for tt in t.tasks
|
||||
],
|
||||
}
|
||||
for t in TEMPLATES
|
||||
],
|
||||
}
|
||||
connection.send_result(msg["id"], result)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/export",
|
||||
vol.Optional("format", default="json"): vol.In(["json", "yaml"]),
|
||||
vol.Optional("include_history", default=True): bool,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def ws_export_data(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Export all maintenance data as JSON or YAML."""
|
||||
from ..export import build_export_data, serialize_export
|
||||
|
||||
fmt = msg.get("format", "json")
|
||||
include_history = msg.get("include_history", True)
|
||||
|
||||
# Phase 1: gather data on the event loop (accesses HA APIs)
|
||||
data = build_export_data(hass, include_history=include_history)
|
||||
|
||||
# Phase 2: serialize in executor (CPU-bound, no HA API calls)
|
||||
result = await hass.async_add_executor_job(serialize_export, data, fmt)
|
||||
|
||||
connection.send_result(msg["id"], {"format": fmt, "data": result})
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/csv/export"})
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def ws_export_csv(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Export all maintenance data as CSV."""
|
||||
from ..helpers.csv_handler import export_objects_csv
|
||||
|
||||
csv_data = export_objects_csv(hass)
|
||||
connection.send_result(msg["id"], {"csv": csv_data})
|
||||
|
||||
|
||||
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/objects/csv"})
|
||||
@websocket_api.async_response
|
||||
async def ws_export_objects_csv(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Export one row per maintenance object as CSV (#67).
|
||||
|
||||
Not admin-gated: it exposes only the asset fields the panel already sends
|
||||
to every user via ``maintenance_supporter/objects`` (no cost/history).
|
||||
"""
|
||||
from ..helpers.csv_handler import export_object_records_csv
|
||||
|
||||
csv_data = export_object_records_csv(hass)
|
||||
connection.send_result(msg["id"], {"csv": csv_data})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/csv/import",
|
||||
vol.Required("csv_content"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def ws_import_csv(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Import maintenance objects from CSV content."""
|
||||
from ..helpers.csv_handler import import_objects_csv
|
||||
|
||||
csv_content = msg["csv_content"]
|
||||
# Guard against oversized payloads (max 1MB / 1000 objects)
|
||||
if len(csv_content) > 1_048_576:
|
||||
connection.send_error(msg["id"], "too_large", "CSV content exceeds 1MB limit")
|
||||
return
|
||||
|
||||
objects = import_objects_csv(csv_content, hass=hass)
|
||||
if len(objects) > 1000:
|
||||
connection.send_error(msg["id"], "too_many", "CSV contains more than 1000 objects")
|
||||
return
|
||||
|
||||
if not objects:
|
||||
connection.send_error(msg["id"], "empty_csv", "No valid objects found in CSV")
|
||||
return
|
||||
|
||||
created = []
|
||||
errors: list[dict[str, str]] = []
|
||||
for idx, obj_data in enumerate(objects):
|
||||
# Check for NFC tag duplicates in CSV-imported tasks
|
||||
nfc_warnings: list[str] = []
|
||||
for t_data in obj_data.get("tasks", {}).values():
|
||||
nfc_val = t_data.get("nfc_tag_id")
|
||||
if nfc_val:
|
||||
nfc_warn = _check_nfc_tag_duplicate(hass, nfc_val)
|
||||
if nfc_warn:
|
||||
nfc_warnings.append(nfc_warn)
|
||||
|
||||
try:
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": "websocket"},
|
||||
data={
|
||||
CONF_OBJECT: obj_data["object"],
|
||||
CONF_TASKS: obj_data["tasks"],
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
obj_name = obj_data.get("object", {}).get("name", f"row {idx + 1}")
|
||||
_LOGGER.exception("CSV import failed for %s", obj_name)
|
||||
errors.append({"name": obj_name, "reason": "unexpected error"})
|
||||
continue
|
||||
if result["type"] == "create_entry":
|
||||
entry_info: dict[str, Any] = {
|
||||
"entry_id": result["result"].entry_id,
|
||||
"name": obj_data["object"].get("name", ""),
|
||||
"task_count": len(obj_data["tasks"]),
|
||||
}
|
||||
if nfc_warnings:
|
||||
entry_info["warnings"] = nfc_warnings
|
||||
created.append(entry_info)
|
||||
else:
|
||||
obj_name = obj_data.get("object", {}).get("name", f"row {idx + 1}")
|
||||
errors.append({"name": obj_name, "reason": result.get("reason", "unknown")})
|
||||
|
||||
resp: dict[str, Any] = {
|
||||
"imported": created,
|
||||
"total": len(objects),
|
||||
"created": len(created),
|
||||
}
|
||||
if errors:
|
||||
resp["errors"] = errors
|
||||
connection.send_result(msg["id"], resp)
|
||||
|
||||
|
||||
def _parse_structured(raw: str) -> Any:
|
||||
"""Parse JSON *or* YAML export content into a Python object.
|
||||
|
||||
Both formats are accepted so every structured export (JSON and YAML)
|
||||
round-trips back through the importer. Raises ValueError if the content
|
||||
parses to neither a mapping nor a list.
|
||||
"""
|
||||
try:
|
||||
return json_mod.loads(raw)
|
||||
except (json_mod.JSONDecodeError, ValueError):
|
||||
pass
|
||||
import yaml # type: ignore[import-untyped]
|
||||
|
||||
try:
|
||||
loaded = yaml.safe_load(raw)
|
||||
except yaml.YAMLError as err:
|
||||
raise ValueError("not valid JSON or YAML") from err
|
||||
# safe_load returns a bare string/scalar for non-structured text (e.g. a
|
||||
# CSV blob) — require an object/array so those route elsewhere cleanly.
|
||||
if not isinstance(loaded, (dict, list)):
|
||||
raise ValueError("not valid JSON or YAML")
|
||||
return loaded
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/json/import",
|
||||
vol.Required("json_content"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.async_response
|
||||
async def ws_import_json(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Import maintenance objects from JSON or YAML content (from /export)."""
|
||||
raw = msg["json_content"]
|
||||
if len(raw) > 10_485_760:
|
||||
connection.send_error(msg["id"], "too_large", "Content exceeds 10MB limit")
|
||||
return
|
||||
|
||||
try:
|
||||
data = _parse_structured(raw)
|
||||
except ValueError:
|
||||
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:
|
||||
connection.send_error(msg["id"], "invalid_format", "JSON must contain an 'objects' array")
|
||||
return
|
||||
|
||||
objects = data["objects"]
|
||||
if not isinstance(objects, list):
|
||||
connection.send_error(msg["id"], "invalid_format", "'objects' must be an array")
|
||||
return
|
||||
|
||||
if len(objects) > 1000:
|
||||
connection.send_error(msg["id"], "too_many", "JSON contains more than 1000 objects")
|
||||
return
|
||||
|
||||
if not objects:
|
||||
connection.send_error(msg["id"], "empty", "No objects found in JSON")
|
||||
return
|
||||
|
||||
created = []
|
||||
errors: list[dict[str, str]] = []
|
||||
for idx, obj_entry in enumerate(objects):
|
||||
# Guard against malformed-but-schema-valid input (the schema only checks
|
||||
# json_content is a str): a non-dict entry / non-dict object would raise
|
||||
# AttributeError and escape the per-object try/except below.
|
||||
if not isinstance(obj_entry, dict):
|
||||
errors.append({"name": f"object {idx + 1}", "reason": "not an object"})
|
||||
continue
|
||||
obj_data = obj_entry.get("object", {})
|
||||
if not isinstance(obj_data, dict):
|
||||
errors.append({"name": f"object {idx + 1}", "reason": "invalid object data"})
|
||||
continue
|
||||
obj_name = (obj_data.get("name") or "").strip()
|
||||
if not obj_name:
|
||||
errors.append({"name": f"object {idx + 1}", "reason": "missing name"})
|
||||
continue
|
||||
|
||||
obj_id = uuid4().hex
|
||||
import_obj: dict[str, Any] = {
|
||||
"id": obj_id,
|
||||
"name": obj_name,
|
||||
"manufacturer": obj_data.get("manufacturer"),
|
||||
"model": obj_data.get("model"),
|
||||
"serial_number": obj_data.get("serial_number"),
|
||||
"area_id": obj_data.get("area_id"),
|
||||
"installation_date": obj_data.get("installation_date"),
|
||||
"warranty_expiry": obj_data.get("warranty_expiry"),
|
||||
# Imported counterparts of the export fields above; length-capped by
|
||||
# cap_object_fields and the frontend only renders http(s) doc URLs.
|
||||
"documentation_url": obj_data.get("documentation_url"),
|
||||
"notes": obj_data.get("notes"),
|
||||
# 2.19: device link / parent hierarchy — same-instance restores
|
||||
# keep them valid; stale ids degrade gracefully at read time.
|
||||
"ha_device_id": obj_data.get("ha_device_id"),
|
||||
"parent_entry_id": obj_data.get("parent_entry_id"),
|
||||
# 2.20: seasonal pause round-trips (a paused pool restored in
|
||||
# winter stays paused); replace-flow lineage ids are the same
|
||||
# instance-specific story as parent_entry_id above.
|
||||
"paused_at": obj_data.get("paused_at"),
|
||||
"paused_until": obj_data.get("paused_until"),
|
||||
"predecessor_entry_id": obj_data.get("predecessor_entry_id"),
|
||||
"replaced_by_entry_id": obj_data.get("replaced_by_entry_id"),
|
||||
"task_ids": [],
|
||||
}
|
||||
|
||||
import_tasks: dict[str, dict[str, Any]] = {}
|
||||
tasks_list = obj_entry.get("tasks", [])
|
||||
if not isinstance(tasks_list, list):
|
||||
tasks_list = []
|
||||
for task_entry in tasks_list:
|
||||
if not isinstance(task_entry, dict):
|
||||
continue
|
||||
task_name = (task_entry.get("name") or "").strip()
|
||||
if not task_name:
|
||||
continue
|
||||
task_id = uuid4().hex
|
||||
task_data: dict[str, Any] = {
|
||||
"id": task_id,
|
||||
"object_id": obj_id,
|
||||
"name": task_name,
|
||||
"type": task_entry.get("type", "custom"),
|
||||
"enabled": task_entry.get("enabled", True),
|
||||
"schedule_type": task_entry.get("schedule_type", "time_based"),
|
||||
"warning_days": task_entry.get("warning_days", get_default_warning_days(hass)),
|
||||
"history": task_entry.get("history", []),
|
||||
}
|
||||
for key in (
|
||||
"interval_days",
|
||||
"interval_unit",
|
||||
"due_date",
|
||||
"interval_anchor",
|
||||
"last_planned_due",
|
||||
# nested recurrence (calendar kinds) — config-flow normalize
|
||||
# treats it as authoritative when present.
|
||||
"schedule",
|
||||
"last_performed",
|
||||
"notes",
|
||||
"documentation_url",
|
||||
"custom_icon",
|
||||
"nfc_tag_id",
|
||||
"responsible_user_id",
|
||||
"entity_slug",
|
||||
"trigger_config",
|
||||
"adaptive_config",
|
||||
"checklist",
|
||||
"schedule_time",
|
||||
):
|
||||
val = task_entry.get(key)
|
||||
if val is not None:
|
||||
task_data[key] = val
|
||||
|
||||
# Sanitize critical fields from import data
|
||||
iv = task_data.get("interval_days")
|
||||
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)
|
||||
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)
|
||||
# Sanitize checklist: only keep string items within length budget,
|
||||
# cap total items. Drops malformed entries silently rather than
|
||||
# rejecting the whole import — same forgiving model as the other
|
||||
# fields above.
|
||||
cl = task_data.get("checklist")
|
||||
if cl is not None:
|
||||
if not isinstance(cl, list):
|
||||
task_data.pop("checklist", None)
|
||||
else:
|
||||
cleaned = [item.strip() for item in cl if isinstance(item, str) and len(item) <= MAX_CHECKLIST_ITEM_LENGTH]
|
||||
cleaned = [c for c in cleaned if c]
|
||||
task_data["checklist"] = cleaned[:MAX_CHECKLIST_ITEMS]
|
||||
|
||||
# schedule_time: strict HH:MM, otherwise drop
|
||||
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):
|
||||
task_data.pop("schedule_time", None)
|
||||
|
||||
# Validate an imported trigger_config the same way the WS create/update
|
||||
# path does — strip unknown keys, normalize entity_ids, and drop it
|
||||
# entirely if invalid — so import isn't a hole around trigger validation.
|
||||
tc = task_data.get("trigger_config")
|
||||
if isinstance(tc, dict):
|
||||
errors, _warnings = _validate_trigger_config(hass, tc)
|
||||
if errors:
|
||||
task_data.pop("trigger_config", None)
|
||||
elif tc is not None:
|
||||
task_data.pop("trigger_config", None)
|
||||
|
||||
import_tasks[task_id] = task_data
|
||||
import_obj["task_ids"].append(task_id)
|
||||
|
||||
# Check for NFC tag duplicates across imported tasks
|
||||
nfc_warnings: list[str] = []
|
||||
for t_data in import_tasks.values():
|
||||
nfc_val = t_data.get("nfc_tag_id")
|
||||
if nfc_val:
|
||||
nfc_warn = _check_nfc_tag_duplicate(hass, nfc_val)
|
||||
if nfc_warn:
|
||||
nfc_warnings.append(nfc_warn)
|
||||
|
||||
try:
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": "websocket"},
|
||||
data={
|
||||
CONF_OBJECT: import_obj,
|
||||
CONF_TASKS: import_tasks,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.exception("JSON import failed for %s", obj_name)
|
||||
errors.append({"name": obj_name, "reason": "unexpected error"})
|
||||
continue
|
||||
if result["type"] == "create_entry":
|
||||
entry_info: dict[str, Any] = {
|
||||
"entry_id": result["result"].entry_id,
|
||||
"name": obj_name,
|
||||
"task_count": len(import_tasks),
|
||||
}
|
||||
if nfc_warnings:
|
||||
entry_info["warnings"] = nfc_warnings
|
||||
created.append(entry_info)
|
||||
|
||||
# (roadmap P6) recreate document metadata + web-links for the object
|
||||
# (blobs travel via the /config backup; a JSON-only import leaves
|
||||
# file docs dangling, which the storage-hygiene repair issue catches).
|
||||
import_docs = obj_entry.get("documents")
|
||||
if isinstance(import_docs, list) and import_docs:
|
||||
from .. import DOCUMENT_STORE_KEY
|
||||
|
||||
doc_store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
|
||||
if doc_store is not None:
|
||||
await doc_store.async_import_documents(obj_id, import_docs)
|
||||
else:
|
||||
errors.append({"name": obj_name, "reason": result.get("reason", "unknown")})
|
||||
|
||||
resp: dict[str, Any] = {
|
||||
"imported": created,
|
||||
"total": len(objects),
|
||||
"created": len(created),
|
||||
}
|
||||
if errors:
|
||||
resp["errors"] = errors
|
||||
connection.send_result(msg["id"], resp)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/qr/generate",
|
||||
vol.Required("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Optional("task_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
|
||||
vol.Optional("action", default="view"): vol.In(["view", "complete", "quick_complete"]),
|
||||
vol.Optional("url_mode", default="server"): vol.In(["server", "local", "companion"]),
|
||||
vol.Optional("base_url"): vol.Url(),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_generate_qr(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
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")
|
||||
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")
|
||||
return
|
||||
task_name = tasks_data[task_id].get("name", "")
|
||||
|
||||
action = msg.get("action", "view")
|
||||
url_mode = msg.get("url_mode", "server")
|
||||
base_url = msg.get("base_url")
|
||||
try:
|
||||
url = build_qr_url(
|
||||
hass,
|
||||
entry_id,
|
||||
task_id=task_id,
|
||||
action=action,
|
||||
base_url_override=base_url,
|
||||
url_mode=url_mode,
|
||||
)
|
||||
except ValueError as err:
|
||||
connection.send_error(msg["id"], "no_url", str(err))
|
||||
return
|
||||
from functools import partial
|
||||
|
||||
icon = _ACTION_ICON_MAP.get(action)
|
||||
gen_fn = partial(generate_qr_svg_data_uri, url, border=2, icon=icon)
|
||||
svg_data_uri = await hass.async_add_executor_job(gen_fn)
|
||||
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{
|
||||
"svg_data_uri": svg_data_uri,
|
||||
"url": url,
|
||||
"label": {
|
||||
"object_name": obj_data.get(CONF_OBJECT_NAME, ""),
|
||||
"manufacturer": obj_data.get(CONF_OBJECT_MANUFACTURER, ""),
|
||||
"model": obj_data.get(CONF_OBJECT_MODEL, ""),
|
||||
"task_name": task_name,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Batch QR generation — used by the "Print QR codes" panel section.
|
||||
#
|
||||
# Typical household: 20-30 tasks × 2 actions = 40-60 QRs. Benchmarked at
|
||||
# ~40 ms each with icon embed (HIGH ECC) → 2.5 s for 60, 7 s for 200.
|
||||
# The raw SVG is ~32 KB each, so 200 × 32 KB = ~6 MB over the websocket;
|
||||
# we cap at 200 to keep the payload bounded and the print layout sane
|
||||
# (generous 6 QRs/A4 page = 34 pages).
|
||||
_MAX_BATCH_QRS = 200
|
||||
|
||||
|
||||
# LRU cache keyed on (url, icon). Two users printing the same task twice
|
||||
# in a session hit this cache; so does re-running the batch after
|
||||
# narrowing the filter. Bounded size so long-running HA instances with
|
||||
# thousands of task-action combos can't grow the cache forever.
|
||||
@lru_cache(maxsize=512)
|
||||
def _cached_qr_svg(url: str, icon: str | None) -> str:
|
||||
return generate_qr_svg(url, border=2, icon=icon)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "maintenance_supporter/qr/batch_generate",
|
||||
vol.Optional("entry_ids"): vol.All(
|
||||
[vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
|
||||
vol.Length(max=1000),
|
||||
),
|
||||
vol.Optional("task_ids"): vol.All(
|
||||
[vol.All(str, vol.Length(max=MAX_ID_LENGTH))],
|
||||
vol.Length(max=2000),
|
||||
),
|
||||
vol.Required("actions"): vol.All(
|
||||
[vol.In(["view", "complete", "skip", "quick_complete"])],
|
||||
vol.Length(min=1, max=4),
|
||||
),
|
||||
vol.Optional("url_mode", default="server"): vol.In(["server", "local", "companion"]),
|
||||
vol.Optional("base_url"): vol.Url(),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def ws_batch_generate_qr(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Generate multiple QR codes in one call for the print-all-QRs page.
|
||||
|
||||
Resolves (entry × task × action) combinations and returns SVG strings
|
||||
ready to inline into a printable grid. Empty ``entry_ids`` / ``task_ids``
|
||||
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]
|
||||
entry_filter = msg.get("entry_ids")
|
||||
if entry_filter:
|
||||
wanted = set(entry_filter)
|
||||
entries = [e for e in all_entries if e.entry_id in wanted]
|
||||
else:
|
||||
entries = all_entries
|
||||
|
||||
# Build the flat (entry_id, object_name, task_id, task_name) target list,
|
||||
# honouring the optional task_ids filter.
|
||||
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, "")
|
||||
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:
|
||||
continue
|
||||
targets.append((entry.entry_id, obj_name, task_id, task_data.get("name", "")))
|
||||
|
||||
actions: list[str] = msg["actions"]
|
||||
total = len(targets) * len(actions)
|
||||
if total == 0:
|
||||
connection.send_result(msg["id"], {"qrs": [], "total": 0})
|
||||
return
|
||||
if total > _MAX_BATCH_QRS:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
"too_many",
|
||||
f"Batch would produce {total} QR codes; the per-request cap is "
|
||||
f"{_MAX_BATCH_QRS}. Narrow the object/task/action filter.",
|
||||
)
|
||||
return
|
||||
|
||||
url_mode = msg.get("url_mode", "server")
|
||||
base_url = msg.get("base_url")
|
||||
|
||||
# Generate URL first (fast), then offload the SVG encoding to the executor
|
||||
# since it's CPU-bound (~30-40 ms/QR). Each SVG passes through the LRU
|
||||
# cache so re-runs after a filter change are near-instant.
|
||||
results: list[dict[str, Any]] = []
|
||||
for entry_id, obj_name, task_id, task_name in targets:
|
||||
for action in actions:
|
||||
try:
|
||||
url = build_qr_url(
|
||||
hass,
|
||||
entry_id,
|
||||
task_id=task_id,
|
||||
action=action,
|
||||
base_url_override=base_url,
|
||||
url_mode=url_mode,
|
||||
)
|
||||
except ValueError:
|
||||
# No HA URL configured — skip this row rather than fail the
|
||||
# whole batch. "server" mode is the only path that raises;
|
||||
# "companion" and "local" always resolve.
|
||||
continue
|
||||
icon = _ACTION_ICON_MAP.get(action) # None for "skip" (no icon)
|
||||
svg = await hass.async_add_executor_job(_cached_qr_svg, url, icon)
|
||||
results.append(
|
||||
{
|
||||
"entry_id": entry_id,
|
||||
"task_id": task_id,
|
||||
"object_name": obj_name,
|
||||
"task_name": task_name,
|
||||
"action": action,
|
||||
"svg": svg,
|
||||
}
|
||||
)
|
||||
|
||||
connection.send_result(msg["id"], {"qrs": results, "total": len(results)})
|
||||
Reference in New Issue
Block a user