Updated apps

This commit is contained in:
2026-07-20 22:52:35 -04:00
parent 28a8cb98f6
commit a0c3271743
1164 changed files with 94781 additions and 6892 deletions
@@ -0,0 +1,353 @@
"""Battery-fleet aggregation over the Battery Notes integration.
The user does NOT want one maintenance task per battery (30-70+ devices would
bury the task list). Instead this aggregates every Battery Notes ``battery_plus``
sensor into ONE fleet view: which batteries are low now, grouped by battery
type (so you know *what to buy*), plus a simple deterministic forecast of what
will be needed soon (so you can order in time).
Battery Notes exposes everything we need as ATTRIBUTES on the single
``battery_plus`` sensor (device_class ``battery``): ``battery_type``,
``battery_quantity``, ``battery_low``, ``battery_low_threshold``,
``battery_last_replaced``. We read that one sensor kind — no dependency on the
(optional, often-disabled) battery-low binary.
The pure builder ``build_overview`` takes plain battery dicts + an injected
``today`` so the forecast is unit-testable with synthetic dates; ``read_batteries``
is the thin HA-reading adapter.
"""
from __future__ import annotations
from collections import OrderedDict
from dataclasses import dataclass, field
from datetime import date
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
# Editorial typical service life per battery type, in MONTHS — the forecast
# anchor (battery_last_replaced + lifetime = predicted replacement). These are
# deliberately conservative sensor-use estimates and are meant to be tunable;
# unknown types fall back to DEFAULT_LIFETIME_MONTHS.
TYPICAL_LIFETIME_MONTHS: dict[str, int] = {
"AAAA": 10,
"AAA": 10,
"AA": 12,
"C": 18,
"D": 24,
"9V": 12,
"CR2": 18,
"CR123A": 18,
"CR2032": 18,
"CR2450": 24,
"CR2477": 24,
"CR2016": 18,
"CR2025": 18,
}
DEFAULT_LIFETIME_MONTHS = 12
# How far ahead "needed soon" looks by default (days).
DEFAULT_HORIZON_DAYS = 28
# A native battery %-sensor without a dedicated low binary is treated as low
# at or below this level (editorial — HA has no universal low threshold).
NATIVE_LOW_PERCENT = 20
# States that mean "no reading" — a removed device leaves nothing; an offline
# one leaves these. We keep an OFFLINE battery visible only when its last-known
# low flag says it needs attention (a dead battery often takes its device
# offline — that's exactly the one you must not hide).
_NO_READING = {"unavailable", "unknown", "none", ""}
def _norm_type(raw: Any) -> str:
"""Canonicalize a battery-type label for grouping (upper, trimmed)."""
s = str(raw or "").strip()
return s.upper() if s else "UNKNOWN"
def lifetime_months(battery_type: str) -> int:
"""Typical service life for a (canonicalized) battery type."""
return TYPICAL_LIFETIME_MONTHS.get(_norm_type(battery_type), DEFAULT_LIFETIME_MONTHS)
@dataclass
class Battery:
"""One battery-powered device — from Battery Notes (rich) or a native
``device_class: battery`` entity (degraded: no type/quantity/forecast)."""
entity_id: str
device_name: str
battery_type: str
quantity: int
low: bool
level: float | None
last_replaced: date | None
available: bool = True
source: str = "battery_notes"
@dataclass
class BatteryOverview:
"""The aggregated fleet view backing the single fleet task + its detail."""
total: int = 0
low: list[dict[str, Any]] = field(default_factory=list)
soon: list[dict[str, Any]] = field(default_factory=list)
# Grouped quantities by canonical type.
needs_now: OrderedDict[str, int] = field(default_factory=OrderedDict)
needs_soon: OrderedDict[str, int] = field(default_factory=OrderedDict)
types: list[str] = field(default_factory=list)
@property
def low_count(self) -> int:
return len(self.low)
def _predicted_date(bat: Battery) -> date | None:
if bat.last_replaced is None:
return None
months = lifetime_months(bat.battery_type)
# Month arithmetic without dateutil: add whole months, clamp the day.
y, m = bat.last_replaced.year, bat.last_replaced.month + months
y += (m - 1) // 12
m = (m - 1) % 12 + 1
day = min(bat.last_replaced.day, 28)
return date(y, m, day)
def build_overview(
batteries: list[Battery],
*,
today: date,
horizon_days: int = DEFAULT_HORIZON_DAYS,
) -> BatteryOverview:
"""Aggregate batteries into the fleet view.
* ``low`` = reported low right now (Battery Notes' own threshold).
* ``soon`` = NOT low yet but predicted to reach end-of-life within
``horizon_days`` (deterministic last_replaced + typical-lifetime forecast).
A battery already low is never double-counted into soon.
* ``needs_now`` / ``needs_soon`` = summed quantities per type — the shopping
grouping ("2× AA, 4× AAA").
"""
ov = BatteryOverview(total=len(batteries))
types_seen: OrderedDict[str, None] = OrderedDict()
for bat in sorted(batteries, key=lambda b: b.device_name.lower()):
t = _norm_type(bat.battery_type)
types_seen[t] = None
if bat.low:
ov.low.append(_row(bat, t, None))
ov.needs_now[t] = ov.needs_now.get(t, 0) + bat.quantity
continue
pred = _predicted_date(bat)
if pred is not None:
days = (pred - today).days
if days <= horizon_days:
ov.soon.append(_row(bat, t, days))
ov.needs_soon[t] = ov.needs_soon.get(t, 0) + bat.quantity
ov.soon.sort(key=lambda r: r["days_until"] if r["days_until"] is not None else 1 << 30)
ov.types = sorted(types_seen)
ov.needs_now = OrderedDict(sorted(ov.needs_now.items()))
ov.needs_soon = OrderedDict(sorted(ov.needs_soon.items()))
return ov
def _row(bat: Battery, canon_type: str, days_until: int | None) -> dict[str, Any]:
return {
"entity_id": bat.entity_id,
"device_name": bat.device_name,
"battery_type": canon_type,
"quantity": bat.quantity,
"level": bat.level,
"days_until": days_until,
"available": bat.available,
}
def _parse_last_replaced(raw: Any) -> date | None:
if not raw:
return None
try:
parsed = dt_util.parse_datetime(str(raw))
if parsed is not None:
return parsed.date()
return date.fromisoformat(str(raw)[:10])
except (ValueError, TypeError):
return None
def _level_of(state_val: str) -> float | None:
try:
return float(state_val)
except (ValueError, TypeError):
return None
def read_batteries(hass: HomeAssistant) -> list[Battery]:
"""Read the battery fleet from HA state — Battery Notes AND native.
* **Battery Notes** ``battery_plus`` sensors (device_class ``battery`` + a
``battery_type`` attribute) give the rich view: type, quantity, low,
last-replaced. When the source goes offline the sensor reads
unavailable/unknown but RETAINS its last-known ``battery_low`` — so a
dead battery that took its device offline stays visible.
* **Native** ``device_class: battery`` entities (a %-sensor and/or a
battery-low binary), grouped per device, give a degraded view (type
"Unknown", quantity 1, no forecast). A device already covered by a
Battery Notes note is skipped (dedup by the note's source entity + its
device) so it isn't counted twice.
"""
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
ent_reg = er.async_get(hass)
dev_reg = dr.async_get(hass)
out: list[Battery] = []
covered_sources: set[str] = set()
covered_devices: set[str] = set()
# ── Pass 1: Battery Notes battery_plus ──────────────────────────────────
for state in hass.states.async_all("sensor"):
attrs = state.attributes
if attrs.get("device_class") != "battery" or "battery_type" not in attrs:
continue
src = attrs.get("source_entity_id")
if src:
covered_sources.add(src)
reg = ent_reg.async_get(state.entity_id)
if reg and reg.device_id:
covered_devices.add(reg.device_id)
level = _level_of(state.state)
available = state.state not in _NO_READING and level is not None
low = bool(attrs.get("battery_low"))
# Offline AND not last-known-low = pure connectivity noise → drop it.
if not available and not low:
continue
out.append(
Battery(
entity_id=state.entity_id,
device_name=attrs.get("device_name") or attrs.get("friendly_name") or state.entity_id,
battery_type=str(attrs.get("battery_type") or "Unknown"),
quantity=int(attrs.get("battery_quantity") or 1),
low=low,
level=level,
last_replaced=_parse_last_replaced(attrs.get("battery_last_replaced")),
available=available,
source="battery_notes",
)
)
# ── Pass 2: native battery entities, grouped per device ─────────────────
# {group_key: {"level_state": s, "low_state": s, "name": ..., "device_id": ..., "eid": ...}}
native: dict[str, dict[str, Any]] = {}
for domain in ("sensor", "binary_sensor"):
for state in hass.states.async_all(domain):
if state.attributes.get("device_class") != "battery":
continue
eid = state.entity_id
if "battery_type" in state.attributes: # Battery Notes battery_plus — handled above
continue
if eid in covered_sources:
continue
reg = ent_reg.async_get(eid)
dev_id = reg.device_id if reg else None
if dev_id and dev_id in covered_devices:
continue
key = dev_id or eid
rec = native.setdefault(
key,
{"level_state": None, "low_state": None, "device_id": dev_id, "eid": eid, "name": None},
)
friendly = state.attributes.get("friendly_name")
if domain == "sensor":
rec["level_state"] = state.state
rec["eid"] = eid
else:
rec["low_state"] = state.state
if rec["name"] is None and friendly:
rec["name"] = friendly
for rec in native.values():
level = _level_of(rec["level_state"]) if rec["level_state"] is not None else None
low_state = rec["low_state"]
level_available = rec["level_state"] not in _NO_READING if rec["level_state"] is not None else False
low_available = low_state not in _NO_READING if low_state is not None else False
available = level_available or low_available
if low_state is not None:
low = low_available and str(low_state).lower() in ("on", "true", "1")
else:
low = level is not None and level <= NATIVE_LOW_PERCENT
if not available and not low:
continue
name = rec["name"]
if not name and rec["device_id"] and (dev := dev_reg.async_get(rec["device_id"])):
name = dev.name_by_user or dev.name
out.append(
Battery(
entity_id=rec["eid"],
device_name=name or rec["eid"],
battery_type="Unknown",
quantity=1,
low=low,
level=level,
last_replaced=None,
available=available,
source="native",
)
)
return out
def has_battery_notes(hass: HomeAssistant) -> bool:
"""Whether the Battery Notes integration is present (any battery_plus)."""
for state in hass.states.async_all("sensor"):
a = state.attributes
if a.get("device_class") == "battery" and "battery_type" in a:
return True
return False
def has_batteries(hass: HomeAssistant) -> bool:
"""Whether ANY battery is trackable — Battery Notes OR native. Gates setup."""
for domain in ("sensor", "binary_sensor"):
for state in hass.states.async_all(domain):
if state.attributes.get("device_class") == "battery":
return True
return False
def compute_overview(hass: HomeAssistant, *, horizon_days: int = DEFAULT_HORIZON_DAYS) -> BatteryOverview:
"""Read + aggregate in one call (HA-side entry point)."""
today = dt_util.now().date()
return build_overview(read_batteries(hass), today=today, horizon_days=horizon_days)
def discover_battery_types(hass: HomeAssistant) -> OrderedDict[str, int]:
"""Battery types present across the fleet → total quantity, for part setup."""
totals: OrderedDict[str, int] = OrderedDict()
for bat in read_batteries(hass):
t = _norm_type(bat.battery_type)
totals[t] = totals.get(t, 0) + bat.quantity
return OrderedDict(sorted(totals.items()))
__all__ = [
"DEFAULT_HORIZON_DAYS",
"NATIVE_LOW_PERCENT",
"TYPICAL_LIFETIME_MONTHS",
"Battery",
"BatteryOverview",
"build_overview",
"compute_overview",
"discover_battery_types",
"has_batteries",
"has_battery_notes",
"lifetime_months",
"read_batteries",
]
@@ -0,0 +1,284 @@
"""One-click setup of the Battery Fleet: an object whose PARTS are battery
types and whose single task aggregates all low batteries.
Design (see helpers/battery_fleet.py for the aggregation): the fleet is ONE
object; each battery TYPE present becomes a tracked spare-part (so the existing
stock/reorder machinery handles "order in time"); ONE task "Replace low
batteries" hangs off the global battery-low count sensor via an ordinary
threshold trigger. No per-battery task.
"""
from __future__ import annotations
from typing import Any
from uuid import uuid4
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import dt as dt_util
from ..const import CONF_OBJECT, CONF_PARTS, CONF_TASKS, DOMAIN
from .battery_fleet import _norm_type, discover_battery_types, lifetime_months, read_batteries
# The global aggregate sensor the fleet task triggers on (fixed entity_id).
LOW_COUNT_ENTITY_ID = "sensor.maintenance_supporter_batteries_to_replace"
# Marker on the object + task so the panel renders the battery detail section
# and setup is idempotent (never a second fleet).
OBJECT_FLAG = "battery_fleet"
TASK_FLAG = "battery_fleet_task"
def find_fleet_entry(hass: HomeAssistant) -> ConfigEntry | None:
"""The existing Battery Fleet object entry, or None."""
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.data.get(CONF_OBJECT, {}).get(OBJECT_FLAG):
return entry
return None
async def async_setup_battery_fleet(hass: HomeAssistant) -> dict[str, Any]:
"""Create (or return) the Battery Fleet object with type-parts + the task.
Idempotent: a second call reconciles the type-parts against the current
fleet (adds parts for newly-seen types) and returns the existing entry.
"""
from ..websocket.objects import async_create_object
from ..websocket.tasks_persist import async_persist_task
from .parts import normalize_part
types = discover_battery_types(hass) # {TYPE: total_qty}
existing = find_fleet_entry(hass)
if existing is not None:
added = _reconcile_type_parts(hass, existing, types)
repaired = await _reconcile_fleet_task(hass, existing)
return {
"entry_id": existing.entry_id,
"created": False,
"types": list(types),
"parts_added": added,
"task_repaired": repaired,
}
entry_id = await async_create_object(hass, name="Battery Fleet")
entry = hass.config_entries.async_get_entry(entry_id)
if entry is None: # pragma: no cover — just created above
raise HomeAssistantError("Battery Fleet object entry vanished after creation")
# Flag the object + attach a type-part per battery type present.
new_data = dict(entry.data)
obj = dict(new_data.get(CONF_OBJECT, {}))
obj[OBJECT_FLAG] = True
new_data[CONF_OBJECT] = obj
parts: dict[str, dict[str, Any]] = {}
for btype, total_qty in types.items():
part = normalize_part(_type_part(btype, total_qty))
parts[part["id"]] = part
new_data[CONF_PARTS] = parts
hass.config_entries.async_update_entry(entry, data=new_data)
# Track stock at 0 for each type (user counts their drawer later).
rd = getattr(entry, "runtime_data", None)
store = getattr(rd, "store", None) if rd else None
if store is not None:
for pid in parts:
store.set_part_stock(pid, 0)
await store.async_save()
# The single aggregate task, triggered by the global low-count sensor.
obj_id = obj.get("id", "")
task = {
"id": uuid4().hex,
"object_id": obj_id,
"name": "Replace low batteries",
"type": "inspection",
"enabled": True,
TASK_FLAG: True,
"schedule": {"kind": "manual"},
"trigger_config": _fleet_trigger_config(),
"created_at": dt_util.now().date().isoformat(),
"notes": ("Aggregate battery check. The detail view lists which devices are low and which battery types to buy."),
}
await async_persist_task(hass, entry, task)
return {
"entry_id": entry_id,
"created": True,
"types": list(types),
"parts_added": len(parts),
"task_id": task["id"],
}
def _fleet_trigger_config() -> dict[str, Any]:
"""The canonical fleet-task trigger: threshold >0 on the low-count sensor.
Carries BOTH the singular ``entity_id`` and plural ``entity_ids`` — the
task-dialog's save path gates on the singular field, and a (possibly
cached) frontend that hydrates only ``entity_id`` would otherwise wipe
the trigger on an unrelated edit (issue #106).
"""
return {
"type": "threshold",
"entity_id": LOW_COUNT_ENTITY_ID,
"entity_ids": [LOW_COUNT_ENTITY_ID],
"trigger_above": 0,
"entity_logic": "any",
"auto_complete_on_recovery": True,
}
def _type_part(btype: str, total_qty: int) -> dict[str, Any]:
"""A spare-part definition for one battery type.
reorder_threshold defaults to keeping a spare set roughly the size of the
fleet's need for that type (min 2); restock is double that. auto_buy_task
stays off so setup never spawns extra buy-tasks — the fleet task's detail
is the shopping surface; the user can enable auto-buy per type later.
"""
threshold = max(2, total_qty)
return {
"id": f"batt_{btype.lower()}",
"name": f"{btype} battery",
"unit": "pcs",
"reorder_threshold": threshold,
"restock_quantity": threshold * 2,
"auto_buy_task": False,
"notes": f"Typical service life ~{lifetime_months(btype)} months (editorial).",
}
def replaced_button_for(battery_plus_entity_id: str) -> str:
"""The Battery Notes 'replaced' button entity id for a battery_plus sensor.
Battery Notes mints them in parallel: sensor.<x>_battery_plus ->
button.<x>_battery_replaced.
"""
return battery_plus_entity_id.replace("sensor.", "button.", 1).replace("_battery_plus", "_battery_replaced")
async def async_mark_replaced(hass: HomeAssistant, entity_ids: list[str] | None = None) -> dict[str, Any]:
"""Mark batteries replaced: press their Battery Notes 'replaced' button
(records the replacement date → resets the forecast) and consume the
matching type-part spares from stock.
``entity_ids`` = battery_plus sensors to mark; default = all currently low.
The fleet task auto-completes on its own once the devices report fresh
(low count → 0), so this does NOT complete the task directly (which would
race that recovery).
"""
by_eid = {b.entity_id: b for b in read_batteries(hass)}
targets = entity_ids if entity_ids is not None else [e for e, b in by_eid.items() if b.low]
pressed = 0
by_type: dict[str, int] = {}
for eid in targets:
bat = by_eid.get(eid)
if bat is None:
continue
button = replaced_button_for(eid)
if hass.states.get(button) is not None:
await hass.services.async_call("button", "press", {"entity_id": button}, blocking=False)
pressed += 1
t = _norm_type(bat.battery_type)
by_type[t] = by_type.get(t, 0) + bat.quantity
consumed: dict[str, int] = {}
fleet = find_fleet_entry(hass)
if fleet is not None and by_type:
from ..parts_runtime import async_change_part_stock
parts = fleet.data.get(CONF_PARTS) or {}
for btype, qty in by_type.items():
pid = f"batt_{btype.lower()}"
if pid in parts:
await async_change_part_stock(hass, fleet, pid, delta=-qty)
consumed[pid] = qty
return {"marked": len(targets), "pressed": pressed, "consumed": consumed}
def find_fleet_task(entry: ConfigEntry) -> tuple[str, dict[str, Any]] | None:
"""The flagged fleet task (id, data) on the fleet entry, or None."""
for task_id, task_data in (entry.data.get(CONF_TASKS) or {}).items():
if task_data.get(TASK_FLAG):
return task_id, task_data
return None
def fleet_task_trigger_ok(entry: ConfigEntry) -> bool:
"""Whether the fleet task exists and still carries a usable trigger.
A user edit can wipe the trigger (issue #106: the dialog nulled a trigger
stored with only the plural ``entity_ids``); without it the task never
fires or auto-completes. This is the health signal behind the repair path.
"""
found = find_fleet_task(entry)
if found is None:
return False
tc = found[1].get("trigger_config") or {}
eids = tc.get("entity_ids") or ([tc["entity_id"]] if tc.get("entity_id") else [])
return tc.get("type") == "threshold" and LOW_COUNT_ENTITY_ID in eids
async def _reconcile_fleet_task(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Repair the fleet task if broken. Returns True when something was fixed.
* Trigger lost (issue #106) → restore the canonical threshold trigger,
keeping the user's name/type/translations untouched.
* Task deleted entirely → recreate it fresh.
"""
from ..websocket.tasks_persist import async_persist_task
if fleet_task_trigger_ok(entry):
return False
found = find_fleet_task(entry)
if found is not None:
task_id, task_data = found
new_task = dict(task_data)
new_task["trigger_config"] = _fleet_trigger_config()
new_data = dict(entry.data)
new_tasks = dict(new_data.get(CONF_TASKS, {}))
new_tasks[task_id] = new_task
new_data[CONF_TASKS] = new_tasks
hass.config_entries.async_update_entry(entry, data=new_data)
await hass.config_entries.async_reload(entry.entry_id)
return True
obj = entry.data.get(CONF_OBJECT, {})
task = {
"id": uuid4().hex,
"object_id": obj.get("id", ""),
"name": "Replace low batteries",
"type": "inspection",
"enabled": True,
TASK_FLAG: True,
"schedule": {"kind": "manual"},
"trigger_config": _fleet_trigger_config(),
"created_at": dt_util.now().date().isoformat(),
"notes": ("Aggregate battery check. The detail view lists which devices are low and which battery types to buy."),
}
await async_persist_task(hass, entry, task)
return True
def _reconcile_type_parts(hass: HomeAssistant, entry: ConfigEntry, types: dict[str, int]) -> int:
"""Add parts for battery types newly seen since setup. Returns count added."""
from .parts import normalize_part
parts = dict(entry.data.get(CONF_PARTS) or {})
existing_ids = set(parts)
added = 0
for btype, total_qty in types.items():
pid = f"batt_{btype.lower()}"
if pid not in existing_ids:
parts[pid] = normalize_part(_type_part(btype, total_qty))
added += 1
if added:
new_data = dict(entry.data)
new_data[CONF_PARTS] = parts
hass.config_entries.async_update_entry(entry, data=new_data)
return added
@@ -0,0 +1,26 @@
"""Compatibility shim — the catalog moved to ``helpers/signatures/``.
Split 2026-07-19 (the data dict had grown past 1,400 lines): dataclasses
and matcher/trigger mechanics live in ``signatures/_model``, the entries
in one data module per category, assembly in ``signatures/_registry``.
Import from here or from the package both stay supported."""
from __future__ import annotations
from .signatures import (
SIGNATURES,
ConsumableSignature,
IntegrationSignature,
build_setup_trigger,
discover_integration_setups,
task_name_variants,
)
__all__ = [
"SIGNATURES",
"ConsumableSignature",
"IntegrationSignature",
"build_setup_trigger",
"discover_integration_setups",
"task_name_variants",
]
@@ -156,17 +156,28 @@ def _clean_cost(raw: Any) -> float | None:
return v
def _clean_stock(raw: Any, field: str) -> int | None:
"""Stock-ish int or None. ``stock: None`` = inventory not tracked."""
def round_qty(v: float) -> float | int:
"""Canonical quantity rounding (#98 decimal consumables): 2 decimals,
whole numbers collapse back to int so exports/UI stay clean."""
r = round(float(v), 2)
return int(r) if r.is_integer() else r
def _clean_stock(raw: Any, field: str) -> float | int | None:
"""Stock-ish number or None. ``stock: None`` = inventory not tracked.
Decimal quantities are allowed (#98 — half a can of spray is 0.5),
rounded to 2 decimals.
"""
if raw in (None, ""):
return None
try:
v = int(raw)
v = float(raw)
except (TypeError, ValueError) as err:
raise PartValidationError(f"{field} must be an integer") from err
raise PartValidationError(f"{field} must be a number") from err
if not 0 <= v <= MAX_PART_STOCK:
raise PartValidationError(f"{field} out of range (0-{MAX_PART_STOCK})")
return v
return round_qty(v)
def normalize_part(raw: Mapping[str, Any]) -> dict[str, Any]:
@@ -217,27 +228,31 @@ def sanitize_consumes_parts(raw: Any, valid_part_ids: set[str] | None = None) ->
if not part_id or (valid_part_ids is not None and part_id not in valid_part_ids):
continue
try:
qty = int(item.get("quantity", 1))
qty = float(item.get("quantity", 1))
except (TypeError, ValueError):
qty = 1
out[part_id] = {"part_id": part_id, "quantity": max(1, min(qty, MAX_CONSUME_QUANTITY))}
qty = 1.0
# Decimal consumption is allowed (#98). Zero/negative behaves like
# invalid input (falls back to 1), matching the old integer clamp.
if qty <= 0:
qty = 1.0
out[part_id] = {"part_id": part_id, "quantity": round_qty(min(qty, MAX_CONSUME_QUANTITY))}
return list(out.values())
# ── Stock rules ──────────────────────────────────────────────────────────────
def part_is_low(part: Mapping[str, Any], stock: int | None) -> bool:
def part_is_low(part: Mapping[str, Any], stock: float | None) -> bool:
"""Tracked stock at/below the reorder threshold."""
threshold = part.get("reorder_threshold")
return stock is not None and threshold is not None and stock <= int(threshold)
return stock is not None and threshold is not None and stock <= float(threshold)
def part_wants_buy_task(part: Mapping[str, Any]) -> bool:
return bool(part.get("auto_buy_task")) and part.get("reorder_threshold") is not None
def stock_transition(part: Mapping[str, Any], old: int | None, new: int | None) -> str | None:
def stock_transition(part: Mapping[str, Any], old: float | None, new: float | None) -> str | None:
"""The edge this stock change crossed, if any: ``low`` / ``out`` / ``restocked``.
Edge-triggered on purpose a further decrease while already low never
@@ -292,7 +307,7 @@ def buy_task_name(part_name: str, lang: str) -> str:
return tpl.replace("{name}", part_name)
def buy_task_notes(part: Mapping[str, Any], stock: int | None) -> str:
def buy_task_notes(part: Mapping[str, Any], stock: float | None) -> str:
"""Self-contained purchase notes: identifiers, qty, price, storage spot.
Deliberately mostly language-neutral (labels are identifiers like MPN/GTIN;
@@ -324,7 +339,7 @@ def buy_task_notes(part: Mapping[str, Any], stock: int | None) -> str:
def build_buy_task(
part: Mapping[str, Any],
stock: int | None,
stock: float | None,
*,
object_id: str,
lang: str,
@@ -351,7 +366,7 @@ def build_buy_task(
def reconcile_buy_tasks(
parts: Mapping[str, Mapping[str, Any]],
stocks: Mapping[str, int | None],
stocks: Mapping[str, float | None],
tasks: Mapping[str, Mapping[str, Any]],
*,
object_id: str,
@@ -32,23 +32,20 @@ from ..const import (
)
PROBLEM_DEVICE_CLASS = "problem"
# safety (NAS disk-health / lifespan thresholds) and tamper alarms behave like
# problem sensors for adoption purposes: binary, on = action needed.
ADOPTABLE_DEVICE_CLASSES = frozenset({PROBLEM_DEVICE_CLASS, "safety", "tamper"})
# Words too generic to establish a sensor↔part relationship on their own
# ("Printer problem" must not match a part just because it's ON the printer).
_MATCH_STOPWORDS = frozenset(
{"problem", "low", "empty", "sensor", "status", "warning", "error", "alert", "the", "and"}
)
_MATCH_STOPWORDS = frozenset({"problem", "low", "empty", "sensor", "status", "warning", "error", "alert", "the", "and"})
def _name_tokens(name: str) -> set[str]:
"""Meaningful lowercase tokens (≥3 chars, stopwords removed) of a name."""
import re
return {
tok
for tok in re.split(r"[^a-z0-9]+", name.lower())
if len(tok) >= 3 and tok not in _MATCH_STOPWORDS
}
return {tok for tok in re.split(r"[^a-z0-9]+", name.lower()) if len(tok) >= 3 and tok not in _MATCH_STOPWORDS}
def match_part_for_sensor(sensor_name: str, parts: dict[str, Any]) -> tuple[str, str] | None:
@@ -117,7 +114,10 @@ def discover_problem_sensors(hass: HomeAssistant) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for state in hass.states.async_all("binary_sensor"):
if state.attributes.get("device_class") != PROBLEM_DEVICE_CLASS:
# safety/tamper alarms are maintenance-adjacent the same way problem
# is (NAS disk-health thresholds ship as device_class: safety) —
# adoption stays opt-in per sensor either way.
if state.attributes.get("device_class") not in ADOPTABLE_DEVICE_CLASSES:
continue
if state.entity_id in adopted:
continue
@@ -166,24 +166,35 @@ def discover_problem_sensors(hass: HomeAssistant) -> list[dict[str, Any]]:
return out
def stash_task_notes_for_readopt(hass: HomeAssistant, task: dict[str, Any]) -> None:
"""Preserve a deleted adopted task's notes for a later re-adopt.
# Task fields worth surviving an un-adopt → re-adopt cycle. Notes carry the
# accumulated knowledge; the rest is configuration the user set up once and
# shouldn't have to redo (consumes_parts is re-validated against the target
# object's parts on restore — the object may differ or the part may be gone).
_STASHED_TASK_FIELDS = ("notes", "responsible_user_id", "priority", "labels", "consumes_parts")
def stash_task_config_for_readopt(hass: HomeAssistant, task: dict[str, Any]) -> None:
"""Preserve a deleted adopted task's configuration for a later re-adopt.
Un-adopting a problem sensor = deleting its task, which used to drop the
accumulated notes ("needs part X", "reset via service menu"). For tasks
carrying the adopted signature (``auto_complete_on_recovery`` on watched
``entity_ids``), non-empty notes are stashed on the global entry keyed by
the watched sensor, and restored (consumed) when the sensor is re-adopted.
FIFO-capped at ``MAX_ADOPTED_NOTES`` so the global entry can't grow
unbounded. Called from the shared task-delete path; a no-op for everything
that isn't an adopted task with notes.
accumulated notes ("needs part X") AND the one-time setup (responsible
user, priority, labels, part link). For tasks carrying the adopted
signature (``auto_complete_on_recovery`` on watched ``entity_ids``), the
``_STASHED_TASK_FIELDS`` present on the task are stashed on the global
entry keyed by the watched sensor, and restored (consumed) when the sensor
is re-adopted. FIFO-capped at ``MAX_ADOPTED_NOTES`` so the global entry
can't grow unbounded. Called from the shared task-delete path; a no-op for
everything that isn't an adopted task with stashable fields.
"""
tc = task.get("trigger_config")
if not isinstance(tc, dict) or not tc.get("auto_complete_on_recovery"):
return
entity_ids = tc.get("entity_ids") or []
notes = task.get("notes")
if not entity_ids or not isinstance(notes, str) or not notes.strip():
config = {k: task[k] for k in _STASHED_TASK_FIELDS if task.get(k) and (not isinstance(task[k], str) or task[k].strip())}
# "normal" priority is the default — not worth resurrecting on its own.
if config.get("priority") == "normal":
config.pop("priority")
if not entity_ids or not config:
return
from .global_options import get_global_entry
@@ -194,15 +205,19 @@ def stash_task_notes_for_readopt(hass: HomeAssistant, task: dict[str, Any]) -> N
stash = dict(options.get(CONF_ADOPTED_NOTES) or {})
key = str(entity_ids[0])
stash.pop(key, None) # re-insert as newest (dict order = age)
stash[key] = notes
stash[key] = config
while len(stash) > MAX_ADOPTED_NOTES:
stash.pop(next(iter(stash)))
options[CONF_ADOPTED_NOTES] = stash
hass.config_entries.async_update_entry(entry, options=options)
def pop_stashed_notes(hass: HomeAssistant, entity_id: str) -> str | None:
"""Consume (return + remove) stashed notes for ``entity_id``, if any."""
def pop_stashed_config(hass: HomeAssistant, entity_id: str) -> dict[str, Any] | None:
"""Consume (return + remove) the stashed config for ``entity_id``, if any.
Pre-v2.37 stashes stored the notes string bare normalized here to the
dict shape so the adopt path has a single format to apply.
"""
from .global_options import get_global_entry
entry = get_global_entry(hass)
@@ -210,12 +225,17 @@ def pop_stashed_notes(hass: HomeAssistant, entity_id: str) -> str | None:
return None
options = dict(entry.options or entry.data)
stash = dict(options.get(CONF_ADOPTED_NOTES) or {})
notes = stash.pop(entity_id, None)
if notes is None:
stored = stash.pop(entity_id, None)
if stored is None:
return None
options[CONF_ADOPTED_NOTES] = stash
hass.config_entries.async_update_entry(entry, options=options)
return notes if isinstance(notes, str) and notes.strip() else None
if isinstance(stored, str):
return {"notes": stored} if stored.strip() else None
if isinstance(stored, dict):
config = {k: v for k, v in stored.items() if k in _STASHED_TASK_FIELDS and v}
return config or None
return None
def build_problem_task(entity_id: str, name: str) -> dict[str, Any]:
@@ -198,17 +198,34 @@ class Schedule:
return result
def _roll_to_season(self, d: date | None) -> date | None:
"""Roll a due date outside the seasonal window to the 1st of the next
active month; a no-op when there's no window or the date is in season."""
"""Roll a due date outside the seasonal window into the next active
month; a no-op when there's no window or the date is in season.
Interval kinds land on the month's 1st ("due once the season starts").
Calendar kinds PRESERVE their pattern inside the window a "2nd
Saturday" task must come due on the 2nd Saturday of the active month,
not on the 1st (#83). If the pattern (or its ±offset) misses the
active month, the search continues into the next one, bounded.
"""
if d is None or not self.season_months or d.month in self.season_months:
return d
year, month = d.year, d.month
for _ in range(12):
for _ in range(24):
month += 1
if month > 12:
month, year = 1, year + 1
if month in self.season_months:
if month not in self.season_months:
continue
if self.kind not in _CALENDAR_KINDS:
return date(year, month, 1)
occ = self._calendar_occurrence(date(year, month, 1), inclusive=True)
if occ is None:
return date(year, month, 1)
if occ.month in self.season_months:
return occ
# Pattern (e.g. a 5th Friday) or its offset fell outside the
# window — keep searching from where the occurrence landed.
year, month = occ.year, occ.month
return d # season_months held only invalid values — leave the date as-is
def _compute_next_due(
@@ -441,6 +458,50 @@ class Schedule:
)
def preview_occurrences(
schedule: Schedule,
*,
last_performed: date | None,
times_performed: int = 0,
today: date,
count: int = 3,
) -> tuple[list[date], bool]:
"""The next ``count`` occurrences a schedule produces, plus whether the
series ends within them.
Simulates an ON-TIME completion per step (last_performed and the
planned anchor advance, times_performed increments), so completion-
anchored intervals, calendar kinds, season windows, business-day rolls,
±offsets and finite series all advance exactly as the engine would.
Single source of truth for BOTH preview surfaces the panel's
``schedule/preview`` WS command and the options flow's next-dates line
(#83; keep them DRY through this helper).
"""
occurrences: list[date] = []
series_ended = False
lp = last_performed
lpd: date | None = None
times = times_performed
for _ in range(count):
nxt = schedule.next_due(
last_performed=lp,
created_at=today,
last_planned_due=lpd,
today=today,
times_performed=times,
)
if nxt is None:
series_ended = True
break
if occurrences and nxt <= occurrences[-1]: # pragma: no cover
break # safety net: the engine must advance — never loop
occurrences.append(nxt)
lp = nxt
lpd = nxt
times += 1
return occurrences, series_ended
def is_recurring(task: Mapping[str, Any]) -> bool:
"""True iff the task dict has a cycling schedule (interval or calendar kind).
@@ -0,0 +1,25 @@
"""Suggested-setups signature catalog (split from integration_signatures.py).
Layout: ``_model`` (dataclasses + matcher/trigger mechanics), one data
module per category, ``_registry`` (merge + duplicate guard),
``_discovery``. The old import path keeps working via the shim."""
from __future__ import annotations
from ._discovery import discover_integration_setups
from ._model import (
ConsumableSignature,
IntegrationSignature,
build_setup_trigger,
task_name_variants,
)
from ._registry import SIGNATURES
__all__ = [
"SIGNATURES",
"ConsumableSignature",
"IntegrationSignature",
"build_setup_trigger",
"discover_integration_setups",
"task_name_variants",
]
@@ -0,0 +1,147 @@
"""Device discovery over the assembled signature catalog."""
from __future__ import annotations
from typing import Any
from homeassistant.core import HomeAssistant
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 ._model import (
_entity_matches,
_entity_unit,
_threshold_for,
_unit_compatible,
task_name_variants,
)
from ._registry import SIGNATURES
def discover_integration_setups(hass: HomeAssistant) -> list[dict[str, Any]]:
"""Devices of catalogued integrations with their matchable task wiring.
Groups matched entities per device; carries the maintenance object already
attached to the device (if any) so adoption can extend it instead of
creating a duplicate. Entities already watched by some task's trigger are
skipped re-running discovery never proposes what is already wired.
"""
from ...templates import localize_template_text
from ..i18n import normalize_language
from ..problem_sensors import _adopted_entity_ids, _object_by_device
lang = normalize_language(hass)
ent_reg = er.async_get(hass)
dev_reg = dr.async_get(hass)
area_reg = ar.async_get(hass)
already_watched = _adopted_entity_ids(hass)
by_device = _object_by_device(hass)
# Collect the enabled registry entities of cataloged integrations per
# (device, integration) first — the device-type gates need the device's
# FULL entity list (siblings identify the appliance type).
by_device_integration: dict[tuple[str, str], list[er.RegistryEntry]] = {}
for entry in ent_reg.entities.values():
if SIGNATURES.get(entry.platform) is None or not entry.device_id:
continue
if entry.disabled_by is not None:
continue
by_device_integration.setdefault((entry.device_id, entry.platform), []).append(entry)
# device_id → {(integration, task_name, direction): {sig, entity_ids}}.
# The direction is part of the key so an integration that ships one task
# name in two directions (LG ThinQ filter: hours vs percent) stays split.
matched: dict[str, dict[tuple[str, str, str], dict[str, Any]]] = {}
for (device_id, integration), entries in by_device_integration.items():
catalog = SIGNATURES[integration]
device = dev_reg.async_get(device_id)
model = ((device.model or "") if device else "").lower()
for sig in catalog.tasks:
# Device-type gates: registry model substring and/or a
# type-identifying sibling entity (watched siblings still count —
# only the match TARGET must be unwatched).
if sig.models and not any(m.lower() in model for m in sig.models):
continue
if sig.models_exclude and any(m.lower() in model for m in sig.models_exclude):
continue
if sig.require_sibling_keys and not any(
any(_entity_matches(e, key) for key in sig.require_sibling_keys) for e in entries
):
continue
for entry in entries:
if entry.domain != sig.entity_domain:
continue
if entry.entity_id in already_watched:
continue
if not _unit_compatible(sig.direction, _entity_unit(hass, entry)):
continue
# Empty keys (non-sensor domains only, tripwire-enforced) match
# the device's single entity of that domain — THE lawn_mower.
if sig.keys and not any(_entity_matches(entry, key) for key in sig.keys):
continue
group = matched.setdefault(device_id, {}).setdefault(
(integration, sig.task_name, sig.direction),
{"sig": sig, "entity_ids": []},
)
group["entity_ids"].append(entry.entity_id)
# One source entity may back SEVERAL duties (a mower's hours
# counter drives blades AND undercarriage). Adopting any duty
# marks the entity watched — adopt-all is the default,
# deselecting a duty forfeits its later proposal.
out: list[dict[str, Any]] = []
for device_id, sig_map in matched.items():
device = dev_reg.async_get(device_id)
if device is None:
continue
device_name = device.name_by_user or device.name or device_id
area_name = ""
if device.area_id and (area := area_reg.async_get_area(device.area_id)):
area_name = area.name
integration = next(iter(sig_map))[0]
catalog = SIGNATURES[integration]
suggested = by_device.get(device_id)
# Duties already present on the bound object BY NAME (any language) are
# not re-proposed — covers manually created calendar tasks whose
# trigger watches no entity (the entity-watched exclusion misses them).
existing_names: set[str] = set()
if suggested and (target := hass.config_entries.async_get_entry(suggested["entry_id"])):
from ...const import CONF_TASKS
existing_names = {str(t.get("name", "")).lower() for t in target.data.get(CONF_TASKS, {}).values()}
tasks = []
for (_integ, task_name, direction), group in sig_map.items():
entity_ids = sorted(group["entity_ids"])
if not entity_ids:
continue
if existing_names and existing_names & task_name_variants(task_name):
continue
tasks.append(
{
# task_name stays the EN catalog key (adopt selections
# match on it); the dialog renders the localized twin.
"task_name": task_name,
"task_name_localized": localize_template_text(task_name, lang) or task_name,
"entity_ids": entity_ids,
"threshold": _threshold_for(group["sig"], hass, entity_ids[0]),
"direction": direction,
}
)
if not tasks:
continue
tasks.sort(key=lambda t: (t["task_name"], t["direction"]))
out.append(
{
"device_id": device_id,
"device_name": device_name,
"area_name": area_name,
"integration": integration,
"integration_name": catalog.name,
"suggested_entry_id": suggested["entry_id"] if suggested else None,
"suggested_object_name": suggested["name"] if suggested else device_name,
"tasks": tasks,
}
)
out.sort(key=lambda s: (s["integration_name"], s["device_name"]))
return out
@@ -0,0 +1,306 @@
"""Verified maintenance entity signatures of popular integrations (roadmap).
Popular integrations expose consumable/wear entities that map 1:1 onto
maintenance tasks a Roborock reports *filter time left*, a Brother printer
its *drum remaining life*. This catalog lets discovery propose a maintenance
object **with sensor-based triggers pre-wired** instead of bare calendar
intervals.
METHOD CONTRACT: every signature is verified against the integration's actual
source code the ``source`` field records where, ``verified`` records when and
against which ref. Evaluation follows the directderived ladder in
docs/design/signature-evaluation-scheme.md: inventory ALL entity platforms,
then direct signals (percent/countdown/resettable counter/event), then derived
(lifetime counters via delta, attributes), then engine-derived (runtime on
state entities) a negative verdict only after all rungs.
Matching uses the entity registry's ``translation_key`` (the stable id from the
integration's EntityDescription, immune to renames) with an entity_id-suffix
fallback for custom integrations that don't set one.
Direction semantics:
* ``duration_left`` countdown to the next replacement (device_class
duration). Trigger: below N hours, converted into the entity's display unit.
* ``percent_left`` remaining life/level in percent. Trigger: below N %.
* ``usage_above`` a wear counter that counts UP since the device's own
last reset (blade usage time, tub-clean cycles). Trigger: a delta counter
from an explicit 0 baseline absolute semantics at adoption, but a manual
completion re-baselines instead of immediately re-firing, and a device-side
reset both re-baselines (rollover handling) and auto-completes the task.
* ``event_present`` an ENUM *event* sensor (no unit) that reports an
actionable maintenance state (``present``) vs. ``off``/``confirmed`` Home
Connect salt/rinse-aid/descale/clean events. Trigger: a state_change latch on
``present`` (not a numeric threshold); the task auto-completes when the event
clears. The appliance emitting the clearing event is required for auto-resolve
otherwise the task waits for a manual completion.
* ``usage_delta`` a LIFETIME counter with no reset anywhere (printer
usage hours, burner hours, car odometer). Trigger: a counter trigger in
delta mode fires every N canonical units (hours for operating-time
counters, kilometres for odometers) of accumulated use since the task was
last completed; completing the task re-baselines the counter. No
auto_complete_on_recovery (a lifetime counter never recovers).
* ``runtime_hours`` the integration exposes NO usage counter at all, only a
STATE entity (a ``lawn_mower`` reporting ``mowing``). The ENGINE accumulates
the time spent in the given states itself (runtime trigger: persisted every
5 min, restart-safe, paused while unavailable) and fires after N accumulated
hours; completing the task resets the accumulation. Signatures of this
direction set ``entity_domain``/``on_states`` and may leave ``keys`` empty
meaning "the device's single entity of that domain".
* ``alert_above`` a MEASUREMENT that signals a maintenance condition
while it is high (AMS humidity desiccant saturated). Trigger: plain
threshold above N in the entity's own unit (no conversion); performing the
maintenance genuinely lowers the value, so auto_complete_on_recovery is
correct here unlike wear counters, where a plain above-threshold would
re-fire after a manual completion.
* ``value_below`` the mirror image: a MEASUREMENT that signals the
condition while LOW (heating-loop pressure refill water). Plain threshold
below N in the entity's own unit; the maintenance raises the value back.
* ``cycle_count`` the ENGINE counts state transitions itself: a lock has
no wear sensor, but every transition to ``locked`` is one mechanical cycle.
state_change trigger with ``trigger_target_changes = N``; completing the
task resets the counter. No auto-complete (cycles don't recover).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
# Hours a duration-countdown may still hold when the task should trigger.
_DEFAULT_BELOW_HOURS = 24
# Usage-hours a wear counter may accumulate before the task should trigger.
_DEFAULT_ABOVE_HOURS = 100
# Canonical units between services for lifetime counters (usage_delta mode):
# hours for operating-time counters, kilometres for odometers.
_DEFAULT_DELTA_UNITS = 500
# Percent floor for percent-remaining consumables (ink, toner, drum, brush %).
_DEFAULT_BELOW_PERCENT = 10
@dataclass(frozen=True)
class ConsumableSignature:
"""One maintenance task backed by 1..n verified consumable entities."""
keys: tuple[str, ...] # translation_key values (also matched as _<key> entity-id suffix)
task_name: str # EN task name; localized through templates_i18n
direction: str # duration_left | percent_left | usage_above | event_present | usage_delta | runtime_hours
below_hours: int = _DEFAULT_BELOW_HOURS
below_percent: int = _DEFAULT_BELOW_PERCENT
above_hours: int = _DEFAULT_ABOVE_HOURS
delta_units: int = _DEFAULT_DELTA_UNITS
# runtime_hours signatures target a non-sensor STATE entity; empty keys
# then mean "the device's single entity of this domain".
entity_domain: str = "sensor"
on_states: tuple[str, ...] = ()
# runtime signatures may track an ATTRIBUTE instead of the state — a
# climate entity's hvac_action says whether it actually conditions.
attribute: str = ""
# Device-type gates. Some integrations reuse one entity key across ALL
# appliance types (Miele's status sensor) — require_sibling_keys restricts
# the signature to devices that ALSO carry a type-identifying entity
# (a washer has twin_dos/spin_speed). models gates on the device
# registry's model string (case-insensitive substring — Bambu X1C vs A1).
require_sibling_keys: tuple[str, ...] = ()
models: tuple[str, ...] = ()
# Substring exclusion applied AFTER models: ("AMS",) matches "AMS Lite"
# too, so the desiccant duty excludes it explicitly (the Lite has no
# desiccant compartment).
models_exclude: tuple[str, ...] = ()
@dataclass(frozen=True)
class IntegrationSignature:
"""All verified signatures of one integration domain."""
name: str # human-readable integration name
source: str # where the entity keys were verified
# When and against which ref the source was read (branch head at that
# date, not a pinned commit) — the audit trail for "verified against what".
verified: str = ""
tasks: tuple[ConsumableSignature, ...] = field(default_factory=tuple)
def task_name_variants(task_name: str) -> set[str]:
"""The EN signature task name plus all its localizations, lowercased —
used to recognise an equivalent EXISTING task on the target object
regardless of the language it was created in."""
from ...templates_i18n import _T
variants = {task_name.lower()}
variants.update(v.lower() for v in _T.get(task_name, {}).values())
return variants
def _entity_matches(entry: er.RegistryEntry, key: str) -> bool:
"""translation_key match, with an entity_id-suffix fallback for custom
integrations that don't set translation_key on their descriptions.
Third pattern: xiaomi_home embeds the MIoT property name mid-entity_id with
a ``_p_{siid}_{piid}`` tail (``..._filter_life_level_p_4_1``) and sets no
translation_key matched via the distinctive ``_<key>_p_`` infix. Matching
is already scoped to the signature's integration (entry.platform), so this
cannot bleed across integrations."""
if entry.translation_key == key:
return True
if entry.entity_id.endswith(f"_{key}"):
return True
if f"_{key}_p_" in entry.entity_id:
return True
# Fourth pattern: integrations that name entities WITHOUT a device prefix
# (bosch thermostat: ``sensor.system_pressure``) — exact object-id match.
# Platform scoping keeps this from bleeding across integrations.
return entry.entity_id.split(".", 1)[1] == key
def _entity_unit(hass: HomeAssistant, entry: er.RegistryEntry) -> str | None:
"""The entity's live display unit, falling back to the registry unit."""
state = hass.states.get(entry.entity_id)
if state and (unit := state.attributes.get("unit_of_measurement")):
return str(unit)
reg_unit = entry.unit_of_measurement
return str(reg_unit) if reg_unit is not None else None
def _unit_compatible(direction: str, unit: str | None) -> bool:
"""Whether an entity's unit fits a signature's direction.
Some integrations (LG ThinQ) reuse ONE translation_key for both an
hours-remaining and a percent-remaining sensor; the key alone can't say
which direction applies. A concrete unit disambiguates: percent_left wants
``%``; the duration/counter directions want anything else. The check is
lenient a missing unit (disabled/just-added entity) never rejects a
key match, so existing single-shape signatures are unaffected. The one
strict case is ``event_present``: ENUM event sensors carry no unit, so a
unit-bearing entity that happens to share the key is NOT an event."""
if direction in ("event_present", "runtime_hours", "cycle_count"):
return unit is None # ENUM events and state entities carry no unit
if direction in ("alert_above", "value_below"):
return True # measurement alert in the entity's own unit (any unit)
if unit is None:
return True
if direction == "percent_left":
return unit == "%"
return unit != "%"
def _threshold_for(sig: ConsumableSignature, hass: HomeAssistant, entity_id: str) -> float:
"""The trigger threshold in the entity's CURRENT display unit.
Duration values are stored in the signature as hours; HA may present the
state in s/min/h/d depending on the entity's unit settings.
"""
if sig.direction == "event_present":
return 0.0 # ENUM event latch — no numeric threshold
if sig.direction in ("runtime_hours", "alert_above", "value_below", "cycle_count"):
# Engine-accumulated hours resp. a raw measurement threshold in the
# entity's own unit — no conversion in either case.
return float(sig.delta_units)
if sig.direction == "percent_left":
return float(sig.below_percent)
state = hass.states.get(entity_id)
unit = (state.attributes.get("unit_of_measurement") if state else None) or "h"
# Canonical → display unit: time counters are stored in hours, odometers in
# kilometres; the entity may display s/min/d resp. miles.
factor = {
"s": 3600.0,
"min": 60.0,
"h": 1.0,
"d": 1 / 24,
"km": 1.0,
"mi": 0.62137,
# energy counters are canonical in kWh (wallbox cable inspection)
"Wh": 1000.0,
"kWh": 1.0,
"MWh": 0.001,
}.get(unit, 1.0)
hours = {
"usage_above": sig.above_hours,
"usage_delta": sig.delta_units,
}.get(sig.direction, sig.below_hours)
return round(hours * factor, 3)
def build_setup_trigger(sig: ConsumableSignature, hass: HomeAssistant, entity_ids: list[str]) -> dict[str, Any]:
"""A pre-wired trigger for one signature's matched entities.
Numeric signatures build a threshold trigger with ``entity_logic: any``
(any low consumable triggers) and auto-complete on recovery replacing the
consumable resets the countdown/percentage (or, for ``usage_above`` wear
counters, resetting the counter drops it back below the threshold), which
resolves the task just like a cleared problem sensor.
``event_present`` signatures build a single-entity state-change LATCH on the
``present`` state (Home Connect salt/rinse-aid/descale/clean events): the
task activates while the event is present and auto-completes when the
appliance clears it (to ``off``/``confirmed``).
"""
if sig.direction == "event_present":
return {
"type": "state_change",
"entity_id": entity_ids[0], # state latch watches a single entity
"entity_ids": list(entity_ids),
# Home Connect events use "present"; other integrations latch on
# their own alert state (Dolphin filter bag: "full").
"trigger_to_state": sig.on_states[0] if sig.on_states else "present",
"trigger_target_changes": 1,
"auto_complete_on_recovery": True,
}
if sig.direction == "cycle_count":
# Engine-counted mechanical cycles: every transition into on_states[0]
# increments; the task fires at N and completing it resets the count.
return {
"type": "state_change",
"entity_id": entity_ids[0],
"entity_ids": list(entity_ids),
"trigger_to_state": sig.on_states[0],
"trigger_target_changes": int(_threshold_for(sig, hass, entity_ids[0])),
}
if sig.direction == "runtime_hours":
# The engine accumulates the time the entity spends in on_states
# itself (no integration counter needed); completing the task resets
# the accumulation.
runtime_trigger: dict[str, Any] = {
"type": "runtime",
"entity_id": entity_ids[0],
"entity_ids": list(entity_ids),
"trigger_on_states": list(sig.on_states) or ["on"],
"trigger_runtime_hours": _threshold_for(sig, hass, entity_ids[0]),
}
if sig.attribute:
runtime_trigger["attribute"] = sig.attribute
return runtime_trigger
if sig.direction in ("usage_delta", "usage_above"):
# Counter trigger in delta mode for both wear-counter flavours — a
# plain trigger_above threshold would re-fire immediately after a
# manual completion (the counter is still past the mark), whereas the
# delta baseline moves on completion.
# * usage_delta (lifetime counter): baseline = current value at setup;
# the task is due every N units from the adoption/completion point.
# * usage_above (counts since the device's own reset): explicit 0
# baseline keeps absolute semantics at adoption (80 h old blades are
# 80 h old), manual completion re-baselines, and a device-side reset
# drops the value below the baseline — the rollover handling
# re-baselines and the deactivation auto-completes the task.
trigger: dict[str, Any] = {
"type": "counter",
"entity_id": entity_ids[0], # counter watches a single entity
"entity_ids": list(entity_ids),
"trigger_delta_mode": True,
"trigger_target_value": _threshold_for(sig, hass, entity_ids[0]),
}
if sig.direction == "usage_above":
trigger["trigger_baseline_value"] = 0
trigger["auto_complete_on_recovery"] = True
return trigger
threshold_key = (
"trigger_above" if sig.direction == "alert_above" else "trigger_below"
) # value_below + consumables use trigger_below
return {
"type": "threshold",
"entity_ids": list(entity_ids),
threshold_key: _threshold_for(sig, hass, entity_ids[0]),
"entity_logic": "any",
"auto_complete_on_recovery": True,
}
@@ -0,0 +1,30 @@
"""Assembles the full signature catalog from the category data modules."""
from __future__ import annotations
from . import (
air,
cars,
garden,
heating,
home_it,
kitchen,
locks,
personal,
pets,
printers,
transports,
vacuums,
wallboxes,
xiaomi,
)
from ._model import IntegrationSignature
_MODULES = (air, cars, garden, heating, home_it, kitchen, locks, personal, pets, printers, transports, vacuums, wallboxes, xiaomi)
SIGNATURES: dict[str, IntegrationSignature] = {}
for _mod in _MODULES:
for _domain, _sig in _mod.SIGNATURES.items():
if _domain in SIGNATURES: # pragma: no cover — tripwired
raise ValueError(f"duplicate signature domain {_domain!r} in {_mod.__name__}")
SIGNATURES[_domain] = _sig
@@ -0,0 +1,256 @@
"""Air treatment — purifiers, ACs and HRV/ventilation filters.
Data module of the suggested-setups signature catalog see
``helpers/signatures/_model.py`` for the direction semantics and the
method contract (every entry cites and is verified against the
integration's source; drift-probed weekly)."""
from __future__ import annotations
from ._model import ConsumableSignature, IntegrationSignature
SIGNATURES: dict[str, IntegrationSignature] = {
"hass_dyson": IntegrationSignature(
name="Dyson",
verified="2026-07-18 @ 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."
),
tasks=(ConsumableSignature(("filter_life",), "Replace Filter", "percent_left"),),
),
"dreo": IntegrationSignature(
name="Dreo",
verified="2026-07-18 @ JeffSteinbok/hass-dreo main",
source=(
"JeffSteinbok/hass-dreo sensor.py (translation_key 'filter_life', unit '%', humidifiers with FILTERTIME support)."
),
tasks=(ConsumableSignature(("filter_life",), "Replace Filter", "percent_left"),),
),
"vesync": IntegrationSignature(
name="VeSync (Levoit)",
verified="2026-07-19 @ core/dev vesync/sensor.py",
source="core vesync: tk 'filter_life', PERCENTAGE, MEASUREMENT (Levoit purifiers).",
tasks=(ConsumableSignature(("filter_life",), "Replace Filter", "percent_left"),),
),
"daikin": IntegrationSignature(
name="Daikin AC",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/daikin/climate.py "
"(climate platform verified present; AC-only integration, so the "
"climate entity IS an air conditioner). Runtime on the hvac_action "
"ATTRIBUTE — the state only reports the standby mode. Interval per Daikin's official 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",
attribute="hvac_action",
on_states=("cooling", "heating", "fan", "drying"),
),
),
),
"gree": IntegrationSignature(
name="Gree AC",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/gree/climate.py "
"(climate platform verified present; AC-only integration, so the "
"climate entity IS an air conditioner). Runtime on the hvac_action "
"ATTRIBUTE — the state only reports the standby mode. Interval per Daikin's official 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",
attribute="hvac_action",
on_states=("cooling", "heating", "fan", "drying"),
),
),
),
"comfoconnect": IntegrationSignature(
name="Zehnder ComfoAirQ",
verified="2026-07-19 @ core/dev comfoconnect/sensor.py",
source=("core comfoconnect: key 'days_to_replace_filter', UnitOfTime.DAYS (name-style, no tk → suffix match)."),
tasks=(
# 168 canonical hours = warn at 7 days remaining (unit 'd' → ÷24).
ConsumableSignature(("days_to_replace_filter",), "Replace Ventilation Filter", "duration_left", below_hours=168),
),
),
"renson": 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),),
),
"philips_airpurifier_coap": IntegrationSignature(
name="Philips AirPurifier (CoAP)",
verified="2026-07-19 @ kongo09/philips-airpurifier-coap master sensor.py+const.py",
source=(
"HACS philips-airpurifier-coap: PhilipsFilterSensor reports "
"PERCENT when the filter total is known, else HOURS remaining — "
"the LG dual-unit pattern, split per direction. tks: pre_filter "
"(cleaning cycle), hepa_filter / active_carbon_filter / "
"nanoprotect_filter (replacements), wick (humidifier "
"evaporation wick)."
),
tasks=(
ConsumableSignature(("pre_filter",), "Filter Cleaning", "percent_left"),
ConsumableSignature(("pre_filter",), "Filter Cleaning", "duration_left", below_hours=72),
ConsumableSignature(
("hepa_filter", "active_carbon_filter", "nanoprotect_filter"),
"Replace Filter",
"percent_left",
),
ConsumableSignature(
("hepa_filter", "active_carbon_filter", "nanoprotect_filter"),
"Replace Filter",
"duration_left",
below_hours=72,
),
# Humidifier models: the evaporation wick (tk 'wick'), same
# dual-unit shape as the filters.
ConsumableSignature(("wick",), "Replace Wick", "percent_left"),
ConsumableSignature(("wick",), "Replace Wick", "duration_left", below_hours=72),
),
),
"dirigera_platform": IntegrationSignature(
name="IKEA DIRIGERA (STARKVIND)",
verified="2026-07-19 @ sanjoyg/dirigera_platform main sensor.py",
source=(
"HACS dirigera_platform: STARKVIND 'Filter Elapsed Time' sensor "
"(suffix filter_elapsed_time, MINUTES, DURATION) counts UP and "
"resets on IKEA's filter-change reset -> usage_above at 4,320 h "
"(= IKEA's 259,200-minute filter lifetime). The sibling "
"'Filter Lifetime' sensor is the constant total — unusable."
),
tasks=(ConsumableSignature(("filter_elapsed_time",), "Replace Filter", "usage_above", above_hours=4320),),
),
"ha_blueair": IntegrationSignature(
name="Blueair",
verified="2026-07-19 @ dahlb/ha_blueair master sensor.py (HACS default)",
source=(
"HACS ha_blueair: name-derived 'Filter Life' / 'Wick Life' / "
"'Water Refresher Life' (PERCENTAGE). Verified % REMAINING: the "
"device-aws coordinator returns 100 - filter_usage_percentage. "
"A filter_expired problem binary also exists (adoption path)."
),
tasks=(
ConsumableSignature(("filter_life",), "Replace Filter", "percent_left"),
ConsumableSignature(("wick_life",), "Replace Wick", "percent_left"),
ConsumableSignature(("water_refresher_life",), "Replace Water Refresher", "percent_left"),
),
),
"coway": IntegrationSignature(
name="Coway IoCare",
verified="2026-07-19 @ robertd502/home-assistant-iocare main sensor.py (HACS default)",
source=(
"HACS coway: name-derived entities, % remaining — 'Pre filter' "
"(AIRMEGA odor variant: 'Charcoal filter') and 'MAX2 filter' "
"(AP-1512HHS EU/UK models: 'HEPA filter'); both name variants "
"listed as keys."
),
tasks=(
ConsumableSignature(("pre_filter", "charcoal_filter"), "Filter Cleaning", "percent_left"),
ConsumableSignature(("max2_filter", "hepa_filter"), "Replace Filter", "percent_left"),
),
),
"winix": IntegrationSignature(
name="Winix",
verified="2026-07-19 @ iprak/winix master sensor.py (HACS default)",
source=(
"HACS winix: tk 'filter_life' (PERCENTAGE) — % remaining derived "
"device-side from filter hours vs the model's "
"filter_alarm_duration."
),
tasks=(ConsumableSignature(("filter_life",), "Replace Filter", "percent_left"),),
),
"duco": IntegrationSignature(
name="Duco ventilation",
verified="2026-07-20 @ home-assistant/core dev",
source=("core duco: tk 'filter_remaining' (DURATION, DAYS) — the box's own filter countdown."),
tasks=(ConsumableSignature(("filter_remaining",), "Replace Ventilation Filter", "duration_left", below_hours=168),),
),
"flexit_bacnet": IntegrationSignature(
name="Flexit Nordic",
verified="2026-07-20 @ home-assistant/core dev",
source=(
"core flexit_bacnet: tk 'air_filter_operating_time' "
"(TOTAL_INCREASING, HOURS — counts UP, reset on filter change) → "
"usage_above at 4380 h (Flexit: change the filter every 6-12 "
"months). An 'air_filter_polluted' problem binary also exists "
"(adoption path)."
),
tasks=(
ConsumableSignature(
("air_filter_operating_time",),
"Replace Ventilation Filter",
"usage_above",
above_hours=4380,
),
),
),
"tradfri": IntegrationSignature(
name="IKEA Trådfri (STARKVIND)",
verified="2026-07-20 @ home-assistant/core dev",
source=(
"core tradfri: tk 'filter_life_remaining' (MEASUREMENT, HOURS "
"remaining) — STARKVIND purifiers on the NATIVE IKEA gateway "
"(the DIRIGERA path is covered separately)."
),
tasks=(ConsumableSignature(("filter_life_remaining",), "Replace Filter", "duration_left", below_hours=72),),
),
"dyson_local": IntegrationSignature(
name="Dyson (local)",
verified="2026-07-20 @ libdyson-wg/ha-dyson main sensor.py (HACS default)",
source=(
"HACS dyson_local (libdyson-wg — the maintained fork): "
"name-derived 'Filter Life' (HOURS remaining) and 'Filter Life "
"Percentage' / 'Carbon Filter Life' / 'HEPA Filter Life' / "
"'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)."
),
tasks=(
# The Pure Cool "combined" sensor is NAMED plain 'Filter Life'
# (suffix _filter_life) but reports PERCENT, while older models'
# 'Filter Life' reports HOURS — the same suffix appears in BOTH
# key tuples and the unit check routes each entity.
ConsumableSignature(
(
"filter_life_percentage",
"carbon_filter_life",
"hepa_filter_life",
"filter_life",
),
"Replace Filter",
"percent_left",
),
ConsumableSignature(("filter_life",), "Replace Filter", "duration_left", below_hours=72),
),
),
"venstar": IntegrationSignature(
name="Venstar thermostat",
verified="2026-07-20 @ home-assistant/core dev",
source=(
"core venstar CONSUMABLE_ENTITIES: key 'filterHours' carries tk "
"'filter_install_time' (HOURS of filter RUNTIME, counts UP, "
"user-reset on change) — 300 h ≈ the typical 1-3-month "
"furnace-filter guidance. The sibling 'filterDays'/tk "
"'filter_usage' (CALENDAR days since install) ships alongside it "
"and is skipped: same duty, weaker wear proxy, and two "
"same-direction signatures of one task name would collide in "
"discovery's per-device dedupe."
),
tasks=(ConsumableSignature(("filter_install_time",), "Replace Filter", "usage_above", above_hours=300),),
),
}
@@ -0,0 +1,255 @@
"""Cars and EVs — odometer-driven service duties.
Data module of the suggested-setups signature catalog see
``helpers/signatures/_model.py`` for the direction semantics and the
method contract (every entry cites and is verified against the
integration's source; drift-probed weekly)."""
from __future__ import annotations
from ._model import ConsumableSignature, IntegrationSignature
SIGNATURES: dict[str, IntegrationSignature] = {
"kia_uvo": IntegrationSignature(
name="Hyundai / Kia Connect",
verified="2026-07-18 @ Hyundai-Kia-Connect/kia_uvo master",
source=(
"Hyundai-Kia-Connect/kia_uvo custom_components/kia_uvo/sensor.py "
"(translation_key 'odometer', DISTANCE, TOTAL_INCREASING, dynamic "
"km/mi unit). next/last_service_distance exist but their semantics "
"(target vs remaining) are unverified — odometer delta instead."
),
tasks=(
ConsumableSignature(("odometer",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("odometer",), "Tire Rotation", "usage_delta", delta_units=10000),
),
),
"tesla_custom": IntegrationSignature(
name="Tesla (custom)",
verified="2026-07-18 @ alandtse/tesla dev",
source=(
"alandtse/tesla custom_components/tesla_custom/sensor.py "
"TeslaCarOdometer (type='odometer' → entity_id suffix, no "
"translation_key; DISTANCE, TOTAL_INCREASING, native miles)."
),
tasks=(
ConsumableSignature(("odometer",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("odometer",), "Tire Rotation", "usage_delta", delta_units=10000),
),
),
"renault": IntegrationSignature(
name="Renault",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/renault/sensor.py "
"(translation_key 'mileage', DISTANCE, TOTAL_INCREASING, km)."
),
tasks=(
ConsumableSignature(("mileage",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("mileage",), "Tire Rotation", "usage_delta", delta_units=10000),
),
),
"mbapi2020": IntegrationSignature(
name="Mercedes-Benz",
verified="2026-07-18 @ ReneNulschDE/mbapi2020 master",
source=(
"ReneNulschDE/mbapi2020 const.py SENSORS 'odometer' (name "
"'Odometer' → entity_id suffix; attributes carry "
"serviceintervaldays/distance) — lifetime km counter."
),
tasks=(
ConsumableSignature(("odometer",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("odometer",), "Tire Rotation", "usage_delta", delta_units=10000),
),
),
"vw_eu_data_act": IntegrationSignature(
name="VW Group (EU Data Act)",
verified="2026-07-18 @ mikrohard/hass-vw-eu-data-act main",
source=(
"mikrohard/hass-vw-eu-data-act data.py CuratedSensor('mileage', "
"'Mileage', 'distance', 'km', 'total_increasing') — official EU "
"Data Act portal data for VW/Audi/Škoda/SEAT/Cupra/Bentley (the "
"unofficial WeConnect APIs were locked down upstream)."
),
tasks=(
ConsumableSignature(("mileage",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("mileage",), "Tire Rotation", "usage_delta", delta_units=10000),
),
),
"subaru": IntegrationSignature(
name="Subaru",
verified="2026-07-18 @ home-assistant/core dev",
source=("home-assistant/core homeassistant/components/subaru/sensor.py (key sc.ODOMETER, translation_key 'odometer')."),
tasks=(
ConsumableSignature(("odometer",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("odometer",), "Tire Rotation", "usage_delta", delta_units=10000),
),
),
"volvo": IntegrationSignature(
name="Volvo",
verified="2026-07-18 @ home-assistant/core dev",
source=("home-assistant/core homeassistant/components/volvo/sensor.py (key 'odometer', api_field 'odometer')."),
tasks=(
ConsumableSignature(("odometer",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("odometer",), "Tire Rotation", "usage_delta", delta_units=10000),
),
),
"polestar_api": IntegrationSignature(
name="Polestar",
verified="2026-07-19 @ pypolestar/polestar_api main sensor.py",
source=(
"HACS polestar_api: key 'current_odometer' (native METERS, "
"suggested display KILOMETERS — the unit-aware threshold reads "
"the display unit)."
),
tasks=(
ConsumableSignature(("current_odometer",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("current_odometer",), "Tire Rotation", "usage_delta", delta_units=10000),
),
),
"fordpass": IntegrationSignature(
name="Ford (FordPass)",
verified="2026-07-19 @ itchannel/fordpass-ha master sensor.py",
source="HACS fordpass: dict-key 'odometer' sensor (name-style, suffix match).",
tasks=(
ConsumableSignature(("odometer",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("odometer",), "Tire Rotation", "usage_delta", delta_units=10000),
),
),
"toyota": IntegrationSignature(
name="Toyota Connected",
verified="2026-07-19 @ DurgNomis-drol/ha_toyota master sensor.py",
source="HACS toyota: tk 'odometer', DISTANCE, TOTAL_INCREASING.",
tasks=(
ConsumableSignature(("odometer",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("odometer",), "Tire Rotation", "usage_delta", delta_units=10000),
),
),
"mg_saic": IntegrationSignature(
name="MG/SAIC iSMART",
verified="2026-07-19 @ ad-ha/mg-saic-ha main sensor.py (HACS default)",
source=(
"HACS mg_saic: 'Mileage' sensor (suffix _mileage; the sibling "
"'Mileage Since Last Charge' does not end in _mileage — no clash)."
),
tasks=(
ConsumableSignature(("mileage",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("mileage",), "Tire Rotation", "usage_delta", delta_units=10000),
),
),
"myskoda": IntegrationSignature(
name="Škoda (MySkoda)",
verified="2026-07-19 @ skodaconnect/homeassistant-myskoda main sensor.py",
source=(
"HACS myskoda: tk 'mileage' (key 'milage', km, TOTAL_INCREASING); "
"tk 'inspection' (DAYS) / 'inspection_in_km' (km) and "
"'oil_service_in_days' / 'oil_service_in_km' — the car's own "
"maintenance_report *_due_in countdowns (remaining until due). "
"The countdown replaces a generic odometer service duty, so no "
"editorial 15000 km interval here."
),
tasks=(
ConsumableSignature(("mileage",), "Tire Rotation", "usage_delta", delta_units=10000),
ConsumableSignature(("inspection",), "Annual Service", "duration_left", below_hours=336),
ConsumableSignature(("inspection_in_km",), "Annual Service", "value_below", delta_units=1000),
ConsumableSignature(("oil_service_in_days",), "Oil Service", "duration_left", below_hours=336),
ConsumableSignature(("oil_service_in_km",), "Oil Service", "value_below", delta_units=1000),
),
),
"audiconnect": IntegrationSignature(
name="Audi Connect",
verified="2026-07-19 @ audiconnect/audi_connect_ha master sensor.py",
source=(
"HACS audiconnect (name-derived entity ids, no translation_key): "
"'Mileage' (km, TOTAL_INCREASING); 'Service inspection time' "
"(days) / 'Service inspection distance' (km) and 'Oil change "
"time' / 'Oil change distance' — VAG API inspectionDue_*/"
"oilServiceDue_* remaining-until countdowns (audi_models.py). "
"Countdowns replace the generic odometer service duty."
),
tasks=(
ConsumableSignature(("mileage",), "Tire Rotation", "usage_delta", delta_units=10000),
ConsumableSignature(("service_inspection_time",), "Annual Service", "duration_left", below_hours=336),
ConsumableSignature(("service_inspection_distance",), "Annual Service", "value_below", delta_units=1000),
ConsumableSignature(("oil_change_time",), "Oil Service", "duration_left", below_hours=336),
ConsumableSignature(("oil_change_distance",), "Oil Service", "value_below", delta_units=1000),
),
),
# The three core Tesla integrations share the same entity pattern:
# translation_key = description key (entity.py `_attr_translation_key =
# self.key`), odometer in native MILES — the unit-aware threshold
# converts. Complements the HACS tesla_custom already covered.
"tesla_fleet": IntegrationSignature(
name="Tesla Fleet",
verified="2026-07-20 @ home-assistant/core dev",
source=("core tesla_fleet: key/tk 'vehicle_state_odometer' (TOTAL_INCREASING, MILES, DISTANCE)."),
tasks=(
ConsumableSignature(("vehicle_state_odometer",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("vehicle_state_odometer",), "Tire Rotation", "usage_delta", delta_units=10000),
),
),
"teslemetry": IntegrationSignature(
name="Teslemetry",
verified="2026-07-20 @ home-assistant/core dev",
source=("core teslemetry: key/tk 'vehicle_state_odometer' (TOTAL_INCREASING, MILES, DISTANCE)."),
tasks=(
ConsumableSignature(("vehicle_state_odometer",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("vehicle_state_odometer",), "Tire Rotation", "usage_delta", delta_units=10000),
),
),
"tessie": IntegrationSignature(
name="Tessie",
verified="2026-07-20 @ home-assistant/core dev",
source=("core tessie: key/tk 'vehicle_state_odometer' (TOTAL_INCREASING, MILES, DISTANCE)."),
tasks=(
ConsumableSignature(("vehicle_state_odometer",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("vehicle_state_odometer",), "Tire Rotation", "usage_delta", delta_units=10000),
),
),
"ituran": IntegrationSignature(
name="Ituran",
verified="2026-07-20 @ home-assistant/core dev",
source=("core ituran: tk 'mileage' (KILOMETERS, DISTANCE) — fleet-tracker odometer."),
tasks=(
ConsumableSignature(("mileage",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("mileage",), "Tire Rotation", "usage_delta", delta_units=10000),
),
),
"starline": IntegrationSignature(
name="StarLine",
verified="2026-07-20 @ home-assistant/core dev",
source=("core starline: tk 'mileage' (KILOMETERS, TOTAL_INCREASING) — alarm-system odometer."),
tasks=(
ConsumableSignature(("mileage",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("mileage",), "Tire Rotation", "usage_delta", delta_units=10000),
),
),
"bosch_ebike": IntegrationSignature(
name="Bosch eBike",
verified="2026-07-20 @ Phil-Barker/hass-bosch-ebike + marq24/ha-bosch-ebike-flow main (HACS default)",
source=(
"HACS bosch_ebike (both forks share the domain and the "
"'total_distance' key/tk, KILOMETERS, TOTAL_INCREASING) — the "
"eBike's odometer. Chain lubrication every ~250 km (bicycle "
"maintenance standard) and a drivetrain service every ~2,000 km "
"(Bosch eBike's service-interval guidance). Odometer delta "
"re-baselines on completion, like the car duties."
),
tasks=(
ConsumableSignature(("total_distance",), "Lubricate Chain", "usage_delta", delta_units=250),
ConsumableSignature(("total_distance",), "Bike Service", "usage_delta", delta_units=2000),
),
),
"stromer": IntegrationSignature(
name="Stromer eBike",
verified="2026-07-20 @ CoMPaTech/stromer main sensor.py (HACS default)",
source=(
"HACS stromer: tk 'total_distance' (KILOMETERS, TOTAL_INCREASING) "
"— the eBike's odometer. Same drivetrain duties as Bosch eBike."
),
tasks=(
ConsumableSignature(("total_distance",), "Lubricate Chain", "usage_delta", delta_units=250),
ConsumableSignature(("total_distance",), "Bike Service", "usage_delta", delta_units=2000),
),
),
}
@@ -0,0 +1,199 @@
"""Robot lawn mowers.
Data module of the suggested-setups signature catalog see
``helpers/signatures/_model.py`` for the direction semantics and the
method contract (every entry cites and is verified against the
integration's source; drift-probed weekly)."""
from __future__ import annotations
from ._model import ConsumableSignature, IntegrationSignature
SIGNATURES: dict[str, IntegrationSignature] = {
"husqvarna_automower": IntegrationSignature(
name="Husqvarna Automower",
verified="2026-07-17 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/husqvarna_automower/sensor.py "
"(translation_key 'cutting_blade_usage_time', DURATION s→h; matching reset button exists)"
),
tasks=(
ConsumableSignature(("cutting_blade_usage_time",), "Replace Blades", "usage_above"),
# 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).
ConsumableSignature(("total_cutting_time",), "Clean Undercarriage", "usage_delta", delta_units=25),
ConsumableSignature(
("number_of_charging_cycles",),
"Clean Charging Contacts",
"usage_delta",
delta_units=100,
),
),
),
"landroid_cloud": IntegrationSignature(
name="Worx Landroid",
verified="2026-07-17 @ 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)"
),
tasks=(
ConsumableSignature(("blade_runtime_current",), "Replace Blades", "usage_above"),
ConsumableSignature(("mower_runtime_total",), "Clean Undercarriage", "usage_delta", delta_units=25),
),
),
"gardena_smart_system": IntegrationSignature(
name="Gardena Smart System",
verified="2026-07-18 @ py-smart-gardena/hass-gardena-smart-system master",
source=(
"py-smart-gardena/hass-gardena-smart-system sensor.py "
"GardenaMowerOperatingHoursSensor (entity_id "
"'{device.id}_{service.id}_operating_hours', UnitOfTime.HOURS, "
"TOTAL_INCREASING lifetime — no reset anywhere) → usage_delta."
),
tasks=(
# Sileno mowers: pivoting razor blades wear by mowing time — every
# 100 operating hours since the last change (delta re-baselines on
# completion, matching the Husqvarna default).
ConsumableSignature(("operating_hours",), "Replace Blades", "usage_delta", delta_units=100),
ConsumableSignature(("operating_hours",), "Clean Undercarriage", "usage_delta", delta_units=25),
),
),
# Same source entity, second duty — the matcher allows multi-duty.
"navimow": IntegrationSignature(
name="Segway Navimow",
verified="2026-07-18 @ pgoutsos/NavimowHA main",
source=(
"pgoutsos/NavimowHA lawn_mower.py (one LawnMower entity per "
"device) + const.py MOWER_STATUS_TO_ACTIVITY ('mowing'"
"LawnMowerActivity.MOWING). The integration exposes NO usage "
"counter — the ENGINE accumulates mowing time itself via the "
"runtime trigger on the lawn_mower entity."
),
tasks=(
ConsumableSignature(
(),
"Replace Blades",
"runtime_hours",
delta_units=100,
entity_domain="lawn_mower",
on_states=("mowing",),
),
ConsumableSignature(
(),
"Clean Undercarriage",
"runtime_hours",
delta_units=25,
entity_domain="lawn_mower",
on_states=("mowing",),
),
),
),
"sunseeker": IntegrationSignature(
name="Sunseeker mowers",
verified="2026-07-20 @ Sdahl1234/Sunseeker-lawn-mower main sensor.py (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."
),
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",
),
"Replace Blades",
"duration_left",
below_hours=24,
),
ConsumableSignature(
(
"blade_health",
"cutterplade_health",
"small_blade_health",
"sunseeker_blade_health",
"sunseeker_cutterplade_health",
"sunseeker_small_blade_health",
),
"Replace Blades",
"percent_left",
),
),
),
"husqvarna_automower_ble": IntegrationSignature(
name="Husqvarna Automower BLE",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/husqvarna_automower_ble/"
"lawn_mower.py (lawn_mower entity; the BLE variant exposes no blade "
"counter) — the ENGINE accumulates mowing time."
),
tasks=(
ConsumableSignature(
(),
"Replace Blades",
"runtime_hours",
delta_units=100,
entity_domain="lawn_mower",
on_states=("mowing",),
),
ConsumableSignature(
(),
"Clean Undercarriage",
"runtime_hours",
delta_units=25,
entity_domain="lawn_mower",
on_states=("mowing",),
),
),
),
"rainbird": IntegrationSignature(
name="Rain Bird irrigation",
verified="2026-07-20 @ home-assistant/core dev",
source=(
"core rainbird: switch.py creates one irrigation-zone switch per "
"zone, each on its own 'Rain Bird Sprinkler <n>' device (single "
"switch entity per device). No consumable sensors; the rainsensor "
"binary carries no device_class (not adoptable) and raindelay is "
"operational — the ENGINE accumulates actual watering time on the "
"zone switch instead. Cadence per Rain Bird's maintenance "
"guidance: inspect/clean heads and (drip) filters at least once a "
"season — ~30 h of watering at typical in-season use."
),
tasks=(
ConsumableSignature(
(),
"Clean Sprinkler Heads",
"runtime_hours",
delta_units=30,
entity_domain="switch",
on_states=("on",),
),
),
),
# Pool chlorinator salt: refill when the water's salt concentration
# drops below the generator's operating band (Pentair: 2600-4500 ppm;
# low-salt cells stop producing chlorine). Topping up raises the value
# back — auto-resolve.
"screenlogic": IntegrationSignature(
name="Pentair ScreenLogic",
verified="2026-07-20 @ home-assistant/core dev",
source=("core screenlogic: tk 'salt_ppm' (MEASUREMENT, ppm) — IntelliChlor salt concentration."),
tasks=(ConsumableSignature(("salt_ppm",), "Refill Pool Salt", "value_below", delta_units=2700),),
),
"ondilo_ico": IntegrationSignature(
name="Ondilo ICO",
verified="2026-07-20 @ home-assistant/core dev",
source=("core ondilo_ico: tk 'salt' (mg/L ≡ ppm numerically) — pool salt concentration."),
tasks=(ConsumableSignature(("salt",), "Refill Pool Salt", "value_below", delta_units=2700),),
),
}
@@ -0,0 +1,214 @@
"""Boilers, heating & water treatment.
Data module of the suggested-setups signature catalog see
``helpers/signatures/_model.py`` for the direction semantics and the
method contract (every entry cites and is verified against the
integration's source; drift-probed weekly)."""
from __future__ import annotations
from ._model import ConsumableSignature, IntegrationSignature
SIGNATURES: dict[str, IntegrationSignature] = {
"vicare": IntegrationSignature(
name="Viessmann ViCare",
verified="2026-07-17 (filter) / 2026-07-18 (burner) @ home-assistant/core dev + openviess/PyViCare master",
source=(
"home-assistant/core homeassistant/components/vicare/sensor.py "
"(GLOBAL_SENSORS translation_key 'filter_remaining_hours', UnitOfTime.HOURS, "
"disabled-by-default; PyViCare ventilation.filter.runtime.remainingHours; "
"BURNER_SENSORS/COMPRESSOR_SENSORS 'burner_hours'/'compressor_hours', "
"UnitOfTime.HOURS, TOTAL_INCREASING lifetime → usage_delta)."
),
tasks=(
ConsumableSignature(("filter_remaining_hours",), "Replace Filter", "duration_left"),
# Boiler/heat-pump service by accumulated operating hours since the
# last service — the counters are lifetime (no reset), which is
# exactly what the delta-baseline trigger models.
ConsumableSignature(
("burner_hours", "compressor_hours"),
"Annual Inspection",
"usage_delta",
delta_units=2000,
),
),
),
"bosch": IntegrationSignature(
name="Bosch/Buderus heating",
verified="2026-07-18 @ bosch-thermostat/home-assistant-bosch-custom-component master + live RC300 registry",
source=(
"bosch-thermostat custom component: sensors are DYNAMIC (named "
"from the device's XMPP data, sensor/base.py builds names without "
"a device prefix → entity ids like sensor.system_pressure; no "
"translation_keys). Key verified against a live Buderus RC300 "
"registry; matched via the exact-object-id pattern."
),
tasks=(
# Heating-loop pressure: refill water when it drops below 1 bar;
# topping up raises the value back (auto-resolve).
ConsumableSignature(("system_pressure",), "Refill Heating Water", "value_below", delta_units=1),
),
),
# ─── Research round 4 (2026-07-19): boiler pressure, HRV filters, ───
# ─── purifiers, espresso, pet tech, Klipper ─────────────────────────
"opentherm_gw": IntegrationSignature(
name="OpenTherm Gateway",
verified="2026-07-19 @ core/dev opentherm_gw/sensor.py",
source=(
"core opentherm_gw: tk 'central_heating_pressure', BAR, MEASUREMENT — generic for EVERY OpenTherm-connected boiler."
),
tasks=(ConsumableSignature(("central_heating_pressure",), "Refill Heating Water", "value_below", delta_units=1),),
),
"plugwise": IntegrationSignature(
name="Plugwise (Anna/Adam)",
verified="2026-07-19 @ core/dev plugwise/sensor.py",
source="core plugwise: tk 'water_pressure', BAR, MEASUREMENT (boiler loop).",
tasks=(ConsumableSignature(("water_pressure",), "Refill Heating Water", "value_below", delta_units=1),),
),
"incomfort": IntegrationSignature(
name="Intergas InComfort",
verified="2026-07-19 @ core/dev incomfort/sensor.py",
source=(
"core incomfort: key 'cv_pressure', BAR, MEASUREMENT. NOTE: "
"entity_registry_enabled_default=False — the suggestion appears "
"once the user enables the sensor."
),
tasks=(ConsumableSignature(("cv_pressure",), "Refill Heating Water", "value_below", delta_units=1),),
),
"atag": IntegrationSignature(
name="ATAG One",
verified="2026-07-19 @ core/dev atag/sensor.py",
source=(
"core atag: legacy name-based sensors ('CH Water Pressure'"
"object id ch_water_pressure), BAR — matched via suffix or the "
"exact-object-id pattern."
),
tasks=(ConsumableSignature(("ch_water_pressure",), "Refill Heating Water", "value_below", delta_units=1),),
),
"bwt_perla": IntegrationSignature(
name="BWT Perla",
verified="2026-07-19 @ dkarv/ha-bwt-perla main sensor.py (HACS default)",
source=(
"HACS bwt_perla (dkarv): tk 'regenerativ_level' (salt reserve, "
"PERCENTAGE) and tk 'regenerativ_days' (days of salt left, "
"UnitOfTime.DAYS) — refilling raises both (auto-resolve)."
),
tasks=(
ConsumableSignature(("regenerativ_level",), "Refill Softener Salt", "percent_left"),
ConsumableSignature(("regenerativ_days",), "Refill Softener Salt", "duration_left", below_hours=168),
),
),
"ecowater_softener": IntegrationSignature(
name="EcoWater softener",
verified="2026-07-19 @ 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."
),
tasks=(
ConsumableSignature(("salt_level_percentage",), "Refill Softener Salt", "percent_left"),
ConsumableSignature(("out_of_salt_days",), "Refill Softener Salt", "duration_left", below_hours=168),
),
),
"wolflink": IntegrationSignature(
name="Wolf SmartSet",
verified="2026-07-19 @ core/dev wolflink/sensor.py",
source="core wolflink: key 'pressure', BAR (heating loop).",
tasks=(ConsumableSignature(("pressure",), "Refill Heating Water", "value_below", delta_units=1),),
),
"palazzetti": IntegrationSignature(
name="Palazzetti pellet stove",
verified="2026-07-19 @ core/dev palazzetti/sensor.py",
source=(
"core palazzetti: tk 'pellet_quantity' (KILOGRAMS consumed, "
"cumulative) -> usage_delta; ash-pan cadence ~100 kg of pellets "
"(editorial: roughly weekly in season; manuals prescribe "
"calendar-based cleaning). 'pellet_level' is a CM tank gauge — "
"inventory, not wear; skipped."
),
tasks=(ConsumableSignature(("pellet_quantity",), "Empty Ash Pan", "usage_delta", delta_units=100),),
),
"mypyllant": IntegrationSignature(
name="Vaillant (myVAILLANT)",
verified="2026-07-19 @ signalkraft/mypyllant-component main sensor.py",
source=(
"HACS mypyllant: SystemWaterPressureSensor (name '... System "
"Water Pressure' -> suffix system_water_pressure) and the "
"device-level operational-data variant (suffix water_pressure), "
"both BAR — matched any-low."
),
tasks=(
ConsumableSignature(
("system_water_pressure", "water_pressure"),
"Refill Heating Water",
"value_below",
delta_units=1,
),
),
),
"grohe_smarthome": IntegrationSignature(
name="Grohe Blue",
verified="2026-07-19 @ 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."
),
tasks=(
ConsumableSignature(("remaining_filter",), "Replace Water Filter", "percent_left"),
ConsumableSignature(("remaining_co2",), "Replace CO2 Bottle", "percent_left"),
),
),
"iqua_softener": IntegrationSignature(
name="iQua softener",
verified="2026-07-20 @ mutilator/homeassistant-iqua-softener master sensor.py (HACS default)",
source=(
"HACS iqua_softener: 'Salt level' (PERCENTAGE, name-style -> "
"suffix _salt_level). 'Out of salt estimated day' is a DATE "
"sensor - parked for the date direction."
),
tasks=(ConsumableSignature(("salt_level",), "Refill Softener Salt", "percent_left"),),
),
"fumis": IntegrationSignature(
name="Fumis (pellet stoves)",
verified="2026-07-20 @ home-assistant/core dev",
source=(
"core fumis: tk 'time_to_service' (DURATION, HOURS remaining) — "
"the controller's own service countdown for Fumis-driven pellet "
"stoves/boilers."
),
tasks=(ConsumableSignature(("time_to_service",), "Annual Service", "duration_left"),),
),
"rehlko": IntegrationSignature(
name="Rehlko / Kohler generators",
verified="2026-07-20 @ home-assistant/core dev",
source=(
"core rehlko: tk 'runtime_since_last_maintenance' (HOURS since "
"the last maintenance, resets when maintenance is recorded) → "
"usage_above at 100 h — Kohler's oil-change interval (every "
"100 run-hours or annually). An 'oil_pressure' problem binary "
"also exists (adoption path)."
),
tasks=(ConsumableSignature(("runtime_since_last_maintenance",), "Oil Service", "usage_above", above_hours=100),),
),
"aquacell": IntegrationSignature(
name="AquaCell softener",
verified="2026-07-20 @ home-assistant/core dev",
source=(
"core aquacell: tk 'salt_left_side_percentage' and "
"'salt_right_side_percentage' (PERCENTAGE) — dual salt tanks, "
"matched any-low."
),
tasks=(
ConsumableSignature(
("salt_left_side_percentage", "salt_right_side_percentage"),
"Refill Softener Salt",
"percent_left",
),
),
),
}
@@ -0,0 +1,34 @@
"""NAS & home IT.
Data module of the suggested-setups signature catalog see
``helpers/signatures/_model.py`` for the direction semantics and the
method contract (every entry cites and is verified against the
integration's source; drift-probed weekly)."""
from __future__ import annotations
from ._model import ConsumableSignature, IntegrationSignature
SIGNATURES: dict[str, IntegrationSignature] = {
"synology_dsm": IntegrationSignature(
name="Synology NAS",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/synology_dsm/sensor.py "
"STORAGE_VOL_SENSORS (translation_key 'volume_percentage_used', "
"PERCENTAGE). Disk-health thresholds ship as device_class: safety "
"binaries → covered by problem-sensor adoption (widened to safety)."
),
tasks=(ConsumableSignature(("volume_percentage_used",), "Storage Cleanup", "alert_above", delta_units=85),),
),
"qnap": IntegrationSignature(
name="QNAP NAS",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/qnap/sensor.py "
"(translation_key 'volume_percentage_used', PERCENTAGE — same key "
"shape as synology_dsm)."
),
tasks=(ConsumableSignature(("volume_percentage_used",), "Storage Cleanup", "alert_above", delta_units=85),),
),
}
@@ -0,0 +1,263 @@
"""Kitchen & household appliances incl. espresso machines.
Data module of the suggested-setups signature catalog see
``helpers/signatures/_model.py`` for the direction semantics and the
method contract (every entry cites and is verified against the
integration's source; drift-probed weekly)."""
from __future__ import annotations
from ._model import ConsumableSignature, IntegrationSignature
SIGNATURES: dict[str, IntegrationSignature] = {
"lg_thinq": IntegrationSignature(
name="LG ThinQ",
verified="2026-07-17 @ home-assistant/core dev + thinq-connect/pythinqconnect main",
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"
),
tasks=(
# AC filter reports hours-remaining; air-purifier/RAC filters report
# percent — both under translation_key 'filter_lifetime'. Two
# directions, unit-disambiguated at match time.
ConsumableSignature(("filter_lifetime", "top_filter_remain_percent"), "Replace Filter", "percent_left"),
ConsumableSignature(("filter_lifetime",), "Replace Filter", "duration_left"),
ConsumableSignature(
(
"water_filter_1_remain_percent",
"water_filter_2_remain_percent",
"water_filter_3_remain_percent",
),
"Replace Water Filter",
"percent_left",
),
),
),
"smartthinq_sensors": IntegrationSignature(
name="LG ThinQ (SmartThinQ)",
verified="2026-07-17 / re-audited 2026-07-20 @ ollo69/ha-smartthinq-sensors master",
source=(
"ollo69/ha-smartthinq-sensors custom_components/smartthinq_sensors/sensor.py "
"(legacy name= entities, NO translation_key → matched by entity_id suffix; "
"FILTER_*_LIFE / *_REMAIN_PERC are percent via wideq device.py "
"_get_filter_life(); TUBCLEAN_COUNT counts up per wash cycle and the "
"machine resets it when a tub-clean course runs). binary_sensor.py: "
"dishwasher RINSEREFILL/SALTREFILL binaries carry NO device_class "
"(and are disabled-by-default) → not adoptable, latched here "
"instead; the washer DETERGENTLOW/SOFTENERLOW binaries ARE "
"problem-class (adoption path)."
),
tasks=(
ConsumableSignature(
(
"filter_remaining_life",
"filter_remaining_life_main",
"filter_remaining_life_bottom",
"filter_remaining_life_dust",
"filter_remaining_life_middle",
"filter_remaining_life_top",
"fresh_air_filter_remaining",
),
"Replace Filter",
"percent_left",
),
ConsumableSignature(("water_filter_remaining",), "Replace Water Filter", "percent_left"),
# Unitless wash-cycle counter: above_hours here is the cycle count
# (~monthly cadence); resetting on a tub-clean course resolves it.
ConsumableSignature(("tub_clean_counter",), "Clean Tub", "usage_above", above_hours=30),
# 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",),
),
ConsumableSignature(
("salt_refill",),
"Refill Salt",
"event_present",
entity_domain="binary_sensor",
on_states=("on",),
),
),
),
# Enclosed printers only (device registry model = the device_type
# enum: X1C/X1E/P1S/H2*) — the activated-carbon/chamber filter
# duty makes no sense on open-frame A1/A1MINI/P1P.
# Model-aware duties (Bambu maintenance guides): the CoreXY
# X1/P1 series runs on carbon rods that want regular wipe-downs;
# the A1 bed-slingers have a replaceable purge wiper. Intervals
# are tunable print-hour defaults.
# AMS desiccant by MEASURED humidity: the AMS/AMS 2 Pro/AMS HT are
# separate devices (model = 'AMS'/'AMS 2 Pro'/'AMS HT') with a
# humidity sensor; saturated desiccant shows as high humidity and
# replacing it brings the value down (auto-resolve). The AMS Lite
# has NO desiccant compartment and is excluded.
"home_connect": IntegrationSignature(
name="Home Connect",
verified="2026-07-17 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/home_connect/sensor.py "
"EVENT_SENSORS (HomeConnectEventSensor, device_class ENUM, "
"EVENT_OPTIONS ['confirmed','off','present']; translation_key per "
"EventKey). No percent/countdown consumables exist (coffee counters "
"are lifetime, no reset) — these actionable events are the only "
"maintenance-usable signal, matched as a state latch on 'present'."
),
tasks=(
ConsumableSignature(("salt_nearly_empty",), "Refill Salt", "event_present"),
ConsumableSignature(("rinse_aid_nearly_empty",), "Refill Rinse Aid", "event_present"),
ConsumableSignature(("device_should_be_descaled",), "Descale Appliance", "event_present"),
ConsumableSignature(("device_should_be_cleaned",), "Clean Appliance", "event_present"),
ConsumableSignature(("grease_filter_max_saturation_reached",), "Clean Grease Filter", "event_present"),
),
),
"miele": IntegrationSignature(
name="Miele",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/miele/sensor.py "
"(dishwasher salt_level/rinse_aid_level/power_disk_level PERCENTAGE "
"fill levels; washer twin_dos_1/2_level PERCENTAGE detergent "
"containers). Coffee descaling/degreasing counters are lifetime "
"tallies of PERFORMED maintenance — unclear delta semantics, "
"skipped."
),
tasks=(
ConsumableSignature(("salt_level",), "Refill Salt", "percent_left"),
ConsumableSignature(("rinse_aid_level",), "Refill Rinse Aid", "percent_left"),
# PowerDisk (dishwasher AutoDos) and TwinDos (washer) are both
# detergent reservoirs — one any-low task per device.
ConsumableSignature(
("power_disk_level", "twin_dos_1_level", "twin_dos_2_level"),
"Refill Detergent",
"percent_left",
),
ConsumableSignature(
("status",),
"Clean Tub",
"runtime_hours",
delta_units=60,
on_states=("in_use",),
require_sibling_keys=("twin_dos_1_level", "twin_dos_2_level", "spin_speed"),
),
),
),
"electrolux_status": IntegrationSignature(
name="Electrolux / AEG",
verified="2026-07-18 @ albaintor/homeassistant_electrolux_status master",
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."
),
tasks=(ConsumableSignature(("filterlife", "filter_life"), "Replace Filter", "percent_left"),),
),
"midea_ac_lan": IntegrationSignature(
name="Midea (LAN)",
verified="2026-07-18 @ wuwentao/midea_ac_lan master",
source=(
"wuwentao/midea_ac_lan midea_devices.py + midea_entity.py "
"(_attr_translation_key from the per-attribute config; entity_id = "
"f'{device_id}_{entity_key}'). 0xED water purifier: filter1/2/3_life "
"PERCENTAGE; 0xC2: filter_life PERCENTAGE. The filterN_days "
"countdowns describe the SAME filters — percent only, no duplicate "
"tasks. Filter cleaning/change reminders (A1/CE/AC full_dust) are "
"device_class problem binaries — covered by problem-sensor adoption."
),
tasks=(
ConsumableSignature(
("filter1_life", "filter2_life", "filter3_life"),
"Replace Water Filter",
"percent_left",
),
ConsumableSignature(("filter_life",), "Replace Filter", "percent_left"),
),
),
"lamarzocco": IntegrationSignature(
name="La Marzocco",
verified="2026-07-19 @ core/dev lamarzocco/sensor.py",
source=(
"core lamarzocco: tk 'total_coffees_made', TOTAL_INCREASING "
"lifetime shot counter — one entity, two duties (intervals are "
"intervals cross-checked 2026-07-19 against home-barista guidance: detergent backflush every 4-6 weeks ≈ 100 shots at 3/day; "
"water filter ≈ 1000 shots (editorial)."
),
tasks=(
ConsumableSignature(("total_coffees_made",), "Backflush Espresso Group", "usage_delta", delta_units=100),
ConsumableSignature(("total_coffees_made",), "Replace Water Filter", "usage_delta", delta_units=1000),
),
),
"hon": IntegrationSignature(
name="Haier hOn (Haier/Candy/Hoover)",
verified="2026-07-19 @ Andre0512/hon main sensor.py (1.5k stars; open #101 ask)",
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."
),
tasks=(
ConsumableSignature(("filter_life",), "Replace Filter", "percent_left"),
ConsumableSignature(("filter_cleaning",), "Filter Cleaning", "percent_left"),
ConsumableSignature(("cycles_total",), "Clean Tub", "usage_delta", delta_units=30),
),
),
"whirlpool": IntegrationSignature(
name="Whirlpool",
verified="2026-07-19 @ core/dev whirlpool/sensor.py",
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."
),
tasks=(
ConsumableSignature(
("washer_state",),
"Clean Tub",
"runtime_hours",
delta_units=60,
on_states=("running_maincycle",),
),
),
),
"ha_washdata": IntegrationSignature(
name="WashData (smart-plug cycles)",
verified="2026-07-20 @ 3dg1luk43/ha_washdata main sensor.py (HACS default)",
source=(
"HACS ha_washdata: tk 'cycle_count' (unit 'cycles') — lifetime "
"count of appliance cycles DETECTED from smart-plug power "
"monitoring. Brings the tub-clean cadence to washers with no "
"smarts at all (LG's official 30-cycle interval)."
),
tasks=(ConsumableSignature(("cycle_count",), "Clean Tub", "usage_delta", delta_units=30),),
),
"traeger": IntegrationSignature(
name="Traeger grill",
verified="2026-07-20 @ njobrien1006/hass_traeger master + johnvoipguy/Traeger-WiFire main (HACS default, shared sensor map)",
source=(
"HACS traeger (both default-store forks share the domain and "
"sensor map): 'Cook Cycle' sensor (usage;cook_cycles — lifetime "
"counter, suffix _cook_cycle, disabled-by-default DIAGNOSTIC; the "
"suggestion appears once the user enables it). Cadence per "
"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)."
),
tasks=(
ConsumableSignature(("cook_cycle",), "Clean Grease Trap", "usage_delta", delta_units=5),
ConsumableSignature(("cook_cycle",), "Clean Appliance", "usage_delta", delta_units=20),
),
),
}
@@ -0,0 +1,240 @@
"""Smart locks — cycle-count lubrication duties.
Interval audit 2026-07-19: Nuki's official guidance is ANNUAL cylinder
lubrication 2,000 cycles a year at a typical main door (5-6
cycles/day); heavy doors reach it sooner, matching wear.
Data module of the suggested-setups signature catalog see
``helpers/signatures/_model.py`` for the direction semantics and the
method contract (every entry cites and is verified against the
integration's source; drift-probed weekly)."""
from __future__ import annotations
from ._model import ConsumableSignature, IntegrationSignature
SIGNATURES: dict[str, IntegrationSignature] = {
"nuki": IntegrationSignature(
name="Nuki Smart Lock",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/nuki/lock.py "
"(NukiLockEntity, one lock entity per device). No wear sensor — "
"the ENGINE counts locking cycles on the lock entity."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
),
),
"tedee": IntegrationSignature(
name="Tedee Smart Lock",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/tedee/lock.py "
"(lock platform verified present). Locks carry no wear sensor — "
"the ENGINE counts locking cycles; entity_domain-gated to locks."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
),
),
"august": IntegrationSignature(
name="August lock",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/august/lock.py "
"(lock platform verified present). Locks carry no wear sensor — "
"the ENGINE counts locking cycles; entity_domain-gated to locks."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
),
),
"yale": IntegrationSignature(
name="Yale lock",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/yale/lock.py "
"(lock platform verified present). Locks carry no wear sensor — "
"the ENGINE counts locking cycles; entity_domain-gated to locks."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
),
),
"switchbot": IntegrationSignature(
name="SwitchBot Lock",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/switchbot/lock.py "
"(lock platform verified present). Locks carry no wear sensor — "
"the ENGINE counts locking cycles; entity_domain-gated to locks."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
),
),
"loqed": IntegrationSignature(
name="LOQED Smart Lock",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/loqed/lock.py "
"(lock platform verified present). Locks carry no wear sensor — "
"the ENGINE counts locking cycles; entity_domain-gated to locks."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
),
),
"homematicip_cloud": IntegrationSignature(
name="Homematic IP lock",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/homematicip_cloud/lock.py "
"(lock platform verified present). Locks carry no wear sensor — "
"the ENGINE counts locking cycles; entity_domain-gated to locks."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
),
),
"schlage": IntegrationSignature(
name="Schlage lock",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/schlage/lock.py "
"(lock platform verified present) — engine-counted locking cycles."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
),
),
"sesame": IntegrationSignature(
name="Sesame lock",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/sesame/lock.py "
"(lock platform verified present) — engine-counted locking cycles."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
),
),
"yalexs_ble": IntegrationSignature(
name="Yale/August BLE lock",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/yalexs_ble/lock.py "
"(lock platform verified present) — engine-counted locking cycles."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
),
),
"dormakaba_dkey": IntegrationSignature(
name="dormakaba dKey lock",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/dormakaba_dkey/lock.py "
"(lock platform verified present) — engine-counted locking cycles."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
),
),
"homematic": IntegrationSignature(
name="Homematic KeyMatic",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/homematic/lock.py "
"(lock platform verified present) — engine-counted locking cycles, entity_domain-gated so the hub's other device types are untouched."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
),
),
}
@@ -0,0 +1,33 @@
"""Personal-care devices.
Data module of the suggested-setups signature catalog see
``helpers/signatures/_model.py`` for the direction semantics and the
method contract (every entry cites and is verified against the
integration's source; drift-probed weekly)."""
from __future__ import annotations
from ._model import ConsumableSignature, IntegrationSignature
SIGNATURES: dict[str, IntegrationSignature] = {
"oralb": IntegrationSignature(
name="Oral-B toothbrush",
verified="2026-07-19 @ core/dev oralb/sensor.py",
source=(
"core oralb (BLE): tk 'toothbrush_state', ENUM incl. 'running'"
"the engine accumulates brushing time (the per-session 'time' "
"sensor is session-scoped, unusable for deltas). 6 h of brushing "
"= the dentist's 3 months at 2x2 minutes a day. BLE gaps pause "
"the runtime trigger; brushing only happens while connected."
),
tasks=(
ConsumableSignature(
("toothbrush_state",),
"Replace Brush Head",
"runtime_hours",
delta_units=6,
on_states=("running",),
),
),
),
}
@@ -0,0 +1,52 @@
"""Pet tech — feeders, fountains, litter boxes.
Data module of the suggested-setups signature catalog see
``helpers/signatures/_model.py`` for the direction semantics and the
method contract (every entry cites and is verified against the
integration's source; drift-probed weekly)."""
from __future__ import annotations
from ._model import ConsumableSignature, IntegrationSignature
SIGNATURES: dict[str, IntegrationSignature] = {
"petkit": IntegrationSignature(
name="PetKit",
verified="2026-07-19 @ Jezza34000/homeassistant_petkit main sensor.py",
source=(
"HACS petkit (Jezza34000): tk 'desiccant_left_days' (feeder, "
"UnitOfTime.DAYS) and tk 'filter_percent' (water fountain, "
"PERCENTAGE)."
),
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"),
),
),
"litterrobot": IntegrationSignature(
name="Litter-Robot",
verified="2026-07-18 @ home-assistant/core dev",
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)."
),
tasks=(
ConsumableSignature(("waste_drawer",), "Empty Waste Drawer", "alert_above", delta_units=90),
ConsumableSignature(("litter_level",), "Refill Litter", "percent_left"),
ConsumableSignature(("total_cycles",), "Wash Litter Box", "usage_delta", delta_units=150),
),
),
"eheimdigital": IntegrationSignature(
name="EHEIM Digital (aquarium)",
verified="2026-07-20 @ home-assistant/core dev",
source=(
"core eheimdigital: tk 'service_hours' (DURATION, HOURS remaining "
"to the next filter service, suggested display DAYS) — the "
"filter's own service countdown."
),
tasks=(ConsumableSignature(("service_hours",), "Filter Cleaning", "duration_left"),),
),
}
@@ -0,0 +1,163 @@
"""2D and 3D printers incl. Klipper via Moonraker.
Threshold audit 2026-07-19: the AMS desiccant trigger targets the REAL
percentage humidity sensor (tk 'humidity', PERCENTAGE + HUMIDITY class,
exists only on hygrometer-equipped AMS units via Features.AMS_HUMIDITY)
NOT the 1-5 'humidity_index' scale sensor (distinct tk; an endswith
'_humidity' match cannot hit '_humidity_index' either). Bambu publishes
no official RH threshold (their desiccant status is color-based), so
>40 % RH stays an editorial trip point: fresh desiccant holds an AMS at
~10-20 % RH, 40 % means it is spent.
Interval audit 2026-07-19: rail/rod lubrication follows Prusa's OFFICIAL
200-print-hour maintenance interval (octoprint/prusalink); Bambu's 500 h
stays editorial (different motion system, wiki unfetchable).
Data module of the suggested-setups signature catalog see
``helpers/signatures/_model.py`` for the direction semantics and the
method contract (every entry cites and is verified against the
integration's source; drift-probed weekly)."""
from __future__ import annotations
from ._model import ConsumableSignature, IntegrationSignature
SIGNATURES: dict[str, IntegrationSignature] = {
"bambu_lab": IntegrationSignature(
name="Bambu Lab",
verified="2026-07-18 @ greghesp/ha-bambulab main",
source=(
"greghesp/ha-bambulab definitions.py (key/translation_key "
"'total_usage_hours', UnitOfTime.HOURS, TOTAL_INCREASING lifetime "
"usage → usage_delta every 500 print-hours; filament remaining is "
"only a tray-sensor attribute and hms/print_error are device_class "
"problem → problem-sensor adoption)."
),
tasks=(
ConsumableSignature(
("total_usage_hours",),
"Lubricate Rails and Rods",
"usage_delta",
delta_units=500,
),
ConsumableSignature(
("total_usage_hours",),
"Replace Filter",
"usage_delta",
delta_units=300,
models=("X1C", "X1E", "P1S", "H2"),
),
ConsumableSignature(
("total_usage_hours",),
"Clean Carbon Rods",
"usage_delta",
delta_units=100,
models=("X1", "P1S", "P1P"),
),
ConsumableSignature(
("total_usage_hours",),
"Replace Purge Wiper",
"usage_delta",
delta_units=300,
models=("A1",),
),
ConsumableSignature(
("humidity",),
"Replace Desiccant",
"alert_above",
delta_units=40,
models=("AMS",),
models_exclude=("AMS Lite",),
),
),
),
"octoprint": IntegrationSignature(
name="OctoPrint",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/octoprint/"
"binary_sensor.py (OctoPrintPrintingBinarySensor named 'Printing' "
"-> entity suffix _printing; no lifetime counter exists) — the "
"ENGINE accumulates print time."
),
tasks=(
ConsumableSignature(
("printing",),
"Lubricate Rails and Rods",
"runtime_hours",
delta_units=200,
entity_domain="binary_sensor",
on_states=("on",),
),
),
),
"prusalink": IntegrationSignature(
name="PrusaLink",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/prusalink/sensor.py "
"(translation_key 'printer_state', ENUM incl. 'printing') — the "
"ENGINE accumulates print time on the state sensor."
),
tasks=(
ConsumableSignature(
("printer_state",),
"Lubricate Rails and Rods",
"runtime_hours",
delta_units=200,
on_states=("printing",),
),
),
),
"moonraker": IntegrationSignature(
name="Moonraker (Klipper)",
verified="2026-07-19 @ marcolivierarsenault/moonraker-home-assistant main sensor.py+base.py",
source=(
"HACS moonraker: name 'Totals Filament Used' (has_entity_name → "
"suffix totals_filament_used), METERS, TOTAL_INCREASING lifetime. "
"NOTE: 'Totals Print Time' is a formatted STRING — unusable. "
"Nozzle interval is an editorial default (~1000 m on brass)."
),
tasks=(ConsumableSignature(("totals_filament_used",), "Replace Nozzle", "usage_delta", delta_units=1000),),
),
"ipp": IntegrationSignature(
name="IPP printer",
verified="2026-07-16 @ home-assistant/core dev",
source="home-assistant/core homeassistant/components/ipp/sensor.py (marker_<i>, translation_key 'marker', %)",
tasks=(
# Every marker (each ink/toner) shares translation_key "marker" —
# ONE task watches them all with entity_logic any.
ConsumableSignature(("marker",), "Replace Ink or Toner", "percent_left"),
),
),
"brother": IntegrationSignature(
name="Brother printer",
verified="2026-07-16 @ home-assistant/core dev",
source="home-assistant/core homeassistant/components/brother/sensor.py (*_toner_remaining / *_remaining_life, %)",
tasks=(
ConsumableSignature(
(
"black_toner_remaining",
"cyan_toner_remaining",
"magenta_toner_remaining",
"yellow_toner_remaining",
),
"Replace Toner",
"percent_left",
),
ConsumableSignature(
(
"drum_remaining_life",
"black_drum_remaining_life",
"cyan_drum_remaining_life",
"magenta_drum_remaining_life",
"yellow_drum_remaining_life",
),
"Replace Drum Unit",
"percent_left",
),
ConsumableSignature(("belt_unit_remaining_life",), "Replace Belt Unit", "percent_left"),
ConsumableSignature(("fuser_remaining_life",), "Replace Fuser", "percent_left"),
),
),
}
@@ -0,0 +1,161 @@
"""Protocol/hub transports whose duties are entity-domain-gated.
Data module of the suggested-setups signature catalog see
``helpers/signatures/_model.py`` for the direction semantics and the
method contract (every entry cites and is verified against the
integration's source; drift-probed weekly)."""
from __future__ import annotations
from ._model import ConsumableSignature, IntegrationSignature
SIGNATURES: dict[str, IntegrationSignature] = {
"matter": IntegrationSignature(
name="Matter lock",
verified="2026-07-18 @ home-assistant/core 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."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
),
),
"zwave_js": IntegrationSignature(
name="Z-Wave lock",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/zwave_js/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."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
),
),
"zha": IntegrationSignature(
name="Zigbee (ZHA) lock",
verified="2026-07-18 @ home-assistant/core 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."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
),
),
"mqtt": IntegrationSignature(
name="MQTT lock (Zigbee2MQTT etc.)",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/mqtt/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."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
ConsumableSignature(
(),
"Filter Cleaning",
"runtime_hours",
delta_units=15,
entity_domain="vacuum",
on_states=("cleaning",),
),
ConsumableSignature(
(),
"Clean Main Brush",
"runtime_hours",
delta_units=30,
entity_domain="vacuum",
on_states=("cleaning",),
),
ConsumableSignature(
(),
"Replace Blades",
"runtime_hours",
delta_units=100,
entity_domain="lawn_mower",
on_states=("mowing",),
),
ConsumableSignature(
(),
"Clean Undercarriage",
"runtime_hours",
delta_units=25,
entity_domain="lawn_mower",
on_states=("mowing",),
),
),
),
# 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",
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."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
),
),
"deconz": IntegrationSignature(
name="deCONZ (Zigbee) lock",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/deconz/lock.py "
"(lock platform verified present) — engine-counted locking cycles, entity_domain-gated so the bridge's other device types are untouched."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
),
),
}
@@ -0,0 +1,347 @@
"""Cleaning robots — vacuums, mops and the Dolphin pool robot.
Interval audit 2026-07-19: the sensor-less runtime duties (filter wash
15 h / main-brush clean 30 h of cleaning time) map to Roborock's official
biweekly cleaning cadence at typical 1-2 h/day usage.
Data module of the suggested-setups signature catalog see
``helpers/signatures/_model.py`` for the direction semantics and the
method contract (every entry cites and is verified against the
integration's source; drift-probed weekly)."""
from __future__ import annotations
from ._model import ConsumableSignature, IntegrationSignature
SIGNATURES: dict[str, IntegrationSignature] = {
"roborock": IntegrationSignature(
name="Roborock",
verified="2026-07-16 @ home-assistant/core dev",
source="home-assistant/core homeassistant/components/roborock/sensor.py (translation_key, duration s→h)",
tasks=(
ConsumableSignature(("main_brush_time_left",), "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"),
),
),
"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)",
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"),
),
),
"dreame_vacuum": IntegrationSignature(
name="Dreame Vacuum",
verified="2026-07-16 @ Tasshack/dreame-vacuum master",
source="Tasshack/dreame-vacuum custom_components/dreame_vacuum/sensor.py (property keys *_left, percent)",
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"),
),
),
"ecovacs": IntegrationSignature(
name="Ecovacs",
verified="2026-07-17 @ home-assistant/core dev + DeebotUniverse/client.py main",
source=(
"home-assistant/core homeassistant/components/ecovacs/sensor.py "
"(translation_key f'lifespan_{component.name.lower()}', PERCENTAGE) + "
"DeebotUniverse/client.py deebot_client/events LifeSpan enum members"
),
tasks=(
ConsumableSignature(("lifespan_brush",), "Replace Main Brush", "percent_left"),
ConsumableSignature(("lifespan_side_brush",), "Replace Side Brush", "percent_left"),
ConsumableSignature(("lifespan_filter",), "Replace Filter", "percent_left"),
ConsumableSignature(("lifespan_dust_bag",), "Replace Dust Bag", "percent_left"),
ConsumableSignature(("lifespan_round_mop",), "Replace Mop Pads", "percent_left"),
# GOAT robotic mowers report blade lifespan through the same platform.
ConsumableSignature(("lifespan_blade",), "Replace Blades", "percent_left"),
),
),
"weback_vacuum": IntegrationSignature(
name="WeBack Vacuum",
verified="2026-07-18 @ Jezza34000/homeassistant_weback_component main",
source=(
"Jezza34000/homeassistant_weback_component vacuum.py (NO sensors "
"at all — STATE_MAPPING maps all clean modes to STATE_CLEANING) — "
"the ENGINE accumulates cleaning time on the vacuum entity."
),
tasks=(
ConsumableSignature(
(),
"Filter Cleaning",
"runtime_hours",
delta_units=15,
entity_domain="vacuum",
on_states=("cleaning",),
),
ConsumableSignature(
(),
"Clean Main Brush",
"runtime_hours",
delta_units=30,
entity_domain="vacuum",
on_states=("cleaning",),
),
),
),
"roomba": IntegrationSignature(
name="iRobot Roomba",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/roomba/vacuum.py "
"(vacuum platform verified present; no consumable sensors) — the "
"ENGINE accumulates cleaning time."
),
tasks=(
ConsumableSignature(
(),
"Filter Cleaning",
"runtime_hours",
delta_units=15,
entity_domain="vacuum",
on_states=("cleaning",),
),
ConsumableSignature(
(),
"Clean Main Brush",
"runtime_hours",
delta_units=30,
entity_domain="vacuum",
on_states=("cleaning",),
),
ConsumableSignature(
("bin_full",),
"Empty Dustbin",
"event_present",
entity_domain="binary_sensor",
on_states=("on",),
),
),
),
# bin_full is a plain binary (no problem device_class, so the
# problem-sensor adoption does NOT cover it) -> event latch.
"neato": IntegrationSignature(
name="Neato Botvac",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/neato/vacuum.py "
"(vacuum platform verified present; no consumable sensors) — the "
"ENGINE accumulates cleaning time."
),
tasks=(
ConsumableSignature(
(),
"Filter Cleaning",
"runtime_hours",
delta_units=15,
entity_domain="vacuum",
on_states=("cleaning",),
),
ConsumableSignature(
(),
"Clean Main Brush",
"runtime_hours",
delta_units=30,
entity_domain="vacuum",
on_states=("cleaning",),
),
),
),
"romy": IntegrationSignature(
name="ROMY Vacuum",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/romy/vacuum.py "
"(vacuum platform verified present; no consumable sensors) — the "
"ENGINE accumulates cleaning time."
),
tasks=(
ConsumableSignature(
(),
"Filter Cleaning",
"runtime_hours",
delta_units=15,
entity_domain="vacuum",
on_states=("cleaning",),
),
ConsumableSignature(
(),
"Clean Main Brush",
"runtime_hours",
delta_units=30,
entity_domain="vacuum",
on_states=("cleaning",),
),
),
),
"tuya": IntegrationSignature(
name="Tuya vacuum",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/tuya/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."
),
tasks=(
ConsumableSignature(
(),
"Filter Cleaning",
"runtime_hours",
delta_units=15,
entity_domain="vacuum",
on_states=("cleaning",),
),
ConsumableSignature(
(),
"Clean Main Brush",
"runtime_hours",
delta_units=30,
entity_domain="vacuum",
on_states=("cleaning",),
),
),
),
"switchbot_cloud": IntegrationSignature(
name="SwitchBot vacuum",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/switchbot_cloud/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."
),
tasks=(
ConsumableSignature(
(),
"Filter Cleaning",
"runtime_hours",
delta_units=15,
entity_domain="vacuum",
on_states=("cleaning",),
),
ConsumableSignature(
(),
"Clean Main Brush",
"runtime_hours",
delta_units=30,
entity_domain="vacuum",
on_states=("cleaning",),
),
),
),
"smartthings": IntegrationSignature(
name="SmartThings",
verified="2026-07-18 (vacuum) / 2026-07-20 (filters) @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/smartthings/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. "
"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."
),
tasks=(
ConsumableSignature(
(),
"Filter Cleaning",
"runtime_hours",
delta_units=15,
entity_domain="vacuum",
on_states=("cleaning",),
),
ConsumableSignature(
(),
"Clean Main Brush",
"runtime_hours",
delta_units=30,
entity_domain="vacuum",
on_states=("cleaning",),
),
# 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),
ConsumableSignature(("hood_filter_usage",), "Clean Grease Filter", "alert_above", delta_units=90),
),
),
"sharkiq": IntegrationSignature(
name="Shark IQ",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/sharkiq/vacuum.py "
"(vacuum platform verified present; no consumable sensors) — the "
"ENGINE accumulates cleaning time."
),
tasks=(
ConsumableSignature(
(),
"Filter Cleaning",
"runtime_hours",
delta_units=15,
entity_domain="vacuum",
on_states=("cleaning",),
),
ConsumableSignature(
(),
"Clean Main Brush",
"runtime_hours",
delta_units=30,
entity_domain="vacuum",
on_states=("cleaning",),
),
),
),
"tplink": IntegrationSignature(
name="TP-Link Tapo vacuum",
verified="2026-07-18 @ home-assistant/core dev",
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."
),
tasks=(
ConsumableSignature(
(),
"Filter Cleaning",
"runtime_hours",
delta_units=15,
entity_domain="vacuum",
on_states=("cleaning",),
),
ConsumableSignature(
(),
"Clean Main Brush",
"runtime_hours",
delta_units=30,
entity_domain="vacuum",
on_states=("cleaning",),
),
),
),
"mydolphin_plus": IntegrationSignature(
name="Maytronics Dolphin",
verified="2026-07-18 @ sh00t2kill/dolphin-robot master",
source=(
"sh00t2kill/dolphin-robot common/consts.py "
"(DATA_KEY_FILTER_STATUS 'Filter Status' -> entity suffix "
"_filter_status; FILTER_BAG_STATUS enum empty/partially_full/"
"getting_full/almost_full/full/fault) — latch on 'full', emptying "
"the bag drops the state back (auto-resolve)."
),
tasks=(
ConsumableSignature(
("filter_status",),
"Filter Cleaning",
"event_present",
on_states=("full",),
),
),
),
}
@@ -0,0 +1,62 @@
"""EV chargers — cable/plug inspection by delivered energy.
Data module of the suggested-setups signature catalog see
``helpers/signatures/_model.py`` for the direction semantics and the
method contract (every entry cites and is verified against the
integration's source; drift-probed weekly)."""
from __future__ import annotations
from ._model import ConsumableSignature, IntegrationSignature
SIGNATURES: dict[str, IntegrationSignature] = {
"easee": IntegrationSignature(
name="Easee Wallbox",
verified="2026-07-18 @ nordicopen/easee_hass master",
source=(
"nordicopen/easee_hass const.py 'lifetime_energy' "
"(state.lifetimeEnergy, translation_key 'lifetime_energy', kWh "
"lifetime counter) → cable/plug inspection by delivered energy. "
"Core `wallbox` verified NEGATIVE: its added_energy is per-session."
),
tasks=(
ConsumableSignature(
("lifetime_energy",),
"Inspect Cable and Plug",
"usage_delta",
delta_units=5000,
),
),
),
"keba": IntegrationSignature(
name="KEBA Wallbox",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/keba/sensor.py "
"('E total' description, name 'Total Energy' → entity_id suffix "
"_total_energy, kWh, TOTAL_INCREASING lifetime)."
),
tasks=(ConsumableSignature(("total_energy",), "Inspect Cable and Plug", "usage_delta", delta_units=5000),),
),
"goecharger_api2": IntegrationSignature(
name="go-e Charger",
verified="2026-07-18 @ marq24/ha-goecharger-api2 main",
source=(
"marq24/ha-goecharger-api2 const.py Tag.ETO sensor (key 'eto', "
"native WATT_HOUR with suggested kWh display, TOTAL_INCREASING "
"lifetime energy) — the unit map converts the 5,000 kWh target "
"into the live display unit."
),
tasks=(ConsumableSignature(("eto",), "Inspect Cable and Plug", "usage_delta", delta_units=5000),),
),
"openevse": IntegrationSignature(
name="OpenEVSE",
verified="2026-07-18 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/openevse/sensor.py "
"(translation_key 'usage_total', kWh lifetime; usage_session is "
"per-session and deliberately not used)."
),
tasks=(ConsumableSignature(("usage_total",), "Inspect Cable and Plug", "usage_delta", delta_units=5000),),
),
}
@@ -0,0 +1,47 @@
"""Xiaomi ecosystem integrations (MIoT / Xiaomi Home) — multi-category.
Data module of the suggested-setups signature catalog see
``helpers/signatures/_model.py`` for the direction semantics and the
method contract (every entry cites and is verified against the
integration's source; drift-probed weekly)."""
from __future__ import annotations
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",
source=(
"al-one/hass-xiaomi-miot — generic MIoT-spec entities; entity_id "
"suffix = the spec property name (core/miot_spec.py format_name + "
"eid = f'{model}_{mac[-4:]}_{desc_name}'). Cross-device consumables: "
"'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."
),
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.
ConsumableSignature(("filter_life_level",), "Replace Filter", "percent_left"),
ConsumableSignature(("brush_life_level",), "Replace Main Brush", "percent_left"),
),
),
"xiaomi_home": IntegrationSignature(
name="Xiaomi Home",
verified="2026-07-18 @ XiaoMi/ha_xiaomi_home main",
source=(
"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."
),
tasks=(
ConsumableSignature(("filter_life_level",), "Replace Filter", "percent_left"),
ConsumableSignature(("brush_life_level",), "Replace Main Brush", "percent_left"),
),
),
}