456 files
This commit is contained in:
@@ -17,11 +17,12 @@ recorded as completed.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import Context, Event, EventOrigin, HomeAssistant, callback
|
||||
from homeassistant.core import Context, Event, EventOrigin, EventStateChangedData, HomeAssistant, callback
|
||||
|
||||
from ..const import (
|
||||
CONF_TASKS,
|
||||
@@ -59,22 +60,76 @@ def _split_service(spec: str) -> tuple[str, str] | None:
|
||||
return domain, name
|
||||
|
||||
|
||||
async def _dispatch_action(hass: HomeAssistant, action: dict[str, Any]) -> None:
|
||||
"""Run the configured service-call with HA's standard call signature."""
|
||||
# How long a completion action waits for a target entity that is enabled but
|
||||
# not loaded yet (see _await_loaded_targets).
|
||||
_LOAD_WAIT_S = 60
|
||||
|
||||
|
||||
async def _await_loaded_targets(hass: HomeAssistant, target: dict[str, Any] | None) -> None:
|
||||
"""Wait for target entities that are enabled but not loaded yet.
|
||||
|
||||
Wiring a counter reset switches on a button the integration shipped
|
||||
disabled; Home Assistant only reloads that integration 30 s later, and
|
||||
until then the button has no state — pressing it was a silent no-op
|
||||
(HA logs "referenced entities are missing"). The typical first
|
||||
completion comes right after adopting (the brush IS worn out), so it
|
||||
lost its reset (found building the docs GIF, 2.95). Entities that are
|
||||
unknown or disabled are not waited for — that is a broken action, which
|
||||
the stale-action repair reports.
|
||||
"""
|
||||
raw = (target or {}).get("entity_id")
|
||||
entity_ids = [raw] if isinstance(raw, str) else [e for e in (raw or []) if isinstance(e, str)]
|
||||
if not entity_ids:
|
||||
return
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
def _pending() -> list[str]:
|
||||
out = []
|
||||
for entity_id in entity_ids:
|
||||
entry = ent_reg.async_get(entity_id)
|
||||
if hass.states.get(entity_id) is None and entry is not None and entry.disabled_by is None:
|
||||
out.append(entity_id)
|
||||
return out
|
||||
|
||||
waiting = _pending()
|
||||
if not waiting:
|
||||
return
|
||||
loaded = asyncio.Event()
|
||||
|
||||
@callback
|
||||
def _on_change(_event: Event[EventStateChangedData]) -> None:
|
||||
if not _pending():
|
||||
loaded.set()
|
||||
|
||||
unsub = async_track_state_change_event(hass, waiting, _on_change)
|
||||
try:
|
||||
await asyncio.wait_for(loaded.wait(), _LOAD_WAIT_S)
|
||||
except TimeoutError:
|
||||
_LOGGER.warning("on_complete_action: %s did not load within %s s — running it anyway", ", ".join(_pending()), _LOAD_WAIT_S)
|
||||
finally:
|
||||
unsub()
|
||||
|
||||
|
||||
async def _dispatch_action(hass: HomeAssistant, action: dict[str, Any]) -> bool:
|
||||
"""Run the configured service-call with HA's standard call signature.
|
||||
True when the call was issued."""
|
||||
parts = _split_service(action.get("service", ""))
|
||||
if parts is None:
|
||||
_LOGGER.warning(
|
||||
"on_complete_action.service must be 'domain.service', got %r",
|
||||
action.get("service"),
|
||||
)
|
||||
return
|
||||
return False
|
||||
domain, name = parts
|
||||
# Defense-in-depth: refuse privileged domains at dispatch too, so an action
|
||||
# stored before the write-time denylist existed (or via any path that skips
|
||||
# cap_action_field) can never run shell/scripts/host control on completion.
|
||||
if domain in _FORBIDDEN_ACTION_DOMAINS:
|
||||
_LOGGER.warning("on_complete_action refused: %s is a privileged service domain", domain)
|
||||
return
|
||||
return False
|
||||
data = action.get("data") if isinstance(action.get("data"), dict) else None
|
||||
target = action.get("target") if isinstance(action.get("target"), dict) else None
|
||||
# Run as the user who configured it: HA then refuses admin-only services
|
||||
@@ -91,8 +146,9 @@ async def _dispatch_action(hass: HomeAssistant, action: dict[str, Any]) -> None:
|
||||
domain,
|
||||
name,
|
||||
)
|
||||
return
|
||||
return False
|
||||
context = Context(user_id=owner)
|
||||
await _await_loaded_targets(hass, target)
|
||||
try:
|
||||
await hass.services.async_call(domain, name, service_data=data, target=target, blocking=False, context=context)
|
||||
except Exception:
|
||||
@@ -103,6 +159,8 @@ async def _dispatch_action(hass: HomeAssistant, action: dict[str, Any]) -> None:
|
||||
data,
|
||||
target,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@callback
|
||||
@@ -113,6 +171,10 @@ def register_action_listener(hass: HomeAssistant) -> Callable[[], None]:
|
||||
teardown.
|
||||
"""
|
||||
|
||||
# entry_id → its pending post-action refresh (cancelled on unload; a
|
||||
# background task, so a stopping Home Assistant cancels it too)
|
||||
pending: dict[str, asyncio.Task[None]] = {}
|
||||
|
||||
async def _on_task_completed(event: Event) -> None:
|
||||
# Only OUR completions: the coordinator fires the event locally. The
|
||||
# same event type can be fired from outside — the REST/WebSocket
|
||||
@@ -135,6 +197,42 @@ def register_action_listener(hass: HomeAssistant) -> Callable[[], None]:
|
||||
action = _resolve_task_action(hass, entry_id, task_id)
|
||||
if action is None:
|
||||
return
|
||||
await _dispatch_action(hass, action)
|
||||
# 2.95: a counter reset is pointless when the task completed itself
|
||||
# because that counter recovered (reset in the vendor app).
|
||||
if action.get("skip_auto") and event.data.get("source") == "auto_recovery":
|
||||
return
|
||||
if not await _dispatch_action(hass, action):
|
||||
return
|
||||
# The action often changes what the task watches (a reset button puts
|
||||
# the counter back to full) — but the completion's own refresh ran
|
||||
# before it landed, so the task kept showing the old reading until the
|
||||
# next periodic update (found live, 2.95). Read it again shortly after.
|
||||
if (previous := pending.pop(entry_id, None)) is not None:
|
||||
previous.cancel()
|
||||
pending[entry_id] = hass.async_create_background_task(
|
||||
_refresh_entry(hass, pending, entry_id), f"{DOMAIN} refresh after completion action"
|
||||
)
|
||||
|
||||
return hass.bus.async_listen(EVENT_TASK_COMPLETED, _on_task_completed)
|
||||
unsub_event = hass.bus.async_listen(EVENT_TASK_COMPLETED, _on_task_completed)
|
||||
|
||||
@callback
|
||||
def _unsubscribe() -> None:
|
||||
unsub_event()
|
||||
for task in pending.values():
|
||||
task.cancel()
|
||||
pending.clear()
|
||||
|
||||
return _unsubscribe
|
||||
|
||||
|
||||
_POST_ACTION_REFRESH_S = 3
|
||||
|
||||
|
||||
async def _refresh_entry(hass: HomeAssistant, pending: dict[str, asyncio.Task[None]], entry_id: str) -> None:
|
||||
await asyncio.sleep(_POST_ACTION_REFRESH_S)
|
||||
pending.pop(entry_id, None)
|
||||
entry = hass.config_entries.async_get_entry(entry_id)
|
||||
runtime = getattr(entry, "runtime_data", None) if entry is not None else None
|
||||
coordinator = getattr(runtime, "coordinator", None)
|
||||
if coordinator is not None:
|
||||
await coordinator.async_refresh_now()
|
||||
|
||||
@@ -29,7 +29,7 @@ from ..const import (
|
||||
DOMAIN,
|
||||
GLOBAL_UNIQUE_ID,
|
||||
MAX_PANEL_TITLE_LENGTH,
|
||||
PANEL_TITLE,
|
||||
PANEL_TITLES,
|
||||
THRESHOLD_PERCENT_RANGE,
|
||||
)
|
||||
from .settings_registry import setting_default
|
||||
@@ -103,18 +103,24 @@ def get_panel_title(hass: HomeAssistant) -> str:
|
||||
|
||||
Reads the user-set `panel_title` option from the global config entry so a
|
||||
user can rename the sidebar entry (e.g. to avoid clashing with HA's built-in
|
||||
"Maintenance" dashboard, 2026.5+). Falls back to the default `PANEL_TITLE`
|
||||
when unset, blank, or not a string. Trimmed and length-capped.
|
||||
"Maintenance" dashboard, 2026.5+). Falls back to the default title in the
|
||||
server's language when unset, blank, or not a string. Trimmed and
|
||||
length-capped.
|
||||
"""
|
||||
raw = get_global_options(hass).get(CONF_PANEL_TITLE)
|
||||
if not isinstance(raw, str):
|
||||
return PANEL_TITLE
|
||||
title = raw.strip()
|
||||
title = raw.strip() if isinstance(raw, str) else ""
|
||||
if not title:
|
||||
return PANEL_TITLE
|
||||
return default_panel_title(hass)
|
||||
return title[:MAX_PANEL_TITLE_LENGTH]
|
||||
|
||||
|
||||
def default_panel_title(hass: HomeAssistant) -> str:
|
||||
"""The sidebar title an unset ``panel_title`` means, in the server's language."""
|
||||
from .i18n import normalize_language
|
||||
|
||||
return PANEL_TITLES.get(normalize_language(hass), PANEL_TITLES["en"])
|
||||
|
||||
|
||||
def _percent_option(hass: HomeAssistant, key: str, default: int) -> int:
|
||||
"""A percent option within THRESHOLD_PERCENT_RANGE, else its default."""
|
||||
raw = global_option(hass, key)
|
||||
|
||||
@@ -159,8 +159,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"quiet_end_title": "{count} herinneringen uit de stille uren",
|
||||
"digest_title": "Wekelijks onderhoudsoverzicht",
|
||||
"digest_message": "{overdue} achterstallig, {due_soon} deze week.",
|
||||
"warranty_title": "Warranty expiring soon",
|
||||
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
|
||||
"warranty_title": "Garantie verloopt binnenkort",
|
||||
"warranty_message": "{count} object(en) met garantie die binnen {days} dagen verloopt: {names}",
|
||||
"completed_title": 'Taak voltooid',
|
||||
"completed_message": '{task} voor {object}: {reason}{who}',
|
||||
"completed_by": ' — door {name}',
|
||||
@@ -199,8 +199,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"quiet_end_title": "{count} rappels des heures calmes",
|
||||
"digest_title": "Récapitulatif hebdomadaire d'entretien",
|
||||
"digest_message": "{overdue} en retard, {due_soon} cette semaine.",
|
||||
"warranty_title": "Warranty expiring soon",
|
||||
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
|
||||
"warranty_title": "Garantie bientôt expirée",
|
||||
"warranty_message": "{count} objet(s) dont la garantie expire dans {days} jours : {names}",
|
||||
"completed_title": 'Tâche terminée',
|
||||
"completed_message": '{task} pour {object} : {reason}{who}',
|
||||
"completed_by": ' — par {name}',
|
||||
@@ -239,8 +239,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"quiet_end_title": "{count} promemoria dalle ore di silenzio",
|
||||
"digest_title": "Riepilogo settimanale manutenzione",
|
||||
"digest_message": "{overdue} scadute, {due_soon} questa settimana.",
|
||||
"warranty_title": "Warranty expiring soon",
|
||||
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
|
||||
"warranty_title": "Garanzia in scadenza",
|
||||
"warranty_message": "{count} oggetto/i con garanzia in scadenza entro {days} giorni: {names}",
|
||||
"completed_title": 'Attività completata',
|
||||
"completed_message": '{task} per {object}: {reason}{who}',
|
||||
"completed_by": ' — da {name}',
|
||||
@@ -279,8 +279,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"quiet_end_title": "{count} recordatorios de las horas de silencio",
|
||||
"digest_title": "Resumen semanal de mantenimiento",
|
||||
"digest_message": "{overdue} vencidas, {due_soon} esta semana.",
|
||||
"warranty_title": "Warranty expiring soon",
|
||||
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
|
||||
"warranty_title": "Garantía a punto de vencer",
|
||||
"warranty_message": "{count} objeto(s) con garantía que vence en {days} días: {names}",
|
||||
"completed_title": 'Tarea completada',
|
||||
"completed_message": '{task} para {object}: {reason}{who}',
|
||||
"completed_by": ' — por {name}',
|
||||
@@ -359,8 +359,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"quiet_end_title": "{count} påmindelser fra de stille timer",
|
||||
"digest_title": "Ugentlig vedligeholdelsesoversigt",
|
||||
"digest_message": "{overdue} forfaldne, {due_soon} denne uge.",
|
||||
"warranty_title": "Warranty expiring soon",
|
||||
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
|
||||
"warranty_title": "Garantien udløber snart",
|
||||
"warranty_message": "{count} objekt(er) med garanti, der udløber inden for {days} dage: {names}",
|
||||
"completed_title": 'Opgave fuldført',
|
||||
"completed_message": '{task} for {object}: {reason}{who}',
|
||||
"completed_by": ' — af {name}',
|
||||
@@ -399,8 +399,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"quiet_end_title": "{count} muistutusta hiljaisilta tunneilta",
|
||||
"digest_title": "Viikoittainen huoltokooste",
|
||||
"digest_message": "{overdue} myöhässä, {due_soon} tällä viikolla.",
|
||||
"warranty_title": "Warranty expiring soon",
|
||||
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
|
||||
"warranty_title": "Takuu päättymässä pian",
|
||||
"warranty_message": "{count} kohdetta, joiden takuu päättyy {days} päivän sisällä: {names}",
|
||||
"completed_title": 'Tehtävä valmis',
|
||||
"completed_message": '{task} kohteelle {object}: {reason}{who}',
|
||||
"completed_by": ' — tekijä {name}',
|
||||
@@ -439,8 +439,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"quiet_end_title": "{count} påminnelser fra de stille timene",
|
||||
"digest_title": "Ukentlig vedlikeholdsoversikt",
|
||||
"digest_message": "{overdue} forfalt, {due_soon} denne uken.",
|
||||
"warranty_title": "Warranty expiring soon",
|
||||
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
|
||||
"warranty_title": "Garantien utløper snart",
|
||||
"warranty_message": "{count} objekt(er) med garanti som utløper innen {days} dager: {names}",
|
||||
"completed_title": 'Oppgave fullført',
|
||||
"completed_message": '{task} for {object}: {reason}{who}',
|
||||
"completed_by": ' — av {name}',
|
||||
@@ -479,8 +479,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"quiet_end_title": "静音時間中の{count}件のリマインダー",
|
||||
"digest_title": "週間メンテナンスまとめ",
|
||||
"digest_message": "期限切れ {overdue} 件、今週 {due_soon} 件。",
|
||||
"warranty_title": "Warranty expiring soon",
|
||||
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
|
||||
"warranty_title": "保証期限が近づいています",
|
||||
"warranty_message": "{count}件の保証が{days}日以内に切れます: {names}",
|
||||
"completed_title": 'タスク完了',
|
||||
"completed_message": '{object} の {task}:{reason}{who}',
|
||||
"completed_by": '({name})',
|
||||
@@ -519,8 +519,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"quiet_end_title": "शांत घंटों के {count} अनुस्मारक",
|
||||
"digest_title": "साप्ताहिक रखरखाव सारांश",
|
||||
"digest_message": "{overdue} अतिदेय, {due_soon} इस सप्ताह।",
|
||||
"warranty_title": "Warranty expiring soon",
|
||||
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
|
||||
"warranty_title": "वारंटी जल्द समाप्त हो रही है",
|
||||
"warranty_message": "{count} वस्तु(ओं) की वारंटी {days} दिनों में समाप्त हो रही है: {names}",
|
||||
"completed_title": 'कार्य पूरा हुआ',
|
||||
"completed_message": '{object} के लिए {task}: {reason}{who}',
|
||||
"completed_by": ' — {name} द्वारा',
|
||||
@@ -559,8 +559,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"quiet_end_title": "静音时段积累的 {count} 条提醒",
|
||||
"digest_title": "每周维护摘要",
|
||||
"digest_message": "逾期 {overdue} 项,本周 {due_soon} 项。",
|
||||
"warranty_title": "Warranty expiring soon",
|
||||
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
|
||||
"warranty_title": "保修即将到期",
|
||||
"warranty_message": "{count} 个对象的保修将在 {days} 天内到期:{names}",
|
||||
"completed_title": '任务已完成',
|
||||
"completed_message": '{object} 的 {task}:{reason}{who}',
|
||||
"completed_by": '({name})',
|
||||
@@ -599,8 +599,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"quiet_end_title": "{count} напоминаний за тихие часы",
|
||||
"digest_title": "Еженедельная сводка обслуживания",
|
||||
"digest_message": "{overdue} просрочено, {due_soon} на этой неделе.",
|
||||
"warranty_title": "Warranty expiring soon",
|
||||
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
|
||||
"warranty_title": "Гарантия скоро истекает",
|
||||
"warranty_message": "У {count} объект(ов) гарантия истекает в течение {days} дн.: {names}",
|
||||
"completed_title": 'Задача выполнена',
|
||||
"completed_message": '{task} для {object}: {reason}{who}',
|
||||
"completed_by": ' — {name}',
|
||||
@@ -639,8 +639,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"quiet_end_title": "{count} нагадувань за тихі години",
|
||||
"digest_title": "Щотижневий огляд обслуговування",
|
||||
"digest_message": "{overdue} прострочено, {due_soon} цього тижня.",
|
||||
"warranty_title": "Warranty expiring soon",
|
||||
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
|
||||
"warranty_title": "Гарантія скоро закінчується",
|
||||
"warranty_message": "У {count} об'єкт(ів) гарантія закінчується протягом {days} дн.: {names}",
|
||||
"completed_title": 'Завдання виконано',
|
||||
"completed_message": '{task} для {object}: {reason}{who}',
|
||||
"completed_by": ' — {name}',
|
||||
@@ -679,8 +679,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"quiet_end_title": "{count} lembretes das horas de silêncio",
|
||||
"digest_title": "Resumo semanal de manutenção",
|
||||
"digest_message": "{overdue} atrasadas, {due_soon} esta semana.",
|
||||
"warranty_title": "Warranty expiring soon",
|
||||
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
|
||||
"warranty_title": "Garantia a expirar em breve",
|
||||
"warranty_message": "{count} objeto(s) com garantia a expirar dentro de {days} dias: {names}",
|
||||
"completed_title": 'Tarefa concluída',
|
||||
"completed_message": '{task} para {object}: {reason}{who}',
|
||||
"completed_by": ' — por {name}',
|
||||
@@ -721,8 +721,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"quiet_end_title": "{count} przypomnień z godzin ciszy",
|
||||
"digest_title": "Cotygodniowe podsumowanie konserwacji",
|
||||
"digest_message": "{overdue} zaległych, {due_soon} w tym tygodniu.",
|
||||
"warranty_title": "Warranty expiring soon",
|
||||
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
|
||||
"warranty_title": "Gwarancja wkrótce wygasa",
|
||||
"warranty_message": "{count} obiekt(y) z gwarancją wygasającą w ciągu {days} dni: {names}",
|
||||
"completed_title": 'Zadanie ukończone',
|
||||
"completed_message": '{task} dla {object}: {reason}{who}',
|
||||
"completed_by": ' — przez {name}',
|
||||
@@ -763,8 +763,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"quiet_end_title": "{count} připomínek z tichých hodin",
|
||||
"digest_title": "Týdenní přehled údržby",
|
||||
"digest_message": "{overdue} po termínu, {due_soon} tento týden.",
|
||||
"warranty_title": "Warranty expiring soon",
|
||||
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
|
||||
"warranty_title": "Záruka brzy vyprší",
|
||||
"warranty_message": "{count} objekt(ů) se zárukou končící do {days} dnů: {names}",
|
||||
"completed_title": 'Úkol dokončen',
|
||||
"completed_message": '{task} pro {object}: {reason}{who}',
|
||||
"completed_by": ' — {name}',
|
||||
@@ -804,8 +804,8 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
|
||||
"quiet_end_title": "{count} påminnelser från de tysta timmarna",
|
||||
"digest_title": "Veckovis underhållssammanfattning",
|
||||
"digest_message": "{overdue} försenade, {due_soon} denna vecka.",
|
||||
"warranty_title": "Warranty expiring soon",
|
||||
"warranty_message": "{count} object(s) with warranty expiring within {days} days: {names}",
|
||||
"warranty_title": "Garantin går snart ut",
|
||||
"warranty_message": "{count} objekt med garanti som går ut inom {days} dagar: {names}",
|
||||
"completed_title": 'Uppgift slutförd',
|
||||
"completed_message": '{task} för {object}: {reason}{who}',
|
||||
"completed_by": ' — av {name}',
|
||||
|
||||
@@ -447,6 +447,11 @@ def cap_action_field(task_data: dict[str, Any], *, keep_owner: bool = False) ->
|
||||
if keep_owner and isinstance(owner, str) and 0 < len(owner) <= 64:
|
||||
cleaned[ACTION_OWNER_KEY] = owner
|
||||
|
||||
# 2.95: not when the task completed itself (its counter recovered) —
|
||||
# helpers/reset_wiring.py wires resets with it.
|
||||
if action.get("skip_auto") is True:
|
||||
cleaned["skip_auto"] = True
|
||||
|
||||
task_data["on_complete_action"] = cleaned
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from homeassistant.helpers import area_registry as ar
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from ..task_origin import origin_is_duty, task_origin
|
||||
from ._model import (
|
||||
_entity_matches,
|
||||
_entity_unit,
|
||||
@@ -42,6 +43,11 @@ def _entity_watchers(hass: HomeAssistant) -> dict[str, set[str]]:
|
||||
base = catalog_base_name(name)
|
||||
if base != name and base in known:
|
||||
name = base
|
||||
# 2.95: a task adopted from a duty claims as that duty,
|
||||
# whatever it is called now (its fingerprint).
|
||||
origin = task_origin(task)
|
||||
if origin is not None and origin.get("kind") == "integration" and origin.get("duty"):
|
||||
name = origin["duty"].lower()
|
||||
for eid in normalize_entity_ids(tc):
|
||||
out.setdefault(eid, set()).add(name)
|
||||
return out
|
||||
@@ -80,6 +86,42 @@ def _matches_catalog_key(entry: er.RegistryEntry, key: str, catalog_keys: set[st
|
||||
)
|
||||
|
||||
|
||||
def reset_button_for(
|
||||
hass: HomeAssistant,
|
||||
sig: Any,
|
||||
matched: list[er.RegistryEntry],
|
||||
device_id: str,
|
||||
integration: str,
|
||||
catalog_keys: set[str],
|
||||
*,
|
||||
tk_authoritative: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
"""The integration's own reset button for this duty's counter on the same
|
||||
device (2.95, helpers/reset_wiring.py) — ``{entity_id, name, disabled}``,
|
||||
or None when the duty has none or the button is missing / user-disabled."""
|
||||
if not sig.resets or not matched:
|
||||
return None
|
||||
from ..reset_wiring import pressable_reset_buttons
|
||||
|
||||
buttons = pressable_reset_buttons(hass, device_id, integration)
|
||||
for entry in matched:
|
||||
for sensor_key, button_key in sig.resets:
|
||||
if not _matches_catalog_key(entry, sensor_key, catalog_keys, tk_authoritative=tk_authoritative):
|
||||
continue
|
||||
hits = [b for b in buttons if _entity_matches(b, button_key, tk_authoritative=tk_authoritative)]
|
||||
# Several buttons under one key (a Matter purifier's HEPA and
|
||||
# carbon filter both reset via 'reset_filter_condition') cannot be
|
||||
# told apart — wiring a guess could reset the wrong filter.
|
||||
if len(hits) == 1:
|
||||
button = hits[0]
|
||||
return {
|
||||
"entity_id": button.entity_id,
|
||||
"name": button.name or button.original_name or button.entity_id,
|
||||
"disabled": button.disabled_by is not None,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _appliance_type(hass: HomeAssistant, device: Any, integration: str, key: str | None) -> str | None:
|
||||
"""The appliance type the integration's own config entry names for this
|
||||
device (WashData: ``device_type``) — options first, then data."""
|
||||
@@ -93,15 +135,32 @@ def _appliance_type(hass: HomeAssistant, device: Any, integration: str, key: str
|
||||
return None
|
||||
|
||||
|
||||
def _adopted_as(tasks: dict[str, Any], integration: str | None, proposal: dict[str, Any]) -> str | None:
|
||||
"""The name of the target's task adopted from this very duty (and entity
|
||||
label, for per-entity duties) — found by its fingerprint, not its name."""
|
||||
if not integration:
|
||||
return None
|
||||
for task in tasks.values():
|
||||
origin = task_origin(task)
|
||||
if (
|
||||
origin_is_duty(task, integration, proposal["catalog_task_name"], proposal["direction"])
|
||||
and origin is not None
|
||||
and (origin.get("entity_label") or None) == (proposal.get("entity_label") or None)
|
||||
):
|
||||
return str(task.get("name", ""))
|
||||
return None
|
||||
|
||||
|
||||
def annotate_for_target(
|
||||
hass: HomeAssistant, proposals: list[dict[str, Any]], entry: Any, *, device_name: str = ""
|
||||
hass: HomeAssistant, proposals: list[dict[str, Any]], entry: Any, *, device_name: str = "", integration: str | None = None
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
|
||||
"""Split proposals against the target object's tasks (2.94).
|
||||
|
||||
A duty the target already has BY NAME (any language) goes to ``already``
|
||||
with the existing task's name — shown, never adopted. The rest keeps
|
||||
``covered_by`` (helpers.adopt_match.covering_task): a likely duplicate
|
||||
under another name, which the dialog shows unticked.
|
||||
under another name, which the dialog shows unticked. A task adopted from
|
||||
the same duty counts as present under any name (2.95 fingerprint).
|
||||
"""
|
||||
from ...const import CONF_TASKS
|
||||
from ..adopt_match import covering_task
|
||||
@@ -113,8 +172,9 @@ def annotate_for_target(
|
||||
already: list[dict[str, str]] = []
|
||||
for proposal in proposals:
|
||||
hit = set(by_lower) & proposal_name_variants(proposal["catalog_task_name"], proposal["entity_label"])
|
||||
if hit:
|
||||
already.append({"task_name": proposal["task_name"], "task_name_localized": proposal["task_name_localized"], "existing_name": by_lower[sorted(hit)[0]]})
|
||||
existing = by_lower[sorted(hit)[0]] if hit else _adopted_as(tasks, integration, proposal)
|
||||
if existing is not None:
|
||||
already.append({"task_name": proposal["task_name"], "task_name_localized": proposal["task_name_localized"], "existing_name": existing})
|
||||
continue
|
||||
covered = (
|
||||
covering_task(hass, (proposal["task_name_localized"], proposal["task_name"]), proposal["entity_ids"], tasks, ignore=ignore)
|
||||
@@ -252,6 +312,7 @@ def discover_integration_setups(hass: HomeAssistant, *, targets: dict[str, str]
|
||||
else:
|
||||
target_id = suggested["entry_id"] if suggested else (candidate["entry_id"] if candidate else None)
|
||||
target = hass.config_entries.async_get_entry(target_id) if target_id else None
|
||||
device_catalog_keys = {key for s in catalog.tasks for key in s.keys}
|
||||
raw: list[dict[str, Any]] = []
|
||||
for (_integ, task_name, direction), group in sig_map.items():
|
||||
entity_ids = sorted(group["entity_ids"])
|
||||
@@ -283,13 +344,23 @@ def discover_integration_setups(hass: HomeAssistant, *, targets: dict[str, str]
|
||||
"entity_ids": ids,
|
||||
"threshold": _threshold_for(sig, hass, ids[0]),
|
||||
"direction": direction,
|
||||
# 2.95: completing it also presses this reset button
|
||||
"reset": reset_button_for(
|
||||
hass,
|
||||
sig,
|
||||
[group["entries"][eid] for eid in ids],
|
||||
device_id,
|
||||
integration,
|
||||
device_catalog_keys,
|
||||
tk_authoritative=catalog.translation_keys_authoritative,
|
||||
),
|
||||
}
|
||||
)
|
||||
# Duties already present on the target BY NAME (any language) are not
|
||||
# proposed — covers manually created calendar tasks whose trigger
|
||||
# watches no entity (the entity-watched exclusion misses them); since
|
||||
# 2.94 they are listed as ``already`` so the dialog can say so.
|
||||
tasks, already = annotate_for_target(hass, raw, target, device_name=device_name)
|
||||
tasks, already = annotate_for_target(hass, raw, target, device_name=device_name, integration=integration)
|
||||
if not tasks and targets is None:
|
||||
continue
|
||||
tasks.sort(key=lambda t: (t["task_name"], t["direction"]))
|
||||
|
||||
@@ -137,6 +137,13 @@ class ConsumableSignature:
|
||||
# WashData offers "descale" for every appliance, a heat-pump dryer has
|
||||
# no water circuit to descale.
|
||||
exclude_appliance_types: tuple[str, ...] = ()
|
||||
# 2.95: the integration's own reset for this counter — (sensor key,
|
||||
# button key) pairs. Adopting the duty wires the matched sensor's sibling
|
||||
# button on the SAME device as the task's completion action, so completing
|
||||
# the task here also resets the counter in the integration (Roborock's
|
||||
# "Reset main brush consumable"); without it the counter kept running and
|
||||
# the task fell due again. Verified against the integration's button.py.
|
||||
resets: tuple[tuple[str, str], ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -301,7 +308,7 @@ def _threshold_for(sig: ConsumableSignature, hass: HomeAssistant, entity_id: str
|
||||
# kilometres; the entity may display s/min/d resp. miles.
|
||||
# Every duration unit HA lets a user pick as display unit is listed —
|
||||
# a missing one silently fell back to 1.0 (a "24 h" floor became "24 w").
|
||||
factor = {
|
||||
factors = {
|
||||
"μs": 3_600_000_000.0,
|
||||
"ms": 3_600_000.0,
|
||||
"s": 3600.0,
|
||||
@@ -309,13 +316,28 @@ def _threshold_for(sig: ConsumableSignature, hass: HomeAssistant, entity_id: str
|
||||
"h": 1.0,
|
||||
"d": 1 / 24,
|
||||
"w": 1 / 168,
|
||||
# 2.95: custom integrations that spell the unit out (HERU, CatLink:
|
||||
# "days") — the 1.0 fallback turned a 7-day floor into 168 days.
|
||||
"sec": 3600.0,
|
||||
"seconds": 3600.0,
|
||||
"mins": 60.0,
|
||||
"minutes": 60.0,
|
||||
"hr": 1.0,
|
||||
"hrs": 1.0,
|
||||
"hours": 1.0,
|
||||
"day": 1 / 24,
|
||||
"days": 1 / 24,
|
||||
"week": 1 / 168,
|
||||
"weeks": 1 / 168,
|
||||
"km": 1.0,
|
||||
"mi": 0.62137,
|
||||
"miles": 0.62137,
|
||||
# energy counters are canonical in kWh (wallbox cable inspection)
|
||||
"Wh": 1000.0,
|
||||
"kWh": 1.0,
|
||||
"MWh": 0.001,
|
||||
}.get(unit, 1.0)
|
||||
}
|
||||
factor = factors.get(unit) or factors.get(str(unit).strip().lower(), 1.0)
|
||||
hours = {
|
||||
"usage_above": sig.above_hours,
|
||||
"usage_delta": sig.delta_units,
|
||||
|
||||
@@ -8,18 +8,48 @@ integration's source; drift-probed weekly)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ._model import ConsumableSignature, IntegrationSignature
|
||||
from ._shared import FILTER_LIFE_PERCENT, HOOD_GREASE_FILTER_USAGE, VENTILATION_FILTER_OPERATING_TIME, VENTILATION_FILTER_REMAIN
|
||||
from ._shared import (
|
||||
FILTER_LIFE_PERCENT,
|
||||
PRE_FILTER_CLEANING_PERCENT,
|
||||
VENTILATION_FILTER_OPERATING_TIME,
|
||||
VENTILATION_FILTER_REMAIN,
|
||||
)
|
||||
|
||||
# One object for the AC-only integrations whose climate entity runs through
|
||||
# these HVAC modes (gree, midea_ac) — shared here, next to its users, so the
|
||||
# verbatim-duplicate tripwire holds without a _shared.py entry.
|
||||
_AC_FILTER_CLEANING_RUNTIME = ConsumableSignature(
|
||||
(),
|
||||
"Filter Cleaning",
|
||||
"runtime_hours",
|
||||
delta_units=100,
|
||||
entity_domain="climate",
|
||||
on_states=("auto", "cool", "dry", "fan_only", "heat"),
|
||||
)
|
||||
|
||||
SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"hass_dyson": IntegrationSignature(
|
||||
name="Dyson",
|
||||
verified="2026-07-18 @ cmgrayb/hass-dyson main",
|
||||
verified="2026-07-18 / 2026-09-27 (live keys, deep clean) @ cmgrayb/hass-dyson main",
|
||||
source=(
|
||||
"cmgrayb/hass-dyson sensor.py DysonFilterLifeSensor "
|
||||
"(translation_key 'filter_life' for BOTH hepa and carbon "
|
||||
"instances, PERCENTAGE) — one any-low task covers both filters."
|
||||
"cmgrayb/hass-dyson sensor.py (verified @ 3dde06e): the live "
|
||||
"filter sensors are DysonHEPAFilterLifeSensor tk "
|
||||
"'hepa_filter_life' and DysonCarbonFilterLifeSensor tk "
|
||||
"'carbon_filter_life' (PERCENTAGE; the carbon one only when the "
|
||||
"device reports a separate carbon filter) — one any-low task "
|
||||
"covers both filters. The older DysonFilterLifeSensor (tk "
|
||||
"'filter_life') is never instantiated; the catalog matched the "
|
||||
"live sensors only through the _filter_life suffix until "
|
||||
"2026-09-27. Humidifiers: DysonNextCleaningCycleSensor tk "
|
||||
"'next_cleaning_cycle' (cltr, HOURS until the deep-clean cycle, "
|
||||
"i.e. the citric-acid descale; unknown at 0) → Descale Appliance "
|
||||
"24 h ahead. The filter reset is only the service "
|
||||
"hass_dyson.reset_filter (no button)."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(("hepa_filter_life", "carbon_filter_life"), "Replace Filter", "percent_left"),
|
||||
ConsumableSignature(("next_cleaning_cycle",), "Descale Appliance", "duration_left"),
|
||||
),
|
||||
tasks=(FILTER_LIFE_PERCENT,),
|
||||
),
|
||||
"dreo": IntegrationSignature(
|
||||
name="Dreo",
|
||||
@@ -78,16 +108,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"guidance (clean filters every 2 weeks; ≈100 runtime-hours at "
|
||||
"typical in-season use)."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
(),
|
||||
"Filter Cleaning",
|
||||
"runtime_hours",
|
||||
delta_units=100,
|
||||
entity_domain="climate",
|
||||
on_states=("auto", "cool", "dry", "fan_only", "heat"),
|
||||
),
|
||||
),
|
||||
tasks=(_AC_FILTER_CLEANING_RUNTIME,),
|
||||
),
|
||||
"comfoconnect": IntegrationSignature(
|
||||
name="Zehnder ComfoAirQ",
|
||||
@@ -102,7 +123,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
name="Renson Endura Delta",
|
||||
verified="2026-07-19 @ core/dev renson/sensor.py",
|
||||
source="core renson: tk 'filter_change', DURATION, DAYS, MEASUREMENT.",
|
||||
tasks=(ConsumableSignature(("filter_change",), "Replace Ventilation Filter", "duration_left", below_hours=168),),
|
||||
tasks=(ConsumableSignature(("filter_change",), "Replace Ventilation Filter", "duration_left", below_hours=168, resets=(("filter_change", "reset_filter"),)),),
|
||||
),
|
||||
"philips_airpurifier_coap": IntegrationSignature(
|
||||
name="Philips AirPurifier (CoAP)",
|
||||
@@ -116,7 +137,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"evaporation wick)."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(("pre_filter",), "Filter Cleaning", "percent_left"),
|
||||
PRE_FILTER_CLEANING_PERCENT,
|
||||
ConsumableSignature(("pre_filter",), "Filter Cleaning", "duration_left", below_hours=72),
|
||||
ConsumableSignature(
|
||||
("hepa_filter", "active_carbon_filter", "nanoprotect_filter"),
|
||||
@@ -178,7 +199,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
),
|
||||
"winix": IntegrationSignature(
|
||||
name="Winix",
|
||||
verified="2026-09-02 @ iprak/winix master sensor.py (HACS default)",
|
||||
verified="2026-09-02 @ iprak/winix main sensor.py (HACS default)",
|
||||
source=(
|
||||
"HACS winix: tk 'filter_life' (PERCENTAGE) — % remaining derived "
|
||||
"from filter hours vs the model's max filter life "
|
||||
@@ -226,7 +247,14 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"'Combined Filter Life' (PERCENTAGE, value/4300 h budget). The "
|
||||
"percent suffixes end in _filter_life too — the unit-aware "
|
||||
"matcher routes each entity to the right direction (the "
|
||||
"lg_thinq dual-unit pattern)."
|
||||
"lg_thinq dual-unit pattern). button.py (verified @ 41c3338): "
|
||||
"'Reset Filter Life' (no translation_key → suffix "
|
||||
"_reset_filter_life, CONFIG) exists only on Pure Cool Link units "
|
||||
"(hasattr filter_life) and resets the 'filf' hours behind both "
|
||||
"'Filter Life' (h) and 'Filter Life Percentage'; the Pure Cool "
|
||||
"carbon/HEPA/combined % sensors have no reset. Humidify+Cool: "
|
||||
"'Next Deep Clean' (HOURS until the citric-acid deep-clean cycle, "
|
||||
"libdyson cltr) → Descale Appliance."
|
||||
),
|
||||
tasks=(
|
||||
# The Pure Cool "combined" sensor is NAMED plain 'Filter Life'
|
||||
@@ -242,8 +270,16 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
),
|
||||
"Replace Filter",
|
||||
"percent_left",
|
||||
resets=(("filter_life_percentage", "reset_filter_life"),),
|
||||
),
|
||||
ConsumableSignature(("filter_life",), "Replace Filter", "duration_left", below_hours=72),
|
||||
ConsumableSignature(
|
||||
("filter_life",),
|
||||
"Replace Filter",
|
||||
"duration_left",
|
||||
below_hours=72,
|
||||
resets=(("filter_life", "reset_filter_life"),),
|
||||
),
|
||||
ConsumableSignature(("next_deep_clean",), "Descale Appliance", "duration_left"),
|
||||
),
|
||||
),
|
||||
"venstar": IntegrationSignature(
|
||||
@@ -295,9 +331,21 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"The duration-unit filter_life variants (d/h/min/s) are NOT "
|
||||
"signed: some count UP without the 'invert' mapping "
|
||||
"(fresco_hydrateultra_petfountain v1 vs v2). 'entity: lock' in "
|
||||
"these configs is the CHILD lock — no lock signature."
|
||||
"these configs is the CHILD lock — no lock signature. Resets "
|
||||
"(verified @ 986d71e): 37 of the '%' configs carry a button with "
|
||||
"translation_key 'filter_reset' (e.g. ap402_airpurifier.yaml), two "
|
||||
"robot vacuums a name-only 'Reset filter' button (suffix "
|
||||
"_reset_filter); the press writes the yaml's reset DP. A device "
|
||||
"with several matching buttons gets none wired."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
("filter_life",),
|
||||
"Replace Filter",
|
||||
"percent_left",
|
||||
resets=(("filter_life", "filter_reset"), ("filter_life", "reset_filter")),
|
||||
),
|
||||
),
|
||||
tasks=(FILTER_LIFE_PERCENT,),
|
||||
),
|
||||
"govee": IntegrationSignature(
|
||||
name="Govee (purifiers)",
|
||||
@@ -331,9 +379,20 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"917, PERCENTAGE, MEASUREMENT; KomfoventSensor sets "
|
||||
"translation_key = key). Clogging climbs to 100 % (warning 0x81 "
|
||||
"'Change Air Filter'); the 'Clean Filters Calibration' button "
|
||||
"resets it after a change → alert_above 90 % with auto-resolve."
|
||||
"resets it after a change → alert_above 90 % with auto-resolve. "
|
||||
"button.py (verified @ e5be948): tk 'clean_filters' (name 'Clean "
|
||||
"Filters Calibration', CONFIG) writes register 1051 = 1, the "
|
||||
"'Reset filters counter'."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
("filter_clogging",),
|
||||
"Replace Ventilation Filter",
|
||||
"alert_above",
|
||||
delta_units=90,
|
||||
resets=(("filter_clogging", "clean_filters"),),
|
||||
),
|
||||
),
|
||||
tasks=(ConsumableSignature(("filter_clogging",), "Replace Ventilation Filter", "alert_above", delta_units=90),),
|
||||
),
|
||||
"pluggit": IntegrationSignature(
|
||||
name="Pluggit ventilation",
|
||||
@@ -344,7 +403,10 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"property returns the key) — Modbus register 554 = filter days "
|
||||
"REMAINING (device.py derives the replace level from lifetime − "
|
||||
"remain; the translated state 0 reads 'Replace the filter now'). "
|
||||
"Absent on Servo_flow units (not_component_class)."
|
||||
"Absent on Servo_flow units (not_component_class). Button key "
|
||||
"'filter_reset' (ATTR_FILTER_RESET, translation_key = key) → "
|
||||
"set_filter_reset writes register 558 = 1, restoring the "
|
||||
"remaining days (verified @ ee0e13d)."
|
||||
),
|
||||
tasks=(VENTILATION_FILTER_REMAIN,),
|
||||
),
|
||||
@@ -354,7 +416,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
source=(
|
||||
"HACS dantherm — the same code base as pluggit (device_map.py "
|
||||
"key 'filter_remain', DURATION, unit 'd', Modbus register 554 = "
|
||||
"filter days REMAINING; translation_key = key)."
|
||||
"filter days REMAINING; translation_key = key) and the same "
|
||||
"'filter_reset' button → register 558 (verified @ da9dfaa)."
|
||||
),
|
||||
tasks=(VENTILATION_FILTER_REMAIN,),
|
||||
),
|
||||
@@ -388,7 +451,12 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"Clean Grease Filter (mirrors the core smartthings entry). "
|
||||
"Skipped: the shared water-filter key 'filter_usage' (fridge, "
|
||||
"dishwasher, water purifier AND the AMF microfiber lint unit on "
|
||||
"the washer registry — one key, different duties)."
|
||||
"the washer registry — one key, different duties). Reset "
|
||||
"(verified @ 2e942fc): 'air_filter_reset' (common."
|
||||
"filter_reset_button, filterReset 'On' to the same "
|
||||
"/filter/airdustfilter resource; measured on a RAC board, #449). "
|
||||
"The hepa/hood resets are only extrapolated upstream and stay "
|
||||
"unwired."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
@@ -397,9 +465,10 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"alert_above",
|
||||
delta_units=90,
|
||||
require_sibling_keys=("air_filter_usage_hours",),
|
||||
resets=(("air_filter_usage", "air_filter_reset"),),
|
||||
),
|
||||
ConsumableSignature(("hepa_filter_usage", "filter_progress"), "Replace Filter", "alert_above", delta_units=90),
|
||||
HOOD_GREASE_FILTER_USAGE,
|
||||
ConsumableSignature(("hood_filter_usage",), "Clean Grease Filter", "alert_above", delta_units=90),
|
||||
),
|
||||
),
|
||||
"flexit": IntegrationSignature(
|
||||
@@ -418,4 +487,137 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
VENTILATION_FILTER_OPERATING_TIME,
|
||||
),
|
||||
),
|
||||
# ─── Round 15 part 2 (2026-09-27): HACS HRVs, ACs, thermostats, ──────
|
||||
# ─── fragrance diffusers ─────────────────────────────────────────────
|
||||
"genvex_connect": IntegrationSignature(
|
||||
name="Genvex Connect / Nilan gateway",
|
||||
verified="2026-09-27 @ superrob/genvexconnect main (70a1c55) + superrob/genvexnabto 1.5.4",
|
||||
source=(
|
||||
"HACS genvex_connect entity.py: translation_key = the genvexnabto "
|
||||
"value key (plain str constants, every entity). sensor.py: "
|
||||
"'filter_days_left' (unit 'd', 'Days left until filter change'; "
|
||||
"Nilan CTS400/CTS602/CTS602light) → duration_left 7 days; "
|
||||
"'filter_days' (unit 'd', 'Days since filter change'; Genvex "
|
||||
"Optima 270/314, counts up until the reset) → usage_above 4,380 h "
|
||||
"like the other HRV filter timers. The two never exist on one "
|
||||
"model. button.py 'filter_reset' (CONFIG, created when the model "
|
||||
"provides FILTER_RESET — every model above) writes the setpoint 1. "
|
||||
"The DHW 'sacrificial_anode' enum ('service' state) is left out: "
|
||||
"its meaning is only inferred from the option names."
|
||||
),
|
||||
translation_keys_authoritative=True,
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
("filter_days_left",),
|
||||
"Replace Ventilation Filter",
|
||||
"duration_left",
|
||||
below_hours=168,
|
||||
resets=(("filter_days_left", "filter_reset"),),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("filter_days",),
|
||||
"Replace Ventilation Filter",
|
||||
"usage_above",
|
||||
above_hours=4380,
|
||||
resets=(("filter_days", "filter_reset"),),
|
||||
),
|
||||
),
|
||||
),
|
||||
"nilan": IntegrationSignature(
|
||||
name="Nilan (CTS602 Modbus)",
|
||||
verified="2026-09-27 @ veista/nilan master (6cdbbfe)",
|
||||
source=(
|
||||
"HACS nilan sensor.py: translation_key = the map name on every "
|
||||
"sensor; 'days_to_air_filter_change' (UnitOfTime.DAYS, input "
|
||||
"register air_flow_to_filt_day, bus version >= 9) → duration_left "
|
||||
"7 days. 'days_since_air_filter_change' is the same duty from the "
|
||||
"other side and is not signed twice; the DHW 'anode_state' raw "
|
||||
"register (2 = Service) is left out (meaning unconfirmed). No "
|
||||
"filter-reset button (button.py has only sync_time)."
|
||||
),
|
||||
translation_keys_authoritative=True,
|
||||
tasks=(ConsumableSignature(("days_to_air_filter_change",), "Replace Ventilation Filter", "duration_left", below_hours=168),),
|
||||
),
|
||||
"ha_comfoconnectpro": IntegrationSignature(
|
||||
name="Zehnder ComfoConnect Pro (Modbus)",
|
||||
verified="2026-09-27 @ hstrohmaier/ha_comfoconnectpro main (e03e4b6)",
|
||||
source=(
|
||||
"HACS ha_comfoconnectpro const.py: 'filter_days_remaining' (input "
|
||||
"register 25, unit 'd' → DURATION; translation_key = key for every "
|
||||
"generated entity) — days until the filter change, the ComfoAir "
|
||||
"Q's own countdown → duration_left 7 days like the core "
|
||||
"comfoconnect entry. The 'filter_dirty' binary is the same duty "
|
||||
"(not signed twice). No reset entity."
|
||||
),
|
||||
translation_keys_authoritative=True,
|
||||
tasks=(ConsumableSignature(("filter_days_remaining",), "Replace Ventilation Filter", "duration_left", below_hours=168),),
|
||||
),
|
||||
"heru": IntegrationSignature(
|
||||
name="Östberg HERU",
|
||||
verified="2026-09-27 @ toringer/home-assistant-heru master (ec1294f)",
|
||||
source=(
|
||||
"HACS heru const.py (name-based entities under has_entity_name, no "
|
||||
"translation_key): 'Filter days left' (input register 3x00020, "
|
||||
"unit spelled out as 'days') → suffix _filter_days_left, "
|
||||
"duration_left 7 days; button 'Reset filter timer' (coil "
|
||||
"0x00006) → suffix _reset_filter_timer resets it."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
("filter_days_left",),
|
||||
"Replace Ventilation Filter",
|
||||
"duration_left",
|
||||
below_hours=168,
|
||||
resets=(("filter_days_left", "reset_filter_timer"),),
|
||||
),
|
||||
),
|
||||
),
|
||||
"midea_ac": IntegrationSignature(
|
||||
name="Midea Smart AC (msmart-ng)",
|
||||
verified="2026-09-27 @ mill1000/midea-ac-py main (d433bbb)",
|
||||
source=(
|
||||
"HACS midea_ac climate.py (AC and commercial AC devices only): "
|
||||
"_OPERATIONAL_MODE_TO_HVAC_MODE maps auto/cool/dry/heat/fan to "
|
||||
"auto/cool/dry/heat/fan_only, off otherwise — every mode except "
|
||||
"off moves air through the filter → the same engine-runtime duty "
|
||||
"as gree, with its cadence (Daikin's guidance: clean filters every "
|
||||
"2 weeks ≈ 100 runtime hours at in-season use). The unit's own "
|
||||
"'filter_alert' binary (supports_filter_reminder only) is "
|
||||
"device_class problem → problem-sensor adoption."
|
||||
),
|
||||
tasks=(_AC_FILTER_CLEANING_RUNTIME,),
|
||||
),
|
||||
"nest_legacy": IntegrationSignature(
|
||||
name="Nest (legacy API)",
|
||||
verified="2026-09-27 @ tronikos/nest_legacy main (018eb74)",
|
||||
source=(
|
||||
"HACS nest_legacy sensor.py: tk 'filter_runtime' (thermostats with "
|
||||
"an air filter; SECONDS, DURATION, TOTAL_INCREASING) = the "
|
||||
"FilterReminder trait's filterRuntime, the HVAC runtime on the "
|
||||
"current filter → usage_above 300 h, the furnace-filter cadence of "
|
||||
"the venstar duty. Nest's own reminder is the problem-class "
|
||||
"'filter_replacement_needed' binary (adoption path); Protect "
|
||||
"'replace_by' is a DATE sensor (no direction)."
|
||||
),
|
||||
tasks=(ConsumableSignature(("filter_runtime",), "Replace Filter", "usage_above", above_hours=300),),
|
||||
),
|
||||
"pura": IntegrationSignature(
|
||||
name="Pura fragrance diffusers",
|
||||
verified="2026-09-27 @ natekspencer/ha-pura main (6166f16)",
|
||||
source=(
|
||||
"HACS pura sensor.py: tk 'fragrance_remaining' (car/mini) and "
|
||||
"'bay_fragrance_remaining' (wall/plus, one per bay with the "
|
||||
"placeholder {bay}), PERCENTAGE — pypura's bay.remaining.percent, "
|
||||
"else (expectedLifeHours − runtime) / expectedLife → % REMAINING. "
|
||||
"Two-bay diffusers get one task per vial."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
("fragrance_remaining", "bay_fragrance_remaining"),
|
||||
"Replace Air Freshener",
|
||||
"percent_left",
|
||||
per_entity=True,
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -21,6 +21,17 @@ from ._shared import (
|
||||
TIRE_ROTATION_TESLA_ODOMETER,
|
||||
)
|
||||
|
||||
# Duties two integrations of this module name identically (same key, same
|
||||
# countdown) — one object each, so the verbatim-duplicate tripwire holds.
|
||||
# 'Service inspection distance': Audi Connect and Volkswagen (WeConnect).
|
||||
_ANNUAL_SERVICE_INSPECTION_DISTANCE = ConsumableSignature(
|
||||
("service_inspection_distance",), "Annual Service", "value_below", delta_units=1000
|
||||
)
|
||||
# 'Days to service': the unofficial Polestar API and Smart #1/#3.
|
||||
_ANNUAL_SERVICE_DAYS_TO_SERVICE = ConsumableSignature(
|
||||
("days_to_service",), "Annual Service", "duration_left", below_hours=336
|
||||
)
|
||||
|
||||
SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"kia_uvo": IntegrationSignature(
|
||||
name="Hyundai / Kia Connect",
|
||||
@@ -185,7 +196,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
tasks=(
|
||||
TIRE_ROTATION_MILEAGE,
|
||||
ConsumableSignature(("service_inspection_time",), "Annual Service", "duration_left", below_hours=336),
|
||||
ConsumableSignature(("service_inspection_distance",), "Annual Service", "value_below", delta_units=1000),
|
||||
_ANNUAL_SERVICE_INSPECTION_DISTANCE,
|
||||
ConsumableSignature(("oil_change_time",), "Oil Service", "duration_left", below_hours=336),
|
||||
ConsumableSignature(("oil_change_distance",), "Oil Service", "value_below", delta_units=1000),
|
||||
),
|
||||
@@ -422,7 +433,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
),
|
||||
tasks=(
|
||||
TIRE_ROTATION_ODOMETER,
|
||||
ConsumableSignature(("days_to_service",), "Annual Service", "duration_left", below_hours=336),
|
||||
_ANNUAL_SERVICE_DAYS_TO_SERVICE,
|
||||
ANNUAL_SERVICE_DISTANCE_TO_SERVICE,
|
||||
),
|
||||
),
|
||||
@@ -469,4 +480,65 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
BIKE_CHAIN_ODOMETER,
|
||||
),
|
||||
),
|
||||
# ─── Round 15 part 2 (2026-09-27): HACS car integrations ─────────────
|
||||
"volkswagencarnet": IntegrationSignature(
|
||||
name="Volkswagen (WeConnect)",
|
||||
verified="2026-09-27 @ robinostlund/homeassistant-volkswagencarnet master (8b6524c) + volkswagencarnet v5.5.1",
|
||||
source=(
|
||||
"HACS volkswagencarnet sensor.py: has_entity_name, entity name = "
|
||||
"the library instrument name, no translation_key → entity-id "
|
||||
"suffixes. volkswagencarnet vw_dashboard.py instruments "
|
||||
"'Service inspection days' (unit 'd', DURATION; "
|
||||
"vw_vehicle.service_inspection = 'time left for service "
|
||||
"inspection') / 'Service inspection distance' (km left) and 'Oil "
|
||||
"inspection days' / 'Oil inspection distance' (combustion engines "
|
||||
"only) — the car's own countdowns, as for Audi and Škoda; "
|
||||
"'Odometer' (km, TOTAL_INCREASING) drives the tire rotation."
|
||||
),
|
||||
tasks=(
|
||||
TIRE_ROTATION_ODOMETER,
|
||||
ConsumableSignature(("service_inspection_days",), "Annual Service", "duration_left", below_hours=336),
|
||||
_ANNUAL_SERVICE_INSPECTION_DISTANCE,
|
||||
ConsumableSignature(("oil_inspection_days",), "Oil Service", "duration_left", below_hours=336),
|
||||
ConsumableSignature(("oil_inspection_distance",), "Oil Service", "value_below", delta_units=1000),
|
||||
),
|
||||
),
|
||||
"smarthashtag": IntegrationSignature(
|
||||
name="Smart #1 / #3 (Hello Smart)",
|
||||
verified="2026-09-27 @ DasBasti/SmartHashtag main (cf8cd46)",
|
||||
source=(
|
||||
"HACS smarthashtag sensor_groups/maintenance.py (translation_key "
|
||||
"on every description): 'odometer' (km, TOTAL_INCREASING), "
|
||||
"'days_to_service' (DURATION, 'd') and 'distance_to_service' "
|
||||
"(km) — pysmarthashtag maintenance.daysToService / "
|
||||
"distanceToService, remaining. The service sensors are DISABLED "
|
||||
"by default and share the display name 'Service due in' (entity "
|
||||
"ids get _2/_3 suffixes) → matched by translation_key only "
|
||||
"(translation_keys_authoritative)."
|
||||
),
|
||||
translation_keys_authoritative=True,
|
||||
tasks=(
|
||||
TIRE_ROTATION_ODOMETER,
|
||||
_ANNUAL_SERVICE_DAYS_TO_SERVICE,
|
||||
ANNUAL_SERVICE_DISTANCE_TO_SERVICE,
|
||||
),
|
||||
),
|
||||
"fordconnect_query": IntegrationSignature(
|
||||
name="FordConnect Query",
|
||||
verified="2026-09-27 @ marq24/ha-fordconnect-query main (27ef561)",
|
||||
source=(
|
||||
"HACS fordconnect_query: translation_key = the tag key (entity.py) "
|
||||
"and entity_id sensor.fcq_<vin>_<tag>; const_tags.py 'oil' = "
|
||||
"metrics.oilLifeRemaining (PERCENTAGE, only created when the "
|
||||
"vehicle reports it — combustion engines) → Oil Service at the "
|
||||
"household floor; 'odometer' (km, TOTAL_INCREASING) → the "
|
||||
"FordPass pair (annual service + tire rotation)."
|
||||
),
|
||||
translation_keys_authoritative=True,
|
||||
tasks=(
|
||||
ANNUAL_SERVICE_ODOMETER,
|
||||
TIRE_ROTATION_ODOMETER,
|
||||
ConsumableSignature(("oil",), "Oil Service", "percent_left"),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ from __future__ import annotations
|
||||
|
||||
from ._model import ConsumableSignature, IntegrationSignature
|
||||
from ._shared import (
|
||||
LANDROID_BLADE_RUNTIME,
|
||||
LANDROID_UNDERCARRIAGE_RUNTIME,
|
||||
MOWER_BLADES_RUNTIME,
|
||||
MOWER_UNDERCARRIAGE_RUNTIME,
|
||||
@@ -25,7 +24,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"(translation_key 'cutting_blade_usage_time', DURATION s→h; matching reset button exists)"
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(("cutting_blade_usage_time",), "Replace Blades", "usage_above"),
|
||||
ConsumableSignature(("cutting_blade_usage_time",), "Replace Blades", "usage_above", resets=(("cutting_blade_usage_time", "reset_cutting_blade_usage_time"),)),
|
||||
# Lifetime statistics sensors (SECONDS, suggested h) carry two more
|
||||
# duties: undercarriage washing by mowing time, contact cleaning by
|
||||
# docking cycles (unitless counter -> delta target is the count).
|
||||
@@ -40,13 +39,22 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
),
|
||||
"landroid_cloud": IntegrationSignature(
|
||||
name="Worx Landroid",
|
||||
verified="2026-07-17 @ MTrab/landroid_cloud master",
|
||||
verified="2026-07-17 (sensors) / 2026-09-27 (reset) @ MTrab/landroid_cloud master",
|
||||
source=(
|
||||
"MTrab/landroid_cloud custom_components/landroid_cloud/sensor.py "
|
||||
"(translation_key 'blade_runtime_current' — since last reset, DURATION min→h)"
|
||||
"(translation_key 'blade_runtime_current' — since last reset, DURATION min→h). "
|
||||
"button.py tk 'reset_blade_time' (CONFIG, disabled by default; "
|
||||
"en 'Reset blade runtime' → suffix _reset_blade_runtime) → "
|
||||
"pyworxcloud reset_blade_counter moves the reset marker, so the "
|
||||
"current runtime drops to 0 (verified @ f30af7f)."
|
||||
),
|
||||
tasks=(
|
||||
LANDROID_BLADE_RUNTIME,
|
||||
ConsumableSignature(
|
||||
("blade_runtime_current",),
|
||||
"Replace Blades",
|
||||
"usage_above",
|
||||
resets=(("blade_runtime_current", "reset_blade_time"),),
|
||||
),
|
||||
LANDROID_UNDERCARRIAGE_RUNTIME,
|
||||
),
|
||||
),
|
||||
@@ -85,40 +93,58 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
),
|
||||
"sunseeker": IntegrationSignature(
|
||||
name="Sunseeker mowers",
|
||||
verified="2026-07-20 @ Sdahl1234/Sunseeker-lawn-mower main sensor.py (HACS default)",
|
||||
verified="2026-07-20 (sensors) / 2026-09-27 (parts split, resets) @ Sdahl1234/Sunseeker-lawn-mower main (HACS default)",
|
||||
source=(
|
||||
"HACS sunseeker (also Ambrogio/Techline via ZCS): REAL blade-wear "
|
||||
"sensors — 'Blade time left' / 'Cutterplade time left' / 'Small "
|
||||
"blade time left' (UnitOfTime.HOURS remaining, tk "
|
||||
"sunseeker_*_time_left) and the matching '*_health' (PERCENTAGE "
|
||||
"remaining). Dual-unit like the LG filter: hours→duration_left, "
|
||||
"percent→percent_left, both the one blade-replacement duty."
|
||||
"HACS sunseeker (also Ambrogio/Techline via ZCS), models S/X: "
|
||||
"four consumables, each with its own reset — 'Blade' (tk "
|
||||
"sunseeker_blade_time_left, HOURS, and sunseeker_blade_health, %), "
|
||||
"'Cutterplade' = the cutting disc (sunseeker_cutterplade_*), and "
|
||||
"with support_edge_trim the edge trimmer's 'Small blade' / 'Small "
|
||||
"cutterplade' (sunseeker_small_*); remaining = mp minus at of the "
|
||||
"consumable item. button.py (tk set on every entity): "
|
||||
"sunseeker_reset_blade / _reset_bladeplade / _reset_small_blade / "
|
||||
"_reset_small_bladeplade → maintain_consumable_item "
|
||||
"blade/cutter/small_blade/small_cutter (verified @ 659ef13). The "
|
||||
"parts wear and are reset independently, so each is its own duty "
|
||||
"(one reset per duty; the former any-low 'Replace Blades' over "
|
||||
"all parts could only have pressed one of them). The blade keeps "
|
||||
"both directions (hours + %); the other parts use the % sensor. "
|
||||
"Plain alias keys only match entities without a translation_key "
|
||||
"(translation_keys_authoritative) and are never paired with a "
|
||||
"reset."
|
||||
),
|
||||
translation_keys_authoritative=True,
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
(
|
||||
"blade_time_left",
|
||||
"cutterplade_time_left",
|
||||
"small_blade_time_left",
|
||||
"sunseeker_blade_time_left",
|
||||
"sunseeker_cutterplade_time_left",
|
||||
"sunseeker_small_blade_time_left",
|
||||
),
|
||||
("blade_time_left", "sunseeker_blade_time_left"),
|
||||
"Replace Blades",
|
||||
"duration_left",
|
||||
below_hours=24,
|
||||
resets=(("sunseeker_blade_time_left", "sunseeker_reset_blade"),),
|
||||
),
|
||||
ConsumableSignature(
|
||||
(
|
||||
"blade_health",
|
||||
"cutterplade_health",
|
||||
"small_blade_health",
|
||||
"sunseeker_blade_health",
|
||||
"sunseeker_cutterplade_health",
|
||||
"sunseeker_small_blade_health",
|
||||
),
|
||||
("blade_health", "sunseeker_blade_health"),
|
||||
"Replace Blades",
|
||||
"percent_left",
|
||||
resets=(("sunseeker_blade_health", "sunseeker_reset_blade"),),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("cutterplade_health", "sunseeker_cutterplade_health"),
|
||||
"Replace Cutting Disc",
|
||||
"percent_left",
|
||||
resets=(("sunseeker_cutterplade_health", "sunseeker_reset_bladeplade"),),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("small_blade_health", "sunseeker_small_blade_health"),
|
||||
"Replace Edge Trimmer Blade",
|
||||
"percent_left",
|
||||
resets=(("sunseeker_small_blade_health", "sunseeker_reset_small_blade"),),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("small_cutterplade_health", "sunseeker_small_cutterplade_health"),
|
||||
"Replace Edge Trimmer Disc",
|
||||
"percent_left",
|
||||
resets=(("sunseeker_small_cutterplade_health", "sunseeker_reset_small_bladeplade"),),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -199,9 +225,22 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"HACS dreame_mower sensor.py DreameMowerProperty.BLADES_LEFT → "
|
||||
"key/translation_key 'blades_left' (dreame/const.py; siid 9 piid "
|
||||
"2, UNIT_PERCENT — device.blades_life 'blade remaining life in "
|
||||
"percent'; only created when the mower reports it, exists_fn)."
|
||||
"percent'; only created when the mower reports it, exists_fn). "
|
||||
"button.py RESET_BLADES: entity.py sets translation_key = "
|
||||
"ACTION_TO_NAME key 'reset_blades' (entity_id <device>_reset_blades); "
|
||||
"device.py sets BLADES_LEFT back to 100 (verified @ 33a89dd). The "
|
||||
"button is unavailable while the blades are at 100 %. The sibling "
|
||||
"'blades_time_left' (hours, same reset) is the same duty and is not "
|
||||
"signed twice."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
("blades_left",),
|
||||
"Replace Blades",
|
||||
"percent_left",
|
||||
resets=(("blades_left", "reset_blades"),),
|
||||
),
|
||||
),
|
||||
tasks=(ConsumableSignature(("blades_left",), "Replace Blades", "percent_left"),),
|
||||
),
|
||||
# Two HACS forks share the domain and the keys (SmartServicePL and
|
||||
# ADNPolymerase) — mirrors landroid_cloud.
|
||||
@@ -213,10 +252,18 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"'blade_runtime_current' (blades.current_on or blade_work_time "
|
||||
"minus the reset marker — since the last blade reset, MINUTES) "
|
||||
"and 'mower_runtime_total' (worktime_total, MINUTES, "
|
||||
"TOTAL_INCREASING lifetime)."
|
||||
"TOTAL_INCREASING lifetime). button.py (both forks, verified @ "
|
||||
"ddccee6 / 2540f6c): tk 'reset_blade_counter' (CONFIG, enabled; "
|
||||
"note: not landroid_cloud's 'reset_blade_time') → pyworxcloud "
|
||||
"reset_blade_counter, the same reset marker."
|
||||
),
|
||||
tasks=(
|
||||
LANDROID_BLADE_RUNTIME,
|
||||
ConsumableSignature(
|
||||
("blade_runtime_current",),
|
||||
"Replace Blades",
|
||||
"usage_above",
|
||||
resets=(("blade_runtime_current", "reset_blade_counter"),),
|
||||
),
|
||||
LANDROID_UNDERCARRIAGE_RUNTIME,
|
||||
),
|
||||
),
|
||||
@@ -233,7 +280,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
),
|
||||
"hotspring": IntegrationSignature(
|
||||
name="Hot Spring spas",
|
||||
verified="2026-09-25 @ home-assistant/core dev",
|
||||
verified="2026-09-25 (cartridge) / 2026-09-27 (10-day check) @ home-assistant/core 2026.9 + dev",
|
||||
source=(
|
||||
"core hotspring sensor.py (new in 2026.9): tk "
|
||||
"'water_care_120_day_timer' ('Salt cartridge age' — per the "
|
||||
@@ -241,9 +288,19 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"cartridge has been in use', i.e. counts UP; DURATION DAYS; only "
|
||||
"created while a cartridge is installed; python-hotspring passes "
|
||||
"the spa's raw 120DayTimer). Hot Spring rates the cartridge for "
|
||||
"up to four months → 120 days = 2,880 h."
|
||||
"up to four months → 120 days = 2,880 h. tk "
|
||||
"'water_care_10_day_timer' ('Salt 10-day check timer', DURATION "
|
||||
"DAYS, same cartridge gate) — the docs: 'number of days remaining "
|
||||
"until the next 10-day salt water test reminder', their example "
|
||||
"automation fires below 1 day → duration_left at 24 h (= 1 d) "
|
||||
"for the test-strip water test; the task auto-resolves when the "
|
||||
"spa's timer starts over. 'water_care_salt_value' (unitless "
|
||||
"reading, no documented scale) is not used."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(("water_care_120_day_timer",), "Replace Salt Cartridge", "usage_above", above_hours=2880),
|
||||
ConsumableSignature(("water_care_10_day_timer",), "Water Test", "duration_left"),
|
||||
),
|
||||
tasks=(ConsumableSignature(("water_care_120_day_timer",), "Replace Salt Cartridge", "usage_above", above_hours=2880),),
|
||||
),
|
||||
"neopool": IntegrationSignature(
|
||||
name="Sugar Valley NeoPool",
|
||||
@@ -257,7 +314,12 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"8,000 hours'. NOTE: 'measure_cl' is chlorine ppm despite its "
|
||||
"'Salt level' label and is never mapped to salt; "
|
||||
"cell_runtime_part has no documented cleaning interval (cells "
|
||||
"self-clean by polarity reversal) and is not used."
|
||||
"self-clean by polarity reversal) and is not used. Re-checked "
|
||||
"2026-09-27 @ core 2026.9: button.py 'reset_cell_partial' "
|
||||
"(disabled by default) calls neopool_modbus "
|
||||
"async_reset_user_counters, which clears the cell partial "
|
||||
"runtime AND the ION/UV partial work-time — no duty watches "
|
||||
"those counters, so it stays unwired."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
@@ -292,4 +354,121 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
),
|
||||
),
|
||||
),
|
||||
# --- Round 15 (2026-09-27): HACS mowers and pool dosing -----------------
|
||||
"robonect": IntegrationSignature(
|
||||
name="Robonect (Husqvarna/Gardena/Flymo)",
|
||||
verified="2026-09-27 @ geertmeersman/robonect main (114b8ca)",
|
||||
source=(
|
||||
"HACS robonect definitions.py: 'mower/blades/hours' "
|
||||
"($.status.blades.hours — blade hours since the last blade reset; "
|
||||
"DURATION, h, TOTAL_INCREASING) and 'mower/statistic/hours' "
|
||||
"($.status.status.hours, lifetime mowing hours); entity.py sets "
|
||||
"translation_key = slugify(key.replace('/', '_')) → tk "
|
||||
"'mower_blades_hours' / 'mower_statistic_hours'. button.py "
|
||||
"'blades_reset' (cmd reset_blades; created when the REST API is "
|
||||
"enabled) resets the blade counter. Same 100 h blades / 25 h "
|
||||
"undercarriage cadence as the Husqvarna Automower entry."
|
||||
),
|
||||
translation_keys_authoritative=True,
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
("mower_blades_hours",),
|
||||
"Replace Blades",
|
||||
"usage_above",
|
||||
resets=(("mower_blades_hours", "blades_reset"),),
|
||||
),
|
||||
ConsumableSignature(("mower_statistic_hours",), "Clean Undercarriage", "usage_delta", delta_units=25),
|
||||
),
|
||||
),
|
||||
"indego": IntegrationSignature(
|
||||
name="Bosch Indego",
|
||||
verified="2026-09-27 @ sander1988/Indego develop (bf10e09)",
|
||||
source=(
|
||||
"HACS indego __init__.py ENTITY_RUNTIME: tk 'runtime_total' "
|
||||
"(DURATION, h, TOTAL_INCREASING; state = runtime.total.cut, never "
|
||||
"allowed to decrease) — LIFETIME cutting hours → usage_delta, like "
|
||||
"the Gardena operating-hours pair (100 h blades / 25 h "
|
||||
"undercarriage). 'maintenance_hours' (lifetime operate hours, "
|
||||
"integration-side good/due banding, not resettable) is not used; "
|
||||
"the mower's own blade reminder arrives via the problem-class "
|
||||
"alert binary (adoption path). No reset button."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(("runtime_total",), "Replace Blades", "usage_delta", delta_units=100),
|
||||
ConsumableSignature(("runtime_total",), "Clean Undercarriage", "usage_delta", delta_units=25),
|
||||
),
|
||||
),
|
||||
"dreame_lawn_mower": IntegrationSignature(
|
||||
name="Dreame / MOVA lawn mowers",
|
||||
verified="2026-09-27 @ EvotecIT/homeassistant-dreamelawnmower main (967e50b)",
|
||||
source=(
|
||||
"HACS dreame_lawn_mower (not the dreame_mower domain) "
|
||||
"sensor_operations.py DreameLawnMowerMaintenanceRemainingSensor: "
|
||||
"name '<item> Remaining' under has_entity_name, no "
|
||||
"translation_key, '%' = the cloud CMS counter's remaining_percent "
|
||||
"— items (dreame_lawn_mower_client/maintenance.py) 'Blade' (6000 "
|
||||
"min), 'Cleaning Brush' (30000 min), 'Robot Maintenance' (3600 "
|
||||
"min) → suffixes _blade_remaining / _cleaning_brush_remaining / "
|
||||
"_robot_maintenance_remaining. button.py "
|
||||
"DreameLawnMowerResetMaintenanceButton 'Reset <item> Maintenance' "
|
||||
"(DIAGNOSTIC, disabled by default — wiring enables it) resets "
|
||||
"that item's counter. 'Robot Maintenance' (the app's 60-hour "
|
||||
"check) is mapped to Clean Undercarriage."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
("blade_remaining",),
|
||||
"Replace Blades",
|
||||
"percent_left",
|
||||
resets=(("blade_remaining", "reset_blade_maintenance"),),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("cleaning_brush_remaining",),
|
||||
"Replace Cleaning Brush",
|
||||
"percent_left",
|
||||
resets=(("cleaning_brush_remaining", "reset_cleaning_brush_maintenance"),),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("robot_maintenance_remaining",),
|
||||
"Clean Undercarriage",
|
||||
"percent_left",
|
||||
resets=(("robot_maintenance_remaining", "reset_robot_maintenance_maintenance"),),
|
||||
),
|
||||
),
|
||||
),
|
||||
"bayrol": IntegrationSignature(
|
||||
name="Bayrol pool (Automatic SALT / Cl-pH, PoolManager 5)",
|
||||
verified="2026-09-27 @ 0xQuantumHome/bayrol-home-hassistant main (c84c5ca)",
|
||||
source=(
|
||||
"HACS bayrol sensor.py BayrolSensor: entity_id = "
|
||||
"sensor.bayrol_<device_id>_<normalize_entity_id_part(name)>, no "
|
||||
"translation_key. const.py: 'pH Minus Canister Status' (5.80, "
|
||||
"Automatic) and 'pH Canister Level' (5.6064, PM5); 'Cl Canister "
|
||||
"Status' (5.169, Automatic Cl-pH), 'Cl Canister Level' / 'Redox "
|
||||
"Canister Level' (5.6066 / 5.6068, PM5); the decode table maps "
|
||||
"19.259 and 7523 to the state 'Empty' (19.258 'Not Empty', 7521 "
|
||||
"'Full', 7522 'Low') → latch on 'Empty', cleared by a new "
|
||||
"canister. 'Salt To Add' (4.138, kg, Automatic SALT) = salt "
|
||||
"missing to the preferred level → alert_above 5 kg (editorial, a "
|
||||
"fifth of a 25 kg bag; clears once salted). 'Missing' canisters "
|
||||
"and the salt alarms (messages sensor only) are faults, not "
|
||||
"duties."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
("ph_minus_canister_status", "ph_canister_level"),
|
||||
"Replace pH Canister",
|
||||
"event_present",
|
||||
on_states=("Empty",),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("cl_canister_status", "cl_canister_level", "redox_canister_level"),
|
||||
"Replace Chlorine Canister",
|
||||
"event_present",
|
||||
on_states=("Empty",),
|
||||
per_entity=True,
|
||||
),
|
||||
ConsumableSignature(("salt_to_add",), "Refill Pool Salt", "alert_above", delta_units=5),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -101,15 +101,20 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
),
|
||||
"ecowater_softener": IntegrationSignature(
|
||||
name="EcoWater softener",
|
||||
verified="2026-07-19 @ barleybobs/homeassistant-ecowater-softener master (HACS default)",
|
||||
verified="2026-07-19 / 2026-09-27 (days key fixed) @ barleybobs/homeassistant-ecowater-softener master (HACS default)",
|
||||
source=(
|
||||
"HACS ecowater_softener (barleybobs): key 'salt_level_percentage' "
|
||||
"(PERCENTAGE) and key 'out_of_salt_days' (days) — name-style "
|
||||
"entities, suffix-matched."
|
||||
"HACS ecowater_softener (barleybobs) sensor.py: EcowaterSensor "
|
||||
"has_entity_name, no translation_key, so the entity id comes from "
|
||||
"the NAME: 'Salt Level Percentage' (PERCENTAGE) → suffix "
|
||||
"_salt_level_percentage and 'Days Until Out of Salt' "
|
||||
"(UnitOfTime.DAYS) → suffix _days_until_out_of_salt. (Until "
|
||||
"2026-09-27 the catalog used the description key "
|
||||
"'out_of_salt_days', which no entity id carries — the days duty "
|
||||
"never matched; verified @ 6403e5c.)"
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(("salt_level_percentage",), "Refill Softener Salt", "percent_left"),
|
||||
ConsumableSignature(("out_of_salt_days",), "Refill Softener Salt", "duration_left", below_hours=168),
|
||||
ConsumableSignature(("days_until_out_of_salt",), "Refill Softener Salt", "duration_left", below_hours=168),
|
||||
),
|
||||
),
|
||||
"wolflink": IntegrationSignature(
|
||||
@@ -150,18 +155,32 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
),
|
||||
"grohe_smarthome": IntegrationSignature(
|
||||
name="Grohe Blue",
|
||||
verified="2026-07-19 @ flo-schilli/ha-grohe_smarthome main (HACS default)",
|
||||
verified="2026-07-19 / 2026-09-27 (resets) @ Flo-Schilli/ha-grohe_smarthome main (HACS default)",
|
||||
source=(
|
||||
"HACS grohe_smarthome, yaml-driven name-derived entities "
|
||||
"(config/config.yaml, GroheBlueHome/GroheBlueProf): 'Remaining "
|
||||
"Filter' (%) and 'Remaining CO2' (%) — reset by the device's own "
|
||||
"filter/CO2 reset commands. The sibling 'Remaining Filter (App)' "
|
||||
"slugs to _remaining_filter_app and cannot clash with the exact "
|
||||
"_remaining_filter suffix."
|
||||
"HACS grohe_smarthome (owner now 'Flo-Schilli'), yaml-driven "
|
||||
"name-derived entities (config/config.yaml, "
|
||||
"GroheBlueHome/GroheBlueProf): 'Remaining Filter' (%) and "
|
||||
"'Remaining CO2' (%). The sibling 'Remaining Filter (App)' slugs "
|
||||
"to _remaining_filter_app and cannot clash with the exact "
|
||||
"_remaining_filter suffix. The same yaml declares the buttons "
|
||||
"'Reset Filter' (command.filter_status_reset) and 'Reset CO2' "
|
||||
"(command.co2_status_reset) — has_entity_name, no "
|
||||
"translation_key → suffixes _reset_filter / _reset_co2 (verified "
|
||||
"@ 806abcd)."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(("remaining_filter",), "Replace Water Filter", "percent_left"),
|
||||
ConsumableSignature(("remaining_co2",), "Replace CO2 Bottle", "percent_left"),
|
||||
ConsumableSignature(
|
||||
("remaining_filter",),
|
||||
"Replace Water Filter",
|
||||
"percent_left",
|
||||
resets=(("remaining_filter", "reset_filter"),),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("remaining_co2",),
|
||||
"Replace CO2 Bottle",
|
||||
"percent_left",
|
||||
resets=(("remaining_co2", "reset_co2"),),
|
||||
),
|
||||
),
|
||||
),
|
||||
"iqua_softener": IntegrationSignature(
|
||||
@@ -202,7 +221,12 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
source=(
|
||||
"core aquacell: tk 'salt_left_side_percentage' and "
|
||||
"'salt_right_side_percentage' (PERCENTAGE) — dual salt tanks, "
|
||||
"matched any-low."
|
||||
"matched any-low. 2026-09-27 @ core 2026.9: also tk "
|
||||
"'salt_left_side_time_remaining' / 'salt_right_side_time_remaining' "
|
||||
"(DURATION, DAYS) — the softener's own days-of-salt forecast, a "
|
||||
"week's lead like the BWT/EcoWater days duties (adopting takes one "
|
||||
"'Refill Softener Salt' task per object, whichever is ticked "
|
||||
"first)."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
@@ -210,6 +234,12 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"Refill Softener Salt",
|
||||
"percent_left",
|
||||
),
|
||||
ConsumableSignature(
|
||||
("salt_left_side_time_remaining", "salt_right_side_time_remaining"),
|
||||
"Refill Softener Salt",
|
||||
"duration_left",
|
||||
below_hours=168,
|
||||
),
|
||||
),
|
||||
),
|
||||
# ─── Round 14 (2026-09-25): boiler pressure, softener salt, ─────────
|
||||
@@ -318,4 +348,93 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
),
|
||||
tasks=(ConsumableSignature(("engine_hours",), "Oil Service", "usage_delta", delta_units=250),),
|
||||
),
|
||||
# ─── Round 15 (2026-09-27): water-treatment cartridges and salt, ────
|
||||
# ─── generator service countdown ─────────────────────────────────────
|
||||
"drop_connect": IntegrationSignature(
|
||||
name="DROP (water treatment)",
|
||||
verified="2026-09-27 @ home-assistant/core 2026.9 + dev",
|
||||
source=(
|
||||
"core drop_connect (one config entry per DROP device, "
|
||||
"DEVICE_SENSORS / DEVICE_BINARY_SENSORS by device type). RO filter "
|
||||
"('ro'): sensor.py tk 'cart1'/'cart2'/'cart3' ('Cartridge N life "
|
||||
"remaining', PERCENTAGE, MEASUREMENT, DIAGNOSTIC) — three "
|
||||
"cartridges replaced one at a time → per-entity 'Replace Water "
|
||||
"Filter'. Salt sensor ('salt'): binary_sensor.py tk 'salt' ('Salt "
|
||||
"low', NO device class, so problem-sensor adoption does not see "
|
||||
"it) → state latch on 'on', cleared by the refill. Skipped: the "
|
||||
"softener's 'capacity_remaining' (GALLONS of softening capacity "
|
||||
"until the next regeneration) / 'reserve_in_use' — the "
|
||||
"regeneration cycle, operational, not salt."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(("cart1", "cart2", "cart3"), "Replace Water Filter", "percent_left", per_entity=True),
|
||||
ConsumableSignature(
|
||||
("salt",),
|
||||
"Refill Softener Salt",
|
||||
"event_present",
|
||||
entity_domain="binary_sensor",
|
||||
on_states=("on",),
|
||||
),
|
||||
),
|
||||
),
|
||||
"victron_gx": IntegrationSignature(
|
||||
name="Victron GX (generator)",
|
||||
verified="2026-09-27 @ home-assistant/core 2026.9 + dev, victron-mqtt 2026.8.4 + main",
|
||||
source=(
|
||||
"core victron_gx entity.py sets translation_key = the metric's "
|
||||
"generic_short_id; victron_mqtt _victron_topics.py "
|
||||
"'generator_service_counter' (N/…/generator/<id>/ServiceCounter, "
|
||||
"'Service counter', DURATION, h; core strings.json 'Service "
|
||||
"counter'). Venus OS dbus_generator startstop.py computes it as "
|
||||
"(lastservicereset + serviceinterval) − accumulated runtime, i.e. "
|
||||
"run-hours LEFT until the service the user configured (None while "
|
||||
"no interval is set; negative when overdue) → duration_left, "
|
||||
"task at 24 run-hours left. The counter reset "
|
||||
"(ServiceCounterReset) is a library SERVICE topic, not an HA "
|
||||
"button — nothing to wire."
|
||||
),
|
||||
tasks=(ConsumableSignature(("generator_service_counter",), "Oil Service", "duration_left"),),
|
||||
),
|
||||
# ─── Round 15 part 2 (2026-09-27): HACS heat pumps and stoves ────────
|
||||
"stiebel_eltron_isg": IntegrationSignature(
|
||||
name="Stiebel Eltron ISG (LWZ)",
|
||||
verified="2026-09-27 @ pail23/stiebel_eltron_isg_component main (f47bcf8)",
|
||||
source=(
|
||||
"HACS stiebel_eltron_isg binary_sensor.py LWZ_BINARY_SENSOR_TYPES "
|
||||
"(LWZ ventilation heat pumps only — WPM controllers get other "
|
||||
"lists): tk 'filter' (operating_status bit 8), "
|
||||
"'filter_extract_air' (bit 12) and 'filter_ventilation_air' (bit "
|
||||
"13) — the controller's filter-change requests; plain binaries "
|
||||
"with NO device class, so problem-sensor adoption does not see "
|
||||
"them → one latch on 'on' over the three (one filter change "
|
||||
"clears them together). The 'service' bit (6) has no documented "
|
||||
"meaning and is left out. Every entity description sets a "
|
||||
"translation_key."
|
||||
),
|
||||
translation_keys_authoritative=True,
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
("filter", "filter_extract_air", "filter_ventilation_air"),
|
||||
"Replace Ventilation Filter",
|
||||
"event_present",
|
||||
entity_domain="binary_sensor",
|
||||
on_states=("on",),
|
||||
),
|
||||
),
|
||||
),
|
||||
"aguaiot": IntegrationSignature(
|
||||
name="Micronova Agua IOT (hydro stoves)",
|
||||
verified="2026-09-27 @ vincentwolsink/home_assistant_micronova_agua_iot master (4d1883f)",
|
||||
source=(
|
||||
"HACS aguaiot const.py SENSORS (has_entity_name, name from the "
|
||||
"description, no translation_key; created only when the stove "
|
||||
"exposes the register): 'Water Pressure' (pres_h2o_get, BAR, "
|
||||
"hydro stoves) → suffix _water_pressure. 'Service Hours' "
|
||||
"(ore_service_get, h, TOTAL_INCREASING, hours since the last "
|
||||
"service) is NOT signed: its limit sibling 'Threshold Service "
|
||||
"Hours' also ends in _service_hours, and the suffix match cannot "
|
||||
"exclude it (no translation_key to tell them apart)."
|
||||
),
|
||||
tasks=(HEATING_WATER_PRESSURE_LOW,),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -8,18 +8,53 @@ integration's source; drift-probed weekly)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ._model import ConsumableSignature, IntegrationSignature
|
||||
from ._shared import FILTER_LIFE_PERCENT
|
||||
from ._shared import PRE_FILTER_CLEANING_PERCENT
|
||||
|
||||
# LG dishwasher "rinse refill needed": a plain binary (no problem class, so
|
||||
# problem-sensor adoption does not cover it) in both LG integrations — one
|
||||
# object, the DRY tripwire's verbatim-duplicate rule (both users live here).
|
||||
LG_RINSE_REFILL_LATCH = ConsumableSignature(
|
||||
("rinse_refill",),
|
||||
"Refill Rinse Aid",
|
||||
"event_present",
|
||||
entity_domain="binary_sensor",
|
||||
on_states=("on",),
|
||||
)
|
||||
|
||||
# Electrolux fridge water filter: both Electrolux integrations expose the
|
||||
# appliance's own 'waterFilterState' verdict (title-cased 'Change' when due)
|
||||
# and its 'waterFilterStateReset' write capability as a button.
|
||||
ELECTROLUX_WATER_FILTER_STATE = ConsumableSignature(
|
||||
("waterfilterstate",),
|
||||
"Replace Water Filter",
|
||||
"event_present",
|
||||
on_states=("Change",),
|
||||
resets=(("waterfilterstate", "waterfilterstatereset"),),
|
||||
)
|
||||
|
||||
SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"lg_thinq": IntegrationSignature(
|
||||
name="LG ThinQ",
|
||||
verified="2026-07-17 @ home-assistant/core dev + thinq-connect/pythinqconnect main",
|
||||
verified="2026-07-17 @ home-assistant/core dev + thinq-connect/pythinqconnect main; dishwasher/fridge latches 2026-09-27 @ home-assistant/core 2026.9",
|
||||
source=(
|
||||
"home-assistant/core homeassistant/components/lg_thinq/sensor.py "
|
||||
"(ThinQProperty StrEnum translation_key; FILTER_LIFETIME is shared by "
|
||||
"an HOURS description and a PERCENTAGE one — the unit-aware matcher "
|
||||
"routes each entity to the right direction) + "
|
||||
"thinq-connect/pythinqconnect devices/const.py Property members"
|
||||
"thinq-connect/pythinqconnect devices/const.py Property members. "
|
||||
"2026-09 round: binary_sensor.py RINSE_REFILL (dishwasher, "
|
||||
"pythinqconnect dish_washer.py dishWashingStatus.rinseRefill, no device_class, "
|
||||
"no on_key → is_on = the boolean) → latch on 'on'. sensor.py FRESH_AIR_FILTER "
|
||||
"(refrigerator / kimchi refrigerator, ENUM, created only when the property is "
|
||||
"READ_ONLY — a writable one is a select and is not matched; strings.json state "
|
||||
"'replace': 'Replace filter') → latch on 'replace'; its PERCENTAGE twin "
|
||||
"FRESH_AIR_FILTER_REMAIN_PERCENT shares the translation_key and is kept out by "
|
||||
"the unit gate (a percent duty on that key would also claim the unit-less ENUM). "
|
||||
"Skipped: preference settings mCReminder / cleanLReminder / signalLevel "
|
||||
"(binaries machine_clean_reminder / clean_light_reminder / signal_level) and "
|
||||
"rinse_level (dispenser SETTING 0-4); used_time (MONTHS, the same water filter as "
|
||||
"water_filter_*_remain_percent — no gate can keep both off one fridge) and "
|
||||
"water_filter_state (values undocumented)."
|
||||
),
|
||||
tasks=(
|
||||
# AC filter reports hours-remaining; air-purifier/RAC filters report
|
||||
@@ -36,6 +71,10 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"Replace Water Filter",
|
||||
"percent_left",
|
||||
),
|
||||
# Fridge Pure-N-Fresh filter: the ENUM shows 'replace' when due and
|
||||
# leaves it after the swap + reset on the appliance (auto-resolve).
|
||||
ConsumableSignature(("fresh_air_filter",), "Replace Filter", "event_present", on_states=("replace",)),
|
||||
LG_RINSE_REFILL_LATCH,
|
||||
),
|
||||
),
|
||||
"smartthinq_sensors": IntegrationSignature(
|
||||
@@ -73,13 +112,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
# Dishwasher refill alerts: plain binaries (no problem class) →
|
||||
# state latch; the appliance clearing them after a refill resolves
|
||||
# the task. Enable the entities first (disabled-by-default).
|
||||
ConsumableSignature(
|
||||
("rinse_refill",),
|
||||
"Refill Rinse Aid",
|
||||
"event_present",
|
||||
entity_domain="binary_sensor",
|
||||
on_states=("on",),
|
||||
),
|
||||
LG_RINSE_REFILL_LATCH,
|
||||
ConsumableSignature(
|
||||
("salt_refill",),
|
||||
"Refill Salt",
|
||||
@@ -184,7 +217,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
),
|
||||
"homeconnect_ws": IntegrationSignature(
|
||||
name="Home Connect Local",
|
||||
verified="2026-09-25 @ chris-mc1/homeconnect_local_hass main (HACS default)",
|
||||
verified="2026-09-25 @ chris-mc1/homeconnect_local_hass main (HACS default); resets + fridge filter 2026-09-27 @ c20ce8f",
|
||||
source=(
|
||||
"HACS homeconnect_ws: entity.py sets translation_key = description "
|
||||
"key. entity_descriptions/dishcare.py 'sensor_salt' / "
|
||||
@@ -203,16 +236,40 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"relation to the separate descale countdown is undocumented), "
|
||||
"'sensor_machinecare_remaining_runs' and the dishwasher/washer/"
|
||||
"dryer reminder binaries (device_class problem → problem-sensor "
|
||||
"adoption)."
|
||||
"adoption). 2026-09-27: cooking.py button 'button_hood_grease_filter_reset' "
|
||||
"(Cooking.Common.Command.Hood.GreaseFilterReset, CONFIG; press = "
|
||||
"Command.set_value(True)) resets the grease saturation; refrigeration.py "
|
||||
"'sensor_water_filter_saturation' (Refrigeration.Common.Status.Dispenser."
|
||||
"WaterFilterSaturation, %, counts UP) with its reset 'button_water_filter_reset' "
|
||||
"(Dispenser.WaterFilterReset) → alert_above 90 like the hood filters. The carbon "
|
||||
"filter reset stays unwired: regenerative-filter hoods also carry "
|
||||
"'button_hood_regenerative_carbon_filter_reset' / '_lifetime_reset', so which "
|
||||
"button resets 'sensor_carbon_filter_saturation' is not established."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(("sensor_salt",), "Refill Salt", "event_present", ok_state="full"),
|
||||
ConsumableSignature(("sensor_rinse_aid",), "Refill Rinse Aid", "event_present", ok_state="full"),
|
||||
ConsumableSignature(("sensor_grease_filter_saturation",), "Clean Grease Filter", "alert_above", delta_units=90),
|
||||
ConsumableSignature(
|
||||
("sensor_grease_filter_saturation",),
|
||||
"Clean Grease Filter",
|
||||
"alert_above",
|
||||
delta_units=90,
|
||||
resets=(("sensor_grease_filter_saturation", "button_hood_grease_filter_reset"),),
|
||||
),
|
||||
ConsumableSignature(("sensor_carbon_filter_saturation",), "Replace Filter", "alert_above", delta_units=90),
|
||||
ConsumableSignature(("sensor_countdown_descaling",), "Descale Appliance", "value_below", delta_units=10),
|
||||
ConsumableSignature(("sensor_countdown_cleaning",), "Clean Appliance", "value_below", delta_units=10),
|
||||
ConsumableSignature(("sensor_countdown_water_filter",), "Replace Water Filter", "value_below", delta_units=10),
|
||||
# Fridge water dispenser filter: saturation counts UP, the fridge's
|
||||
# own reset brings it back to 0 (coffee machines use the countdown
|
||||
# above — different appliances, different direction).
|
||||
ConsumableSignature(
|
||||
("sensor_water_filter_saturation",),
|
||||
"Replace Water Filter",
|
||||
"alert_above",
|
||||
delta_units=90,
|
||||
resets=(("sensor_water_filter_saturation", "button_water_filter_reset"),),
|
||||
),
|
||||
),
|
||||
),
|
||||
"miele": IntegrationSignature(
|
||||
@@ -248,18 +305,36 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
),
|
||||
"electrolux_status": IntegrationSignature(
|
||||
name="Electrolux / AEG",
|
||||
verified="2026-07-18 @ albaintor/homeassistant_electrolux_status master",
|
||||
verified="2026-07-18 @ albaintor/homeassistant_electrolux_status master; fridge filters 2026-09-27 @ bd55519",
|
||||
source=(
|
||||
"albaintor/homeassistant_electrolux_status catalog_purifier.py "
|
||||
"'FilterLife' (PERCENTAGE) + entity.py entity_id = "
|
||||
"f'..._{entity_attr}' — HA slugifies the raw 'FilterLife' tail, so "
|
||||
"both slug forms are matched."
|
||||
"both slug forms are matched. 2026-09-27: catalog_core.py fridge "
|
||||
"'waterFilterState' / 'airFilterState' (read, values BUY / CHANGE / CLEAN / "
|
||||
"GOOD; sensor.py title-cases string states → 'Change') with their write "
|
||||
"capabilities 'waterFilterStateReset' / 'airFilterStateReset' (values "
|
||||
"{'RESET'} → ONE button each, api.py: write access = BUTTON) — entity ids "
|
||||
"end in _waterfilterstate / _waterfilterstatereset (entity.py lowercases the "
|
||||
"raw id). Latch on 'Change' (BUY is the order-a-spare pre-warning). The "
|
||||
"'*LifeTime' seconds counters are skipped (used vs left undocumented; the "
|
||||
"state is the fridge's own verdict against its thresholds)."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(("filterlife", "filter_life"), "Replace Filter", "percent_left"),
|
||||
ELECTROLUX_WATER_FILTER_STATE,
|
||||
ConsumableSignature(
|
||||
("airfilterstate",),
|
||||
"Replace Filter",
|
||||
"event_present",
|
||||
on_states=("Change",),
|
||||
resets=(("airfilterstate", "airfilterstatereset"),),
|
||||
),
|
||||
),
|
||||
tasks=(ConsumableSignature(("filterlife", "filter_life"), "Replace Filter", "percent_left"),),
|
||||
),
|
||||
"midea_ac_lan": IntegrationSignature(
|
||||
name="Midea (LAN)",
|
||||
verified="2026-07-18 / re-audited 2026-09-25 @ wuwentao/midea_ac_lan master",
|
||||
verified="2026-07-18 / re-audited 2026-09-25; keys re-checked 2026-09-27 @ wuwentao/midea_ac_lan main (the default branch)",
|
||||
source=(
|
||||
"wuwentao/midea_ac_lan midea_devices.py + midea_entity.py "
|
||||
"(_attr_translation_key from the per-attribute config; entity_id = "
|
||||
@@ -342,28 +417,43 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
),
|
||||
"hon": IntegrationSignature(
|
||||
name="Haier hOn (Haier/Candy/Hoover)",
|
||||
verified="2026-07-19 @ Andre0512/hon main sensor.py (1.5k stars; open #101 ask)",
|
||||
verified="2026-09-27 @ gvigroux/hon master (cbc0376, the HACS-default fork) + Andre0512/hon main (70eb6c0, idle since 2024-08)",
|
||||
source=(
|
||||
"HACS hon: purifiers (type AP) expose tk 'filter_life' (main "
|
||||
"filter, %) and tk 'filter_cleaning' (pre-filter, %); washers "
|
||||
"(WM/WD) expose tk 'cycles_total' (lifetime wash-cycle counter) — "
|
||||
"tub-clean cadence reuses LG's manufacturer value of 30 cycles."
|
||||
"Two forks share the domain. gvigroux/hon (the store's 'hon' today) "
|
||||
"sensor.py: no translation_key, _attr_name = f'{nickName} {sensor_name}' → "
|
||||
"entity-id suffixes; purifiers (AP) HonBaseMainFilter 'Main filter' / "
|
||||
"HonBasePreFilter 'Pre filter' = 100 − mainFilterStatus / preFilterStatus "
|
||||
"(PERCENTAGE, life LEFT); washers HonBaseTotalWashCycle 'Total wash cycle' "
|
||||
"(totalWashCycle − 1, TOTAL_INCREASING). Andre0512/hon (older installs) "
|
||||
"reports the RAW mainFilterStatus / preFilterStatus under tk 'filter_life' / "
|
||||
"'filter_cleaning' and tk 'cycles_total'. The raw status counts UP (used): "
|
||||
"Andre0512/hon#244 — after a filter reset the hOn app shows 100 % life while "
|
||||
"the raw sensor reads 0 %, which is also why gvigroux inverts it. So the "
|
||||
"Andre0512 keys are wear gauges (alert_above 90; the former percent_left "
|
||||
"reading was inverted) and the gvigroux keys are life gauges (percent_left). "
|
||||
"Tub-clean cadence reuses LG's manufacturer value of 30 cycles."
|
||||
),
|
||||
tasks=(
|
||||
FILTER_LIFE_PERCENT,
|
||||
ConsumableSignature(("filter_cleaning",), "Filter Cleaning", "percent_left"),
|
||||
ConsumableSignature(("cycles_total",), "Clean Tub", "usage_delta", delta_units=30),
|
||||
ConsumableSignature(("main_filter",), "Replace Filter", "percent_left"),
|
||||
ConsumableSignature(("filter_life",), "Replace Filter", "alert_above", delta_units=90),
|
||||
PRE_FILTER_CLEANING_PERCENT,
|
||||
ConsumableSignature(("filter_cleaning",), "Filter Cleaning", "alert_above", delta_units=90),
|
||||
ConsumableSignature(("cycles_total", "total_wash_cycle"), "Clean Tub", "usage_delta", delta_units=30),
|
||||
),
|
||||
),
|
||||
"whirlpool": IntegrationSignature(
|
||||
name="Whirlpool",
|
||||
verified="2026-07-19 @ core/dev whirlpool/sensor.py",
|
||||
verified="2026-07-19 @ core/dev whirlpool/sensor.py; detergent tank 2026-09-27 @ home-assistant/core 2026.9",
|
||||
source=(
|
||||
"core whirlpool: tk 'washer_state' ENUM incl. 'running_maincycle' "
|
||||
"— no cycle counter exists, so the ENGINE accumulates wash time "
|
||||
"(the Miele Clean-Tub pattern; 60 h of washing ~= LG's 30-cycle "
|
||||
"cadence at a typical 2-h cycle). The dryer's distinct "
|
||||
"'dryer_state' tk cannot match."
|
||||
"'dryer_state' tk cannot match. 2026-09 round: washer key 'DispenseLevel', "
|
||||
"tk 'whirlpool_tank' ('Detergent level', ENUM from WASHER_TANK_FILL: "
|
||||
"empty / 25 / 50 / 100 / active; the bulk-dispenser tank, "
|
||||
"entity_registry_enabled_default=False) → latch on 'empty'; refilling moves "
|
||||
"it back to a level (auto-resolve)."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
@@ -373,6 +463,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
delta_units=60,
|
||||
on_states=("running_maincycle",),
|
||||
),
|
||||
ConsumableSignature(("whirlpool_tank",), "Refill Detergent", "event_present", on_states=("empty",)),
|
||||
),
|
||||
),
|
||||
"ha_washdata": IntegrationSignature(
|
||||
@@ -424,7 +515,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
),
|
||||
"traeger": IntegrationSignature(
|
||||
name="Traeger grill",
|
||||
verified="2026-07-20 @ njobrien1006/hass_traeger master + johnvoipguy/Traeger-WiFire main (HACS default, shared sensor map)",
|
||||
verified="2026-07-20 @ njobrien1006/hass_traeger master + johnvoipguy/Traeger-WiFire main (HACS default); jv drift fix 2026-09-27 @ 8971c5b",
|
||||
source=(
|
||||
"HACS traeger (both default-store forks share the domain and "
|
||||
"sensor map): 'Cook Cycle' sensor (usage;cook_cycles — lifetime "
|
||||
@@ -433,16 +524,22 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"Traeger's official maintenance guidance: grease management every "
|
||||
"few cooks, deep clean ~every 20 cooks / twice a grilling season. "
|
||||
"'Pellet Level' (%) is hopper inventory, not wear — skipped (same "
|
||||
"rationale as Palazzetti's pellet_level)."
|
||||
"rationale as Palazzetti's pellet_level). 2026-09-27: the johnvoipguy "
|
||||
"fork renamed it TraegerSensor 'Cook Cycles' (key 'cook_cycles', explicit "
|
||||
"entity_id f'sensor.{slugify(grill_id)}_{slugify(name)}' → suffix "
|
||||
"_cook_cycles) — both suffixes are matched. Its MaintenanceAlert sensors "
|
||||
"'Grill Clean Alert' / 'Grease Trap Clean Alert' (True/False from "
|
||||
"usage.*_countdown <= 3600 / <= 5 — the integration's own cut-offs, countdown "
|
||||
"units undocumented) are skipped."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(("cook_cycle",), "Clean Grease Trap", "usage_delta", delta_units=5),
|
||||
ConsumableSignature(("cook_cycle",), "Clean Appliance", "usage_delta", delta_units=20),
|
||||
ConsumableSignature(("cook_cycle", "cook_cycles"), "Clean Grease Trap", "usage_delta", delta_units=5),
|
||||
ConsumableSignature(("cook_cycle", "cook_cycles"), "Clean Appliance", "usage_delta", delta_units=20),
|
||||
),
|
||||
),
|
||||
"electrolux": IntegrationSignature(
|
||||
name="Electrolux (OCP API)",
|
||||
verified="2026-09-25 @ TTLucian/ha-electrolux main (HACS default; not the electrolux_status domain)",
|
||||
verified="2026-09-25 @ TTLucian/ha-electrolux main (HACS default; not the electrolux_status domain); filter states 2026-09-27 @ 60b28fa",
|
||||
source=(
|
||||
"HACS electrolux: entity.py sets translation_key = the lowercased "
|
||||
"capability name (fppn prefix stripped) → 'filterlife', "
|
||||
@@ -452,8 +549,17 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"their own FilterType_1/_2) → one duty per filter (per_entity). "
|
||||
"Skipped: AC/fridge/hood filter lifetimes and timers (seconds or "
|
||||
"minutes with the maintainer's own 'unit is an educated guess' "
|
||||
"caveat — remaining vs used unclear) and the filterState ENUMs "
|
||||
"(rendered state strings not verified)."
|
||||
"caveat — remaining vs used unclear). 2026-09-27: the filter STATE "
|
||||
"strings are verified — sensor.py native_value title-cases string "
|
||||
"values ('CHANGE' → 'Change'). catalogs/catalog_cr.py fridge "
|
||||
"'waterFilterState' / 'airFilterState' (GOOD / CLEAN / CHANGE / BUY) with "
|
||||
"write buttons 'waterFilterStateReset' / 'airFilterStateReset' (tk "
|
||||
"'waterfilterstatereset' / 'airfilterstatereset' in strings.json); "
|
||||
"catalog_ac.py 'hepaFilterState' (GOOD / BUY / CHANGE, disabled by default) "
|
||||
"with 'hepaFilterReset' → latch on 'Change' (BUY = order a spare). The AC's "
|
||||
"main 'filterState' is skipped: it also reports CLEAN for its washable "
|
||||
"filter and which of CLEAN / CHANGE the 'filterReset' button clears is "
|
||||
"not established."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
@@ -462,6 +568,15 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"percent_left",
|
||||
per_entity=True,
|
||||
),
|
||||
ELECTROLUX_WATER_FILTER_STATE,
|
||||
# Fridge air filter / AC HEPA filter — one appliance never has both.
|
||||
ConsumableSignature(
|
||||
("airfilterstate", "hepafilterstate"),
|
||||
"Replace Filter",
|
||||
"event_present",
|
||||
on_states=("Change",),
|
||||
resets=(("airfilterstate", "airfilterstatereset"), ("hepafilterstate", "hepafilterreset")),
|
||||
),
|
||||
),
|
||||
),
|
||||
"ge_home": IntegrationSignature(
|
||||
@@ -505,13 +620,157 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"due or overdue, back to the full interval after the "
|
||||
"integration's reset button) → value_below 1 fires exactly when "
|
||||
"Candy itself reports due. Skipped: 'wash_maint_full_checkup' "
|
||||
"(what the Simply-Fi 'Full Check-up' asks the user to do is "
|
||||
"undocumented)."
|
||||
"(button.py WashFullCheckUpButton sends CheckUpState=1 — the machine's "
|
||||
"self-diagnosis run, not a maintenance action; the integration schedules "
|
||||
"it itself via checkup_schedule). Resets verified 2026-09-27 @ 5e2999f: "
|
||||
"button.py tk 'wash_maint_limescale_reset' / 'wash_maint_filter_reset' "
|
||||
"(DIAGNOSTIC; press stores last_* = total cycles, the counter restarts)."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(("wash_total_cycles",), "Clean Tub", "usage_delta", delta_units=30),
|
||||
ConsumableSignature(("wash_maint_limescale",), "Descaling", "value_below", delta_units=1),
|
||||
ConsumableSignature(("wash_maint_filter",), "Filter Cleaning", "value_below", delta_units=1),
|
||||
# 2.95: full-control mode adds the reset buttons (button.py
|
||||
# WashMaintResetButton, tk '<counter>_reset'); read-only setups have
|
||||
# none and the duty simply keeps no completion action.
|
||||
ConsumableSignature(
|
||||
("wash_maint_limescale",),
|
||||
"Descaling",
|
||||
"value_below",
|
||||
delta_units=1,
|
||||
resets=(("wash_maint_limescale", "wash_maint_limescale_reset"),),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("wash_maint_filter",),
|
||||
"Filter Cleaning",
|
||||
"value_below",
|
||||
delta_units=1,
|
||||
resets=(("wash_maint_filter", "wash_maint_filter_reset"),),
|
||||
),
|
||||
),
|
||||
),
|
||||
"homewhiz": IntegrationSignature(
|
||||
name="HomeWhiz (Beko / Grundig / Arçelik)",
|
||||
verified="2026-09-27 @ home-assistant-HomeWhiz/home-assistant-HomeWhiz main (0b7f8d8, HACS default)",
|
||||
source=(
|
||||
"home-assistant-HomeWhiz custom_components/homewhiz/appliance_controls.py "
|
||||
"build_controls_from_warnings: every entry of the appliance's cloud config "
|
||||
"deviceWarnings becomes a BooleanBitmaskControl with key "
|
||||
"to_friendly_name(strKey) (lower-cased strKey); binary_sensor.py turns them "
|
||||
"into binary sensors and entity.py translation_key returns entity_key.lower() "
|
||||
"for EVERY entity. device_class is the custom string 'homewhiz__<key>' — NOT "
|
||||
"problem, so problem-sensor adoption does not reach them; the appliance clears "
|
||||
"the bit after the refill / cleaning (auto-resolve). strKeys verified in the "
|
||||
"repo's test fixtures: DISHWASHER_WARNING_NO_SALT / _NO_RINSE_AID / "
|
||||
"_CHECK_THE_FILTER / DISHWASHER_LIQUID_DETERGENT_LOW, WASHER_WARNING_NO_LIQUID_"
|
||||
"DETERGENT / _NO_POWDER_DETERGENT / _LOW_DETERGENT / _NO_SOFTENER / _LOW_SOFTENER "
|
||||
"(auto-dosing tanks), DRYER_WARNING_CHECK_THE_FILTER / _CHECK_THE_CONDENSER_FILTER. "
|
||||
"Skipped: *_NO_WATER / *_DOOR_IS_OPEN / *_CALL_SERVICE (status or faults), "
|
||||
"DRYER_WARNING_TANKFULL (emptying the condensate tank is per-load operation), "
|
||||
"OVEN_WARNING_PYRO_CLEANING (a cleaning cycle in progress)."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
("dishwasher_warning_no_salt",),
|
||||
"Refill Salt",
|
||||
"event_present",
|
||||
entity_domain="binary_sensor",
|
||||
on_states=("on",),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("dishwasher_warning_no_rinse_aid",),
|
||||
"Refill Rinse Aid",
|
||||
"event_present",
|
||||
entity_domain="binary_sensor",
|
||||
on_states=("on",),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("dishwasher_warning_check_the_filter",),
|
||||
"Filter Cleaning",
|
||||
"event_present",
|
||||
entity_domain="binary_sensor",
|
||||
on_states=("on",),
|
||||
),
|
||||
# Auto-dosing tanks: the liquid and the powder tank are refilled
|
||||
# separately → one task per warning entity.
|
||||
ConsumableSignature(
|
||||
(
|
||||
"dishwasher_liquid_detergent_low",
|
||||
"washer_warning_low_detergent",
|
||||
"washer_warning_no_liquid_detergent",
|
||||
"washer_warning_no_powder_detergent",
|
||||
),
|
||||
"Refill Detergent",
|
||||
"event_present",
|
||||
entity_domain="binary_sensor",
|
||||
on_states=("on",),
|
||||
per_entity=True,
|
||||
),
|
||||
ConsumableSignature(
|
||||
("washer_warning_no_softener", "washer_warning_low_softener"),
|
||||
"Refill Fabric Softener",
|
||||
"event_present",
|
||||
entity_domain="binary_sensor",
|
||||
on_states=("on",),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("dryer_warning_check_the_filter",),
|
||||
"Lint Filter Cleaning",
|
||||
"event_present",
|
||||
entity_domain="binary_sensor",
|
||||
on_states=("on",),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("dryer_warning_check_the_condenser_filter",),
|
||||
"Condenser Cleaning",
|
||||
"event_present",
|
||||
entity_domain="binary_sensor",
|
||||
on_states=("on",),
|
||||
),
|
||||
),
|
||||
translation_keys_authoritative=True,
|
||||
),
|
||||
"connectlife": IntegrationSignature(
|
||||
name="ConnectLife (Hisense / Gorenje / ASKO)",
|
||||
verified="2026-09-27 @ oyvindwe/connectlife-ha main (fa830d2, HACS default)",
|
||||
source=(
|
||||
"oyvindwe/connectlife-ha: entities come from data_dictionaries/<type>.yaml; "
|
||||
"entity.py to_translation_key = property lower-cased (spaces → '_'), set on "
|
||||
"every sensor/binary description; 'optional: true' → disabled by default. "
|
||||
"012.yaml (hood) 'GreaseFilterUsedHours' and 010.yaml (hob with extractor) "
|
||||
"'Grease_filter_used_hours' (DURATION h, total_increasing — hours since the "
|
||||
"filter counter was reset; 012's reset is the select 'GreaseFilterResetCounter', "
|
||||
"not a button) → usage_above, 30 h (editorial; the hood's own interval is "
|
||||
"exposed as 'GreaseFilterCleaningIntervalInHours'). 012 "
|
||||
"'RecirculationFilter1UsedHours' / '…2UsedHours' and 010 "
|
||||
"'Recirculation_filter_1_used_hours' (carbon filters, h) → usage_above 120 h "
|
||||
"(editorial), one task per filter. 015.yaml (dishwasher) "
|
||||
"'Alarm_run_selfcleaning' (binary, NO device_class, options 2 = on) → latch. "
|
||||
"Skipped: every other alarm (Alarm_salt_refill, rinse aid, clean filters, "
|
||||
"condenser, grease, descale, detergent/softener states, AC 'f-filter') is "
|
||||
"device_class problem → problem-sensor adoption; fridge filter_state (% "
|
||||
"direction unclear)."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
("greasefilterusedhours", "grease_filter_used_hours"),
|
||||
"Clean Grease Filter",
|
||||
"usage_above",
|
||||
above_hours=30,
|
||||
),
|
||||
ConsumableSignature(
|
||||
("recirculationfilter1usedhours", "recirculationfilter2usedhours", "recirculation_filter_1_used_hours"),
|
||||
"Replace Filter",
|
||||
"usage_above",
|
||||
above_hours=120,
|
||||
per_entity=True,
|
||||
),
|
||||
ConsumableSignature(
|
||||
("alarm_run_selfcleaning",),
|
||||
"Clean Appliance",
|
||||
"event_present",
|
||||
entity_domain="binary_sensor",
|
||||
on_states=("on",),
|
||||
),
|
||||
),
|
||||
translation_keys_authoritative=True,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -45,14 +45,51 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
),
|
||||
"philips_shaver": IntegrationSignature(
|
||||
name="Philips shaver",
|
||||
verified="2026-09-25 @ mtheli/philips_shaver main",
|
||||
verified="2026-09-25 @ mtheli/philips_shaver main; resets + cartridge 2026-09-27 @ 31bedaa",
|
||||
source=(
|
||||
"mtheli/philips_shaver custom_components/philips_shaver/sensor.py "
|
||||
"PhilipsHeadRemainingSensor: _attr_translation_key 'head_remaining', "
|
||||
"PERCENTAGE (coordinator: CHAR_HEAD_REMAINING byte — the shaver's own "
|
||||
"head-life counter). 'cleaning_cycles_remaining' is skipped: an "
|
||||
"integration-side evaporation estimate, not a device value."
|
||||
"head-life counter). button.py PhilipsBladeReplacementButton tk "
|
||||
"'blade_replacement' writes 0x01 to the head-replacement characteristic and "
|
||||
"sets head_remaining = 100 (a no-op, logged, while the BLE link is down). "
|
||||
"2026-09-27: PhilipsRemainingCleaningCyclesSensor tk "
|
||||
"'cleaning_cycles_remaining' (unitless, 0-30 = CARTRIDGE_CAPACITY) — the "
|
||||
"Quick Clean cartridge's remaining cycles, estimated from the shaver's own "
|
||||
"cleaning counter plus fluid evaporation (the Philips app's model); only on "
|
||||
"shavers with a cleaning station. Its reset is PhilipsCartridgeResetButton tk "
|
||||
"'cartridge_reset' (back to 30) → value_below 3 cycles, the reset restores it."
|
||||
),
|
||||
tasks=(ConsumableSignature(("head_remaining",), "Replace Shaver Head", "percent_left"),),
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
("head_remaining",),
|
||||
"Replace Shaver Head",
|
||||
"percent_left",
|
||||
resets=(("head_remaining", "blade_replacement"),),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("cleaning_cycles_remaining",),
|
||||
"Replace Cleaning Cartridge",
|
||||
"value_below",
|
||||
delta_units=3,
|
||||
resets=(("cleaning_cycles_remaining", "cartridge_reset"),),
|
||||
),
|
||||
),
|
||||
),
|
||||
"philips_sonicare_ble": IntegrationSignature(
|
||||
name="Philips Sonicare (BLE)",
|
||||
verified="2026-09-27 @ mtheli/philips_sonicare_ble master (93c4257, HACS default)",
|
||||
source=(
|
||||
"mtheli/philips_sonicare_ble custom_components/philips_sonicare_ble/sensor.py "
|
||||
"SonicareBrushHeadWearSensor: _attr_translation_key 'brushhead_wear', "
|
||||
"PERCENTAGE = coordinator brushhead_wear_pct = NFC head lifetime usage / "
|
||||
"lifetime limit × 100 (capped 100) — wear counting UP, back to 0 on a new head "
|
||||
"(auto-resolve). Created only for handles with the NFC brush-head service "
|
||||
"(SVC_BRUSHHEAD or Condor). The brush-head entities live on a SUB-DEVICE "
|
||||
"'<name> Brush Head' (entity.py PhilipsBrushHeadEntity, via_device = the "
|
||||
"handle), so the proposal lands on that sub-device. 'brushhead_sessions_left' "
|
||||
"is the same head in sessions — one signal per duty."
|
||||
),
|
||||
tasks=(ConsumableSignature(("brushhead_wear",), "Replace Brush Head", "alert_above", delta_units=90),),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -12,36 +12,74 @@ from ._model import ConsumableSignature, IntegrationSignature
|
||||
SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"petkit": IntegrationSignature(
|
||||
name="PetKit",
|
||||
verified="2026-07-19 @ Jezza34000/homeassistant_petkit main sensor.py",
|
||||
verified="2026-07-19 @ Jezza34000/homeassistant_petkit main sensor.py; resets + odor eliminator 2026-09-27 @ a749d54",
|
||||
source=(
|
||||
"HACS petkit (Jezza34000): tk 'desiccant_left_days' (feeder, "
|
||||
"UnitOfTime.DAYS) and tk 'filter_percent' (water fountain, "
|
||||
"PERCENTAGE)."
|
||||
"PERCENTAGE). 2026-09-27: button.py tk 'reset_desiccant' (feeders) and "
|
||||
"'reset_filter' (fountains; BLE for all but the W7H, cloud API there — a BLE "
|
||||
"press without a reachable relay is a silent no-op). Litter boxes: tk "
|
||||
"'odor_eliminator_n50_left_days' (state.deodorant_left_days) and "
|
||||
"'odor_eliminator_n60_left_days' (state.spray_left_days), both "
|
||||
"UnitOfTime.DAYS countdowns, created only when the box reports them, with "
|
||||
"the resets 'reset_n50_odor_eliminator' (litter boxes except T7) and "
|
||||
"'reset_n60_odor_eliminator' (camera boxes T5/T6/T7). "
|
||||
"'purification_n60_left_days' is skipped (a third N60 countdown whose part "
|
||||
"is not established)."
|
||||
),
|
||||
tasks=(
|
||||
# 48 canonical hours = warn at 2 days of desiccant left.
|
||||
ConsumableSignature(("desiccant_left_days",), "Replace Desiccant", "duration_left", below_hours=48),
|
||||
ConsumableSignature(("filter_percent",), "Replace Water Filter", "percent_left"),
|
||||
ConsumableSignature(
|
||||
("desiccant_left_days",),
|
||||
"Replace Desiccant",
|
||||
"duration_left",
|
||||
below_hours=48,
|
||||
resets=(("desiccant_left_days", "reset_desiccant"),),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("filter_percent",),
|
||||
"Replace Water Filter",
|
||||
"percent_left",
|
||||
resets=(("filter_percent", "reset_filter"),),
|
||||
),
|
||||
# N50 block / N60 spray are separate parts → one task each where a
|
||||
# box reports both.
|
||||
ConsumableSignature(
|
||||
("odor_eliminator_n50_left_days", "odor_eliminator_n60_left_days"),
|
||||
"Replace Odor Eliminator",
|
||||
"duration_left",
|
||||
below_hours=48,
|
||||
per_entity=True,
|
||||
resets=(
|
||||
("odor_eliminator_n50_left_days", "reset_n50_odor_eliminator"),
|
||||
("odor_eliminator_n60_left_days", "reset_n60_odor_eliminator"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
"litterrobot": IntegrationSignature(
|
||||
name="Litter-Robot",
|
||||
verified="2026-07-18 @ home-assistant/core dev",
|
||||
verified="2026-07-18 @ home-assistant/core dev; re-checked 2026-09-27 @ home-assistant/core 2026.9",
|
||||
source=(
|
||||
"home-assistant/core homeassistant/components/litterrobot/sensor.py "
|
||||
"(waste_drawer_level tk 'waste_drawer' % FULL -> alert_above; "
|
||||
"litter_level tk 'litter_level' % remaining (LR4/5) -> "
|
||||
"percent_left; total_cycles lifetime counter -> usage_delta)."
|
||||
"percent_left; total_cycles lifetime counter -> usage_delta). "
|
||||
"2026-09 re-check, skipped: LR5 tk 'next_filter_replacement' (TIMESTAMP, "
|
||||
"pylitterbot nextFilterReplacementDate — no catalog direction reads a due "
|
||||
"date; its 'change_filter' button has no counter duty to reset), LR5 binary "
|
||||
"'laser_dirty' (device_class problem → problem-sensor adoption), button "
|
||||
"'reset' (LR4/LR5 robot reset, not a counter reset)."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(("waste_drawer",), "Empty Waste Drawer", "alert_above", delta_units=90),
|
||||
ConsumableSignature(("waste_drawer",), "Empty Waste Drawer", "alert_above", delta_units=90, resets=(("waste_drawer", "reset_waste_drawer"),)),
|
||||
ConsumableSignature(("litter_level",), "Refill Litter", "percent_left"),
|
||||
ConsumableSignature(("total_cycles",), "Wash Litter Box", "usage_delta", delta_units=150),
|
||||
),
|
||||
),
|
||||
"petlibro": IntegrationSignature(
|
||||
name="PETLIBRO",
|
||||
verified="2026-09-25 @ jjjonesjr33/petlibro dev",
|
||||
verified="2026-09-25 @ jjjonesjr33/petlibro dev; resets 2026-09-27 @ 7ee757f",
|
||||
source=(
|
||||
"jjjonesjr33/petlibro custom_components/petlibro/sensor.py: tk "
|
||||
"'remaining_desiccant' (feeders, native 'd', DURATION — "
|
||||
@@ -51,15 +89,42 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"litter box, 'd' — remainingCleaningDays), tk "
|
||||
"'remaining_replacement_days' (Luma litter box 'Filter Replacement "
|
||||
"Days', same API field as the fountain filter); button.py "
|
||||
"desiccant_reset / filter_reset / cleaning_reset restart the countdowns. "
|
||||
"Luma's remaining_mat_days skipped (minor accessory, no catalog duty)."
|
||||
"desiccant_reset / filter_reset / cleaning_reset restart the countdowns "
|
||||
"(tk = key; Granary/One RFID desiccant, the Dockstream fountains and the Luma "
|
||||
"filter, fountains + Polar + Luma cleaning — wired as each duty's reset; "
|
||||
"async_press only logs API errors). Luma's remaining_mat_days skipped (minor "
|
||||
"accessory, no catalog duty, no reset)."
|
||||
),
|
||||
tasks=(
|
||||
# 48 canonical hours = warn at 2 days left, like PetKit's desiccant.
|
||||
ConsumableSignature(("remaining_desiccant",), "Replace Desiccant", "duration_left", below_hours=48),
|
||||
ConsumableSignature(("remaining_filter_days",), "Replace Water Filter", "duration_left", below_hours=48),
|
||||
ConsumableSignature(("remaining_cleaning_days",), "Clean Appliance", "duration_left", below_hours=48),
|
||||
ConsumableSignature(("remaining_replacement_days",), "Replace Filter", "duration_left", below_hours=48),
|
||||
ConsumableSignature(
|
||||
("remaining_desiccant",),
|
||||
"Replace Desiccant",
|
||||
"duration_left",
|
||||
below_hours=48,
|
||||
resets=(("remaining_desiccant", "desiccant_reset"),),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("remaining_filter_days",),
|
||||
"Replace Water Filter",
|
||||
"duration_left",
|
||||
below_hours=48,
|
||||
resets=(("remaining_filter_days", "filter_reset"),),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("remaining_cleaning_days",),
|
||||
"Clean Appliance",
|
||||
"duration_left",
|
||||
below_hours=48,
|
||||
resets=(("remaining_cleaning_days", "cleaning_reset"),),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("remaining_replacement_days",),
|
||||
"Replace Filter",
|
||||
"duration_left",
|
||||
below_hours=48,
|
||||
resets=(("remaining_replacement_days", "filter_reset"),),
|
||||
),
|
||||
),
|
||||
),
|
||||
"eheimdigital": IntegrationSignature(
|
||||
@@ -72,4 +137,26 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
),
|
||||
tasks=(ConsumableSignature(("service_hours",), "Filter Cleaning", "duration_left"),),
|
||||
),
|
||||
"petsafe": IntegrationSignature(
|
||||
name="PetSafe ScoopFree",
|
||||
verified="2026-09-27 @ dcmeglio/homeassistant-petsafe master (9c83b49, HACS default) + petsafe 2.0.6",
|
||||
source=(
|
||||
"dcmeglio/homeassistant-petsafe sensor.py + SensorEntities.py: per litter box "
|
||||
"'Rake Counter' (_attr_has_entity_name, no translation_key → suffix "
|
||||
"_rake_counter) = shadow.state.reported.rakeCount, the rakes since the "
|
||||
"counter was last reset; button.py 'Reset' (suffix _reset, litter boxes only) "
|
||||
"→ petsafe devices.DeviceScoopfree.reset(0) PATCHes rakeCount = 0 — the "
|
||||
"reset the app offers after a fresh litter tray. usage_above 120 rakes ≈ a "
|
||||
"month for one cat (tunable). Feeder food level is daily care — skipped."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
("rake_counter",),
|
||||
"Change Litter",
|
||||
"usage_above",
|
||||
above_hours=120,
|
||||
resets=(("rake_counter", "reset"),),
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -91,6 +91,28 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
),
|
||||
),
|
||||
),
|
||||
"dremel_3d_printer": IntegrationSignature(
|
||||
name="Dremel 3D printer",
|
||||
verified="2026-09-27 @ home-assistant/core 2026.9 + dremel3dpy 2.1.1",
|
||||
source=(
|
||||
"home-assistant/core homeassistant/components/dremel_3d_printer/sensor.py: "
|
||||
"key/translation_key 'hours_used' (UnitOfTime.HOURS, DIAGNOSTIC, "
|
||||
"entity_registry_enabled_default=False — the suggestion appears once the user "
|
||||
"enables it), value = get_printer_info()['hours_used'] = dremel3dpy "
|
||||
"USAGE_COUNTER ('UsageCounter', the printer's lifetime usage hours, no reset) → "
|
||||
"usage_delta on the FDM lubrication cadence (Prusa's 200 print-hours, like the "
|
||||
"other FDM printers). The 'running' binary (RUNNING, no tk) would only feed an "
|
||||
"engine runtime — the device counter is the more direct signal."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
("hours_used",),
|
||||
"Lubricate Rails and Rods",
|
||||
"usage_delta",
|
||||
delta_units=200,
|
||||
),
|
||||
),
|
||||
),
|
||||
"prusalink": IntegrationSignature(
|
||||
name="PrusaLink",
|
||||
verified="2026-07-18 @ home-assistant/core dev",
|
||||
@@ -167,14 +189,21 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
),
|
||||
"anycubic_cloud": IntegrationSignature(
|
||||
name="Anycubic Cloud",
|
||||
verified="2026-09-25 @ Nino6689/hass-anycubic main",
|
||||
verified="2026-09-25 @ Nino6689/hass-anycubic main; nozzle wear 2026-09-27 @ 7d18d53",
|
||||
source=(
|
||||
"Nino6689/hass-anycubic custom_components/anycubic_cloud/sensor.py: "
|
||||
"key/translation_key 'print_time_total_hrs' (UnitOfTime.HOURS, "
|
||||
"TOTAL_INCREASING, printer.total_print_time_hrs — lifetime print hours, "
|
||||
"no reset) → usage_delta. Resin (LCD) printers share the sensor, so the "
|
||||
"duty is gated on the FDM-only sibling tk 'curr_nozzle_temp' "
|
||||
"(PrinterEntityType.FDM, helpers.check_descriptor_status_not_fdm)."
|
||||
"(PrinterEntityType.FDM, helpers.check_descriptor_status_not_fdm). "
|
||||
"2026-09-27: tk 'nozzle_wear_percent' (PERCENTAGE, DIAGNOSTIC) = coordinator "
|
||||
"_totals_states min(100, abrasive grams since the nozzle reset / "
|
||||
"const.NOZZLE_ABRASIVE_LIFE_G 1000 × 100) — wear counting UP, back to 0 via "
|
||||
"button tk 'reset_nozzle_wear' (CONFIG, disabled by the integration → wiring "
|
||||
"enables it; async_reset_nozzle zeroes the counters). Only abrasive fill (CF/GF) "
|
||||
"counts, so plain PLA printing never trips it — the integration's own wear "
|
||||
"model. Same FDM gate as the lubrication duty."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
@@ -184,6 +213,14 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
delta_units=200,
|
||||
require_sibling_keys=("curr_nozzle_temp",),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("nozzle_wear_percent",),
|
||||
"Replace Nozzle",
|
||||
"alert_above",
|
||||
delta_units=90,
|
||||
require_sibling_keys=("curr_nozzle_temp",),
|
||||
resets=(("nozzle_wear_percent", "reset_nozzle_wear"),),
|
||||
),
|
||||
),
|
||||
),
|
||||
"ipp": IntegrationSignature(
|
||||
@@ -257,6 +294,35 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
ConsumableSignature(("fuser_remaining_life",), "Replace Fuser", "percent_left"),
|
||||
),
|
||||
),
|
||||
"syncthru": IntegrationSignature(
|
||||
name="Samsung SyncThru printer",
|
||||
verified="2026-09-27 @ home-assistant/core 2026.9 + PySyncThru 0.8.0",
|
||||
source=(
|
||||
"home-assistant/core homeassistant/components/syncthru/sensor.py "
|
||||
"get_toner_entity_description / get_drum_entity_description: key = "
|
||||
"translation_key = f'toner_{color}' / f'drum_{color}' (black/cyan/magenta/"
|
||||
"yellow, strings.json 'Black toner level' … 'Yellow drum level'), PERCENTAGE, "
|
||||
"DIAGNOSTIC, value = the SyncThru status dict's 'remaining' → percent left; "
|
||||
"created only for supported cartridges (pysyncthru toner_status/drum_status "
|
||||
"filter_supported: opt != 0). No reset buttons (the printer resets its own "
|
||||
"counters on a new cartridge). The 'active_alerts' count and the tray "
|
||||
"sensors are status, skipped; the problem binary is problem-sensor adoption."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
("toner_black", "toner_cyan", "toner_magenta", "toner_yellow"),
|
||||
"Replace Toner",
|
||||
"percent_left",
|
||||
per_entity=True, # colour lasers: one task per toner (#145)
|
||||
),
|
||||
ConsumableSignature(
|
||||
("drum_black", "drum_cyan", "drum_magenta", "drum_yellow"),
|
||||
"Replace Drum Unit",
|
||||
"percent_left",
|
||||
per_entity=True, # per-colour imaging units on colour models
|
||||
),
|
||||
),
|
||||
),
|
||||
"hpprinter": IntegrationSignature(
|
||||
name="HP printer",
|
||||
verified="2026-09-25 @ elad-bar/ha-hpprinter master",
|
||||
|
||||
@@ -7,7 +7,7 @@ integration's source; drift-probed weekly)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ._model import IntegrationSignature
|
||||
from ._model import ConsumableSignature, IntegrationSignature
|
||||
from ._shared import (
|
||||
LOCK_CYLINDER_CYCLES,
|
||||
MOWER_BLADES_RUNTIME,
|
||||
@@ -18,16 +18,51 @@ from ._shared import (
|
||||
|
||||
SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"matter": IntegrationSignature(
|
||||
name="Matter lock",
|
||||
verified="2026-07-18 @ home-assistant/core dev",
|
||||
name="Matter",
|
||||
verified="2026-07-18 (lock) / 2026-09-27 (filters, vacuum) @ home-assistant/core 2026.9 + dev",
|
||||
source=(
|
||||
"home-assistant/core homeassistant/components/matter/lock.py "
|
||||
"(MatterLock, lock domain entity per device). Matter bridges many "
|
||||
"device types — the lock entity_domain restricts this signature "
|
||||
"to locks; every transition to 'locked' is one mechanical cycle."
|
||||
"to locks; every transition to 'locked' is one mechanical cycle. "
|
||||
"sensor.py: tk 'hepa_filter_condition' (HepaFilterMonitoring."
|
||||
"Condition) and 'activated_carbon_filter_condition' "
|
||||
"(ActivatedCarbonFilterMonitoring.Condition), PERCENTAGE, "
|
||||
"MEASUREMENT, enabled — air purifiers and extractor hoods. Read "
|
||||
"as % REMAINING: the Matter SDK's ResetCondition sets "
|
||||
"100 for DegradationDirection 'down' (the SDK air-purifier example "
|
||||
"and core's mock_air_purifier / mock_extractor_hood fixtures: "
|
||||
"Condition 100, direction down); HA does not invert 'up' devices. "
|
||||
"The two filters wear independently → two duties. button.py has "
|
||||
"one reset per filter, but BOTH carry tk 'reset_filter_condition' "
|
||||
"(only the unique_id tells HepaFilterMonitoringResetButton from "
|
||||
"ActivatedCarbonFilterMonitoringResetButton). Wired as each duty's "
|
||||
"reset: the lookup presses a button only when exactly ONE matches "
|
||||
"the key on the device, so a single-filter purifier or hood gets "
|
||||
"its reset and a dual-filter device gets none (a guess could "
|
||||
"reset the wrong filter). vacuum.py: not docked/returning/paused/"
|
||||
"error and an RVC run mode tagged Cleaning (or Mapping) → "
|
||||
"VacuumActivity.CLEANING; core's RVC fixtures "
|
||||
"(mock_vacuum_cleaner, eufy_vacuum_omni_e28) carry no "
|
||||
"filter-monitoring cluster, so the ENGINE accumulates cleaning "
|
||||
"time."
|
||||
),
|
||||
tasks=(
|
||||
LOCK_CYLINDER_CYCLES,
|
||||
ConsumableSignature(
|
||||
("hepa_filter_condition",),
|
||||
"Replace Filter",
|
||||
"percent_left",
|
||||
resets=(("hepa_filter_condition", "reset_filter_condition"),),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("activated_carbon_filter_condition",),
|
||||
"Replace Activated Carbon",
|
||||
"percent_left",
|
||||
resets=(("activated_carbon_filter_condition", "reset_filter_condition"),),
|
||||
),
|
||||
VACUUM_FILTER_CLEANING_RUNTIME,
|
||||
VACUUM_MAIN_BRUSH_RUNTIME,
|
||||
),
|
||||
),
|
||||
"zwave_js": IntegrationSignature(
|
||||
@@ -43,15 +78,25 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
),
|
||||
),
|
||||
"zha": IntegrationSignature(
|
||||
name="Zigbee (ZHA) lock",
|
||||
verified="2026-07-18 @ home-assistant/core dev",
|
||||
name="Zigbee (ZHA)",
|
||||
verified="2026-07-18 (lock) / 2026-09-27 (STARKVIND filter) @ home-assistant/core 2026.9 + dev, zigpy/zha 2.2.0 + dev",
|
||||
source=(
|
||||
"home-assistant/core homeassistant/components/zha/lock.py "
|
||||
"(lock platform verified present). Locks carry no wear sensor — "
|
||||
"the ENGINE counts locking cycles; entity_domain-gated to locks, so the bridge's other device types are untouched."
|
||||
"the ENGINE counts locking cycles; entity_domain-gated to locks, so the bridge's other device types are untouched. "
|
||||
"IKEA STARKVIND purifiers: zha library application/platforms/"
|
||||
"sensor IkeaFilterRunTime — tk 'filter_run_time' (HA "
|
||||
"zha/strings.json 'Filter run time'), DURATION, MINUTES, "
|
||||
"DIAGNOSTIC, 'run time of the current filter' (IKEA cluster "
|
||||
"0xFC7D attribute 0x0000; counts UP and restarts at 0 when the "
|
||||
"filter is reset on the purifier) → usage_above at 4,320 h = the "
|
||||
"cluster's default filter_life_time of 259,200 min (the "
|
||||
"DIRIGERA/STARKVIND duty). The 'replace_filter' binary is "
|
||||
"device_class problem (adoption path); no reset button."
|
||||
),
|
||||
tasks=(
|
||||
LOCK_CYLINDER_CYCLES,
|
||||
ConsumableSignature(("filter_run_time",), "Replace Filter", "usage_above", above_hours=4320),
|
||||
),
|
||||
),
|
||||
"mqtt": IntegrationSignature(
|
||||
@@ -73,15 +118,23 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
# MQTT vacuums (Valetudo!) and mowers (OpenMower) expose only
|
||||
# state entities — engine-accumulated usage covers their duties.
|
||||
"homekit_controller": IntegrationSignature(
|
||||
name="HomeKit lock",
|
||||
verified="2026-07-18 @ home-assistant/core dev",
|
||||
name="HomeKit Device",
|
||||
verified="2026-07-18 (lock) / 2026-09-27 (filter) @ home-assistant/core 2026.9 + dev",
|
||||
source=(
|
||||
"home-assistant/core homeassistant/components/homekit_controller/lock.py "
|
||||
"(lock platform verified present). Locks carry no wear sensor — "
|
||||
"the ENGINE counts locking cycles; entity_domain-gated to locks, so the bridge's other device types are untouched."
|
||||
"the ENGINE counts locking cycles; entity_domain-gated to locks, so the bridge's other device types are untouched. "
|
||||
"sensor.py SIMPLE_SENSOR CharacteristicsTypes.FILTER_LIFE_LEVEL: "
|
||||
"name 'Filter lifetime', PERCENTAGE, MEASUREMENT, no "
|
||||
"translation_key — SimpleSensor names it '<accessory> Filter "
|
||||
"lifetime' → suffix _filter_lifetime. HAP Filter Life Level is "
|
||||
"the REMAINING life (core's own homekit bridge, type_air_purifiers"
|
||||
".py, raises Filter Change Indication below 10 %). No reset "
|
||||
"entity (Reset Filter Indication is not exposed)."
|
||||
),
|
||||
tasks=(
|
||||
LOCK_CYLINDER_CYCLES,
|
||||
ConsumableSignature(("filter_lifetime",), "Replace Filter", "percent_left"),
|
||||
),
|
||||
),
|
||||
"deconz": IntegrationSignature(
|
||||
|
||||
@@ -11,15 +11,22 @@ integration's source; drift-probed weekly)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
from ._model import ConsumableSignature, IntegrationSignature
|
||||
from ._shared import (
|
||||
FILTER_LIFE_PERCENT,
|
||||
HOOD_GREASE_FILTER_USAGE,
|
||||
SIDE_BRUSH_LIFE_PERCENT,
|
||||
VACUUM_FILTER_CLEANING_RUNTIME,
|
||||
VACUUM_MAIN_BRUSH_RUNTIME,
|
||||
)
|
||||
|
||||
# Percent-left brush and mop gauges named '<Part> Life' — shared verbatim by
|
||||
# the Xiaomi cloud vacuum and Viomi SE integrations (DRY tripwire: one object
|
||||
# per duplicated duty; both users live in this module).
|
||||
MAIN_BRUSH_LIFE_PERCENT = ConsumableSignature(("main_brush_life",), "Replace Main Brush", "percent_left")
|
||||
SIDE_BRUSH_LIFE_PERCENT = ConsumableSignature(("side_brush_life",), "Replace Side Brush", "percent_left")
|
||||
MOP_LIFE_PERCENT = ConsumableSignature(("mop_life",), "Replace Mop Pads", "percent_left")
|
||||
|
||||
SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"roborock": IntegrationSignature(
|
||||
name="Roborock",
|
||||
@@ -42,15 +49,15 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
tasks=(
|
||||
# Dyad wet-dry vacuums report their roller brush as 'brush_remaining'
|
||||
# — same duty, same direction → one signature (per-device dedupe).
|
||||
ConsumableSignature(("main_brush_time_left", "brush_remaining"), "Replace Main Brush", "duration_left"),
|
||||
ConsumableSignature(("side_brush_time_left",), "Replace Side Brush", "duration_left"),
|
||||
ConsumableSignature(("filter_time_left",), "Replace Filter", "duration_left"),
|
||||
ConsumableSignature(("sensor_time_left",), "Clean Sensors", "duration_left"),
|
||||
ConsumableSignature(("main_brush_time_left", "brush_remaining"), "Replace Main Brush", "duration_left", resets=(("main_brush_time_left", "reset_main_brush_consumable"),)),
|
||||
ConsumableSignature(("side_brush_time_left",), "Replace Side Brush", "duration_left", resets=(("side_brush_time_left", "reset_side_brush_consumable"),)),
|
||||
ConsumableSignature(("filter_time_left",), "Replace Filter", "duration_left", resets=(("filter_time_left", "reset_air_filter_consumable"),)),
|
||||
ConsumableSignature(("sensor_time_left",), "Clean Sensors", "duration_left", resets=(("sensor_time_left", "reset_sensor_consumable"),)),
|
||||
ConsumableSignature(("mop_life_time_left",), "Replace Mop Pads", "duration_left"),
|
||||
# Dock consumables: the 'hours' are the dock's wash-count budget
|
||||
# (150 / 300); 24 left ≈ the last 8-16 % of the part's life.
|
||||
ConsumableSignature(("strainer_time_left",), "Replace Dock Strainer", "duration_left"),
|
||||
ConsumableSignature(("cleaning_brush_time_left",), "Replace Maintenance Brush", "duration_left"),
|
||||
ConsumableSignature(("strainer_time_left",), "Replace Dock Strainer", "duration_left", resets=(("strainer_time_left", "reset_dock_strainer_consumable"),)),
|
||||
ConsumableSignature(("cleaning_brush_time_left",), "Replace Maintenance Brush", "duration_left", resets=(("cleaning_brush_time_left", "reset_dock_cleaning_brush_consumable"),)),
|
||||
# Zeo washer: washes since the last drum-clean program (unitless;
|
||||
# above_hours is the wash count, ~monthly like LG's tub counter).
|
||||
ConsumableSignature(("times_after_clean",), "Clean Tub", "usage_above", above_hours=30),
|
||||
@@ -58,36 +65,66 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
),
|
||||
"xiaomi_miio": IntegrationSignature(
|
||||
name="Xiaomi Miio",
|
||||
verified="2026-07-16 @ home-assistant/core dev",
|
||||
source="home-assistant/core homeassistant/components/xiaomi_miio/sensor.py (consumable_* descriptions, duration s)",
|
||||
verified="2026-07-16 @ home-assistant/core dev; purifier/Air Fresh filters 2026-09-27 @ home-assistant/core 2026.9",
|
||||
source=(
|
||||
"home-assistant/core homeassistant/components/xiaomi_miio/sensor.py (consumable_* "
|
||||
"descriptions, duration s). 2026-09 round: air purifiers + Air Fresh VA2/VA4 tk "
|
||||
"'filter_life_remaining', Air Fresh A1/T2017 tk 'dust_filter_life_remaining', "
|
||||
"T2017 tk 'upper_filter_life_remaining' (all PERCENTAGE, DIAGNOSTIC, enabled) → one "
|
||||
"percent duty, per entity because the T2017's two filters are replaced "
|
||||
"separately. button.py MODEL_TO_BUTTON_MAP: 'reset_dust_filter' (A1, T2017) and "
|
||||
"'reset_upper_filter' (T2017). Skipped: the same filters' days/hours twins "
|
||||
"'filter_left_time' / '*_life_remaining_days' (DAYS) and 'filter_hours_used' "
|
||||
"(HOURS) — one signal per filter; purifiers reset only via the "
|
||||
"xiaomi_miio.fan_reset_filter service (no button to wire)."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(("main_brush_left",), "Replace Main Brush", "duration_left"),
|
||||
ConsumableSignature(("side_brush_left",), "Replace Side Brush", "duration_left"),
|
||||
ConsumableSignature(("filter_left",), "Replace Filter", "duration_left"),
|
||||
ConsumableSignature(("sensor_dirty_left",), "Clean Sensors", "duration_left"),
|
||||
ConsumableSignature(("main_brush_left",), "Replace Main Brush", "duration_left", resets=(("main_brush_left", "reset_vacuum_main_brush"),)),
|
||||
ConsumableSignature(("side_brush_left",), "Replace Side Brush", "duration_left", resets=(("side_brush_left", "reset_vacuum_side_brush"),)),
|
||||
ConsumableSignature(("filter_left",), "Replace Filter", "duration_left", resets=(("filter_left", "reset_vacuum_filter"),)),
|
||||
ConsumableSignature(("sensor_dirty_left",), "Clean Sensors", "duration_left", resets=(("sensor_dirty_left", "reset_vacuum_sensor_dirty"),)),
|
||||
# Purifier / Air Fresh filters (percent, the vacuum's filter is a
|
||||
# duration → a separate direction). The T2017 has two filters
|
||||
# replaced independently → one task per entity there.
|
||||
ConsumableSignature(
|
||||
("filter_life_remaining", "dust_filter_life_remaining", "upper_filter_life_remaining"),
|
||||
"Replace Filter",
|
||||
"percent_left",
|
||||
per_entity=True,
|
||||
resets=(
|
||||
("dust_filter_life_remaining", "reset_dust_filter"),
|
||||
("upper_filter_life_remaining", "reset_upper_filter"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
"dreame_vacuum": IntegrationSignature(
|
||||
name="Dreame Vacuum",
|
||||
verified="2026-09-02 @ Tasshack/dreame-vacuum master (ae8422f2)",
|
||||
verified="2026-09-02 @ Tasshack/dreame-vacuum master (ae8422f2); resets 2026-09-27 @ ae8422f",
|
||||
source=(
|
||||
"Tasshack/dreame-vacuum custom_components/dreame_vacuum/sensor.py "
|
||||
"(property_key *_LEFT, UNIT_PERCENT) + dreame/const.py PROPERTY_TO_NAME "
|
||||
"(translation_key = first element, e.g. mop_pad_left); every *_left "
|
||||
"sensor is entity_registry_enabled_default=False — the user enables "
|
||||
"the ones their model has (#150, station consumables)."
|
||||
"the ones their model has (#150, station consumables). Resets: "
|
||||
"button.py BUTTONS action_key RESET_* → entity.py key/translation_key from "
|
||||
"dreame/const.py ACTION_TO_NAME ('reset_main_brush', 'reset_side_brush', "
|
||||
"'reset_filter', 'reset_sensor', 'reset_mop_pad', 'reset_detergent', "
|
||||
"'reset_silver_ion'; DIAGNOSTIC, enabled; each created only when its counter "
|
||||
"exists). The button is unavailable while its counter reads 100 %. "
|
||||
"secondary_filter_left has no button (service vacuum_reset_consumable only)."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(("main_brush_left",), "Replace Main Brush", "percent_left"),
|
||||
ConsumableSignature(("side_brush_left",), "Replace Side Brush", "percent_left"),
|
||||
ConsumableSignature(("filter_left",), "Replace Filter", "percent_left"),
|
||||
ConsumableSignature(("sensor_dirty_left",), "Clean Sensors", "percent_left"),
|
||||
ConsumableSignature(("main_brush_left",), "Replace Main Brush", "percent_left", resets=(("main_brush_left", "reset_main_brush"),)),
|
||||
ConsumableSignature(("side_brush_left",), "Replace Side Brush", "percent_left", resets=(("side_brush_left", "reset_side_brush"),)),
|
||||
ConsumableSignature(("filter_left",), "Replace Filter", "percent_left", resets=(("filter_left", "reset_filter"),)),
|
||||
ConsumableSignature(("sensor_dirty_left",), "Clean Sensors", "percent_left", resets=(("sensor_dirty_left", "reset_sensor"),)),
|
||||
# Self-wash base stations (L10s/L20/X30/X40 …) add four more wear
|
||||
# counters — same percent scale, same *_left key convention.
|
||||
ConsumableSignature(("mop_pad_left",), "Replace Mop Pads", "percent_left"),
|
||||
ConsumableSignature(("detergent_left",), "Refill Detergent", "percent_left"),
|
||||
ConsumableSignature(("mop_pad_left",), "Replace Mop Pads", "percent_left", resets=(("mop_pad_left", "reset_mop_pad"),)),
|
||||
ConsumableSignature(("detergent_left",), "Refill Detergent", "percent_left", resets=(("detergent_left", "reset_detergent"),)),
|
||||
ConsumableSignature(("secondary_filter_left",), "Replace Secondary Filter", "percent_left"),
|
||||
ConsumableSignature(("silver_ion_left",), "Replace Silver-ion Module", "percent_left"),
|
||||
ConsumableSignature(("silver_ion_left",), "Replace Silver-ion Module", "percent_left", resets=(("silver_ion_left", "reset_silver_ion"),)),
|
||||
),
|
||||
),
|
||||
"ecovacs": IntegrationSignature(
|
||||
@@ -107,24 +144,24 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"SKIPPED (which parts it covers is not established from source)."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(("lifespan_brush", "lifespan_main_brush"), "Replace Main Brush", "percent_left"),
|
||||
ConsumableSignature(("lifespan_side_brush",), "Replace Side Brush", "percent_left"),
|
||||
ConsumableSignature(("lifespan_brush", "lifespan_main_brush"), "Replace Main Brush", "percent_left", resets=(("lifespan_brush", "reset_lifespan_brush"),)),
|
||||
ConsumableSignature(("lifespan_side_brush",), "Replace Side Brush", "percent_left", resets=(("lifespan_side_brush", "reset_lifespan_side_brush"),)),
|
||||
# The robot's filter and the handheld unit's filter (combo models)
|
||||
# are separate parts — one task per entity when a device has both.
|
||||
ConsumableSignature(("lifespan_filter", "lifespan_hand_filter"), "Replace Filter", "percent_left", per_entity=True),
|
||||
ConsumableSignature(("lifespan_station_filter",), "Replace Secondary Filter", "percent_left"),
|
||||
ConsumableSignature(("lifespan_dust_bag",), "Replace Dust Bag", "percent_left"),
|
||||
ConsumableSignature(("lifespan_round_mop",), "Replace Mop Pads", "percent_left"),
|
||||
ConsumableSignature(("lifespan_cleaning_solution",), "Refill Detergent", "percent_left"),
|
||||
ConsumableSignature(("lifespan_sewage_box",), "Empty Dirty Water Tank", "percent_left"),
|
||||
ConsumableSignature(("lifespan_water_sink",), "Clean Mop Tray", "percent_left"),
|
||||
ConsumableSignature(("lifespan_air_freshener",), "Replace Air Freshener", "percent_left"),
|
||||
ConsumableSignature(("lifespan_uv_sanitizer",), "Replace UV Lamp", "percent_left"),
|
||||
ConsumableSignature(("lifespan_filter", "lifespan_hand_filter"), "Replace Filter", "percent_left", per_entity=True, resets=(("lifespan_filter", "reset_lifespan_filter"), ("lifespan_hand_filter", "reset_lifespan_hand_filter"))),
|
||||
ConsumableSignature(("lifespan_station_filter",), "Replace Secondary Filter", "percent_left", resets=(("lifespan_station_filter", "reset_lifespan_station_filter"),)),
|
||||
ConsumableSignature(("lifespan_dust_bag",), "Replace Dust Bag", "percent_left", resets=(("lifespan_dust_bag", "reset_lifespan_dust_bag"),)),
|
||||
ConsumableSignature(("lifespan_round_mop",), "Replace Mop Pads", "percent_left", resets=(("lifespan_round_mop", "reset_lifespan_round_mop"),)),
|
||||
ConsumableSignature(("lifespan_cleaning_solution",), "Refill Detergent", "percent_left", resets=(("lifespan_cleaning_solution", "reset_lifespan_cleaning_solution"),)),
|
||||
ConsumableSignature(("lifespan_sewage_box",), "Empty Dirty Water Tank", "percent_left", resets=(("lifespan_sewage_box", "reset_lifespan_sewage_box"),)),
|
||||
ConsumableSignature(("lifespan_water_sink",), "Clean Mop Tray", "percent_left", resets=(("lifespan_water_sink", "reset_lifespan_water_sink"),)),
|
||||
ConsumableSignature(("lifespan_air_freshener",), "Replace Air Freshener", "percent_left", resets=(("lifespan_air_freshener", "reset_lifespan_air_freshener"),)),
|
||||
ConsumableSignature(("lifespan_uv_sanitizer",), "Replace UV Lamp", "percent_left", resets=(("lifespan_uv_sanitizer", "reset_lifespan_uv_sanitizer"),)),
|
||||
# GOAT robotic mowers report their wear parts through the same platform.
|
||||
ConsumableSignature(("lifespan_blade",), "Replace Blades", "percent_left"),
|
||||
ConsumableSignature(("lifespan_lens_brush",), "Replace Lens Brush", "percent_left"),
|
||||
ConsumableSignature(("lifespan_trimmer_brush",), "Replace Trimmer Brush", "percent_left"),
|
||||
ConsumableSignature(("lifespan_weed_rope",), "Replace Trimmer Line", "percent_left"),
|
||||
ConsumableSignature(("lifespan_blade",), "Replace Blades", "percent_left", resets=(("lifespan_blade", "reset_lifespan_blade"),)),
|
||||
ConsumableSignature(("lifespan_lens_brush",), "Replace Lens Brush", "percent_left", resets=(("lifespan_lens_brush", "reset_lifespan_lens_brush"),)),
|
||||
ConsumableSignature(("lifespan_trimmer_brush",), "Replace Trimmer Brush", "percent_left", resets=(("lifespan_trimmer_brush", "reset_lifespan_trimmer_brush"),)),
|
||||
ConsumableSignature(("lifespan_weed_rope",), "Replace Trimmer Line", "percent_left", resets=(("lifespan_weed_rope", "reset_lifespan_weed_rope"),)),
|
||||
# Lifetime mowing time — undercarriage wash every 25 h like the
|
||||
# automower/landroid signatures.
|
||||
ConsumableSignature(("total_stats_time_mower",), "Clean Undercarriage", "usage_delta", delta_units=25),
|
||||
@@ -214,10 +251,11 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"replacing the part at end of life."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(("rolling_brush_life",), "Replace Main Brush", "percent_left"),
|
||||
SIDE_BRUSH_LIFE_PERCENT,
|
||||
FILTER_LIFE_PERCENT,
|
||||
ConsumableSignature(("duster_cloth_life",), "Replace Mop Pads", "percent_left"),
|
||||
# 2.95: each counter has its reset_* button (button.py category SD).
|
||||
ConsumableSignature(("rolling_brush_life",), "Replace Main Brush", "percent_left", resets=(("rolling_brush_life", "reset_roll_brush"),)),
|
||||
ConsumableSignature(("side_brush_life",), "Replace Side Brush", "percent_left", resets=(("side_brush_life", "reset_edge_brush"),)),
|
||||
replace(FILTER_LIFE_PERCENT, resets=(("filter_life", "reset_filter"),)),
|
||||
ConsumableSignature(("duster_cloth_life",), "Replace Mop Pads", "percent_left", resets=(("duster_cloth_life", "reset_duster_cloth"),)),
|
||||
VACUUM_FILTER_CLEANING_RUNTIME,
|
||||
VACUUM_MAIN_BRUSH_RUNTIME,
|
||||
),
|
||||
@@ -237,7 +275,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
),
|
||||
"smartthings": IntegrationSignature(
|
||||
name="SmartThings",
|
||||
verified="2026-07-18 (vacuum) / 2026-07-20 (filters) @ home-assistant/core dev",
|
||||
verified="2026-07-18 (vacuum) / 2026-07-20 (filters) @ home-assistant/core dev; dust bag 2026-09-27 @ home-assistant/core 2026.9",
|
||||
source=(
|
||||
"home-assistant/core homeassistant/components/smartthings/vacuum.py "
|
||||
"(vacuum platform verified present; no consumable sensors) — the "
|
||||
@@ -245,15 +283,33 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"sensor.py: tk 'water_filter_usage' (custom.waterFilter, "
|
||||
"PERCENTAGE, MEASUREMENT — Samsung fridge water filter, % USED "
|
||||
"counting up; replacement resets to 0) and tk 'hood_filter_usage' "
|
||||
"(SAMSUNG_CE_HOOD_FILTER, PERCENTAGE) — same up-counting shape."
|
||||
"(SAMSUNG_CE_HOOD_FILTER, PERCENTAGE) — same up-counting shape. "
|
||||
"2026-09 round: binary_sensor.py SAMSUNG_CE_ROBOT_CLEANER_DUST_BAG on the "
|
||||
"'station' component, component_translation_key 'robot_cleaner_dust_bag' "
|
||||
"('Dust bag full', is_on_key 'full', NO device_class → not problem-adoptable) → "
|
||||
"latch on 'on'. Skipped: 'stick_cleaner_dust_bag' / 'microfiber_filter_blockage' "
|
||||
"/ 'filter_status' (device_class problem → problem-sensor adoption), "
|
||||
"'stick_cleaner_dust_bag_usage' (unitless TOTAL_INCREASING bag cycles, reset "
|
||||
"semantics not established) and button 'reset_hepa_filter' (custom.hepaFilter — "
|
||||
"no sensor exposes that filter, core 2026.9 nor dev, so there is no duty to wire)."
|
||||
),
|
||||
tasks=(
|
||||
VACUUM_FILTER_CLEANING_RUNTIME,
|
||||
VACUUM_MAIN_BRUSH_RUNTIME,
|
||||
# Samsung fridge water filter / hood grease filter: usage counts
|
||||
# UP in percent; replacing/cleaning resets to 0 (auto-resolve).
|
||||
ConsumableSignature(("water_filter_usage",), "Replace Water Filter", "alert_above", delta_units=90),
|
||||
HOOD_GREASE_FILTER_USAGE,
|
||||
ConsumableSignature(("water_filter_usage",), "Replace Water Filter", "alert_above", delta_units=90, resets=(("water_filter_usage", "reset_water_filter"),)),
|
||||
# 2.95: SmartThings resets both counters itself (button.py).
|
||||
ConsumableSignature(("hood_filter_usage",), "Clean Grease Filter", "alert_above", delta_units=90, resets=(("hood_filter_usage", "reset_hood_filter"),)),
|
||||
# Jet Bot Clean Station: the station's disposable dust bag is full;
|
||||
# the station reporting it not full after the swap auto-resolves.
|
||||
ConsumableSignature(
|
||||
("robot_cleaner_dust_bag",),
|
||||
"Replace Dust Bag",
|
||||
"event_present",
|
||||
entity_domain="binary_sensor",
|
||||
on_states=("on",),
|
||||
),
|
||||
),
|
||||
),
|
||||
"sharkiq": IntegrationSignature(
|
||||
@@ -271,15 +327,41 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
),
|
||||
"tplink": IntegrationSignature(
|
||||
name="TP-Link Tapo vacuum",
|
||||
verified="2026-07-18 @ home-assistant/core dev",
|
||||
verified="2026-07-18 (vacuum) @ home-assistant/core dev; consumables 2026-09-27 @ home-assistant/core 2026.9 + python-kasa 0.10.2",
|
||||
source=(
|
||||
"home-assistant/core homeassistant/components/tplink/vacuum.py "
|
||||
"(vacuum platform verified present; no consumable sensors) — the "
|
||||
"ENGINE accumulates cleaning time, entity_domain-gated so the bridge's other device types are untouched."
|
||||
"(vacuum platform) — the ENGINE accumulates cleaning time, entity_domain-gated "
|
||||
"so the bridge's other device types are untouched (washing the filter / "
|
||||
"de-tangling the brush stays a separate duty from replacing the part). "
|
||||
"2026-09 round: sensor.py consumable descriptions — entity.py "
|
||||
"_description_for_feature sets translation_key = the python-kasa feature id: "
|
||||
"'main_brush_remaining', 'side_brush_remaining', 'filter_remaining', "
|
||||
"'sensor_remaining', 'charging_contacts_remaining' (DURATION, native SECONDS, "
|
||||
"suggested HOURS, entity_registry_enabled_default=False). python-kasa "
|
||||
"smart/modules/consumables.py: remaining = lifetime − used with lifetimes 400 h "
|
||||
"(main brush) / 200 h (side brush, filter) / 30 h (sensor, charging contacts) — "
|
||||
"countdowns to replacement/cleaning. button.py resets 'main_brush_reset' / "
|
||||
"'side_brush_reset' / 'filter_reset' / 'sensor_reset' / 'charging_contacts_reset' "
|
||||
"(kasa resetConsumablesTime; Debug category → disabled by the integration, "
|
||||
"wiring enables them). The '*_used' twins (Debug, disabled) are skipped — one "
|
||||
"signal per part."
|
||||
),
|
||||
tasks=(
|
||||
VACUUM_FILTER_CLEANING_RUNTIME,
|
||||
VACUUM_MAIN_BRUSH_RUNTIME,
|
||||
ConsumableSignature(("main_brush_remaining",), "Replace Main Brush", "duration_left", resets=(("main_brush_remaining", "main_brush_reset"),)),
|
||||
ConsumableSignature(("side_brush_remaining",), "Replace Side Brush", "duration_left", resets=(("side_brush_remaining", "side_brush_reset"),)),
|
||||
ConsumableSignature(("filter_remaining",), "Replace Filter", "duration_left", resets=(("filter_remaining", "filter_reset"),)),
|
||||
# 30-hour cleaning cycles: warn in the last 6 h instead of the 24 h
|
||||
# default, which would fire 6 h after the previous cleaning.
|
||||
ConsumableSignature(("sensor_remaining",), "Clean Sensors", "duration_left", below_hours=6, resets=(("sensor_remaining", "sensor_reset"),)),
|
||||
ConsumableSignature(
|
||||
("charging_contacts_remaining",),
|
||||
"Clean Charging Contacts",
|
||||
"duration_left",
|
||||
below_hours=6,
|
||||
resets=(("charging_contacts_remaining", "charging_contacts_reset"),),
|
||||
),
|
||||
),
|
||||
),
|
||||
"mydolphin_plus": IntegrationSignature(
|
||||
@@ -303,7 +385,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
),
|
||||
"robovac_mqtt": IntegrationSignature(
|
||||
name="Eufy Clean",
|
||||
verified="2026-09-25 @ jeppesens/eufy-clean main",
|
||||
verified="2026-09-25 @ jeppesens/eufy-clean main; resets 2026-09-27 @ baeb32c",
|
||||
source=(
|
||||
"jeppesens/eufy-clean custom_components/robovac_mqtt/sensor.py accessory "
|
||||
"sensors: RoboVacSensor(coordinator, attr.replace('_usage', '_remaining'), "
|
||||
@@ -312,18 +394,29 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"'Cleaning Tray Remaining' / 'Mopping Cloth Remaining' → entity-id "
|
||||
"suffixes; unit 'h', value = max life − usage (const.py "
|
||||
"ACCESSORY_MAX_LIFE / SCALAR_ACCESSORY_MAX_LIFE) → hours REMAINING, reset "
|
||||
"buttons in button.py. Tray and mop only on mop-capable (novel) models."
|
||||
"buttons in button.py. Tray and mop only on mop-capable (novel) models. "
|
||||
"Resets: button.py _ACCESSORY_RESET_BUTTONS, _attr_has_entity_name + names "
|
||||
"'Reset Filter' / 'Reset Rolling Brush' / 'Reset Side Brush' / 'Reset Sensors' "
|
||||
"(plural) / 'Reset Cleaning Tray' / 'Reset Mopping Cloth' (CONFIG, no tk → "
|
||||
"entity-id suffixes; the unique_id suffixes differ and are not used). Legacy "
|
||||
"(Tuya-cloud) devices get neither the accessory sensors nor the buttons."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(("filter_remaining",), "Replace Filter", "duration_left"),
|
||||
ConsumableSignature(("rolling_brush_remaining",), "Replace Main Brush", "duration_left"),
|
||||
ConsumableSignature(("side_brush_remaining",), "Replace Side Brush", "duration_left"),
|
||||
ConsumableSignature(("mopping_cloth_remaining",), "Replace Mop Pads", "duration_left"),
|
||||
ConsumableSignature(("filter_remaining",), "Replace Filter", "duration_left", resets=(("filter_remaining", "reset_filter"),)),
|
||||
ConsumableSignature(("rolling_brush_remaining",), "Replace Main Brush", "duration_left", resets=(("rolling_brush_remaining", "reset_rolling_brush"),)),
|
||||
ConsumableSignature(("side_brush_remaining",), "Replace Side Brush", "duration_left", resets=(("side_brush_remaining", "reset_side_brush"),)),
|
||||
ConsumableSignature(("mopping_cloth_remaining",), "Replace Mop Pads", "duration_left", resets=(("mopping_cloth_remaining", "reset_mopping_cloth"),)),
|
||||
# Short cleaning intervals (sensors 60/35 h, tray 30 h): warn in
|
||||
# the last 6 h instead of the 24 h default, which would fire
|
||||
# almost right after the previous cleaning.
|
||||
ConsumableSignature(("sensor_remaining",), "Clean Sensors", "duration_left", below_hours=6),
|
||||
ConsumableSignature(("cleaning_tray_remaining",), "Clean Mop Tray", "duration_left", below_hours=6),
|
||||
ConsumableSignature(("sensor_remaining",), "Clean Sensors", "duration_left", below_hours=6, resets=(("sensor_remaining", "reset_sensors"),)),
|
||||
ConsumableSignature(
|
||||
("cleaning_tray_remaining",),
|
||||
"Clean Mop Tray",
|
||||
"duration_left",
|
||||
below_hours=6,
|
||||
resets=(("cleaning_tray_remaining", "reset_cleaning_tray"),),
|
||||
),
|
||||
),
|
||||
),
|
||||
"robovac": IntegrationSignature(
|
||||
@@ -380,15 +473,15 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"life_base.py PERCENTAGE 'remaining life 0-100' (MIoT life-level)."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(("main_brush_life",), "Replace Main Brush", "percent_left"),
|
||||
MAIN_BRUSH_LIFE_PERCENT,
|
||||
SIDE_BRUSH_LIFE_PERCENT,
|
||||
FILTER_LIFE_PERCENT,
|
||||
ConsumableSignature(("mop_life",), "Replace Mop Pads", "percent_left"),
|
||||
MOP_LIFE_PERCENT,
|
||||
),
|
||||
),
|
||||
"ilife": IntegrationSignature(
|
||||
name="ILIFE",
|
||||
verified="2026-09-25 @ maximedeprince/ha-ilife main",
|
||||
verified="2026-09-25 @ maximedeprince/ha-ilife main; resets 2026-09-27 @ 6114e0f",
|
||||
source=(
|
||||
"maximedeprince/ha-ilife custom_components/ilife/sensor.py SENSORS "
|
||||
"(ILIFE-cloud backend): translation_key 'main_brush' / 'side_brush' / "
|
||||
@@ -396,12 +489,144 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"FilterLife; api.py reset_part sets the field back to 100 → remaining. "
|
||||
"Gated on the sibling tk 'history' (ILifeHistorySensor, cloud backend "
|
||||
"only): the Tuya backend's TuyaGenericSensor names raw DP codes (a bare "
|
||||
"'filter' DP of unknown unit/direction would otherwise suffix-match)."
|
||||
"'filter' DP of unknown unit/direction would otherwise suffix-match). "
|
||||
"button.py _RESETS: tk 'reset_main_brush' / 'reset_side_brush' / "
|
||||
"'reset_filter' (CONFIG; press = api.reset_part → the PartsStatus field back "
|
||||
"to 100) — cloud backend only, like the duties."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(("main_brush",), "Replace Main Brush", "percent_left", require_sibling_keys=("history",)),
|
||||
ConsumableSignature(("side_brush",), "Replace Side Brush", "percent_left", require_sibling_keys=("history",)),
|
||||
ConsumableSignature(("filter",), "Replace Filter", "percent_left", require_sibling_keys=("history",)),
|
||||
ConsumableSignature(
|
||||
("main_brush",),
|
||||
"Replace Main Brush",
|
||||
"percent_left",
|
||||
require_sibling_keys=("history",),
|
||||
resets=(("main_brush", "reset_main_brush"),),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("side_brush",),
|
||||
"Replace Side Brush",
|
||||
"percent_left",
|
||||
require_sibling_keys=("history",),
|
||||
resets=(("side_brush", "reset_side_brush"),),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("filter",),
|
||||
"Replace Filter",
|
||||
"percent_left",
|
||||
require_sibling_keys=("history",),
|
||||
resets=(("filter", "reset_filter"),),
|
||||
),
|
||||
),
|
||||
),
|
||||
"viomise": IntegrationSignature(
|
||||
name="Viomi SE vacuum",
|
||||
verified="2026-09-27 @ marotoweb/home-assistant-vacuum-viomise master (21deeda, HACS default)",
|
||||
source=(
|
||||
"marotoweb/home-assistant-vacuum-viomise custom_components/viomise/sensor.py "
|
||||
"SENSOR_DESCRIPTIONS: _attr_has_entity_name, names 'Main Brush Life' / 'Side "
|
||||
"Brush Life' / 'Filter Life' / 'Mop Life' (no translation_key → entity-id "
|
||||
"suffixes), PERCENTAGE from the MIoT *_percentage values = life left. No reset "
|
||||
"button or service (services.yaml: clean / goto only)."
|
||||
),
|
||||
tasks=(
|
||||
MAIN_BRUSH_LIFE_PERCENT,
|
||||
SIDE_BRUSH_LIFE_PERCENT,
|
||||
FILTER_LIFE_PERCENT,
|
||||
MOP_LIFE_PERCENT,
|
||||
),
|
||||
),
|
||||
"roomba_rest980": IntegrationSignature(
|
||||
name="Roomba (rest980)",
|
||||
verified="2026-09-27 @ ia74/roomba_rest980 main (012a86f, HACS default)",
|
||||
source=(
|
||||
"ia74/roomba_rest980 custom_components/roomba_rest980/RoombaSensor.py "
|
||||
"(_attr_has_entity_name, _attr_name from _rs_given_info, no translation_key → "
|
||||
"entity-id suffixes). sensor.py RoombaTotalTime 'Total Time' = runtimeStats "
|
||||
"hr*60 + min (DURATION, MINUTES — the robot's LIFETIME runtime, never reset) → "
|
||||
"usage_delta on the catalog's vacuum cadences (filter wash 15 h, brush clean "
|
||||
"30 h) from the robot's own counter, which also runs while HA is down. "
|
||||
"RoombaBinSensor 'Bin' (ENUM 'Not Full' / 'Full' from bin.full) → latch on "
|
||||
"'Full'. 'Total Jobs' carries a bogus MINUTES unit — ignored."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(("total_time",), "Filter Cleaning", "usage_delta", delta_units=15),
|
||||
ConsumableSignature(("total_time",), "Clean Main Brush", "usage_delta", delta_units=30),
|
||||
ConsumableSignature(("bin",), "Empty Dustbin", "event_present", on_states=("Full",)),
|
||||
),
|
||||
),
|
||||
"roomba_plus": IntegrationSignature(
|
||||
name="Roomba+ (local MQTT)",
|
||||
verified="2026-09-27 @ johnnyh1975/ha_roomba_plus main (ca95fc4, HACS default)",
|
||||
source=(
|
||||
"johnnyh1975/ha_roomba_plus sensor_core.py maintenance descriptions (tk = key): "
|
||||
"'filter_remaining_hours', 'brush_remaining_hours' (the main brushes), "
|
||||
"'part_edge_brush' (not on Braava), 'part_dirt_bag' (Clean Base only) — "
|
||||
"DURATION HOURS remaining (iRobot cloud counter first, else the local "
|
||||
"baseline vs const.py defaults 60 h filter / 200 h brushes / 150 h side brush "
|
||||
"/ 30 h bag). button.py maintenance resets tk 'reset_filter', 'reset_brush', "
|
||||
"'reset_side_brush', 'reset_clean_base_bag' (CONFIG, enabled; the press "
|
||||
"restarts the local countdown and pushes the part reset to the cloud). "
|
||||
"Floors ≈ 10 % of each part's default budget (the 24 h default would fire "
|
||||
"with 80 % of a 30 h bag left). bin_full is device_class problem → "
|
||||
"problem-sensor adoption."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(
|
||||
("filter_remaining_hours",),
|
||||
"Replace Filter",
|
||||
"duration_left",
|
||||
below_hours=6,
|
||||
resets=(("filter_remaining_hours", "reset_filter"),),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("brush_remaining_hours",),
|
||||
"Replace Main Brush",
|
||||
"duration_left",
|
||||
below_hours=20,
|
||||
resets=(("brush_remaining_hours", "reset_brush"),),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("part_edge_brush",),
|
||||
"Replace Side Brush",
|
||||
"duration_left",
|
||||
below_hours=15,
|
||||
resets=(("part_edge_brush", "reset_side_brush"),),
|
||||
),
|
||||
ConsumableSignature(
|
||||
("part_dirt_bag",),
|
||||
"Replace Dust Bag",
|
||||
"duration_left",
|
||||
below_hours=3,
|
||||
resets=(("part_dirt_bag", "reset_clean_base_bag"),),
|
||||
),
|
||||
),
|
||||
translation_keys_authoritative=True,
|
||||
),
|
||||
"wellbeing": IntegrationSignature(
|
||||
name="Electrolux Wellbeing",
|
||||
verified="2026-09-27 @ JohNan/homeassistant-wellbeing main (6e294d3, HACS default)",
|
||||
source=(
|
||||
"JohNan/homeassistant-wellbeing entity.py forces entity_id = "
|
||||
"slugify(f'wellbeing_{appliance}_{attr}') (no translation_key) → suffixes. "
|
||||
"api.py PUREi9 robot: ApplianceConsumableSensor 'Main Brush' / 'Side Brush' / "
|
||||
"'Filter' (attrs main_brush_sqm / side_brush_sqm / filter_sqm, PERCENTAGE, "
|
||||
"value = 100 × (1 − m² since reset / rated m²), rated 3000 / 1000 / 1000 m² — "
|
||||
"life LEFT; ratings reverse-engineered from the Electrolux app). Purifiers: "
|
||||
"'FilterLife' (A9) and 'FilterLife_1' / 'FilterLife_2' (two filter slots), "
|
||||
"PERCENTAGE left; entities exist only for attrs the appliance reports. One "
|
||||
"appliance is either a robot or a purifier, so the filter keys share one "
|
||||
"per-entity duty. No reset button or service. 'hepaFilterState' (ENUM) "
|
||||
"skipped — states undocumented."
|
||||
),
|
||||
tasks=(
|
||||
ConsumableSignature(("main_brush_sqm",), "Replace Main Brush", "percent_left"),
|
||||
ConsumableSignature(("side_brush_sqm",), "Replace Side Brush", "percent_left"),
|
||||
ConsumableSignature(
|
||||
("filter_sqm", "filterlife", "filterlife_1", "filterlife_2"),
|
||||
"Replace Filter",
|
||||
"percent_left",
|
||||
per_entity=True,
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -7,13 +7,12 @@ integration's source; drift-probed weekly)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ._model import IntegrationSignature
|
||||
from ._shared import MIOT_BRUSH_LIFE_LEVEL, MIOT_FILTER_LIFE_LEVEL
|
||||
from ._model import ConsumableSignature, IntegrationSignature
|
||||
|
||||
SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"xiaomi_miot": IntegrationSignature(
|
||||
name="Xiaomi MIoT",
|
||||
verified="2026-07-17 @ al-one/hass-xiaomi-miot master",
|
||||
verified="2026-07-17 @ al-one/hass-xiaomi-miot master; reset buttons re-checked 2026-09-27 @ 919b7a49",
|
||||
source=(
|
||||
"al-one/hass-xiaomi-miot — generic MIoT-spec entities; entity_id "
|
||||
"suffix = the spec property name (core/miot_spec.py format_name + "
|
||||
@@ -21,14 +20,25 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"'filter-life-level' (PERCENTAGE) on air purifiers/humidifiers/water "
|
||||
"purifiers/vacuums, 'brush-life-level' (PERCENTAGE) on vacuums. The "
|
||||
"days/used-hours filter counterparts describe the SAME filter, so "
|
||||
"only the percent signal is cataloged to avoid duplicate tasks."
|
||||
"only the percent signal is cataloged to avoid duplicate tasks. "
|
||||
"Resets (re-checked 2026-09-27 @ 919b7a49): MIoT actions become buttons "
|
||||
"only where the device customization lists them in button_actions "
|
||||
"(core/device_customizes.py: '*.vacuum.*' reset_*, and many purifiers / "
|
||||
"humidifiers / pet waterers with reset_filter_life); core/hass_entity.py names "
|
||||
"the action button device.spec.generate_entity_id(action.name) → "
|
||||
"'button.<model>_<mac4>_reset_filter_life' / '_reset_brush_life' (tk "
|
||||
"'filter-reset_filter_life' / 'brush_cleaner-reset_brush_life'). With a "
|
||||
"duplicated service (two filters, main + side brush) only the first instance "
|
||||
"gets a button — the same first instance the sensor suffix matches."
|
||||
),
|
||||
tasks=(
|
||||
# Matched via the entity_id suffix (translation_key is the noisier
|
||||
# 'filter-filter_life_level' form). One % task per filter; the side
|
||||
# brush collides to a '_2' suffix and is intentionally not matched.
|
||||
MIOT_FILTER_LIFE_LEVEL,
|
||||
MIOT_BRUSH_LIFE_LEVEL,
|
||||
# 2.95: completing presses the model's reset button where
|
||||
# button_actions lists it.
|
||||
ConsumableSignature(("filter_life_level",), "Replace Filter", "percent_left", resets=(("filter_life_level", "reset_filter_life"),)),
|
||||
ConsumableSignature(("brush_life_level",), "Replace Main Brush", "percent_left", resets=(("brush_life_level", "reset_brush_life"),)),
|
||||
),
|
||||
),
|
||||
"xiaomi_home": IntegrationSignature(
|
||||
@@ -38,11 +48,19 @@ SIGNATURES: dict[str, IntegrationSignature] = {
|
||||
"XiaoMi/ha_xiaomi_home miot/miot_device.py gen_prop_entity_id: "
|
||||
"entity_id = f'{model}_{did}_{model}_{slugify_name(prop)}_p_{siid}_{piid}' "
|
||||
"(property name mid-string, no translation_key) — matched via the "
|
||||
"'_<key>_p_' infix. Same MIoT spec properties as hass-xiaomi-miot."
|
||||
"'_<key>_p_' infix. Same MIoT spec properties as hass-xiaomi-miot. "
|
||||
"Known limits (2026-09-27 @ bf38d930): on specs with two brush-cleaner "
|
||||
"services (dreame-p2009: siid 9 'Main Cleaning Brush', siid 10 'Side "
|
||||
"Cleaning Brush', both 'brush-life-level') the infix matches BOTH brushes "
|
||||
"into the one 'Replace Main Brush' task (any-low); which siid is the side "
|
||||
"brush differs per spec, so no key can tell them apart. Reset actions become "
|
||||
"buttons '…_reset_filter_life_a_<siid>_<aiid>' — no catalog matcher sees an "
|
||||
"'_a_' infix, so they stay unwired."
|
||||
),
|
||||
tasks=(
|
||||
MIOT_FILTER_LIFE_LEVEL,
|
||||
MIOT_BRUSH_LIFE_LEVEL,
|
||||
# No reset: the action buttons' '_a_' ids are never matched (above).
|
||||
ConsumableSignature(("filter_life_level",), "Replace Filter", "percent_left"),
|
||||
ConsumableSignature(("brush_life_level",), "Replace Main Brush", "percent_left"),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user