456 files

This commit is contained in:
Home Assistant Version Control committed 2026-10-01 05:38:21 +00:00
1 parent 4192ecbaae
commit 5d6d845ebf
456 files changed
+64951 -3828

No files matched your search

@@ -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"),
),
),
}