Files
HomeAssistantVS/custom_components/maintenance_supporter/helpers/signatures/_model.py
T

430 lines
22 KiB
Python

"""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).
"""
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 _<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 | None = _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, ...] = ()
# 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, ...] = ()
@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 ``_<key>_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"):
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":
# #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").
factor = {
"μ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,
"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 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 == "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,
}