Updated apps
This commit is contained in:
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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",
|
||||
]
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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 direct→derived 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"),
|
||||
),
|
||||
),
|
||||
}
|
||||
Reference in New Issue
Block a user