224 files
This commit is contained in:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user