393 files

This commit is contained in:
Home Assistant Version Control
2026-09-27 17:16:54 +00:00
parent b05b7a897e
commit 39d97ac2db
404 changed files with 54006 additions and 11034 deletions
@@ -3,9 +3,12 @@
``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/adopt`` creates the object (or extends the existing one on
that device) with the tasks and their sensor triggers PRE-WIRED. Discovery is
read; adoption is write, mirroring problem-sensor adoption.
``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
@@ -17,14 +20,15 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from ..const import CONF_OBJECT, CONF_TASKS, DOMAIN, MAX_ID_LENGTH, MAX_NAME_LENGTH
from ..helpers.aggregate import is_object_entry
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"})
@@ -38,11 +42,45 @@ async def ws_discover_integration_setups(
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"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
vol.Required("device_id"): ID_FIELD,
# Existing target object; omit to create a fresh object for the device.
vol.Optional("entry_id"): vol.All(str, vol.Length(max=MAX_ID_LENGTH)),
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(
@@ -75,26 +113,21 @@ async def ws_adopt_integration_setups(
msg: dict[str, Any],
) -> None:
"""Create sensor-wired maintenance tasks for the selected suggestions."""
from ..export import object_entries
from ..helpers.i18n import normalize_language
from ..templates import localize_template_text
from .objects import async_create_object
from .tasks_persist import async_persist_task
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)}
tasks_created = 0
objects_created = 0
errors: list[dict[str, str]] = []
batch = AdoptBatch(hass)
for sel in msg["selections"]:
device_id = sel["device_id"]
setup = setups.get(device_id)
if setup is None:
errors.append({"device_id": device_id, "reason": "no suggestion for this device"})
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
@@ -103,25 +136,18 @@ async def ws_adopt_integration_setups(
sig_by_key = {
(s.task_name, s.direction): s for s in SIGNATURES[setup["integration"]].tasks
}
created_entry_id: str | None = None
batch.begin()
try:
entry_id = sel.get("entry_id") or setup["suggested_entry_id"]
if not entry_id:
entry_id = await async_create_object(
hass,
entry_id = await batch.create_object(
name=sel.get("object_name") or setup["suggested_object_name"],
ha_device_id=device_id,
)
created_entry_id = entry_id
objects_created += 1
entry = hass.config_entries.async_get_entry(entry_id)
# Same guard as websocket._load_object_entry: the global settings
# entry is NOT a valid adoption target — async_persist_task writes
# CONF_TASKS + CONF_OBJECT["task_ids"] into whatever entry it is
# handed, so a client-supplied global entry_id would corrupt it.
if not is_object_entry(entry):
errors.append({"device_id": device_id, "reason": "target object not found"})
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
@@ -165,8 +191,7 @@ async def ws_adopt_integration_setups(
baseline = baselines.get(task["task_name"])
if baseline is not None and sig.direction == "usage_delta":
trigger["trigger_baseline_value"] = float(baseline)
await async_persist_task(
hass,
await batch.persist_task(
entry,
{
"id": uuid4().hex,
@@ -180,19 +205,10 @@ async def ws_adopt_integration_setups(
"trigger_config": trigger,
},
)
tasks_created += 1
except (ValueError, KeyError) as err:
errors.append({"device_id": device_id, "reason": str(err)})
if created_entry_id is not None:
objects_created -= 1
if hass.config_entries.async_get_entry(created_entry_id) is not None:
await hass.config_entries.async_remove(created_entry_id)
# 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)})
result: dict[str, Any] = {
"tasks_created": tasks_created,
"objects_created": objects_created,
"total": len(object_entries(hass)),
}
if errors:
result["errors"] = errors
connection.send_result(msg["id"], result)
connection.send_result(msg["id"], batch.result())