224 files

This commit is contained in:
Home Assistant Version Control
2026-07-27 13:30:34 +00:00
parent b5723aa856
commit e36b8a1a22
224 changed files with 9967 additions and 2663 deletions
@@ -21,7 +21,7 @@ from __future__ import annotations
from collections import OrderedDict
from dataclasses import dataclass, field
from datetime import date
from datetime import date, timedelta
from typing import Any
from homeassistant.core import HomeAssistant
@@ -61,6 +61,41 @@ NATIVE_LOW_PERCENT = 20
# offline — that's exactly the one you must not hide).
_NO_READING = {"unavailable", "unknown", "none", ""}
# How long a NATIVE battery that was last seen LOW stays in the fleet after
# its entity goes unavailable. Battery Notes covers this case via its retained
# ``battery_low`` attribute; native entities have no equivalent, so without a
# snapshot the battery would vanish at the exact moment it died and took its
# device offline. Bounded so a permanently removed device eventually drops.
_NATIVE_RETENTION = timedelta(hours=48)
# Heuristic (sensors WITHOUT device_class): a %-sensor whose object_id talks
# about a battery — some Zigbee2MQTT/ESPHome devices ship battery levels
# without the device class. Deliberately strict: the exclusion words keep out
# charging electronics and home-storage state-of-charge sensors (a Powerwall
# is not a battery you replace).
_HEURISTIC_EXCLUDE = ("charging", "current", "power", "voltage", "energy", "load", "soc", "state_of_charge", "storage", "temp")
def _is_native_battery_sensor(state: Any) -> bool:
"""Whether a sensor state looks like a replaceable-battery level."""
attrs = state.attributes
if attrs.get("device_class") == "battery":
return True
if attrs.get("unit_of_measurement") != "%":
return False
object_id = state.entity_id.split(".", 1)[1]
if "battery" not in object_id:
return False
return not any(word in object_id for word in _HEURISTIC_EXCLUDE)
def _native_snapshot_cache(hass: HomeAssistant) -> dict[str, dict[str, Any]]:
"""Runtime cache of last-known native battery readings (per entity_id)."""
from ..const import DOMAIN
cache: dict[str, dict[str, Any]] = hass.data.setdefault(DOMAIN, {}).setdefault("battery_fleet_native_cache", {})
return cache
def _norm_type(raw: Any) -> str:
"""Canonicalize a battery-type label for grouping (upper, trimmed)."""
@@ -188,6 +223,46 @@ def _level_of(state_val: str) -> float | None:
return None
def fleet_excluded_entities(hass: HomeAssistant) -> set[str]:
"""Manually excluded battery entity_ids, stored on the fleet object entry.
Inlined lookup (not via battery_fleet_setup.find_fleet_entry) to keep this
module import-cycle-free — setup imports the aggregation, not vice versa.
"""
from ..const import CONF_OBJECT, DOMAIN
for entry in hass.config_entries.async_entries(DOMAIN):
obj = entry.data.get(CONF_OBJECT, {})
if obj.get("battery_fleet"):
return set(obj.get("battery_fleet_excluded") or [])
return set()
def _is_self_charging(hass: HomeAssistant, device_id: str | None) -> bool:
"""Whether a device recharges itself — its battery is never REPLACED.
Issue #107: a Roborock's native battery sensor reads "low" mid-clean, but
nobody swaps its cells. Heuristics (native pickup only — an explicit
Battery Notes note always wins): the device also has a vacuum/lawn_mower
entity, exposes a ``battery_charging`` binary, or is a Companion-app
phone/tablet (``mobile_app`` identifiers).
"""
if not device_id:
return False
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
device = dr.async_get(hass).async_get(device_id)
if device and any(domain == "mobile_app" for domain, _ in device.identifiers):
return True
for reg_entry in er.async_entries_for_device(er.async_get(hass), device_id, include_disabled_entities=True):
if reg_entry.domain in ("vacuum", "lawn_mower"):
return True
if reg_entry.domain == "binary_sensor" and (reg_entry.device_class or reg_entry.original_device_class) == "battery_charging":
return True
return False
def read_batteries(hass: HomeAssistant) -> list[Battery]:
"""Read the battery fleet from HA state — Battery Notes AND native.
@@ -197,16 +272,25 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
unavailable/unknown but RETAINS its last-known ``battery_low`` — so a
dead battery that took its device offline stays visible.
* **Native** ``device_class: battery`` entities (a %-sensor and/or a
battery-low binary), grouped per device, give a degraded view (type
"Unknown", quantity 1, no forecast). A device already covered by a
Battery Notes note is skipped (dedup by the note's source entity + its
device) so it isn't counted twice.
battery-low binary) — plus %-sensors matching the strict battery-name
heuristic for devices that ship no device class — grouped per device,
give a degraded view (type "Unknown", quantity 1, no forecast). A
device already covered by a Battery Notes note is skipped (dedup by
the note's source entity + its device) so it isn't counted twice;
self-charging devices (vacuums, mowers, phones — see
:func:`_is_self_charging`) are skipped entirely. A native battery
last seen LOW that goes unavailable is retained from a runtime
snapshot for ``_NATIVE_RETENTION`` (the Battery Notes path gets this
for free via its retained ``battery_low`` attribute).
* Manually excluded entity_ids (fleet detail → exclude) are dropped from
BOTH passes.
"""
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
ent_reg = er.async_get(hass)
dev_reg = dr.async_get(hass)
excluded = fleet_excluded_entities(hass)
out: list[Battery] = []
covered_sources: set[str] = set()
@@ -217,17 +301,36 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
attrs = state.attributes
if attrs.get("device_class") != "battery" or "battery_type" not in attrs:
continue
level = _level_of(state.state)
available = state.state not in _NO_READING and level is not None
# B2 (roadmap 2026-07-22 audit): ONE low floor across both passes.
# Battery Notes' own threshold (default 10 %) still counts via its
# battery_low flag, but the fleet-wide NATIVE_LOW_PERCENT floor is
# OR-ed in — a CR2032 at 11.5 % was "healthy" here while the same
# level counted low in the native pass. A HIGHER Battery Notes
# threshold (e.g. 30 %) still wins through battery_low.
low = bool(attrs.get("battery_low")) or (level is not None and level <= NATIVE_LOW_PERCENT)
last_replaced = _parse_last_replaced(attrs.get("battery_last_replaced"))
# B1 (roadmap 2026-07-22 audit): a forecast-only note — no level
# sensor, so the state reads unknown forever — must SURVIVE when it
# carries a replacement date: that date is all `_predicted_date`
# needs, and dropping these hid 11 overdue batteries in a live fleet.
# Offline AND not low AND no date = pure connectivity noise → drop.
if not available and not low and last_replaced is None:
continue
# B3: only a KEPT note covers its source/device — a dropped dead note
# must not suppress the native fallback for its own device (a device
# with a dead note and a working level sensor was invisible in BOTH
# passes).
src = attrs.get("source_entity_id")
if src:
covered_sources.add(src)
reg = ent_reg.async_get(state.entity_id)
if reg and reg.device_id:
covered_devices.add(reg.device_id)
level = _level_of(state.state)
available = state.state not in _NO_READING and level is not None
low = bool(attrs.get("battery_low"))
# Offline AND not last-known-low = pure connectivity noise → drop it.
if not available and not low:
# An EXCLUDED note still covers (above): exclusion hides the battery —
# it must not resurrect as a degraded native "Unknown" row.
if state.entity_id in excluded:
continue
out.append(
Battery(
@@ -237,7 +340,7 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
quantity=int(attrs.get("battery_quantity") or 1),
low=low,
level=level,
last_replaced=_parse_last_replaced(attrs.get("battery_last_replaced")),
last_replaced=last_replaced,
available=available,
source="battery_notes",
)
@@ -248,17 +351,25 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
native: dict[str, dict[str, Any]] = {}
for domain in ("sensor", "binary_sensor"):
for state in hass.states.async_all(domain):
if state.attributes.get("device_class") != "battery":
# Sensors: device_class battery OR the strict name/% heuristic
# (Zigbee2MQTT/ESPHome levels without a device class). Binaries:
# device_class only — name-guessing booleans is too risky.
if domain == "sensor":
if not _is_native_battery_sensor(state):
continue
elif state.attributes.get("device_class") != "battery":
continue
eid = state.entity_id
if "battery_type" in state.attributes: # Battery Notes battery_plus — handled above
continue
if eid in covered_sources:
if eid in covered_sources or eid in excluded:
continue
reg = ent_reg.async_get(eid)
dev_id = reg.device_id if reg else None
if dev_id and dev_id in covered_devices:
continue
if _is_self_charging(hass, dev_id): # #107: vacuums/mowers/phones
continue
key = dev_id or eid
rec = native.setdefault(
key,
@@ -273,6 +384,8 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
if rec["name"] is None and friendly:
rec["name"] = friendly
snapshot_cache = _native_snapshot_cache(hass)
now = dt_util.utcnow()
for rec in native.values():
level = _level_of(rec["level_state"]) if rec["level_state"] is not None else None
low_state = rec["low_state"]
@@ -283,6 +396,19 @@ def read_batteries(hass: HomeAssistant) -> list[Battery]:
low = low_available and str(low_state).lower() in ("on", "true", "1")
else:
low = level is not None and level <= NATIVE_LOW_PERCENT
if available:
# Remember the last real reading — the retention path below needs
# it once the entity goes unavailable.
snapshot_cache[rec["eid"]] = {"low": low, "level": level, "ts": now}
elif not low:
# Native dead-battery retention: an entity that was LOW and then
# went unavailable (the battery died and took the device offline)
# stays visible for _NATIVE_RETENTION instead of vanishing at the
# exact moment it needs replacing.
snap = snapshot_cache.get(rec["eid"])
if snap and snap.get("low") and now - snap["ts"] <= _NATIVE_RETENTION:
low = True
level = snap.get("level")
if not available and not low:
continue
name = rec["name"]
@@ -315,11 +441,9 @@ def has_battery_notes(hass: HomeAssistant) -> bool:
def has_batteries(hass: HomeAssistant) -> bool:
"""Whether ANY battery is trackable — Battery Notes OR native. Gates setup."""
for domain in ("sensor", "binary_sensor"):
for state in hass.states.async_all(domain):
if state.attributes.get("device_class") == "battery":
return True
return False
if any(_is_native_battery_sensor(s) for s in hass.states.async_all("sensor")):
return True
return any(s.attributes.get("device_class") == "battery" for s in hass.states.async_all("binary_sensor"))
def compute_overview(hass: HomeAssistant, *, horizon_days: int = DEFAULT_HORIZON_DAYS) -> BatteryOverview:
@@ -346,6 +470,7 @@ __all__ = [
"build_overview",
"compute_overview",
"discover_battery_types",
"fleet_excluded_entities",
"has_batteries",
"has_battery_notes",
"lifetime_months",
@@ -49,10 +49,10 @@ async def async_setup_battery_fleet(hass: HomeAssistant, language: str | None =
"""
from ..websocket.objects import async_create_object
from ..websocket.tasks_persist import async_persist_task
from .i18n import normalize_language
from .i18n import normalize_language, normalize_language_code
from .parts import normalize_part
lang = (language or normalize_language(hass))[:2].lower()
lang = normalize_language_code(language) if language else normalize_language(hass)
types = discover_battery_types(hass) # {TYPE: total_qty}
existing = find_fleet_entry(hass)
@@ -220,6 +220,30 @@ async def async_mark_replaced(hass: HomeAssistant, entity_ids: list[str] | None
return {"marked": len(targets), "pressed": pressed, "consumed": consumed}
def set_battery_excluded(hass: HomeAssistant, entity_id: str, excluded: bool) -> bool:
"""Persist a manual exclude/include of one battery on the fleet object.
Issue #107: some tracked batteries should never appear (a rechargeable
device the heuristics missed, a neighbour's sensor, …). Stored as
``battery_fleet_excluded`` on the fleet object dict. Returns False when
no fleet exists yet.
"""
entry = find_fleet_entry(hass)
if entry is None:
return False
new_data = dict(entry.data)
obj = dict(new_data.get(CONF_OBJECT, {}))
current = set(obj.get("battery_fleet_excluded") or [])
if excluded:
current.add(entity_id)
else:
current.discard(entity_id)
obj["battery_fleet_excluded"] = sorted(current)
new_data[CONF_OBJECT] = obj
hass.config_entries.async_update_entry(entry, data=new_data)
return True
def find_fleet_task(entry: ConfigEntry) -> tuple[str, dict[str, Any]] | None:
"""The flagged fleet task (id, data) on the fleet entry, or None."""
for task_id, task_data in (entry.data.get(CONF_TASKS) or {}).items():
@@ -6,7 +6,7 @@ from homeassistant.core import HomeAssistant
def normalize_language(hass: HomeAssistant) -> str:
"""Return the HA UI language as a lowercase 2-letter table key.
"""Return the HA UI language as a lowercase table key.
HA emits regional language codes (e.g. ``zh-Hans``, ``zh-Hant``,
``pt-BR``), but the integration's localization tables — the
@@ -15,5 +15,21 @@ def normalize_language(hass: HomeAssistant) -> str:
are keyed by the bare 2-letter prefix. Centralizing the normalization
keeps every consumer identical, so a regional-code user never silently
falls back to English. Defaults to ``en`` when the language is unset.
Brazilian Portuguese is the one regional variant with its OWN tables
(``pt-br``) — it must not collapse into European ``pt``.
"""
return (getattr(hass.config, "language", None) or "en")[:2].lower()
return normalize_language_code(getattr(hass.config, "language", None))
def normalize_language_code(code: str | None) -> str:
"""Normalize a raw language code to a table key (idempotent).
Every consumer that accepts an explicit ``language`` parameter must run
it through here instead of truncating to two letters itself — a bare
``[:2]`` would collapse ``pt-BR`` into European ``pt``.
"""
lang = str(code or "en").lower()
if lang.startswith("pt") and lang.endswith("br"):
return "pt-br"
return lang[:2]
@@ -477,6 +477,98 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
"budget_alert_monthly": "Månadsbudget på {pct}% ({spent} av {budget})",
"budget_alert_yearly": "Årsbudget på {pct}% ({spent} av {budget})",
},
"pt-br": {
"due_soon_title": "Manutenção em breve",
"due_soon_message": "{task} de {object} vence em {days} dia(s) (vencimento: {due}).",
"overdue_title": "Manutenção atrasada!",
"overdue_message": "{task} de {object} está {days} dia(s) atrasada!",
"triggered_title": "Manutenção acionada",
"triggered_message": "{task} de {object} foi acionada por dados de sensor.",
"action_complete": "Concluir",
"action_skip": "Pular",
"action_snooze": "Adiar",
"bundled_title": "Manutenção: {count} tarefas",
"bundled_message": "{object}: {task_list}",
"digest_title": "Resumo semanal de manutenção",
"digest_message": "{overdue} atrasadas, {due_soon} vencem nesta semana.",
"warranty_title": "Garantia expirando em breve",
"warranty_message": "{count} objeto(s) com garantia expirando em {days} dias: {names}",
"bundled_overdue": "{task} (atrasada)",
"bundled_due_soon": "{task} (em breve)",
"bundled_triggered": "{task} (acionada)",
"budget_alert_title": "Alerta de orçamento de manutenção",
"budget_alert_monthly": "Orçamento mensal em {pct}% ({spent} de {budget})",
"budget_alert_yearly": "Orçamento anual em {pct}% ({spent} de {budget})",
},
"hu": {
"due_soon_title": "Karbantartás hamarosan esedékes",
"due_soon_message": "{object} {task} {days} nap múlva esedékes (határidő: {due}).",
"overdue_title": "Karbantartás lejárt!",
"overdue_message": "{object} {task} {days} napja esedékes!",
"triggered_title": "Karbantartás aktiválódott",
"triggered_message": "{object} {task} feladatot érzékelőadatok aktiválták.",
"action_complete": "Kész",
"action_skip": "Kihagyás",
"action_snooze": "Halasztás",
"bundled_title": "Karbantartás: {count} feladat",
"bundled_message": "{object}: {task_list}",
"digest_title": "Heti karbantartási összefoglaló",
"digest_message": "{overdue} lejárt, {due_soon} esedékes ezen a héten.",
"warranty_title": "Hamarosan lejáró garancia",
"warranty_message": "{count} objektum garanciája jár le {days} napon belül: {names}",
"bundled_overdue": "{task} (lejárt)",
"bundled_due_soon": "{task} (hamarosan)",
"bundled_triggered": "{task} (aktiválva)",
"budget_alert_title": "Karbantartási keret figyelmeztetés",
"budget_alert_monthly": "Havi keret {pct}%-on ({spent} / {budget})",
"budget_alert_yearly": "Éves keret {pct}%-on ({spent} / {budget})",
},
"ko": {
"due_soon_title": "곧 예정된 유지보수",
"due_soon_message": "{object}{task}이(가) {days}일 후 예정입니다 (기한: {due}).",
"overdue_title": "유지보수 기한 초과!",
"overdue_message": "{object}{task}이(가) {days}일 지났습니다!",
"triggered_title": "유지보수 트리거됨",
"triggered_message": "{object}{task}이(가) 센서 데이터로 트리거되었습니다.",
"action_complete": "완료",
"action_skip": "건너뛰기",
"action_snooze": "미루기",
"bundled_title": "유지보수: 작업 {count}",
"bundled_message": "{object}: {task_list}",
"digest_title": "주간 유지보수 요약",
"digest_message": "기한 초과 {overdue}건, 이번 주 예정 {due_soon}건.",
"warranty_title": "보증 기간 만료 임박",
"warranty_message": "{days}일 이내에 보증이 만료되는 객체 {count}개: {names}",
"bundled_overdue": "{task} (기한 초과)",
"bundled_due_soon": "{task} (곧 예정)",
"bundled_triggered": "{task} (트리거됨)",
"budget_alert_title": "유지보수 예산 경고",
"budget_alert_monthly": "월 예산 {pct}% 사용 ({budget}{spent})",
"budget_alert_yearly": "연 예산 {pct}% 사용 ({budget}{spent})",
},
"tr": {
"due_soon_title": "Bakım zamanı yaklaşıyor",
"due_soon_message": "{object} için {task}, {days} gün içinde yapılmalı (Tarih: {due}).",
"overdue_title": "Bakım gecikti!",
"overdue_message": "{object} için {task}, {days} gün gecikti!",
"triggered_title": "Bakım tetiklendi",
"triggered_message": "{object} için {task}, sensör verileriyle tetiklendi.",
"action_complete": "Tamamla",
"action_skip": "Atla",
"action_snooze": "Ertele",
"bundled_title": "Bakım: {count} görev",
"bundled_message": "{object}: {task_list}",
"digest_title": "Haftalık bakım özeti",
"digest_message": "{overdue} gecikmiş, {due_soon} bu hafta yapılacak.",
"warranty_title": "Garanti yakında sona eriyor",
"warranty_message": "{days} gün içinde garantisi sona erecek {count} nesne: {names}",
"bundled_overdue": "{task} (gecikmiş)",
"bundled_due_soon": "{task} (yaklaşıyor)",
"bundled_triggered": "{task} (tetiklendi)",
"budget_alert_title": "Bakım bütçesi uyarısı",
"budget_alert_monthly": "Aylık bütçe %{pct} ({spent} / {budget})",
"budget_alert_yearly": "Yıllık bütçe %{pct} ({spent} / {budget})",
},
}
@@ -76,6 +76,10 @@ _BUY_NAME_TEMPLATES = {
"sv": "Köp {name}",
"uk": "Купити {name}",
"zh": "购买{name}",
"pt-br": "Comprar {name}",
"hu": "{name} vásárlása",
"ko": "{name} 구매",
"tr": "{name} satın al",
}
# Default shopping-search templates by UI language (the "Amazon as fallback"
@@ -86,6 +90,8 @@ _DEFAULT_SEARCH_TEMPLATES = {
"it": "https://www.amazon.it/s?k={q}",
"es": "https://www.amazon.es/s?k={q}",
"nl": "https://www.amazon.nl/s?k={q}",
"pt-br": "https://www.amazon.com.br/s?k={q}",
"tr": "https://www.amazon.com.tr/s?k={q}",
}
_FALLBACK_SEARCH_TEMPLATE = "https://www.amazon.com/s?k={q}"
@@ -275,7 +281,9 @@ def stock_transition(part: Mapping[str, Any], old: float | None, new: float | No
def default_search_template(lang: str) -> str:
return _DEFAULT_SEARCH_TEMPLATES.get((lang or "en")[:2].lower(), _FALLBACK_SEARCH_TEMPLATE)
from .i18n import normalize_language_code
return _DEFAULT_SEARCH_TEMPLATES.get(normalize_language_code(lang), _FALLBACK_SEARCH_TEMPLATE)
def search_query(part: Mapping[str, Any]) -> str:
@@ -303,7 +311,9 @@ def resolve_shopping_url(part: Mapping[str, Any], template: str | None, lang: st
def buy_task_name(part_name: str, lang: str) -> str:
tpl = _BUY_NAME_TEMPLATES.get((lang or "en")[:2].lower(), _BUY_NAME_TEMPLATES["en"])
from .i18n import normalize_language_code
tpl = _BUY_NAME_TEMPLATES.get(normalize_language_code(lang), _BUY_NAME_TEMPLATES["en"])
return tpl.replace("{name}", part_name)
@@ -147,6 +147,8 @@ def cap_task_fields(task_data: dict[str, Any]) -> dict[str, Any]:
if rs not in ROTATION_STRATEGIES:
task_data.pop("rotation_strategy", None)
seed_rotation_assignee(task_data)
# v1.3.0: per-task on_complete_action — embedded HA service-call config.
# Strict shape: {service: "domain.name", target?: dict, data?: dict}.
# Drops the field entirely on any structural problem; the action layer
@@ -190,6 +192,25 @@ def sanitize_labels(value: object) -> list[str]:
return out[:MAX_LABELS]
def seed_rotation_assignee(task_data: dict[str, Any]) -> None:
"""Ensure a rotation task always carries an effective assignee.
The rotation resolves "who is on duty" by writing the next pool member
into ``responsible_user_id`` on completion — the field EVERY user filter
reads (panel, Lovelace card, calendar card, saved views, per-user
notifications). But a rotation could be configured without an initial
assignee, leaving the task invisible to all of those until its first
completion ran ``advance_rotation`` (discussion #49). Seed the first
pool member when the assignee is missing — or no longer in the pool
(the pool was edited out from under the current assignee).
"""
pool = [u for u in task_data.get("assignee_pool") or [] if u]
if len(pool) < 2 or not task_data.get("rotation_strategy"):
return
if task_data.get("responsible_user_id") not in pool:
task_data["responsible_user_id"] = pool[0]
def sanitize_assignee_pool(value: object) -> list[str]:
"""Clean an assignee pool: str user-ids, trimmed, deduped, ≤ MAX_ASSIGNEE_POOL."""
if not isinstance(value, list):
@@ -19,23 +19,56 @@ from ._model import (
from ._registry import SIGNATURES
def _entity_watchers(hass: HomeAssistant) -> dict[str, set[str]]:
"""entity_id → lowercased names of the tasks watching it via a trigger."""
from ...const import CONF_TASKS, DOMAIN, GLOBAL_UNIQUE_ID
from ...entity.triggers import normalize_entity_ids
out: dict[str, set[str]] = {}
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
continue
for task in entry.data.get(CONF_TASKS, {}).values():
tc = task.get("trigger_config")
if isinstance(tc, dict):
name = str(task.get("name", "")).lower()
for eid in normalize_entity_ids(tc):
out.setdefault(eid, set()).add(name)
return out
def _catalog_name_variants() -> set[str]:
"""Every catalog task name in every language, lowercased — to recognise
whether an existing watcher task is one of OUR duties or a custom one."""
variants: set[str] = set()
for catalog in SIGNATURES.values():
for sig in catalog.tasks:
variants.update(task_name_variants(sig.task_name))
return variants
def discover_integration_setups(hass: HomeAssistant) -> list[dict[str, Any]]:
"""Devices of catalogued integrations with their matchable task wiring.
Groups matched entities per device; carries the maintenance object already
attached to the device (if any) so adoption can extend it instead of
creating a duplicate. Entities already watched by some task's trigger are
skipped — re-running discovery never proposes what is already wired.
creating a duplicate. Entity claims are per DUTY, not per entity: a task
already watching an entity blocks only its own duty (recognised by
catalog name in any language), so a mower's hours counter still proposes
"Clean Undercarriage" after "Replace Mower Blades" was adopted. A watcher
with a custom/renamed name conservatively claims the whole entity —
re-running discovery never re-proposes against a rename.
"""
from ...templates import localize_template_text
from ..i18n import normalize_language
from ..problem_sensors import _adopted_entity_ids, _object_by_device
from ..problem_sensors import _object_by_device
lang = normalize_language(hass)
ent_reg = er.async_get(hass)
dev_reg = dr.async_get(hass)
area_reg = ar.async_get(hass)
already_watched = _adopted_entity_ids(hass)
watchers = _entity_watchers(hass)
known_variants = _catalog_name_variants()
by_device = _object_by_device(hass)
# Collect the enabled registry entities of cataloged integrations per
@@ -69,10 +102,16 @@ def discover_integration_setups(hass: HomeAssistant) -> list[dict[str, Any]]:
any(_entity_matches(e, key) for key in sig.require_sibling_keys) for e in entries
):
continue
variants = task_name_variants(sig.task_name)
for entry in entries:
if entry.domain != sig.entity_domain:
continue
if entry.entity_id in already_watched:
# Per-duty claims: a watcher task named as THIS duty (any
# language) blocks it; watchers named as other catalog duties
# leave the remaining duties adoptable; a custom/renamed
# watcher claims the whole entity.
watcher_names = watchers.get(entry.entity_id)
if watcher_names and (watcher_names & variants or watcher_names - known_variants):
continue
if not _unit_compatible(sig.direction, _entity_unit(hass, entry)):
continue
@@ -86,9 +125,9 @@ def discover_integration_setups(hass: HomeAssistant) -> list[dict[str, Any]]:
)
group["entity_ids"].append(entry.entity_id)
# One source entity may back SEVERAL duties (a mower's hours
# counter drives blades AND undercarriage). Adopting any duty
# marks the entity watched — adopt-all is the default,
# deselecting a duty forfeits its later proposal.
# counter drives blades AND undercarriage) — both within one
# run and across runs: the per-duty claim above keeps a
# deselected duty proposable after its sibling was adopted.
out: list[dict[str, Any]] = []
for device_id, sig_map in matched.items():