329 files

This commit is contained in:
Home Assistant Version Control
2026-08-06 13:56:25 +00:00
parent 0df89406fa
commit 7afe7add1d
330 changed files with 13098 additions and 5942 deletions
@@ -655,6 +655,140 @@ class MissingGlobalEntryRepairFlow(RepairsFlow):
return self.async_show_form(step_id="init", data_schema=vol.Schema({}))
class DeviceLinkRepairFlow(RepairsFlow):
"""Relink or unlink an object whose stored device link is unusable.
One flow serves both translation keys behind the ``device_link_lost_…``
issue id: the linked device VANISHED (``device_link_lost``), and the link
points at the object's own doppelgänger device (``device_link_self`` —
the old picker offered it under the appliance's exact name).
Two menu options:
1. Relink — a device picker, pre-filled with the best name/manufacturer
match among devices that are NOT ours; Maintenance Supporter devices
are rejected on submit (the same guard the WS write path applies).
2. Unlink — clear the stored link; the object keeps a device of its own.
``self.data`` carries ``{"entry_id": str}``.
"""
_match_cached = False
_match: Any = None
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> data_entry_flow.FlowResult:
entry = _entry_for_issue(self.hass, self.data)
if entry is None:
return self.async_abort(reason="entry_gone")
return self.async_show_menu(
step_id="init",
menu_options=["relink", "unlink"],
description_placeholders=self._placeholders(entry),
)
async def async_step_relink(self, user_input: dict[str, Any] | None = None) -> data_entry_flow.FlowResult:
from homeassistant.helpers import device_registry as dr
from .helpers.device_link import is_maintenance_device
entry = _entry_for_issue(self.hass, self.data)
if entry is None:
return self.async_abort(reason="entry_gone")
errors: dict[str, str] = {}
if user_input is not None:
device_id = str(user_input.get("device", "")).strip()
device = dr.async_get(self.hass).async_get(device_id) if device_id else None
if device is None:
errors["device"] = "device_gone"
elif is_maintenance_device(self.hass, device):
errors["device"] = "self_link"
else:
self._set_link(entry, device_id)
return self.async_create_entry(data={})
suggestion = self._best_match(entry)
device_key = vol.Required("device", default=suggestion.id) if suggestion else vol.Required("device")
return self.async_show_form(
step_id="relink",
data_schema=vol.Schema({device_key: selector.DeviceSelector()}),
errors=errors,
description_placeholders=self._placeholders(entry),
)
async def async_step_unlink(self, user_input: dict[str, Any] | None = None) -> data_entry_flow.FlowResult:
entry = _entry_for_issue(self.hass, self.data)
if entry is None:
return self.async_abort(reason="entry_gone")
if user_input is not None:
self._set_link(entry, None)
return self.async_create_entry(data={})
return self.async_show_form(
step_id="unlink",
data_schema=vol.Schema({}),
description_placeholders=self._placeholders(entry),
)
# ── helpers ──────────────────────────────────────────────────────────
def _placeholders(self, entry: Any) -> dict[str, str]:
from .const import CONF_OBJECT
obj = entry.data.get(CONF_OBJECT, {}) or {}
best = self._best_match(entry)
return {
"object": str(obj.get("name") or entry.title),
"suggestion": str((best.name_by_user or best.name) if best else ""),
}
def _best_match(self, entry: Any) -> Any:
"""The non-maintenance device whose identity best matches the object.
Same scoring the prod diagnosis used: count the object's name /
manufacturer / model words appearing in the device's name-plus-model
string. A device RE-CREATED under a new id (the usual reason a link
dies) keeps its name, so it surfaces as the natural suggestion.
"""
if self._match_cached:
return self._match
self._match_cached = True
from homeassistant.helpers import device_registry as dr
from .const import CONF_OBJECT
from .helpers.device_link import is_maintenance_device
def norm(value: Any) -> str:
import re
return re.sub(r"[^a-z0-9]+", " ", str(value or "").lower()).strip()
obj = entry.data.get(CONF_OBJECT, {}) or {}
words = {w for w in norm(obj.get("name")).split() if len(w) > 2}
words |= {w for w in (norm(obj.get("manufacturer")), norm(obj.get("model"))) if w}
best, best_score = None, 0
for device in dr.async_get(self.hass).devices.values():
if is_maintenance_device(self.hass, device):
continue
hay = norm(f"{device.name_by_user or ''} {device.name or ''} {device.manufacturer or ''} {device.model or ''}")
score = sum(1 for w in words if w and w in hay)
if score > best_score:
best, best_score = device, score
self._match = best
return best
def _set_link(self, entry: Any, device_id: str | None) -> None:
"""Write the new link and reload — the entity→device attachment only
changes on entity re-add (the same reason the WS update path reloads)."""
from .const import CONF_OBJECT
obj = dict(entry.data.get(CONF_OBJECT, {}) or {})
obj["ha_device_id"] = device_id
self.hass.config_entries.async_update_entry(entry, data={**entry.data, CONF_OBJECT: obj})
self.hass.config_entries.async_schedule_reload(entry.entry_id)
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
@@ -665,6 +799,8 @@ async def async_create_fix_flow(
return OrphanAdminPanelUserRepairFlow()
if issue_id.startswith("stale_action_entity_"):
return StaleActionEntityRepairFlow()
if issue_id.startswith("device_link_lost_"):
return DeviceLinkRepairFlow()
if issue_id == "document_storage_issues":
return DocumentStorageRepairFlow()
if issue_id == "missing_global_entry":