"""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. Two variants: ``ok_state`` flips the latch for LEVEL enums whose alert is "anything but OK" (homeconnect_ws salt: ``empty``/``nearly_empty``/``full`` → alert while not ``full``); several ``keys`` in one signature make ONE latch watching every matched entity with ``entity_logic: any`` (Home Connect's ``salt_nearly_empty`` + ``salt_lack`` + ``program_blocked_salt_lack``) — see ``build_setup_trigger`` for when that is (and is not) appropriate. * ``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). * ``due_date`` — the device REPORTS when the maintenance is due (a ``timestamp``/``date`` sensor: Vitesy's "Filter change due"). Trigger: the due_date type, active from ``days_before`` days before that date; the device's own "done" button moves the date forward, which clears the trigger and auto-completes the task. """ 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 %) # — the catalog default is "follow the household setting" (#146), so the # field is None unless a signature pins its own floor. A sentinel value # (the old 10) could not tell "pinned at 10" from "default" (bug review # 2026-09-04). _DEFAULT_BELOW_PERCENT: int | None = None @dataclass(frozen=True) class ConsumableSignature: """One maintenance task backed by 1..n verified consumable entities.""" keys: tuple[str, ...] # translation_key values (also matched as _ 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 | due_date below_hours: int = _DEFAULT_BELOW_HOURS below_percent: int | None = _DEFAULT_BELOW_PERCENT above_hours: int = _DEFAULT_ABOVE_HOURS delta_units: int = _DEFAULT_DELTA_UNITS # due_date only: how many days before the reported date the task fires # (time to order a filter); 0 = on the date itself. days_before: int = 0 # 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, ...] = () # event_present only: the entity's single HEALTHY state. Set, the latch is # built from-only (``trigger_from_state`` without a To-state, #167): it # fires on any transition AWAY from this state and recovers when the # entity returns to it — for level enums that name several alert states # but only one OK state (homeconnect_ws salt: empty / nearly_empty / # full). Takes precedence over on_states; unavailable/unknown never count # as leaving it (the state-change trigger skips them). ok_state: 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, ...] = () # One task PER matched entity instead of one task watching them all — a # colour printer's cartridges are replaced one at a time, so "Replace # Toner — Cyan" must be completable without touching Black. The suffix is # the entity's own registry name; a device matching a single entity keeps # the plain catalog name (a mono printer has no colours to tell apart). per_entity: bool = False # 2.94: appliance types (the integration's own setting, see # IntegrationSignature.appliance_type_key) this duty does not apply to — # WashData offers "descale" for every appliance, a heat-pump dryer has # no water circuit to descale. exclude_appliance_types: tuple[str, ...] = () # 2.95: the integration's own reset for this counter — (sensor key, # button key) pairs. Adopting the duty wires the matched sensor's sibling # button on the SAME device as the task's completion action, so completing # the task here also resets the counter in the integration (Roborock's # "Reset main brush consumable"); without it the counter kept running and # the task fell due again. Verified against the integration's button.py. resets: tuple[tuple[str, str], ...] = () @dataclass(frozen=True) 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) # Opt-in: this integration gives its entities their own translation_keys, # so an entity whose translation_key is set and DIFFERS from a catalog key # is a different entity, whatever its id ends with — the id-suffix / # infix / object-id fallbacks then only apply to entities WITHOUT one. # (A bike's 'odometer' key must not claim '…_next_service_odometer'.) # Deliberately not the default: some integrations set a noisy # translation_key and are matched by suffix on purpose — xiaomi_miot's # 'filter-filter_life_level' for our 'filter_life_level'. translation_keys_authoritative: bool = False # 2.94: the option of the integration's OWN config entry that names the # appliance type (WashData: "device_type" = washing_machine / dryer / …), # read from options first, then data. None = the integration has none. appliance_type_key: str | None = None 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 PER_ENTITY_SEPARATOR = " — " def per_entity_task_name(base: str, entity_label: str) -> str: """The name of a per-entity duty: catalog name + the entity's label.""" return f"{base}{PER_ENTITY_SEPARATOR}{entity_label}" def catalog_base_name(task_name: str) -> str: """Strip a per-entity suffix: 'replace toner — cyan' → 'replace toner'.""" return task_name.split(PER_ENTITY_SEPARATOR, 1)[0] def proposal_name_variants(catalog_task_name: str, entity_label: str | None) -> set[str]: """``task_name_variants`` for one proposal — suffixed with the entity label for per-entity duties, so the existing-task dedupe compares like with like ('Toner ersetzen — Cyan' is the same duty as 'Replace Toner — Cyan', but not the same as 'Replace Toner — Black').""" variants = task_name_variants(catalog_task_name) if not entity_label: return variants suffix = f"{PER_ENTITY_SEPARATOR}{entity_label}".lower() return {v + suffix for v in variants} def entity_label(hass: HomeAssistant, entry: er.RegistryEntry, device_name: str) -> str: """The entity's own display name for a per-entity suffix. Registry name first (user override, then the integration's original name — IPP's 'Black marker'), else the friendly name minus the device-name prefix, else the object id. Capped so the task name stays readable.""" label = entry.name or entry.original_name or "" if not label: state = hass.states.get(entry.entity_id) friendly = str(state.attributes.get("friendly_name", "")) if state else "" if friendly and device_name and friendly.lower().startswith(device_name.lower()): friendly = friendly[len(device_name) :].strip(" -:—") label = friendly or entry.entity_id.split(".", 1)[1] return label.strip()[:60] def _entity_matches(entry: er.RegistryEntry, key: str, *, tk_authoritative: bool = False) -> 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 ``__p_`` infix. Matching is already scoped to the signature's integration (entry.platform), so this cannot bleed across integrations. ``tk_authoritative`` (IntegrationSignature.translation_keys_authoritative): an entity carrying a DIFFERENT translation_key is never matched by the fallbacks — they stay available for the integration's entities without one.""" if entry.translation_key == key: return True if tk_authoritative and entry.translation_key: return False 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", "due_date"): return unit is None # ENUM events, state entities and dates 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 == "due_date": return float(sig.days_before) # days before the reported date 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": # #146: catalog-default floors follow the household setting; a # signature that pins its own floor (any value, 10 included) keeps it. if sig.below_percent is None: from ..global_options import get_consumable_threshold return float(get_consumable_threshold(hass)) 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. # Every duration unit HA lets a user pick as display unit is listed — # a missing one silently fell back to 1.0 (a "24 h" floor became "24 w"). factors = { "μs": 3_600_000_000.0, "ms": 3_600_000.0, "s": 3600.0, "min": 60.0, "h": 1.0, "d": 1 / 24, "w": 1 / 168, # 2.95: custom integrations that spell the unit out (HERU, CatLink: # "days") — the 1.0 fallback turned a 7-day floor into 168 days. "sec": 3600.0, "seconds": 3600.0, "mins": 60.0, "minutes": 60.0, "hr": 1.0, "hrs": 1.0, "hours": 1.0, "day": 1 / 24, "days": 1 / 24, "week": 1 / 168, "weeks": 1 / 168, "km": 1.0, "mi": 0.62137, "miles": 0.62137, # energy counters are canonical in kWh (wallbox cable inspection) "Wh": 1000.0, "kWh": 1.0, "MWh": 0.001, } factor = factors.get(unit) or factors.get(str(unit).strip().lower(), 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 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``). With ``ok_state`` the latch is from-only instead: alert == anything but the OK state. Several matched entities (a signature with several keys) share ONE latch: ``create_triggers`` builds one state-change trigger per entry of ``entity_ids`` and the task sensor aggregates them with ``entity_logic: any`` — so the newer Home Connect ``salt_lack`` event and the older ``salt_nearly_empty`` both fire "Refill Salt", whichever the appliance emits. Chosen over a compound OR trigger (its aggregate deactivation never auto-completes) and over "watch only the preferred key" (drops the other signal). Caveat of the engine's per-entity recovery: the FIRST entity leaving its alert state auto-completes the task and resets every latch, so a sibling still in alert stays silent until its next transition. Put keys into one signature only when they describe the SAME condition that one action clears together (refilling salt clears nearly-empty, lack and program-blocked alike); independently serviced parts (two i-Dos tanks) use ``per_entity`` instead. """ if sig.direction == "event_present": latch: dict[str, Any] = { "type": "state_change", "entity_id": entity_ids[0], # legacy mirror; entity_ids is authoritative "entity_ids": list(entity_ids), "trigger_target_changes": 1, "auto_complete_on_recovery": True, } if len(entity_ids) > 1: latch["entity_logic"] = "any" # the default, spelled out for readers if sig.ok_state: latch["trigger_from_state"] = sig.ok_state else: # Home Connect events use "present"; other integrations latch on # their own alert state (Dolphin filter bag: "full"). latch["trigger_to_state"] = sig.on_states[0] if sig.on_states else "present" return latch if sig.direction == "due_date": # The device keeps the date itself; its own "done" button (wired as # the completion action) moves it forward, which clears the trigger. due: dict[str, Any] = { "type": "due_date", "entity_id": entity_ids[0], "entity_ids": list(entity_ids), "trigger_days_before": sig.days_before, "auto_complete_on_recovery": True, } if len(entity_ids) > 1: due["entity_logic"] = "any" if sig.attribute: due["attribute"] = sig.attribute return due 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_id": entity_ids[0], # legacy mirror (#106); entity_ids is authoritative "entity_ids": list(entity_ids), threshold_key: _threshold_for(sig, hass, entity_ids[0]), "entity_logic": "any", "auto_complete_on_recovery": True, }