155 files

This commit is contained in:
Home Assistant Version Control committed 2026-10-04 20:46:32 +00:00
1 parent a212615470
commit 0ff8c287a8
155 files changed
+19264 -3148

No files matched your search

@@ -1,8 +1,11 @@
"""Helpers for rewriting entity_id references when HA renames an entity.
Used by the global ``EVENT_ENTITY_REGISTRY_UPDATED`` listener in ``__init__.py``
to keep ``trigger_config["entity_id"|"entity_ids"]`` and
``adaptive_config["environmental_entity"]`` in sync with the user's renames.
to keep ``trigger_config["entity_id"|"entity_ids"]``,
``adaptive_config["environmental_entity"]``, calendar schedules, to-do mirror
targets, the completion action's target and the battery fleet's lists in sync
with the user's renames (the listener itself rewrites the global shopping-list
and notify-entity targets).
Without this, ``async_track_state_change_event`` (which subscribes by literal
entity_id) silently misses events on the new id — same dual-storage class of
@@ -101,9 +104,38 @@ def rewrite_task(task_data: dict[str, Any], old_id: str, new_id: str) -> tuple[d
new_task["mirror_todo_entities"] = [new_id if e == old_id else e for e in mirrors]
changed = True
# The completion action's target — above all the integration's reset
# button wired at adoption (2.95): a renamed button used to leave the
# counter running behind a repair message until someone re-picked it.
action = new_task.get("on_complete_action")
if isinstance(action, dict):
new_action, action_changed = _rewrite_action(action, old_id, new_id)
if action_changed:
new_task["on_complete_action"] = new_action
changed = True
return new_task, changed
def _rewrite_action(action: dict[str, Any], old_id: str, new_id: str) -> tuple[dict[str, Any], bool]:
"""An action's ``target.entity_id`` and ``data.entity_id`` (a single id or
a list) with ``old_id`` replaced."""
new_action = dict(action)
changed = False
for part in ("target", "data"):
block = new_action.get(part)
if not isinstance(block, dict):
continue
eid = block.get("entity_id")
if eid == old_id:
new_action[part] = {**block, "entity_id": new_id}
changed = True
elif isinstance(eid, list) and old_id in eid:
new_action[part] = {**block, "entity_id": [new_id if e == old_id else e for e in eid]}
changed = True
return new_action, changed
def rewrite_object(obj: dict[str, Any], old_id: str, new_id: str) -> tuple[dict[str, Any], bool]:
"""Rewrite the battery fleet's manual include / exclude lists (#135) —
a renamed battery sensor fell out of the fleet (include) or came back
@@ -15,7 +15,9 @@ then direct signals (percent/countdown/resettable counter/event), then derived
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.
fallback for custom integrations that don't set one — and, for those, the
registry's ``original_name`` (the integration's own entity name), which a
user's rename of the entity id leaves alone.
Direction semantics:
* ``duration_left`` — countdown to the next replacement (device_class
@@ -78,6 +80,7 @@ from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.util import slugify
# Hours a duration-countdown may still hold when the task should trigger.
_DEFAULT_BELOW_HOURS = 24
@@ -256,7 +259,14 @@ def _entity_matches(entry: er.RegistryEntry, key: str, *, tk_authoritative: bool
# 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
if entry.entity_id.split(".", 1)[1] == key:
return True
# Fifth: the integration's own name for the entity. The entity id was
# derived from it once, but the user may rename the id — ``original_name``
# stays the integration's word ('Filter Life' → filter_life), so a renamed
# sensor of an integration without translation keys is still recognised.
original = slugify(entry.original_name) if entry.original_name else ""
return bool(original) and (original == key or original.endswith(f"_{key}"))
def _entity_unit(hass: HomeAssistant, entry: er.RegistryEntry) -> str | None:
@@ -7,6 +7,8 @@ integration's source; drift-probed weekly)."""
from __future__ import annotations
from dataclasses import replace
from ._model import ConsumableSignature, IntegrationSignature
from ._shared import (
FILTER_LIFE_PERCENT,
@@ -16,8 +18,10 @@ from ._shared import (
)
# One object for the AC-only integrations whose climate entity runs through
# these HVAC modes (gree, midea_ac) — shared here, next to its users, so the
# verbatim-duplicate tripwire holds without a _shared.py entry.
# these HVAC modes (gree, midea_ac, mitsubishi_wf_rac, toshiba_ac,
# fujitsu_airstage; aux_cloud keys a copy to its AC climate) — shared here,
# next to its users, so the verbatim-duplicate tripwire holds without a
# _shared.py entry.
_AC_FILTER_CLEANING_RUNTIME = ConsumableSignature(
(),
"Filter Cleaning",
@@ -620,4 +624,252 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
),
),
# ─── Round 16 (2026-10-03): ventilation units that report the filter ─
# ─── change DATE (due_date engine), core 2026.9 + 2026.10 ────────────
# Reviewed and left out (problem-class binaries → problem-sensor
# adoption): sensibo 'filter_clean' (its 'filter_last_reset' TIMESTAMP is
# the LAST reset, not a due date; the 'reset_filter' button has no duty to
# attach to), coolmaster 'clean_filter' (+ 'reset_filter' button),
# actron_air 'clean_filter', intelliclima 'filter_cleaning', homee
# 'replace_filter', flexit_bacnet 'air_filter_polluted'. helty: only the
# 'reset_filter' button — core exposes no filter sensor (pyhelty 0.2.0
# reads the remaining filter hours but no entity carries them). wemo: the
# humidifier's filter life is only the 'filter_life' ATTRIBUTE of its fan
# entity, reset only by the wemo.reset_filter_life service.
"smarty": IntegrationSignature(
name="Salda Smarty ventilation",
verified="2026-10-03 @ home-assistant/core 2026.10.0b0 (sensor.py/button.py identical in 2026.9.0b0 and dev)",
source=(
"core smarty sensor.py: tk 'filter_days_left' ('Filter days left', "
"device_class TIMESTAMP, no unit, enabled) — get_filter_days_left "
"returns now + pysmarty2 Smarty.filter_timer days (Modbus "
"'IR_FILTERS_TIMER_DAYS_LEFT'), i.e. the DATE the filter needs to "
"be replaced → due_date with a week's lead to order filters. "
"button.py: tk 'reset_filters_timer' ('Reset filters timer', "
"Smarty.reset_filters_timer writes 'COIL_FILTER_TIMER_RESET') "
"restarts the timer, which moves the date forward and "
"auto-completes the task. Every entity sets a translation_key "
"(the fan 'fan')."
),
translation_keys_authoritative=True,
tasks=(
ConsumableSignature(
("filter_days_left",),
"Replace Ventilation Filter",
"due_date",
days_before=7,
resets=(("filter_days_left", "reset_filters_timer"),),
),
),
),
"vallox": IntegrationSignature(
name="Vallox ventilation",
verified="2026-10-03 @ home-assistant/core 2026.10.0b0 (sensor.py identical in 2026.9.0b0 and dev)",
source=(
"core vallox sensor.py: tk 'remaining_time_for_filter' ('Remaining "
"time for filter', device_class TIMESTAMP, DIAGNOSTIC, enabled) — "
"ValloxFilterRemainingSensor returns vallox_websocket_api's "
"next_filter_change_date (last change date "
"A_CYC_FILTER_CHANGED_DAY/MONTH/YEAR + A_CYC_FILTER_CHANGE_INTERVAL "
"days) at 13:00 local → due_date with a week's lead. No reset "
"button: the change date is the CONFIG date entity "
"'filter_change_date' (date.py, set_filter_change_date) — setting "
"it after the change moves the due date forward and auto-completes "
"the task, but a date entity cannot be wired as a button press."
),
tasks=(
ConsumableSignature(("remaining_time_for_filter",), "Replace Ventilation Filter", "due_date", days_before=7),
),
),
# ─── Round 16 HACS wave (2026-10-03): air conditioners by engine ─────
# ─── runtime, decentral HRV filters ──────────────────────────────────
# Reviewed and left out: ithodaalderop 'airfilter_counter' /
# 'filter_use_counter' (hours, TOTAL_INCREASING — whether the unit zeroes
# them at a filter reset is not established in the source); xtend_tuya
# 'filter_life' (its descriptor manager can also hand it core tuya's
# category descriptors, so the key is not specific to fresh-air units,
# and the unit comes from the device's DP).
"panasonic_cc": IntegrationSignature(
name="Panasonic Comfort Cloud (air conditioners)",
verified="2026-10-03 @ sockless-coding/panasonic_cc master (90c653c)",
source=(
"HACS panasonic_cc panasonic/climate.py: the AC's climate entity "
"has translation_key 'climate' (PANASONIC_CLIMATE_DESCRIPTION; "
"has_entity_name, no translated name → climate.<device>); "
"convert_operation_mode_to_hvac_mode maps Auto/Cool/Dry/Fan/Heat to "
"heat_cool (cool under iAUTO-X)/cool/dry/fan_only/heat — every mode "
"except off moves air through the filter → engine runtime with the "
"AC cadence of gree/midea_ac (filters cleaned every 2 weeks ≈ 100 "
"runtime hours in season). The Aquarea heat pumps of the same "
"integration (aquarea/climate.py) carry per-zone climates with tk "
"'zone-<id>-climate' and stay out: every panasonic_cc entity sets a "
"translation_key (PanasonicDataEntity / AquareaDataEntity / "
"HwsDataEntity bases and the buttons), so the key is authoritative."
),
translation_keys_authoritative=True,
tasks=(
ConsumableSignature(
("climate",),
"Filter Cleaning",
"runtime_hours",
delta_units=100,
entity_domain="climate",
on_states=("cool", "dry", "fan_only", "heat", "heat_cool"),
),
),
),
"mitsubishi_wf_rac": IntegrationSignature(
name="Mitsubishi Heavy Industries (WF-RAC)",
verified="2026-10-03 @ blues-sechseck/Mitsubishi-WF-RAC-Integration main (3056b27)",
source=(
"HACS mitsubishi_wf_rac climate.py: one AircoClimate per device "
"(translation_key 'mitsubishi_wf_rac', name None); const.py "
"SUPPORTED_HVAC_MODES off/auto/cool/dry/heat/fan_only — every mode "
"except off moves air through the filter → the shared AC "
"engine-runtime duty of gree/midea_ac. No filter entity."
),
tasks=(_AC_FILTER_CLEANING_RUNTIME,),
),
"toshiba_ac": IntegrationSignature(
name="Toshiba AC",
verified="2026-10-03 @ h4de5/home-assistant-toshiba_ac main (b0b893e)",
source=(
"HACS toshiba_ac climate.py: one ToshibaClimate per AC device "
"(has_entity_name, name None); TOSHIBA_TO_HVAC_MODE maps "
"AUTO/COOL/HEAT/DRY/FAN to auto/cool/heat/dry/fan_only, off "
"otherwise → the shared AC engine-runtime duty. No filter entity."
),
tasks=(_AC_FILTER_CLEANING_RUNTIME,),
),
"fujitsu_airstage": IntegrationSignature(
name="Fujitsu Airstage",
verified="2026-10-03 @ danielkaldheim/ha_airstage main (12d4dbe)",
source=(
"HACS fujitsu_airstage climate.py: one AirstageAC per indoor unit, "
"each its own device (entity.py AirstageAcEntity identifiers = the "
"ac_key); FUJITSU_TO_HA_STATE maps FAN/DRY/COOL/HEAT/AUTO to "
"fan_only/dry/cool/heat/auto → the shared AC engine-runtime duty. "
"No filter entity."
),
tasks=(_AC_FILTER_CLEANING_RUNTIME,),
),
"aux_cloud": IntegrationSignature(
name="AUX Cloud (air conditioners)",
verified="2026-10-03 @ maeek/ha-aux-cloud main (85ae111)",
source=(
"HACS aux_cloud climate.py: an AIR_CONDITIONER device gets "
"AuxACClimateEntity (AC_DESCRIPTION translation_key 'aux_ac', "
"strings.json 'Air Conditioner'), a HEAT_PUMP device "
"AuxHeatPumpClimateEntity (tk 'aux_heater', heat/cool only); "
"const.py MODE_MAP_AUX_AC_TO_HA maps auto/cooling/heating/dry/fan "
"to auto/cool/heat/dry/fan_only → the shared AC engine-runtime "
"duty, keyed to 'aux_ac' so the heat pump's climate stays out."
),
tasks=(replace(_AC_FILTER_CLEANING_RUNTIME, keys=("aux_ac",)),),
),
"daikin_onecta": IntegrationSignature(
name="Daikin Onecta (air conditioners)",
verified=(
"2026-10-03 @ jwillemsen/daikin_onecta master (e45cc60) + jwillemsen/daikin-onecta main "
"(models.py model_info = 'modelInfo')"
),
source=(
"HACS daikin_onecta climate.py: one DaikinClimate per setpoint of "
"the climateControl management point, translation_key from "
"entity_descriptions.py ENTITY_METADATA ('roomtemperature', "
"'leavingwateroffset', 'leavingwatertemperature', "
"'calculatedleavingwatertemperature'); DAIKIN_HVAC_TO_HA maps "
"fanOnly/dry/cooling/heating/auto/humidification to "
"fan_only/dry/cool/heat/heat_cool/dry. The 'roomtemperature' "
"climate also exists on Altherma heat pumps and Daikin gas boilers, "
"and the type-identifying entities (streamer, econo mode, caution "
"state) sit on per-management-point sub-devices out of a sibling "
"gate's reach. Gate: the climate's device model is the gateway "
"adapter (device.py fill_device_info: gateway modelInfo) — the AC "
"WLAN adapter families BRP069A4x / BRP069B4x / BRP069C4x-C8x, "
"while the heat-pump and boiler fixtures report the Altherma "
"LAN/WLAN adapters BRP069A61/A62/A71/A78 or DRGATEWAYAA (NDJ gas "
"boiler) — all 20 API fixtures in the integration's tests "
"checked. Engine runtime with the core daikin cadence (clean "
"filters every 2 weeks ≈ 100 runtime hours)."
),
tasks=(
ConsumableSignature(
("roomtemperature",),
"Filter Cleaning",
"runtime_hours",
delta_units=100,
entity_domain="climate",
on_states=("cool", "dry", "fan_only", "heat", "heat_cool"),
models=("BRP069A4", "BRP069B4", "BRP069C"),
),
),
),
"ambientika": IntegrationSignature(
name="Ambientika (SUEDWIND)",
verified="2026-10-03 @ ambientika/HomeAssistant-integration-for-Ambientika main (98af980)",
source=(
"HACS ambientika sensor.py FilterStatusSensor: translation_key "
"'filter_status' (translations/en.json 'Filter Status'), "
"has_entity_name, no unit; the state is const.py FilterStatus "
"(StrEnum values 'bad' / 'medium' / 'good') from the cloud's "
"filters_status → event latch on 'bad' ('medium' is the "
"pre-warning, not latched), cleared when the status recovers. "
"button.py FilterResetButton: translation_key 'filter_reset' (GET "
"device/reset-filter) resets it. SUEDWIND decentral heat-recovery "
"units, one sensor and one button per unit."
),
tasks=(
ConsumableSignature(
("filter_status",),
"Replace Ventilation Filter",
"event_present",
on_states=("bad",),
resets=(("filter_status", "filter_reset"),),
),
),
),
"siku": IntegrationSignature(
name="Siku / Blauberg ventilation fans",
verified="2026-10-03 @ hmn/siku-integration main (3afa1bb)",
source=(
"HACS siku sensor.py SENSORS key 'filter_timer_minutes', name "
"'Filter timer countdown' (DURATION, native minutes, suggested unit "
"days; V2 API only — api_v2.py parses register 0x64 as the "
"days/hours/minutes LEFT until the filter change). SikuEntity sets "
"neither has_entity_name nor translation_key → "
"sensor.filter_timer_countdown (exact-object-id pattern; a second "
"fan's _2 id is matched by the original name) → duration_left 7 "
"days. button.py 'Reset filter alarm' (key reset_filter_alarm → "
"button.reset_filter_alarm) sends reset-alarms + 0x65 "
"reset-filter-timer, which restarts the countdown."
),
tasks=(
ConsumableSignature(
("filter_timer_countdown",),
"Replace Ventilation Filter",
"duration_left",
below_hours=168,
resets=(("filter_timer_countdown", "reset_filter_alarm"),),
),
),
),
"ecovent_v2": IntegrationSignature(
name="Vents / Blauberg EcoVent (v2)",
verified="2026-10-03 @ gody01/ecovent_v2 main (4851688)",
source=(
"HACS ecovent_v2 sensor_specs.py SensorSpec '_filter_remaining' "
"('Filter remaining', PERCENTAGE, DIAGNOSTIC, enabled; fans with the "
"filter_maintenance capability reporting the filter countdown and "
"its setpoint): sensor.py filter_remaining = countdown hours / "
"(setpoint days × 24) × 100, clamped 0-100 → % REMAINING. "
"VentoSensor has_entity_name without translation_key, object id "
"<fan>_<method> (entity_naming.py) → suffix _filter_remaining. One "
"duty per filter: the hours countdown ('Filter change in', h; older "
"ids of it also ended in _filter_remaining — the % unit gate keeps "
"them out) is the same filter. The timer reset is only the fan "
"entity SERVICE ecovent_v2.filter_timer_reset (no button)."
),
tasks=(ConsumableSignature(("filter_remaining",), "Replace Ventilation Filter", "percent_left"),),
),
}
@@ -23,7 +23,7 @@ from ._shared import (
# Duties two integrations of this module name identically (same key, same
# countdown) — one object each, so the verbatim-duplicate tripwire holds.
# 'Service inspection distance': Audi Connect and Volkswagen (WeConnect).
# 'Service inspection distance': Audi Connect, Volkswagen (WeConnect) and PyCupra.
_ANNUAL_SERVICE_INSPECTION_DISTANCE = ConsumableSignature(
("service_inspection_distance",), "Annual Service", "value_below", delta_units=1000
)
@@ -31,6 +31,15 @@ _ANNUAL_SERVICE_INSPECTION_DISTANCE = ConsumableSignature(
_ANNUAL_SERVICE_DAYS_TO_SERVICE = ConsumableSignature(
("days_to_service",), "Annual Service", "duration_left", below_hours=336
)
# The volkswagencarnet / pycupra dashboard instruments ('Service inspection
# days', 'Oil inspection days' / 'distance') — one object per duty for both.
_ANNUAL_SERVICE_INSPECTION_DAYS = ConsumableSignature(
("service_inspection_days",), "Annual Service", "duration_left", below_hours=336
)
_OIL_SERVICE_INSPECTION_DAYS = ConsumableSignature(("oil_inspection_days",), "Oil Service", "duration_left", below_hours=336)
_OIL_SERVICE_INSPECTION_DISTANCE = ConsumableSignature(
("oil_inspection_distance",), "Oil Service", "value_below", delta_units=1000
)
SIGNATURES: dict[str, IntegrationSignature] = {
"kia_uvo": IntegrationSignature(
@@ -497,10 +506,10 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
tasks=(
TIRE_ROTATION_ODOMETER,
ConsumableSignature(("service_inspection_days",), "Annual Service", "duration_left", below_hours=336),
_ANNUAL_SERVICE_INSPECTION_DAYS,
_ANNUAL_SERVICE_INSPECTION_DISTANCE,
ConsumableSignature(("oil_inspection_days",), "Oil Service", "duration_left", below_hours=336),
ConsumableSignature(("oil_inspection_distance",), "Oil Service", "value_below", delta_units=1000),
_OIL_SERVICE_INSPECTION_DAYS,
_OIL_SERVICE_INSPECTION_DISTANCE,
),
),
"smarthashtag": IntegrationSignature(
@@ -541,4 +550,109 @@ SIGNATURES: dict[str, IntegrationSignature] = {
ConsumableSignature(("oil",), "Oil Service", "percent_left"),
),
),
# ─── Round 16 (2026-10-03): HACS cars, car alarms and the UK MOT ────────
"byd_vehicle": IntegrationSignature(
name="BYD",
verified="2026-10-03 @ jkaberg/hass-byd-vehicle main (0f0cf3f)",
source=(
"HACS byd_vehicle (custom repository) sensor.py BydSensor: _attr_translation_key = "
"description.key on every sensor (every other platform sets one too). tk 'total_mileage' "
"(realtime total_mileage, KILOMETERS, DISTANCE, TOTAL_INCREASING, enabled; en 'Odometer' → "
"entity id …_odometer) — the lifetime odometer → the usual 15,000 km service / 10,000 km "
"tire-rotation pair; no service countdown exists. translation_keys_authoritative keeps the "
"suffix fallback off the twins 'total_mileage_v2' and 'energy_cumulative_total_mileage' "
"(own keys, DIAGNOSTIC, disabled by default)."
),
translation_keys_authoritative=True,
tasks=(
ConsumableSignature(("total_mileage",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("total_mileage",), "Tire Rotation", "usage_delta", delta_units=10000),
),
),
"dvla": IntegrationSignature(
name="DVLA Vehicle Enquiry (UK)",
verified="2026-10-03 @ jampez77/DVLA-Vehicle-Enquiry-Service main (739ebee)",
source=(
"HACS dvla sensor.py: 'motExpiryDate' ('MOT Expiry Date', device_class DATE) — entity_id "
"forced to sensor.dvla_<registration>_motexpirydate (lowercased), no translation_key, no "
"has_entity_name, so the original name 'MOT Expiry Date' slugifies differently: both "
"'motexpirydate' and 'mot_expiry_date' are keys. Always created: without an MOT date in "
"the DVLA record (a car under three years old) update_from_coordinator falls back to the "
"first-registration month + 3 years — the first MOT. → Roadworthiness Test (the car "
"templates' duty) 30 days ahead; the record's next expiry date clears the trigger after "
"the test. Skipped: 'taxDueDate' (vehicle tax, not maintenance) and 'motStatus' / "
"'taxStatus' (status)."
),
tasks=(ConsumableSignature(("motexpirydate", "mot_expiry_date"), "Roadworthiness Test", "due_date", days_before=30),),
),
"pandora_cas": IntegrationSignature(
name="Pandora Car Alarm System",
verified="2026-10-03 @ alryaz/hass-pandora-cas master (ca2194d) + pandora-cas 0.0.16; turbulator/pandora-cas master (1392544)",
source=(
"Two HACS forks share the domain. alryaz/hass-pandora-cas: "
"PandoraCASEntityDescription.__post_init__ sets translation_key = key on every "
"description, entity_id sensor.<device_id>_<key>. sensor.py 'mileage' ('Mileage', "
"KILOMETERS, DISTANCE, state_class TOTAL — the alarm unit's odometer) → Tire Rotation; "
"'days_to_maintenance' ('Days to Maintenance', DURATION, UnitOfTime.DAYS, DIAGNOSTIC; "
"CurrentState.can_days_to_maintenance = the CAN bus's 'CAN_days_to_maintenance') → Annual "
"Service two weeks ahead — the car's own countdown, as for Škoda and Stellantis (the entity "
"is unavailable while the car's CAN bus does not report it, entity.py "
"update_native_value). translation_keys_authoritative keeps 'can_mileage' / "
"'can_mileage_by_battery' / 'can_mileage_to_empty' (own keys) off the mileage duty. "
"turbulator/pandora-cas (older, no translation keys): ENTITY_CONFIGS 'mileage' → "
"sensor.<pandora_id>_mileage (suffix match); it has no maintenance countdown. Skipped: "
"'remaining_engine_runtime' (the remote-start timer)."
),
translation_keys_authoritative=True,
tasks=(
TIRE_ROTATION_MILEAGE,
ConsumableSignature(("days_to_maintenance",), "Annual Service", "duration_left", below_hours=336),
),
),
"vag_connect": IntegrationSignature(
name="VW Group Connect (vag_connect)",
verified="2026-10-03 @ its-me-prash/vwgroup-connect-ha main (3f18940)",
source=(
"HACS vag_connect (Volkswagen / Audi / Škoda / SEAT / CUPRA / Porsche incl. North America) "
"sensor.py SENSOR_DESCRIPTIONS: every description sets translation_key (= key); data "
"sensors spawn only once their value arrives (hide_empty_entities, default on). "
"'service_due_in_days' (unit 'd', days remaining) and 'service_km' (KILOMETERS remaining — "
"vw_eu inspectionDue_km, skoda inspectionDueInKm, porsche MAIN_SERVICE_RANGE) → Annual "
"Service; 'oil_service_due_in_days' / 'oil_service_km' (condition 'combustion') → Oil "
"Service — the car's own countdowns replace the editorial odometer service, as for Škoda "
"and Audi; 'odometer_km' (KILOMETERS, TOTAL_INCREASING) → Tire Rotation. "
"translation_keys_authoritative plus the longer-key guard keep the 'oil_service_*' entities "
"off the 'service_*' keys. Skipped: the DATE twins 'service_due_at' / 'oil_service_at' "
"(the same countdowns as dates) and 'reminder_technical_inspection' (date-or-status mix)."
),
translation_keys_authoritative=True,
tasks=(
ConsumableSignature(("odometer_km",), "Tire Rotation", "usage_delta", delta_units=10000),
ConsumableSignature(("service_due_in_days",), "Annual Service", "duration_left", below_hours=336),
ConsumableSignature(("service_km",), "Annual Service", "value_below", delta_units=1000),
ConsumableSignature(("oil_service_due_in_days",), "Oil Service", "duration_left", below_hours=336),
ConsumableSignature(("oil_service_km",), "Oil Service", "value_below", delta_units=1000),
),
),
"pycupra": IntegrationSignature(
name="CUPRA / SEAT (PyCupra)",
verified="2026-10-03 @ WulfgarW/homeassistant-pycupra main (6187999) + pycupra 0.2.37",
source=(
"HACS pycupra: entity name = f'{vehicle_name} {instrument.name}' (no has_entity_name, no "
"translation_key → entity-id suffixes). The instruments come from the pinned pycupra "
"library (>=0.2.36; 0.2.37 dashboard.py) and exist only where the car supports them "
"(Instrument.setup → is_<attr>_supported): 'Odometer' (km), 'Service inspection days' "
"(unit 'd', maintenance.inspectionDueDays — 'time left until service inspection'), "
"'Service inspection distance' (km, inspectionDueKm), 'Oil inspection days' / 'Oil "
"inspection distance' (oilServiceDueDays / oilServiceDueKm, only where reported) — the "
"volkswagencarnet instruments, names and semantics, so the same duty objects."
),
tasks=(
TIRE_ROTATION_ODOMETER,
_ANNUAL_SERVICE_INSPECTION_DAYS,
_ANNUAL_SERVICE_INSPECTION_DISTANCE,
_OIL_SERVICE_INSPECTION_DAYS,
_OIL_SERVICE_INSPECTION_DISTANCE,
),
),
}
@@ -191,8 +191,13 @@ SIGNATURES: dict[str, IntegrationSignature] = {
# 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."),
verified="2026-07-20 @ home-assistant/core dev; skips re-checked 2026-10-03 @ home-assistant/core 2026.10.0b0",
source=(
"core screenlogic: tk 'salt_ppm' (MEASUREMENT, ppm) — IntelliChlor salt concentration. "
"2026-10-03 skip: 'salt_tds_ppm' is IntelliChem's CONFIGURATION value (a CONFIG "
"number; its sensor twin is disabled by default as superseded) — a setting, not a "
"reading."
),
tasks=(ConsumableSignature(("salt_ppm",), "Refill Pool Salt", "value_below", delta_units=2700),),
),
"ondilo_ico": IntegrationSignature(
@@ -471,4 +476,104 @@ SIGNATURES: dict[str, IntegrationSignature] = {
ConsumableSignature(("salt_to_add",), "Refill Pool Salt", "alert_above", delta_units=5),
),
),
# --- Round 16 (2026-10-03): HACS spas, mowers and pool equipment --------
"gecko": IntegrationSignature(
name="Gecko spa packs (in.touch)",
verified="2026-10-03 @ gazoodle/gecko-home-assistant main (eb3fba4) + geckolib 1.0.16",
source=(
"HACS gecko sensor.py GeckoReminderSensor: one sensor per reminder the spa pack reports "
"(geckolib GeckoReminders.reminders, INVALID slots dropped), name '<spa>: <Type> due' "
"(GeckoEntityBase.name; no has_entity_name, no translation_key → entity-id and "
"original-name suffixes _rinse_filter_due, _clean_filter_due, _change_water_due, "
"_change_ozonator_due, _change_vision_cartridge_due), device_class timestamp, no unit; the "
"value is midnight UTC today + the reminder's remaining days (in the past once overdue). "
"Resetting the reminder on the spa (or through the CONFIG date twin of the same name, "
"date.py set_reminder) moves the date forward, which clears the trigger and auto-completes "
"the task — there is no button to wire. The date.* twins carry the same names and are kept "
"out by the sensor domain. Rinse / clean / change water map onto the Hot Tub template's "
"duties, due on the date itself (the spa's own reminder); the ozonator and the Vision "
"cartridge are parts to order → two weeks' lead. Skipped: 'Check Spa due' (a generic "
"check, no part or action)."
),
tasks=(
ConsumableSignature(("rinse_filter_due",), "Rinse Filter", "due_date"),
ConsumableSignature(("clean_filter_due",), "Deep Clean Filter", "due_date"),
ConsumableSignature(("change_water_due",), "Drain and Refill", "due_date"),
ConsumableSignature(("change_ozonator_due",), "Replace Ozonator", "due_date", days_before=14),
ConsumableSignature(("change_vision_cartridge_due",), "Replace Vision Cartridge", "due_date", days_before=14),
),
),
"terramow": IntegrationSignature(
name="TerraMow",
verified="2026-10-03 @ TerraMow/TerraMowHA main (045d789)",
source=(
"TerraMow's own HACS integration (custom repository) sensor.py — has_entity_name and "
"_attr_translation_key on every entity class: 'remaining_blade_time' ('Remaining Blade "
"Time', de 'Verbleibende Laufzeit der Klingen'; MINUTES, DURATION, DIAGNOSTIC) = "
"const.BLADE_MAINTENANCE_CYCLE_MINUTES (14,400 = 240 h) minus dp_126 'blade disk usage "
"time', floored at 0; 'remaining_base_station_time' (MINUTES) = "
"BASE_STATION_MAINTENANCE_CYCLE_MINUTES (43,200 = 30 days) minus dp_125 'base station usage "
"time' — both computed in the integration from the vendor's recommended cycles and both "
"restart when the robot's counter is reset in the TerraMow app (dp value 0; no HA "
"button). The base-station cycle maps to Clean Charging Contacts (the Robot Lawn Mower "
"template's station duty). 'total_mowing_time' (dp_124 statistics duration, SECONDS, "
"TOTAL_INCREASING, lifetime) → Clean Undercarriage every 25 h like the other lifetime "
"mowing counters. NOTE: docs/en/developers/data_point.md calls both cycles a 'recommended "
"cleaning cycle' (and gives 240 minutes for the blade disk where the integration uses 240 "
"hours)."
),
translation_keys_authoritative=True,
tasks=(
ConsumableSignature(("remaining_blade_time",), "Replace Blades", "duration_left"),
ConsumableSignature(("remaining_base_station_time",), "Clean Charging Contacts", "duration_left"),
ConsumableSignature(("total_mowing_time",), "Clean Undercarriage", "usage_delta", delta_units=25),
),
),
"stihl_imow": IntegrationSignature(
name="STIHL iMOW",
verified="2026-10-03 @ ChrisHaPunkt/ha-stihl-imow main (0fe6c7b)",
source=(
"HACS stihl_imow (custom repository) maps.py IMOW_SENSORS_MAP + entity.py: has_entity_name, "
"translation_key = to_translation_key(property) on every entity → "
"'statistics_total_blade_operating_time' (statistics_totalBladeOperatingTime) and "
"'statistics_total_operating_time' — DURATION, SECONDS with suggested HOURS, "
"TOTAL_INCREASING lifetime counters, enabled (not in DISABLED_BY_DEFAULT_PROPERTIES) → the "
"Gardena / Indego pair: blades every 100 h of blade time, undercarriage every 25 h of "
"operation. Skipped: 'status_blade_service' (status_bladeService, no type or unit, meaning "
"undocumented). No reset button."
),
translation_keys_authoritative=True,
tasks=(
ConsumableSignature(("statistics_total_blade_operating_time",), "Replace Blades", "usage_delta", delta_units=100),
ConsumableSignature(("statistics_total_operating_time",), "Clean Undercarriage", "usage_delta", delta_units=25),
),
),
"omnilogic_local": IntegrationSignature(
name="Hayward OmniLogic (local)",
verified="2026-10-03 @ cryptk/haomnilogic-local main (6a8abb8)",
source=(
"HACS omnilogic_local (custom repository; not the core 'omnilogic' cloud domain) sensor.py "
"CHLORINATOR_SALT_SENSORS 'chlorinator_salt_level_average' (name 'Average Salt Level', "
"PARTS_PER_MILLION, MEASUREMENT); the entity is named f'{equipment.name} Average Salt "
"Level' under has_entity_name, no translation_key → suffix _average_salt_level. Hayward "
"salt cells start their operating range at 2,700 ppm, the IntelliChlor band of the "
"ScreenLogic entry → value_below 2,700; topping up raises the reading (auto-resolve). The "
"'Instant Salt Level' twin (the raw reading) is not used."
),
tasks=(ConsumableSignature(("average_salt_level",), "Refill Pool Salt", "value_below", delta_units=2700),),
),
"fluidra_pool": IntegrationSignature(
name="Fluidra Pool (Fluidra Connect)",
verified="2026-10-03 @ foXaCe/Fluidra-pool main (fef0d33)",
source=(
"HACS fluidra_pool (custom repository) sensor/chlorinator.py FluidraUvRunningHoursSensor: "
"tk 'uv_running_hours' (DURATION, HOURS, TOTAL_INCREASING — 'a plain integer hour counter "
"… the lamp-replacement interval is a running-hours threshold'), created only when the "
"chlorinator reports a UV block with running_hours (sensor/__init__.py) → Replace UV Lamp "
"every 8,000 h (the UV-lamp rating of the NeoPool entry). Whether the controller restarts "
"the counter after a lamp change is not documented, hence usage_delta (completing "
"re-baselines either way)."
),
tasks=(ConsumableSignature(("uv_running_hours",), "Replace UV Lamp", "usage_delta", delta_units=8000),),
),
}
@@ -8,7 +8,7 @@ integration's source; drift-probed weekly)."""
from __future__ import annotations
from ._model import ConsumableSignature, IntegrationSignature
from ._shared import HEATING_WATER_PRESSURE_LOW, SOFTENER_SALT_LEVEL
from ._shared import HEATING_SYSTEM_PRESSURE_LOW, HEATING_WATER_PRESSURE_LOW, SOFTENER_SALT_LEVEL
SIGNATURES: dict[str, IntegrationSignature] = {
"vicare": IntegrationSignature(
@@ -19,7 +19,14 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"(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)."
"UnitOfTime.HOURS, TOTAL_INCREASING lifetime → usage_delta). "
"Re-checked 2026-10-03 @ core 2026.10.0b0: 'filter_hours' "
"(operatingHours) and 'filter_overdue_hours' (overdueHours) read the "
"same ventilation.filter.runtime as the countdown — one duty per "
"filter, not signed; 'compressor_hours_loadclass1'-'5', "
"'heating_rod_hours' and 'supply_fan_hours' are statistics, not "
"duties. No filter-reset button (button.py: one-time DHW charge "
"only)."
),
tasks=(
ConsumableSignature(("filter_remaining_hours",), "Replace Filter", "duration_left"),
@@ -49,8 +56,9 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
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),
# topping up raises the value back (auto-resolve). Shared with
# bosch_homecom and nefiteasy (round 16).
HEATING_SYSTEM_PRESSURE_LOW,
),
),
# ─── Research round 4 (2026-07-19): boiler pressure, HRV filters, ───
@@ -59,7 +67,17 @@ SIGNATURES: dict[str, 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."
"core opentherm_gw: tk 'central_heating_pressure', BAR, MEASUREMENT — generic for EVERY OpenTherm-connected boiler. "
"Re-checked 2026-10-03 @ core 2026.10.0b0: every sensor and "
"binary sensor is registered TWICE, on the 'boiler' and on the "
"'thermostat' device (BOILER_/THERMOSTAT_DEVICE_DESCRIPTION, "
"identical translation_keys, neither device has a model string), "
"so no gate keeps a duty off the thermostat device — the "
"burner/pump running-time counters ('total_burner_hours', "
"'central_heating_pump_hours', 'hot_water_burner_hours', "
"'hot_water_pump_hours'; HOURS, TOTAL) are not signed for that "
"reason. 'service_required' is a problem-class binary (adoption "
"path)."
),
tasks=(ConsumableSignature(("central_heating_pressure",), "Refill Heating Water", "value_below", delta_units=1),),
),
@@ -214,7 +232,11 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"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)."
"also exists (adoption path). Re-checked 2026-10-03 @ core "
"2026.10.0b0: the sensor is disabled by default (the suggestion "
"appears once it is enabled); 'total_runtime' (lifetime HOURS) and "
"'next_maintainance' (TIMESTAMP), both disabled by default too, "
"describe the same service — one duty, not signed twice."
),
tasks=(ConsumableSignature(("runtime_since_last_maintenance",), "Oil Service", "usage_above", above_hours=100),),
),
@@ -388,7 +410,10 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"no interval is set; negative when overdue) → duration_left, "
"task at 24 run-hours left. The counter reset "
"(ServiceCounterReset) is a library SERVICE topic, not an HA "
"button — nothing to wire."
"button — nothing to wire. Re-checked 2026-10-03 @ core "
"2026.10.0b0: 'generator_total_runtime' (AccumulatedRuntime, "
"lifetime h) is the same service seen from the other side — not "
"signed twice."
),
tasks=(ConsumableSignature(("generator_service_counter",), "Oil Service", "duration_left"),),
),
@@ -434,4 +459,142 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
tasks=(HEATING_WATER_PRESSURE_LOW,),
),
# ─── Round 16 (2026-10-03): pellet-stove service by burn hours ──────
# (hr_energy_qube 'alarm_working_hours' is a problem-class binary →
# problem-sensor adoption.)
"ecoforest": IntegrationSignature(
name="Ecoforest pellet stoves",
verified="2026-10-03 @ home-assistant/core 2026.10.0b0 (sensor.py identical in 2026.9.0b0 and dev) + pyecoforest 0.4.0",
source=(
"core ecoforest sensor.py: tk 'working_hours' ('Working time', "
"DURATION, HOURS, no state class, entity_registry_enabled_default="
"False) = pyecoforest Device.working_hours, the stove's stats "
"field 'Nh' — the TOTAL working hours of the stove (HA docs: 'total "
"number of working hours of the device'), never reset → "
"usage_delta. Every 1,500 h — editorial: about one heating season "
"of a stove used as the main heating, i.e. the yearly 'Stove "
"Service' of the Fireplace & Wood Stove template counted in burn "
"hours. NOTE: disabled by default — the suggestion appears once the "
"user enables the sensor. The 'ignitions' counter and the 'alarm' "
"enum (pellets / air depression / CPU overheating) are not "
"maintenance duties."
),
tasks=(ConsumableSignature(("working_hours",), "Stove Service", "usage_delta", delta_units=1500),),
),
# ─── Round 16 HACS wave (2026-10-03): heating-loop pressure, the ─────
# ─── pellet-stove service countdown, a heat pump's vent filter ───────
# (Reviewed and left out: the sfstar 'victron' Modbus integration's
# generator_servicecounter — dbus-modbustcp returns 0 for an invalid
# value, so a GX without a service interval would read "due now".)
"ariston": IntegrationSignature(
name="Ariston NET (Remotethermo)",
verified="2026-10-03 @ fustom/ariston-remotethermo-home-assistant-v3 main (31900cb)",
source=(
"HACS ariston const.py ARISTON_SENSOR_TYPES "
"DeviceProperties.HEATING_CIRCUIT_PRESSURE: name f'{NAME} heating "
"circuit pressure' with NAME 'Ariston' and no has_entity_name / "
"translation_key → sensor.ariston_heating_circuit_pressure, suffix "
"_heating_circuit_pressure (the original name also matches a "
"second boiler's _2 id); PRESSURE, MEASUREMENT, DIAGNOSTIC, "
"enabled, GALEVO systems only. Value and unit are the cloud item's "
"own (ariston library galevo_device.heating_circuit_pressure_value"
" / _unit; HeatingCircuitPressure is in bar) → value_below 1 like "
"the other boiler-loop duties. Core 'ariston' is only a virtual "
"brand entry (supported_by midea) without entities."
),
tasks=(ConsumableSignature(("heating_circuit_pressure",), "Refill Heating Water", "value_below", delta_units=1),),
),
"aquarea": IntegrationSignature(
name="Panasonic Aquarea (HeishaMon)",
verified="2026-10-03 @ kamaradclimber/heishamon-homeassistant main (3a528fd)",
source=(
"HACS aquarea (HeishaMon, MQTT-fed) definitions.py build_sensors "
"TOP115 key f'{mqtt_prefix}main/Water_Pressure', name 'Aquarea "
"Water Pressure', PRESSURE, 'bar', entity_registry_enabled_default="
"False (K/L series only); sensor.py sets entity_id = "
"sensor.<slugify(key)> → sensor.panasonic_heat_pump_main_water_"
"pressure (suffix _water_pressure; the original name ends in it "
"too). No other HeishaMon key ends in water_pressure (High/Low "
"Pressure are the refrigerant side). NOTE: disabled by default — "
"the suggestion appears once the user enables the sensor. The "
"cloud fork wpatrik14/home-assistant-aquarea shares the domain but "
"has no pressure entity (verified @ a588223)."
),
tasks=(HEATING_WATER_PRESSURE_LOW,),
),
"maestro_mcz": IntegrationSignature(
name="MCZ pellet stoves (Maestro)",
verified="2026-10-03 @ Robbe-B/maestro_mcz main (a2ba9cf)",
source=(
"HACS maestro_mcz maestro/models/models.py supported_sensors: "
"SensorMczConfigItem 'Next Maintenance' (status field "
"'ore_prox_manut' = hours to the next maintenance, UnitOfTime.HOURS, "
"MEASUREMENT, DIAGNOSTIC, enabled; created only when the stove "
"reports the field). sensor.py MczSensorEntity has_entity_name with "
"_attr_name = the item name, no translation_key → suffix "
"_next_maintenance. The stove controller's own service countdown "
"(the fumis pattern) → duration_left 24 h; named after the "
"technician's 'Stove Service' of the Fireplace & Wood Stove "
"template, like the ecoforest duty. The only binary ('Alarm', "
"is_in_error) is problem-class."
),
tasks=(ConsumableSignature(("next_maintenance",), "Stove Service", "duration_left"),),
),
"bosch_homecom": IntegrationSignature(
name="Bosch HomeCom Easy",
verified="2026-10-03 @ serbanb11/bosch-homecom-hass main (d9c28a6)",
source=(
"HACS bosch_homecom sensor.py: K40/ICOM coordinators get "
"BoschComK40ExtraSensor('systemPressure', translation_key "
"'system_pressure', PRESSURE, 'bar') when the heat source reports "
"it (strings.json 'System pressure', has_entity_name). ICOM "
"devices additionally get BoschComIcomExtraSensor name "
"'hs_system_pressure' (no translation_key, bar) reading the same "
"heat_sources.systemPressure — its id ends in _system_pressure too, "
"so the duty watches both copies of one reading (any-low). "
"value_below 1 bar → the shared bosch duty."
),
tasks=(HEATING_SYSTEM_PRESSURE_LOW,),
),
"nefiteasy": IntegrationSignature(
name="Nefit Easy (Bosch thermostat)",
verified="2026-10-03 @ ksya/ha-nefiteasy dev (28880e4)",
source=(
"HACS nefiteasy const.py SENSORS: key 'system_pressure', name "
"'System pressure', url /system/appliance/systemPressure, "
"UnitOfPressure.BAR, PRESSURE, MEASUREMENT; nefit_entity.py sets "
"neither has_entity_name nor translation_key → "
"sensor.system_pressure (exact-object-id pattern; a second boiler's "
"_2 id is matched by the original name). value_below 1 bar → the "
"shared bosch duty."
),
tasks=(HEATING_SYSTEM_PRESSURE_LOW,),
),
"waterkotte_heatpump": IntegrationSignature(
name="Waterkotte heat pumps (BasicVent)",
verified="2026-10-03 @ marq24/ha-waterkotte main (f5491c9)",
source=(
"HACS waterkotte_heatpump const.py "
"BASICVENT_FILTER_CHANGE_REMAINING_OPERATING_DAYS_A4504 (DURATION, "
"UnitOfTime.DAYS, MEASUREMENT, vent feature only, "
"entity_registry_enabled_default=False); __init__.py sets "
"translation_key = key.lower() → tk "
"'basicvent_filter_change_remaining_operating_days_a4504' (en.json "
"'Vent Air-Filter-Change operating hours remaining time') — the "
"BasicVent ventilation unit's own filter countdown → duration_left "
"7 days. NOTE: disabled by default — the suggestion appears once "
"the user enables it. The counter reset "
"(BASICVENT_FILTER_CHANGE_OPERATING_HOURS_RESET_D1544) is a SWITCH, "
"not a button, and cannot be wired; the 'filter change display' "
"binary carries device_class running and is not signed."
),
tasks=(
ConsumableSignature(
("basicvent_filter_change_remaining_operating_days_a4504",),
"Replace Ventilation Filter",
"duration_left",
below_hours=168,
),
),
),
}
@@ -1,4 +1,4 @@
"""NAS & home IT.
"""NAS, home IT & safety detectors.
Data module of the suggested-setups signature catalog — see
``helpers/signatures/_model.py`` for the direction semantics and the
@@ -108,4 +108,53 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
tasks=(ConsumableSignature(("pool_usage",), "Storage Cleanup", "alert_above", delta_units=85),),
),
# Reviewed 2026-10-03 @ core 2026.10.0b0 (round 16), not signed: apcupsd
# 'battery_replacement_date' is apcupsd's BATTDATE ('Battery replaced',
# plain string sensor) — the date the battery WAS replaced, a last-done
# date rather than a due date, so the due_date direction does not fit; the
# UPS template's "Replace UPS Battery" covers the duty.
# ─── Round 16 HACS wave (2026-10-03): smoke/CO alarms that report ────
# ─── their own end of life ───────────────────────────────────────────
"nest_protect": IntegrationSignature(
name="Google Nest Protect",
verified="2026-10-03 @ iMicknl/ha-nest-protect main (8b58fc1)",
source=(
"HACS nest_protect sensor.py SENSOR_DESCRIPTIONS: key = "
"translation_key 'replace_by_date_utc_secs' (strings.json 'Replace "
"by'; device_class DATE, DIAGNOSTIC, enabled; created only when the "
"device reports the key) = datetime.utcfromtimestamp of the "
"alarm's replace-by date, the end of its service life → due_date 30 "
"days ahead, time to buy the successor. Core renders the naive "
"datetime under DATE as an ISO datetime, which the due_date trigger "
"parses. The replacement is a new device, so the task is completed "
"by hand. 'last_audio_self_test_end_utc_secs' / "
"'latest_manual_test_end_utc_secs' are LAST-done dates (no due "
"date); every binary carries a device class (smoke/CO/heat, "
"problem-class self-tests → problem-sensor adoption)."
),
tasks=(ConsumableSignature(("replace_by_date_utc_secs",), "Replace Detectors", "due_date", days_before=30),),
),
"kidde_homesafe": IntegrationSignature(
name="Kidde HomeSafe",
verified="2026-10-03 @ snell-evan-itt/Kidde-HomeSafe main (47b9568)",
source=(
"HACS kidde_homesafe sensor.py KiddeSensorLifeEntity (the API field "
"'life', created when the device reports it; has_entity_name, no "
"translation_key, MEASUREMENT): LIFE_SENSOR_CONFIG names it per "
"mb_model — 'Days to replace' (UnitOfTime.DAYS) on the DETECT "
"series (mb_model 46/48), else 'Weeks to replace' "
"(UnitOfTime.WEEKS) → suffixes _days_to_replace / "
"_weeks_to_replace: the alarm's end-of-life countdown → "
"duration_left 30 days (converted into days resp. weeks). The 'End "
"of Life Fault' binaries are problem-class (adoption path)."
),
tasks=(
ConsumableSignature(
("days_to_replace", "weeks_to_replace"),
"Replace Detectors",
"duration_left",
below_hours=720,
),
),
),
}
@@ -35,7 +35,7 @@ ELECTROLUX_WATER_FILTER_STATE = ConsumableSignature(
SIGNATURES: dict[str, IntegrationSignature] = {
"lg_thinq": IntegrationSignature(
name="LG ThinQ",
verified="2026-07-17 @ home-assistant/core dev + thinq-connect/pythinqconnect main; dishwasher/fridge latches 2026-09-27 @ home-assistant/core 2026.9",
verified="2026-07-17 @ home-assistant/core dev + thinq-connect/pythinqconnect main; dishwasher/fridge latches 2026-09-27 @ home-assistant/core 2026.9; skips re-checked 2026-10-03 @ home-assistant/core 2026.10.0b0",
source=(
"home-assistant/core homeassistant/components/lg_thinq/sensor.py "
"(ThinQProperty StrEnum translation_key; FILTER_LIFETIME is shared by "
@@ -54,7 +54,11 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"(binaries machine_clean_reminder / clean_light_reminder / signal_level) and "
"rinse_level (dispenser SETTING 0-4); used_time (MONTHS, the same water filter as "
"water_filter_*_remain_percent — no gate can keep both off one fridge) and "
"water_filter_state (values undocumented)."
"water_filter_state (values undocumented). 2026-10-03 (entity descriptions "
"unchanged since 2026.9): kimchi-refrigerator binary 'one_touch_filter' "
"(refrigeration.oneTouchFilter, on_key 'on', no device_class) is the fresh-air "
"filter FUNCTION being on, not a replace flag — the 'fresh_air_filter' latch "
"already carries that filter; skipped."
),
tasks=(
# AC filter reports hours-remaining; air-purifier/RAC filters report
@@ -367,7 +371,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
"midea": IntegrationSignature(
name="Midea (core)",
verified="2026-09-25 @ home-assistant/core dev (new in 2026.8) + midea-local 12.1.0",
verified="2026-09-25 @ home-assistant/core dev (new in 2026.8) + midea-local 12.1.0; skips re-checked 2026-10-03 @ home-assistant/core 2026.10.0b0",
source=(
"home-assistant/core homeassistant/components/midea/sensor.py "
"SENSOR_ENTITIES (created only when the device reports the "
@@ -383,7 +387,11 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"brands with supported_by: midea share the domain. "
"binary_sensor.py salt / rinse_aid / filter_cleaning_reminder / "
"full_dust / tank_full are device_class problem → problem-sensor "
"adoption."
"adoption. 2026-10-03 skips: 'filter_available_days' (keys filter1/2/3, "
"DURATION DAYS = midea-local ED body-01 hours ÷ 24) counts the SAME three "
"filters as 'filter_life_level' — one signal per filter; the soft-water "
"body-05 'remaining_days' / 'use_days' (meaning undocumented) and the binary "
"'arofene_link' (PLUG, module-attached status)."
),
tasks=(
ConsumableSignature(("salt_available",), "Refill Softener Salt", "percent_left"),
@@ -804,4 +812,157 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
),
),
"tami4": IntegrationSignature(
name="Tami4 Edge / Edge+",
verified="2026-10-03 @ home-assistant/core 2026.10.0b0",
source=(
"core tami4 sensor.py ENTITY_DESCRIPTIONS (unchanged since 2026.9; every sensor "
"and button description sets a translation_key, has_entity_name): tk "
"'filter_upcoming_replacement' / 'uv_upcoming_replacement' (device_class DATE, "
"no unit, enabled; coordinator.py FlattenedWaterQuality ← Tami4EdgeAPI 3.0 "
"water_quality.py QualityInfo.upcoming_replacement = date.fromtimestamp(cloud "
"dynamicData filterInfo / uvInfo 'upcomingReplacement' / 1000); HA docs: 'Date "
"when the filter / UV lamp needs to be replaced') → due_date. button.py has only "
"'boil_water' / 'prepare_drink' — no reset to wire: the Tami4 cloud moves the "
"date after the replacement, which clears the trigger (auto-complete). Skipped: "
"'filter_litters_passed' (LITERS, TOTAL — water through the currently installed "
"filter, the part the date duty already covers) and 'filter_installed' / "
"'uv_installed' (booleans, status)."
),
translation_keys_authoritative=True,
tasks=(
# A week's lead time to order the cartridge / lamp (as for Vitesy's filter).
ConsumableSignature(("filter_upcoming_replacement",), "Replace Water Filter", "due_date", days_before=7),
ConsumableSignature(("uv_upcoming_replacement",), "Replace UV Lamp", "due_date", days_before=7),
),
),
# ─── Round 16 (2026-10-03): HACS water treatment and Polaris / Rusclimate ─
"midea_auto_cloud": IntegrationSignature(
name="Midea Auto Cloud",
verified="2026-10-03 @ sususweet/midea_auto_cloud master (8d0f9a5)",
source=(
"HACS midea_auto_cloud (not the midea_ac_lan / core midea domains): midea_entity.py sets "
"_attr_translation_key = the mapping's translation_key, else the entity key, on every "
"entity; platform_setup.py creates every entity the device's mapping lists "
"(device_mapping/T0x<type>.py, picked by subtype → sn8 → default_<category>). T0xED water "
"purifiers (PERCENTAGE): life_1 / life_2 with tk 'life_fcb' / 'life_ro' "
"(default_water_purifier, 63600118 — en 'FCB filter remaining life' / 'RO filter remaining "
"life'), 'life_ro' / 'life_pcb' (632009F5, 632009C6; 632009C6 lists translation_key twice, "
"the last wins) and 'life_1' / 'life_2_pcb' (632009EN) → one task per cartridge. T0xFC air "
"purifiers (571Z3081, 571Z307F): 'deep_filter_percent' ('Filter Life Remaining', "
"PERCENTAGE). T0xAC fresh-air units (default_central_fresh_air, 26096947): "
"'fresh_filter_time' ('Filter remaining life', %; the default mapping writes "
"native_unit_of_measurement, which the entity ignores → no unit, the lenient unit gate) → "
"the ventilation filter. Skipped: T0xED 'left_salt' (% salt left) exists only in "
"default_water_purifier, which creates its softener, purifier and pipeline-machine "
"attributes for every device it serves (issues #48, #129) — a purifier would get a salt "
"duty on an entity that never reports; 'remind_maintenance_days' (meaning undocumented); "
"the C2 toilet's 'filter_use_per' ('Filter Use Percentage' but BATTERY class — direction "
"contradictory); T0xFA 'filter_life_time' / 'dust_life_time' and T0xCE "
"'clean_net_used_time' / 'change_net_used_time' (hours, used vs left not established); "
"the 'maintenance_remind' / 'filter_value' binaries are device_class problem "
"(problem-sensor adoption). button.py has no filter reset."
),
translation_keys_authoritative=True,
tasks=(
ConsumableSignature(
("life_fcb", "life_ro", "life_pcb", "life_1", "life_2_pcb"),
"Replace Water Filter",
"percent_left",
per_entity=True,
),
ConsumableSignature(("deep_filter_percent",), "Replace Filter", "percent_left"),
ConsumableSignature(("fresh_filter_time",), "Replace Ventilation Filter", "percent_left"),
),
),
"gruenbeck_cloud": IntegrationSignature(
name="Grünbeck softliQ (cloud)",
verified="2026-10-03 @ p0l0/hagruenbeck_cloud main (571eb1d)",
source=(
"HACS gruenbeck_cloud (custom repository) sensor.py SENSORS — every description sets a "
"translation_key, GruenbeckCloudEntity has_entity_name: tk 'salt_range' ('Salt Range' / "
"'Salzvorrat', UnitOfTime.DAYS, no device class, enabled; pygruenbeck_cloud "
"realtime.salt_range = the days the salt supply lasts) → Refill Softener Salt a week ahead "
"(the BWT / EcoWater days precedent); tk 'next_service' ('Next Service', source comment "
"'Perform maintenance in [days]', UnitOfTime.DAYS, DIAGNOSTIC, "
"entity_registry_enabled_default=False — 'Not available at SE devices') → Annual Service "
"two weeks ahead, the Water Softener template's service duty; it is proposed once the "
"entity is enabled. Skipped: the 'has_error' binary (device_class problem → problem-sensor "
"adoption), 'last_service' (DATE of the last service), 'salt_consumption' (kg used). The "
"integration has no button platform."
),
translation_keys_authoritative=True,
tasks=(
ConsumableSignature(("salt_range",), "Refill Softener Salt", "duration_left", below_hours=168),
ConsumableSignature(("next_service",), "Annual Service", "duration_left", below_hours=336),
),
),
"gruenbeck_softliq_sc": IntegrationSignature(
name="Grünbeck softliQ SC (local)",
verified="2026-10-03 @ tizianodeg/gruenbeck_softliQ_SC main (c603343)",
source=(
"HACS gruenbeck_softliq_sc (custom repository; the softliQ:SC's local web interface) "
"sensor.py: every SoftQLinkSensorEntityDescription sets translation_key = the controller's "
"own parameter id (upper case, matched exactly): 'D_A_2_3' ('Salt range in days') and "
"'D_A_2_2' ('days until the next maintenance'), both UnitOfTime.DAYS, DIAGNOSTIC, enabled — "
"the same pair as the cloud integration, same leads. button.py has only "
"'manual_regeneration' / 'reset_error_memory' — no counter reset."
),
translation_keys_authoritative=True,
tasks=(
ConsumableSignature(("D_A_2_3",), "Refill Softener Salt", "duration_left", below_hours=168),
ConsumableSignature(("D_A_2_2",), "Annual Service", "duration_left", below_hours=336),
),
),
"polaris": IntegrationSignature(
name="Polaris IQ Home / Rusclimate (MQTT)",
verified="2026-10-03 @ samoswall/polaris-mqtt main (0db4fa1)",
source=(
"HACS polaris (polaris-mqtt: Polaris IQ Home and Rusclimate — Ballu / Electrolux / Royal "
"Thermo / Zanussi — appliances over MQTT). sensor.py PolarisSensor: entity_id "
"sensor.<class>_<model>_<description.name>, has_entity_name; const.py SENSORS_* carry a "
"translation_key; device model = '<class> - <model>'. The 'expendables' payload '[x,y]' "
"feeds the '… retain' sensors (= remaining, ru 'Остаток …'): tk 'filter_retain' = x — "
"HOURS on humidifiers (SENSORS_HUMIDIFIER; the vendor app's expendable_max 4392 h filter / "
"168 h tank), Polaris air cleaners (SENSORS_AIRCLEANER) and the Electrolux EPVS/ERVX "
"ventilation unit (SENSORS_VENTILATION, model 'ventilation - …' → the ventilation filter, "
"a week's lead), PERCENTAGE on the Ballu ONEAIR ASP-100/200 breezers (SENSORS_CLIMATE / "
"_200) and the Electrolux EAP purifier — the unit routes each entity to its direction; tk "
"'clean_retain' = y (HOURS, humidifiers except type 835: water-tank cleaning); tk "
"'pre_filter_retain' = y (PERCENTAGE, ASP-200); tk 'anode_retain' = x (UnitOfTime.DAYS, "
"water heaters 844/876/877). button.py: humidifiers (not 835/881) 'button_reset_filter' "
"(payload [0,0], 'Reset time filter') and 'button_reset_tank' ([1,0], 'Reset time water "
"tank'), Polaris air cleaners (not PAW-0804) 'button_reset_filter' ([0]) → wired on the "
"hour duties. The Ballu breezers' resets stay unwired: the ASP-100 sends [100], the "
"ASP-200 'button_reset_filter' [0,100] and 'button_reset_prefilter' [100,0] while "
"sensor.py reads the filter from index 0 and the pre-filter from index 1 — which counter "
"each ASP-200 button resets is not established. No anode reset exists. Skipped: the "
"vacuums' 'expendables' brush/filter/mop hours (used vs left not established)."
),
tasks=(
ConsumableSignature(
("filter_retain",),
"Replace Filter",
"duration_left",
models_exclude=("ventilation",),
resets=(("filter_retain", "button_reset_filter"),),
),
ConsumableSignature(("filter_retain",), "Replace Filter", "percent_left"),
ConsumableSignature(
("filter_retain",),
"Replace Ventilation Filter",
"duration_left",
below_hours=168,
models=("ventilation",),
),
ConsumableSignature(
("clean_retain",),
"Clean Tank",
"duration_left",
resets=(("clean_retain", "button_reset_tank"),),
),
ConsumableSignature(("pre_filter_retain",), "Clean Pre-Filter", "percent_left"),
ConsumableSignature(("anode_retain",), "Anode Rod Inspection", "duration_left", below_hours=336),
),
),
}
@@ -59,22 +59,34 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
"litterrobot": IntegrationSignature(
name="Litter-Robot",
verified="2026-07-18 @ home-assistant/core dev; re-checked 2026-09-27 @ home-assistant/core 2026.9",
verified="2026-07-18 @ home-assistant/core dev; re-checked 2026-09-27 @ home-assistant/core 2026.9; filter due date 2026-10-03 @ home-assistant/core 2026.10.0b0",
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). "
"2026-09 re-check, skipped: LR5 tk 'next_filter_replacement' (TIMESTAMP, "
"pylitterbot nextFilterReplacementDate — no catalog direction reads a due "
"date; its 'change_filter' button has no counter duty to reset), LR5 binary "
"'laser_dirty' (device_class problem → problem-sensor adoption), button "
"'reset' (LR4/LR5 robot reset, not a counter reset)."
"2026-10-03 (identical in 2026.9): LR5 tk 'next_filter_replacement' "
"(device_class TIMESTAMP, DIAGNOSTIC, enabled; pylitterbot LitterRobot5."
"next_filter_replacement_date = the cloud's nextFilterReplacementDate) → "
"due_date, now that the catalog reads due dates; button.py LR5 tk "
"'change_filter' (CONFIG; pylitterbot change_filter() 'Reset the filter "
"replacement counter', command CHANGE_FILTER) moves that date forward → the "
"duty's reset. Skipped: LR5 binary 'laser_dirty' (device_class problem → "
"problem-sensor adoption), button 'reset' (LR4/LR5 robot reset, not a counter "
"reset)."
),
tasks=(
ConsumableSignature(("waste_drawer",), "Empty Waste Drawer", "alert_above", delta_units=90, resets=(("waste_drawer", "reset_waste_drawer"),)),
ConsumableSignature(("litter_level",), "Refill Litter", "percent_left"),
ConsumableSignature(("total_cycles",), "Wash Litter Box", "usage_delta", delta_units=150),
# LR5 filter: a week's lead time to order one (as for Vitesy's filter).
ConsumableSignature(
("next_filter_replacement",),
"Replace Filter",
"due_date",
days_before=7,
resets=(("next_filter_replacement", "change_filter"),),
),
),
),
"petlibro": IntegrationSignature(
@@ -159,4 +171,30 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
),
),
# --- Round 16 (2026-10-03) --------------------------------------------
"neakasa": IntegrationSignature(
name="Neakasa litter box",
verified="2026-10-03 @ tabascoz/hass-neakasa main (0052c0a)",
source=(
"HACS neakasa (custom repository; Neakasa M1) — every entity class sets "
"_attr_translation_key: sensor/sand_percent.py tk 'sand_percent' ('Cat litter level', "
"PERCENTAGE = the device's sand level percent) → Refill Litter at the household floor (the "
"Litter-Robot precedent); binary_sensor/bin_full.py tk 'bin_full' ('Garbage can full' = "
"property binFullWaitReset == 1, NO device_class → not adoptable as a problem sensor) → "
"latch on 'on', cleared once the bin is emptied. Skipped: the 'bin_state' / 'sand_state' "
"ENUMs (the same two signals as text) and the clean / level buttons (actions, not "
"counter resets)."
),
translation_keys_authoritative=True,
tasks=(
ConsumableSignature(("sand_percent",), "Refill Litter", "percent_left"),
ConsumableSignature(
("bin_full",),
"Empty Waste Drawer",
"event_present",
entity_domain="binary_sensor",
on_states=("on",),
),
),
),
}
@@ -238,7 +238,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
"brother": IntegrationSignature(
name="Brother printer",
verified="2026-09-25 @ home-assistant/core dev",
verified="2026-09-25 @ home-assistant/core dev; skips re-checked 2026-10-03 @ home-assistant/core 2026.10.0b0",
source=(
"home-assistant/core homeassistant/components/brother/sensor.py "
"(*_toner_remaining / *_ink_remaining / *_remaining_life, PERCENTAGE). "
@@ -246,7 +246,11 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"ink_capture_box_remaining_life (dev, 2026-09; strings.json 'Ink capture "
"box remaining lifetime') = the waste-ink box → the maintenance-box duty. "
"laser_remaining_life, pf_kit_1_remaining_life, pf_kit_mp_remaining_life "
"(paper-feed kits, tray 1 / multipurpose tray)."
"(paper-feed kits, tray 1 / multipurpose tray). 2026-10-03 skips: the unitless "
"'*drum_remaining_pages' / '*drum_page_counter' page counts describe the same "
"drums as the % duty (one signal per part); binary_sensor.py (new in 2026.10: "
"low/no toner or ink, overdue_prevent_maint, jams, trays, door) is device_class "
"problem throughout → problem-sensor adoption."
),
tasks=(
ConsumableSignature(
@@ -92,7 +92,12 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"filter is reset on the purifier) → usage_above at 4,320 h = the "
"cluster's default filter_life_time of 259,200 min (the "
"DIRIGERA/STARKVIND duty). The 'replace_filter' binary is "
"device_class problem (adoption path); no reset button."
"device_class problem (adoption path); no reset button. "
"Re-checked 2026-10-03 @ core 2026.10.0b0 / zha 2.3.0: unchanged; "
"IkeaDeviceRunTime 'device_run_time' is the purifier's own "
"lifetime, not the filter's, and the zhaquirks 'lifetime' sensor "
"(Schneider W599001 smoke alarm, half-years, meaning undocumented) "
"is not signed."
),
tasks=(
LOCK_CYLINDER_CYCLES,
@@ -148,4 +153,9 @@ SIGNATURES: dict[str, IntegrationSignature] = {
LOCK_CYLINDER_CYCLES,
),
),
# Reviewed 2026-10-03 @ core 2026.10.0b0 (round 16), not signed: homee —
# 'replace_filter' (AttributeType.REPLACE_FILTER_ALARM) is a problem-class
# binary (adoption path); 'operating_hours' (DURATION, TOTAL_INCREASING)
# sits on whatever node the hub bridges, so no duty fits it without a
# device-type gate; the 'reset_meter' buttons reset energy meters.
}
@@ -230,7 +230,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
"tuya": IntegrationSignature(
name="Tuya vacuum",
verified="2026-09-25 @ home-assistant/core dev",
verified="2026-09-25 @ home-assistant/core dev; skips re-checked 2026-10-03 @ home-assistant/core 2026.10.0b0",
source=(
"home-assistant/core homeassistant/components/tuya/vacuum.py (vacuum "
"platform) — the ENGINE accumulates cleaning time, entity_domain-gated so "
@@ -248,7 +248,11 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"used-vs-remaining undetermined) and CWYSJ fountain 'filter_duration' (DP "
"filter_life 'hours', direction undetermined). The runtime duties stay: "
"washing the filter / de-tangling the brush is a separate duty from "
"replacing the part at end of life."
"replacing the part at end of life. 2026-10-03 skips: CWYSJ 'uv_runtime' "
"(DURATION, disabled by default, device-defined unit, direction undocumented; "
"the fountain's filter/pump/water resets are SWITCHES, not buttons, and UV has "
"none) and the binary 'filter_cleaning' (a device_class problem fault bit → "
"problem-sensor adoption)."
),
tasks=(
# 2.95: each counter has its reset_* button (button.py category SD).
@@ -275,7 +279,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
"smartthings": IntegrationSignature(
name="SmartThings",
verified="2026-07-18 (vacuum) / 2026-07-20 (filters) @ home-assistant/core dev; dust bag 2026-09-27 @ home-assistant/core 2026.9",
verified="2026-07-18 (vacuum) / 2026-07-20 (filters) @ home-assistant/core dev; dust bag 2026-09-27 @ home-assistant/core 2026.9; skips re-checked 2026-10-03 @ home-assistant/core 2026.10.0b0",
source=(
"home-assistant/core homeassistant/components/smartthings/vacuum.py "
"(vacuum platform verified present; no consumable sensors) — the "
@@ -291,7 +295,9 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"/ 'filter_status' (device_class problem → problem-sensor adoption), "
"'stick_cleaner_dust_bag_usage' (unitless TOTAL_INCREASING bag cycles, reset "
"semantics not established) and button 'reset_hepa_filter' (custom.hepaFilter — "
"no sensor exposes that filter, core 2026.9 nor dev, so there is no duty to wire)."
"no sensor exposes that filter, core 2026.9 nor dev, so there is no duty to wire). "
"2026-10-03: the washer's 'microfiber_filter_job_state' / "
"'microfiber_filter_operating_state' ENUMs are operating status — skipped."
),
tasks=(
VACUUM_FILTER_CLEANING_RUNTIME,
@@ -133,4 +133,18 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
tasks=(CHARGER_CABLE_ETO,),
),
# --- Round 16 (2026-10-03) --------------------------------------------
"wattpilot": IntegrationSignature(
name="Fronius Wattpilot",
verified="2026-10-03 @ mk-maddin/wattpilot-HA master (507a066)",
source=(
"HACS wattpilot (custom repository) sensor.yaml: property 'eto' ('Totally Charged' — "
"'Totally charged power', device_class energy, state_class total, unit Wh; the go-e API "
"lifetime energy the catalogued go-e entries use); entities.py names it "
"f'{charger} Totally Charged' (no has_entity_name, no translation_key) → suffix "
"_totally_charged. The unit map converts the 5,000 kWh target into Wh. 'wh' ('Connection "
"Charged', energy since the car was connected) is per session — not used."
),
tasks=(ConsumableSignature(("totally_charged",), "Inspect Cable and Plug", "usage_delta", delta_units=5000),),
),
}