456 files

This commit is contained in:
Home Assistant Version Control
2026-10-01 05:38:21 +00:00
parent 4192ecbaae
commit 5d6d845ebf
456 changed files with 64951 additions and 3828 deletions
@@ -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"),
),
),
}