215 lines
9.3 KiB
Python
215 lines
9.3 KiB
Python
"""WebSocket commands for integration-aware suggested setups (roadmap).
|
|
|
|
``integration_setups/discover`` lists devices of catalogued integrations
|
|
(helpers/integration_signatures — every signature verified against the
|
|
integration's source) whose consumable entities can back maintenance tasks;
|
|
``integration_setups/preview`` (2.94) recomputes one device's proposals against
|
|
another target object — the dialog's before/after when the user picks a
|
|
different object; ``integration_setups/adopt`` creates the object (or extends
|
|
the existing one on that device) with the tasks and their sensor triggers
|
|
PRE-WIRED. Discovery and preview are read; adoption is write, mirroring
|
|
problem-sensor adoption.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
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_TASKS, DOMAIN, MAX_NAME_LENGTH
|
|
from ..helpers.integration_signatures import (
|
|
SIGNATURES,
|
|
build_setup_trigger,
|
|
discover_integration_setups,
|
|
)
|
|
from ..helpers.permissions import require_write
|
|
from . import ID_FIELD
|
|
from .adopt_batch import AdoptBatch
|
|
|
|
|
|
@websocket_api.websocket_command({vol.Required("type"): f"{DOMAIN}/integration_setups/discover"})
|
|
@websocket_api.async_response
|
|
async def ws_discover_integration_setups(
|
|
hass: HomeAssistant,
|
|
connection: websocket_api.ActiveConnection,
|
|
msg: dict[str, Any],
|
|
) -> None:
|
|
"""List suggested maintenance setups for catalogued integrations."""
|
|
connection.send_result(msg["id"], {"setups": discover_integration_setups(hass)})
|
|
|
|
|
|
@websocket_api.websocket_command(
|
|
{
|
|
vol.Required("type"): f"{DOMAIN}/integration_setups/preview",
|
|
vol.Required("device_id"): ID_FIELD,
|
|
# An existing object as the target; omitted / null = a new object.
|
|
vol.Optional("entry_id"): vol.Any(ID_FIELD, None),
|
|
}
|
|
)
|
|
@websocket_api.async_response
|
|
async def ws_preview_integration_setup(
|
|
hass: HomeAssistant,
|
|
connection: websocket_api.ActiveConnection,
|
|
msg: dict[str, Any],
|
|
) -> None:
|
|
"""One device's proposals judged against the chosen target (2.94): what
|
|
the target already has (``already``), what it likely has under another
|
|
name (``covered_by``) and how many tasks it holds now — the dialog's
|
|
before/after. Read-only; adopt re-runs discovery itself."""
|
|
from ..helpers.aggregate import is_object_entry
|
|
|
|
entry_id = msg.get("entry_id") or ""
|
|
if entry_id and not is_object_entry(hass.config_entries.async_get_entry(entry_id)):
|
|
connection.send_error(msg["id"], "not_found", "Target object not found")
|
|
return
|
|
setup = next(
|
|
(s for s in discover_integration_setups(hass, targets={msg["device_id"]: entry_id}) if s["device_id"] == msg["device_id"]),
|
|
None,
|
|
)
|
|
if setup is None:
|
|
connection.send_error(msg["id"], "not_found", "No suggestion for this device")
|
|
return
|
|
connection.send_result(msg["id"], setup)
|
|
|
|
|
|
_SELECTION_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required("device_id"): ID_FIELD,
|
|
# Existing target object; omit to create a fresh object for the device.
|
|
vol.Optional("entry_id"): ID_FIELD,
|
|
vol.Optional("object_name"): vol.All(str, vol.Length(min=1, max=MAX_NAME_LENGTH)),
|
|
# Subset of suggested task names to adopt; omit = all suggested.
|
|
vol.Optional("task_names"): vol.All(
|
|
[vol.All(str, vol.Length(max=MAX_NAME_LENGTH))], vol.Length(max=20)
|
|
),
|
|
# Optional counting start values per task name (#102): "the last
|
|
# service was at reading X". Only applied to usage_delta duties —
|
|
# the delta then counts from X instead of the adoption reading, so
|
|
# an interval that already elapsed comes due immediately.
|
|
vol.Optional("baselines"): {
|
|
vol.All(str, vol.Length(max=MAX_NAME_LENGTH)): vol.All(
|
|
vol.Coerce(float), vol.Range(min=0)
|
|
)
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
@websocket_api.websocket_command(
|
|
{
|
|
vol.Required("type"): f"{DOMAIN}/integration_setups/adopt",
|
|
vol.Required("selections"): vol.All([_SELECTION_SCHEMA], vol.Length(min=1, max=50)),
|
|
}
|
|
)
|
|
@require_write
|
|
@websocket_api.async_response
|
|
async def ws_adopt_integration_setups(
|
|
hass: HomeAssistant,
|
|
connection: websocket_api.ActiveConnection,
|
|
msg: dict[str, Any],
|
|
) -> None:
|
|
"""Create sensor-wired maintenance tasks for the selected suggestions."""
|
|
from ..helpers.i18n import normalize_language
|
|
from ..templates import localize_template_text
|
|
|
|
lang = normalize_language(hass)
|
|
# Re-run discovery server-side: the wiring (entities, thresholds) comes
|
|
# from the verified catalog, never from the client.
|
|
setups = {s["device_id"]: s for s in discover_integration_setups(hass)}
|
|
|
|
batch = AdoptBatch(hass)
|
|
|
|
for sel in msg["selections"]:
|
|
device_id = sel["device_id"]
|
|
setup = setups.get(device_id)
|
|
if setup is None:
|
|
batch.errors.append({"device_id": device_id, "reason": "no suggestion for this device"})
|
|
continue
|
|
wanted = set(sel.get("task_names") or [t["task_name"] for t in setup["tasks"]])
|
|
# Keyed by (task_name, direction): one integration can ship a task name
|
|
# in two directions (LG ThinQ filter: hours vs percent), and the trigger
|
|
# must be built from the signature matching the discovered direction.
|
|
sig_by_key = {
|
|
(s.task_name, s.direction): s for s in SIGNATURES[setup["integration"]].tasks
|
|
}
|
|
batch.begin()
|
|
try:
|
|
entry_id = sel.get("entry_id") or setup["suggested_entry_id"]
|
|
if not entry_id:
|
|
entry_id = await batch.create_object(
|
|
name=sel.get("object_name") or setup["suggested_object_name"],
|
|
ha_device_id=device_id,
|
|
)
|
|
|
|
entry = batch.target_entry(entry_id)
|
|
if entry is None:
|
|
batch.errors.append({"device_id": device_id, "reason": "target object not found"})
|
|
continue
|
|
|
|
# #105: adopting into a user-picked existing object that isn't
|
|
# device-bound yet — bind it, so model/sibling gates work and
|
|
# future discovery suggests this object instead of a new one.
|
|
# (Objects reached via suggested_entry_id are bound by definition;
|
|
# the guard makes this a no-op for them.)
|
|
obj_data = entry.data.get(CONF_OBJECT, {})
|
|
if not obj_data.get("ha_device_id"):
|
|
new_data = dict(entry.data)
|
|
new_data[CONF_OBJECT] = {**obj_data, "ha_device_id": device_id}
|
|
hass.config_entries.async_update_entry(entry, data=new_data)
|
|
refreshed = hass.config_entries.async_get_entry(entry_id)
|
|
if refreshed is not None:
|
|
entry = refreshed
|
|
|
|
# Second dedup layer (discovery already hides these): never create
|
|
# a task whose name — in any language — already exists on the
|
|
# target object.
|
|
from ..helpers.integration_signatures import proposal_name_variants
|
|
|
|
existing_names = {
|
|
str(t.get("name", "")).lower()
|
|
for t in entry.data.get(CONF_TASKS, {}).values()
|
|
}
|
|
|
|
baselines = sel.get("baselines") or {}
|
|
for task in setup["tasks"]:
|
|
if task["task_name"] not in wanted:
|
|
continue
|
|
# Per-entity duties (colour cartridges) carry the entity label
|
|
# in task_name; the catalog key and the label travel separately.
|
|
catalog_name = task.get("catalog_task_name") or task["task_name"]
|
|
if existing_names & proposal_name_variants(catalog_name, task.get("entity_label")):
|
|
continue
|
|
sig = sig_by_key[(catalog_name, task["direction"])]
|
|
trigger = build_setup_trigger(sig, hass, task["entity_ids"])
|
|
# #102: "last service was at reading X" — usage_delta only.
|
|
# Other directions either already have absolute semantics
|
|
# (usage_above's explicit 0) or no baseline concept at all.
|
|
baseline = baselines.get(task["task_name"])
|
|
if baseline is not None and sig.direction == "usage_delta":
|
|
trigger["trigger_baseline_value"] = float(baseline)
|
|
await batch.persist_task(
|
|
entry,
|
|
{
|
|
"id": uuid4().hex,
|
|
"object_id": entry.data.get(CONF_OBJECT, {}).get("id", ""),
|
|
"name": task.get("task_name_localized")
|
|
or localize_template_text(task["task_name"], lang)
|
|
or task["task_name"],
|
|
"type": "replacement",
|
|
"enabled": True,
|
|
"schedule": {"kind": "manual"},
|
|
"trigger_config": trigger,
|
|
},
|
|
)
|
|
except (ValueError, KeyError) as err:
|
|
# Removes an object created for this device together with the
|
|
# tasks already persisted into it — and un-counts both (the
|
|
# tasks were over-reported before the DRY audit 2026-09-26).
|
|
await batch.fail({"device_id": device_id, "reason": str(err)})
|
|
|
|
connection.send_result(msg["id"], batch.result())
|