393 files

This commit is contained in:
Home Assistant Version Control
2026-09-27 17:16:54 +00:00
parent b05b7a897e
commit 39d97ac2db
404 changed files with 54006 additions and 11034 deletions
@@ -21,7 +21,7 @@ import logging
from collections.abc import Callable
from typing import Any
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.core import Context, Event, EventOrigin, HomeAssistant, callback
from ..const import (
CONF_TASKS,
@@ -29,7 +29,7 @@ from ..const import (
EVENT_TASK_COMPLETED,
GLOBAL_UNIQUE_ID,
)
from .sanitize import _FORBIDDEN_ACTION_DOMAINS
from .sanitize import _FORBIDDEN_ACTION_DOMAINS, ACTION_OWNER_KEY
_LOGGER = logging.getLogger(__name__)
@@ -77,8 +77,24 @@ async def _dispatch_action(hass: HomeAssistant, action: dict[str, Any]) -> None:
return
data = action.get("data") if isinstance(action.get("data"), dict) else None
target = action.get("target") if isinstance(action.get("target"), dict) else None
# Run as the user who configured it: HA then refuses admin-only services
# (cloud.remote_connect, downloader, …) to an operator. Without a user
# the call ran with system rights (bug audit 2026-09-26). Actions saved
# before the stamp existed (or by an import/admin flow) keep running as
# before; a removed user's action is not run in their name.
context: Context | None = None
owner = action.get(ACTION_OWNER_KEY)
if isinstance(owner, str) and owner:
if await hass.auth.async_get_user(owner) is None:
_LOGGER.warning(
"on_complete_action %s.%s skipped: the user who configured it no longer exists — save the task again",
domain,
name,
)
return
context = Context(user_id=owner)
try:
await hass.services.async_call(domain, name, service_data=data, target=target, blocking=False)
await hass.services.async_call(domain, name, service_data=data, target=target, blocking=False, context=context)
except Exception:
_LOGGER.exception(
"on_complete_action service-call failed: %s.%s data=%r target=%r",
@@ -98,6 +114,15 @@ def register_action_listener(hass: HomeAssistant) -> Callable[[], None]:
"""
async def _on_task_completed(event: Event) -> None:
# Only OUR completions: the coordinator fires the event locally. The
# same event type can be fired from outside — the REST/WebSocket
# fire_event API or a mobile_app webhook (any logged-in user's phone)
# — and a forged payload naming a task ran its configured service
# call without the task ever being completed (bug audit 2026-09-26,
# SEC-7). Automations wired on the event itself are unaffected.
if event.origin is not EventOrigin.local:
_LOGGER.warning("Ignoring a %s event fired from outside Home Assistant (origin %s)", EVENT_TASK_COMPLETED, event.origin)
return
entry_id = event.data.get("entry_id")
task_id = event.data.get("task_id")
if not entry_id or not task_id:
@@ -7,12 +7,24 @@ sensors compute their counts here, so the numbers can never diverge.
from __future__ import annotations
from collections.abc import Iterator, Mapping
from typing import TYPE_CHECKING, Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from ..const import CONF_OBJECT, CONF_TASKS, DOMAIN, GLOBAL_UNIQUE_ID, MaintenanceStatus
from ..const import (
CONF_OBJECT,
CONF_TASKS,
DEFAULT_TASK_PRIORITY,
DOMAIN,
GLOBAL_UNIQUE_ID,
MaintenanceStatus,
TaskPriority,
slugify_object_name,
task_unique_id,
)
if TYPE_CHECKING:
from .. import MaintenanceSupporterData
@@ -27,6 +39,17 @@ _COUNTED_STATUSES = (
MaintenanceStatus.OK,
)
# Sort rank of a task priority — high-priority work first (#134). ONE table
# for the notification manager's daily-limit fairness and the Assist
# "what is due?" ordering, which kept its own inline copy (DRY audit
# 2026-09-26 B). An unknown value ranks like "normal".
PRIORITY_RANK: dict[str, int] = {TaskPriority.HIGH: 0, TaskPriority.NORMAL: 1, TaskPriority.LOW: 2}
def priority_rank(priority: Any) -> int:
"""The :data:`PRIORITY_RANK` of a stored priority value (unset = normal)."""
return PRIORITY_RANK.get(str(priority or DEFAULT_TASK_PRIORITY), PRIORITY_RANK[TaskPriority.NORMAL])
def is_object_entry(entry: ConfigEntry | None) -> bool:
"""True iff *entry* is one of OUR object entries (not None, not another
@@ -48,6 +71,33 @@ def object_name(entry: ConfigEntry) -> str:
return str((entry.data.get(CONF_OBJECT) or {}).get("name") or entry.title)
def object_slug(obj_data: Mapping[str, Any]) -> str:
"""The object slug every per-task entity's unique_id is built from.
THE registration rule of the sensor / binary_sensor / button platforms
(``name`` as stored, ``"unknown"`` only when the key is missing). The
registry LOOKUPS of the task sensor (coordinator, list_tasks, the
notification context, the logbook) each derived it on their own and two
had drifted — an object with an empty name resolved to no sensor there
(DRY audit 2026-09-26 B). A non-string name (never registered) reads
like a missing one instead of raising.
"""
name = obj_data.get("name", "unknown")
return slugify_object_name(name if isinstance(name, str) else "unknown")
def task_entity_id(hass: HomeAssistant, slug: str, task_id: str, *, platform: str = "sensor", suffix: str = "") -> str | None:
"""The REGISTERED entity_id of a per-task entity (users rename them), or
None when the registry does not know it. ``slug`` = :func:`object_slug`."""
return er.async_get(hass).async_get_entity_id(platform, DOMAIN, task_unique_id(slug, task_id, suffix))
def task_sensor_entity_id(hass: HomeAssistant, obj_data: Mapping[str, Any], task_id: str) -> str | None:
"""The registered entity_id of a task's status sensor, from the object's
stored data — the one lookup behind every "which sensor is this task?"."""
return task_entity_id(hass, object_slug(obj_data), task_id)
def merged_tasks(entry: ConfigEntry) -> dict[str, Any]:
"""Merged task data (static ConfigEntry + dynamic Store) for an entry.
@@ -106,6 +156,30 @@ def get_coordinator_data(hass: HomeAssistant, entry_id: str) -> dict[str, Any] |
return coordinator.data if coordinator is not None else None
def iter_live_tasks(hass: HomeAssistant, entry_id: str | None = None) -> Iterator[tuple[ConfigEntry, str, dict[str, Any]]]:
"""``(entry, task_id, computed task)`` for every non-archived task of
every LOADED object (optionally one object only).
The coordinator's computed payload, so ``_status`` / ``_next_due`` reflect
the Store rather than stale entry data. An object that is not loaded
(disabled, setup retry, mid-reload) has no coordinator data and
contributes nothing. THE cross-object walk behind the ``list_tasks``
service and the Assist task snapshot, which each carried a copy of it
(DRY audit 2026-09-26 B).
"""
for entry in get_object_entries(hass):
if entry_id is not None and entry.entry_id != entry_id:
continue
rd = getattr(entry, "runtime_data", None)
coordinator = getattr(rd, "coordinator", None) if rd else None
if coordinator is None or not coordinator.data:
continue
for task_id, task in coordinator.data.get(CONF_TASKS, {}).items():
if str(task.get("_status", "")) == MaintenanceStatus.ARCHIVED:
continue
yield entry, task_id, task
def compute_status_counts(hass: HomeAssistant) -> dict[str, Any]:
"""Aggregate task status counts across every maintenance object.
@@ -484,9 +484,9 @@ def get_battery_recovered_percent(hass: HomeAssistant) -> int:
"""#180: the level a low battery must rise ABOVE to count as recovered
(replaced). Out-of-range / junk values fall back to the default."""
from ..const import BATTERY_RECOVERED_PERCENT_RANGE, CONF_BATTERY_RECOVERED_PERCENT, DEFAULT_BATTERY_RECOVERED_PERCENT
from .global_options import get_global_options
from .global_options import global_option
raw = get_global_options(hass).get(CONF_BATTERY_RECOVERED_PERCENT, DEFAULT_BATTERY_RECOVERED_PERCENT)
raw = global_option(hass, CONF_BATTERY_RECOVERED_PERCENT)
try:
value = int(raw)
except (TypeError, ValueError):
@@ -501,10 +501,9 @@ def get_battery_auto_record_recovery(hass: HomeAssistant) -> bool:
type's cells from stock, see :func:`_schedule_auto_record`). Advanced
option, off by default."""
from ..const import CONF_BATTERY_AUTO_RECORD_RECOVERY
from .global_options import get_global_options
from .settings_registry import setting_default
from .global_options import global_option
return bool(get_global_options(hass).get(CONF_BATTERY_AUTO_RECORD_RECOVERY, setting_default(CONF_BATTERY_AUTO_RECORD_RECOVERY)))
return bool(global_option(hass, CONF_BATTERY_AUTO_RECORD_RECOVERY))
# ── #180: the low-recovery latch ─────────────────────────────────────────────
@@ -42,6 +42,7 @@ from .battery_fleet import (
read_batteries,
release_low_latch,
)
from .entry_tasks import write_task, write_tasks
from .global_options import get_default_warning_days
from .trigger_fallback import threshold_limits_overlap
@@ -605,12 +606,13 @@ def fleet_task_trigger_ok(entry: ConfigEntry) -> bool:
stored with only the plural ``entity_ids``); without it the task never
fires or auto-completes. This is the health signal behind the repair path.
"""
from ..entity.triggers import normalize_entity_ids
found = find_fleet_task(entry)
if found is None:
return False
tc = found[1].get("trigger_config") or {}
eids = tc.get("entity_ids") or ([tc["entity_id"]] if tc.get("entity_id") else [])
return tc.get("type") == "threshold" and LOW_COUNT_ENTITY_ID in eids
return tc.get("type") == "threshold" and LOW_COUNT_ENTITY_ID in normalize_entity_ids(tc)
async def _reconcile_fleet_task(hass: HomeAssistant, entry: ConfigEntry, lang: str) -> bool:
@@ -630,11 +632,7 @@ async def _reconcile_fleet_task(hass: HomeAssistant, entry: ConfigEntry, lang: s
task_id, task_data = found
new_task = dict(task_data)
new_task["trigger_config"] = _fleet_trigger_config()
new_data = dict(entry.data)
new_tasks = dict(new_data.get(CONF_TASKS, {}))
new_tasks[task_id] = new_task
new_data[CONF_TASKS] = new_tasks
hass.config_entries.async_update_entry(entry, data=new_data)
write_task(hass, entry, task_id, new_task)
await hass.config_entries.async_reload(entry.entry_id)
return True
@@ -652,23 +650,20 @@ def _heal_fleet_trigger_recovery_flag(hass: HomeAssistant, entry: ConfigEntry) -
entity list stays untouched. Runs at every start, so restored backups
from old exports heal too.
"""
from ..entity.triggers import normalize_entity_ids
found = find_fleet_task(entry)
if found is None:
return False
task_id, task_data = found
tc = task_data.get("trigger_config") or {}
eids = tc.get("entity_ids") or ([tc["entity_id"]] if tc.get("entity_id") else [])
if tc.get("type") != "threshold" or LOW_COUNT_ENTITY_ID not in eids:
if tc.get("type") != "threshold" or LOW_COUNT_ENTITY_ID not in normalize_entity_ids(tc):
return False
if tc.get("auto_complete_on_recovery"):
return False
new_task = dict(task_data)
new_task["trigger_config"] = {**tc, "auto_complete_on_recovery": True}
new_data = dict(entry.data)
new_tasks = dict(new_data.get(CONF_TASKS, {}))
new_tasks[task_id] = new_task
new_data[CONF_TASKS] = new_tasks
hass.config_entries.async_update_entry(entry, data=new_data)
write_task(hass, entry, task_id, new_task)
return True
@@ -1010,7 +1005,7 @@ async def migrate_fleet_part_ids(hass: HomeAssistant, entry: ConfigEntry) -> dic
continue
repointed, changed = _repoint_tasks(other_tasks, moves, owner_id=entry.entry_id, foreign=True)
if changed:
hass.config_entries.async_update_entry(other, data={**other.data, CONF_TASKS: repointed})
write_tasks(hass, other, repointed)
other_store = getattr(getattr(other, "runtime_data", None), "store", None) or stores.get(other.entry_id)
if other_store is not None and _repoint_store_history(other_store, list(other_tasks), moves, owner_id=entry.entry_id, foreign=True):
await other_store.async_save()
@@ -4,6 +4,7 @@ from __future__ import annotations
import csv
import io
import json
import logging
import re
from typing import Any
@@ -20,16 +21,25 @@ from ..const import (
MAX_CHECKLIST_ITEMS,
)
from .aggregate import merged_tasks
from .dates import INTERVAL_UNITS, normalize_hhmm
from .dates import INTERVAL_UNITS, normalize_hhmm, parse_iso_date
from .global_options import get_default_warning_days
from .pause import write_anchor
from .reading_slots import parse_reading_slots_text, reading_slots_text
from .schedule import read_legacy_fields
from .schedule import Schedule, read_legacy_fields
_LOGGER = logging.getLogger(__name__)
# CSV column order
_COLUMNS = [
# Bug audit 2026-09-27: the source object's entry id keys the rows of ONE
# object — grouping by name merged a replaced pair (same name) into one
# object, the archived predecessor's tasks coming back active. With the
# archive marker and the predecessor it also carries the replace lineage
# (remapped to the new ids on import). Old CSVs without these columns
# still group by name.
"object_entry_id",
"object_archived_at",
"object_predecessor_entry_id",
"object_name",
"object_manufacturer",
"object_model",
@@ -50,6 +60,11 @@ _COLUMNS = [
"interval_unit",
"due_date",
"interval_anchor",
# Bug audit 2026-09-27: the nested recurrence as JSON — the flat columns
# above cannot express a calendar kind (a day-of-month task came back as
# "manual", never due), a seasonal window or a series end. The flat
# cells stay (and win for an interval edited in a spreadsheet).
"schedule",
"schedule_time",
"reading_unit",
# #161 phase 2: reading slots, one "Name | Unit" per line (ids are
@@ -110,6 +125,9 @@ def export_objects_csv(hass: HomeAssistant, entry_ids: set[str] | None = None) -
sched = read_legacy_fields(tdata)
writer.writerow(
{
"object_entry_id": entry.entry_id,
"object_archived_at": _csv_safe(str(obj_data.get("archived_at") or "")),
"object_predecessor_entry_id": _csv_safe(str(obj_data.get("predecessor_entry_id") or "")),
"object_name": _csv_safe(obj_data.get("name", "")),
"object_manufacturer": _csv_safe(obj_data.get("manufacturer", "")),
"object_model": _csv_safe(obj_data.get("model", "")),
@@ -120,18 +138,22 @@ def export_objects_csv(hass: HomeAssistant, entry_ids: set[str] | None = None) -
"object_documentation_url": _csv_safe(obj_data.get("documentation_url") or ""),
"object_notes": _csv_safe(obj_data.get("notes") or ""),
"task_name": _csv_safe(tdata.get("name", "")),
"task_type": tdata.get("type", "custom"),
# Free text over the WS API ("=HYPERLINK(…)" was exported
# raw — bug audit 2026-09-26); the dates were only
# length-checked on the service path until then too.
"task_type": _csv_safe(str(tdata.get("type") or "custom")),
"enabled": tdata.get("enabled", True),
"schedule_type": sched["schedule_type"],
"interval_days": sched["interval_days"] if sched["interval_days"] is not None else "",
"interval_unit": sched["interval_unit"],
"due_date": sched["due_date"] or "",
"due_date": _csv_safe(str(sched["due_date"] or "")),
"interval_anchor": sched["interval_anchor"],
"schedule": json.dumps(Schedule.parse(tdata).to_dict(), separators=(",", ":")),
"schedule_time": tdata.get("schedule_time", ""),
"reading_unit": _csv_safe(tdata.get("reading_unit", "")),
"readings": "\n".join(_csv_safe(line) for line in reading_slots_text(tdata.get("readings")).splitlines()),
"warning_days": tdata.get("warning_days", DEFAULT_WARNING_DAYS),
"last_performed": tdata.get("last_performed", ""),
"last_performed": _csv_safe(str(tdata.get("last_performed") or "")),
"notes": _csv_safe(tdata.get("notes", "")),
"documentation_url": _csv_safe(tdata.get("documentation_url", "")),
"custom_icon": _csv_safe(tdata.get("custom_icon", "")),
@@ -219,21 +241,37 @@ def import_objects_csv(
bare constant ``7`` is used.
Returns a list of objects, each with 'object' and 'tasks' dicts
matching the format expected by the config flow.
matching the format expected by the config flow. A row set with the
``object_entry_id`` column also carries the source ``entry_id``, the
archive marker and the (source) ``predecessor_entry_id`` — the importer
remaps those to the new entry ids — and per-object ``warnings`` for
values that were dropped.
"""
from homeassistant.util import dt as dt_util
from ..const import ARCHIVE_REASON_OBJECT
default_warning_days = get_default_warning_days(hass) if hass is not None else 7
today = dt_util.now().date()
reader = csv.DictReader(io.StringIO(csv_content))
# Group rows by object name
# Group rows by the source object (its entry id), or — for a CSV written
# before that column existed — by object name.
objects_map: dict[str, dict[str, Any]] = {}
for row in reader:
obj_name = (row.get("object_name") or "").strip()
if not obj_name:
continue
source_id = (row.get("object_entry_id") or "").strip()
group = f"id:{source_id}" if source_id else f"name:{obj_name}"
if obj_name not in objects_map:
objects_map[obj_name] = {
if group not in objects_map:
archived_at = (row.get("object_archived_at") or "").strip().lstrip("\t")
predecessor = (row.get("object_predecessor_entry_id") or "").strip()
objects_map[group] = {
"entry_id": source_id or None,
"warnings": [],
"object": {
"id": uuid4().hex,
"name": obj_name,
@@ -247,10 +285,13 @@ def import_objects_csv(
# import until now (round-trip gap, audit 2026-07-11).
"documentation_url": (row.get("object_documentation_url") or "").strip() or None,
"notes": (row.get("object_notes") or "").strip() or None,
"archived_at": archived_at if _is_iso_moment(archived_at) else None,
"predecessor_entry_id": predecessor or None,
"task_ids": [],
},
"tasks": {},
}
current = objects_map[group]
task_name = (row.get("task_name") or "").strip()
if not task_name:
@@ -259,7 +300,7 @@ def import_objects_csv(
task_id = uuid4().hex
task_data: dict[str, Any] = {
"id": task_id,
"object_id": objects_map[obj_name]["object"]["id"],
"object_id": current["object"]["id"],
"name": task_name,
"type": (row.get("task_type") or "custom").strip(),
"enabled": True,
@@ -284,6 +325,19 @@ def import_objects_csv(
if anchor in ("planned", "completion"):
task_data["interval_anchor"] = anchor
# The nested recurrence (calendar kinds, season, series end). The
# flat cells above stay alongside: normalize_task_storage keeps a
# calendar kind authoritative and lets edited flat interval cells win
# over an interval schedule while keeping its extras.
schedule_raw = (row.get("schedule") or "").strip()
if schedule_raw:
try:
parsed_schedule = json.loads(schedule_raw)
if isinstance(parsed_schedule, dict):
task_data["schedule"] = Schedule.from_dict(parsed_schedule).to_dict()
except (ValueError, TypeError, KeyError, AttributeError):
current["warnings"].append(f"{task_name}: unreadable schedule cell — the flat columns were used")
# schedule_time round-trip, canonical HH:MM ("HH:MM:SS" from the
# options flow folds); malformed values are dropped silently
# (consistent with other CSV import fields).
@@ -302,7 +356,14 @@ def import_objects_csv(
last_performed = (row.get("last_performed") or "").strip()
if last_performed:
write_anchor(task_data, last_performed)
lp_date = parse_iso_date(last_performed)
if lp_date is not None and lp_date > today:
# A day the work WAS done cannot lie ahead — a far-future one
# overflowed the schedule math and kept the object in
# setup-retry (bug audit 2026-09-27).
current["warnings"].append(f"{task_name}: last performed date {last_performed} is in the future — dropped")
else:
write_anchor(task_data, last_performed)
notes = (row.get("notes") or "").strip()
if notes:
@@ -313,10 +374,9 @@ def import_objects_csv(
task_data["enabled"] = False
doc_url = (row.get("documentation_url") or "").strip()
if doc_url:
from urllib.parse import urlparse
from .url_safety import is_safe_url
scheme = urlparse(doc_url).scheme.lower()
if scheme in ("", "http", "https"):
if is_safe_url(doc_url): # the "//host" form got through the old scheme check
task_data["documentation_url"] = doc_url
custom_icon = (row.get("custom_icon") or "").strip()
if custom_icon:
@@ -351,12 +411,32 @@ def import_objects_csv(
if items:
task_data["checklist"] = items
objects_map[obj_name]["tasks"][task_id] = task_data
objects_map[obj_name]["object"]["task_ids"].append(task_id)
# An archived object's tasks are archived WITH it (reason object, so
# an unarchive restores them): the CSV carries no per-task archive
# state, and an archived object with live tasks kept notifying.
if current["object"].get("archived_at"):
task_data["archived_at"] = current["object"]["archived_at"]
task_data["archived_reason"] = ARCHIVE_REASON_OBJECT
current["tasks"][task_id] = task_data
current["object"]["task_ids"].append(task_id)
return list(objects_map.values())
def _is_iso_moment(value: str) -> bool:
"""An ISO date / datetime string (the archive marker's shape)."""
from datetime import datetime
if not value:
return False
try:
datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return False
return True
def _safe_int(value: str | None, default: int | None) -> int | None:
"""Safely convert a string to int."""
if value is None:
@@ -116,6 +116,22 @@ def add_interval(anchor: date, n: int, unit: str = "days") -> date:
return anchor + timedelta(days=n)
def try_add_interval(anchor: date, n: int, unit: str = "days") -> date | None:
""":func:`add_interval`, or ``None`` when the result would lie past the
calendar's end (year 9999).
``add_interval`` raises OverflowError (day/week steps) or ValueError
(month/year steps) there. A far-future reset date or an absurd interval
reached it from inside the coordinator refresh and took the whole object
down on every refresh and every restart (bug audit 2026-09-27) — the
schedule reads such a date as "no due date" instead.
"""
try:
return add_interval(anchor, n, unit)
except (OverflowError, ValueError):
return None
def nth_weekday_of_month(year: int, month: int, nth: int, weekday: int) -> date | None:
"""The ``nth`` ``weekday`` of ``(year, month)``; ``None`` if it doesn't exist.
@@ -239,4 +255,8 @@ def interval_span_days(n: int | None, unit: str = "days") -> int:
if not n or n <= 0:
return 0
ref = date(2001, 1, 1) # common (non-leap) year; representative span
return (add_interval(ref, n, unit) - ref).days
end = try_add_interval(ref, n, unit)
# An interval reaching past year 9999 (read-path garbage — the schedule
# clamps what it stores) spans "everything left", never a crash in the
# warning-window math of every refresh (bug audit 2026-09-27).
return ((end or date.max) - ref).days
@@ -111,11 +111,14 @@ def _object_part_ids(hass: HomeAssistant, object_id: str) -> set[str]:
return set()
def build_documents_archive(hass: HomeAssistant, entry_ids: set[str] | None = None) -> bytes:
"""Build a documents ZIP for the selected objects (None = all).
class ArchiveTooLarge(ValueError):
"""The selection's files exceed what an import accepts (MAX_ARCHIVE_BYTES)."""
Runs on the event loop for the metadata gather (reads config entries) but
the heavy blob reads happen here synchronously — call via the executor.
def _gather_archive(hass: HomeAssistant, entry_ids: set[str] | None) -> tuple[Any, dict[str, Any], list[str]]:
"""The manifest and the blob digests of the selected objects (None = all).
Metadata only — reads config entries and the document store.
"""
from ..const import CONF_OBJECT
from ..export import object_entries
@@ -141,25 +144,89 @@ def build_documents_archive(hass: HomeAssistant, entry_ids: set[str] | None = No
if docs:
manifest_objects.append({"object_id": object_id, "object_name": obj.get("name", ""), "documents": docs})
manifest = {"version": ARCHIVE_VERSION, "objects": manifest_objects}
return store, {"version": ARCHIVE_VERSION, "objects": manifest_objects}, sorted(blob_hashes)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
def _write_archive(store: Any, manifest: dict[str, Any], blob_hashes: list[str], target: Any) -> None:
"""Write the ZIP to ``target`` (a path or binary file object) — blocking.
Each blob is streamed from disk into the archive (``ZipFile.write``), so
memory stays flat however large the documents are. Refuses up front a
selection whose files exceed MAX_ARCHIVE_BYTES: the import refuses such
an archive anyway, and the export used to assemble the whole ZIP in
memory with no ceiling (bug audit 2026-09-26).
"""
blobs: list[tuple[str, Any]] = []
total = 0
for h in blob_hashes:
if store is None:
break
try:
path = store.blob_path(h)
except ValueError:
continue
if not path.is_file():
_LOGGER.warning("Documents archive: blob %s missing on disk, skipped", h[:12])
continue
total += path.stat().st_size
if total > MAX_ARCHIVE_BYTES:
raise ArchiveTooLarge(
f"The selected documents exceed {MAX_ARCHIVE_BYTES // (1024 * 1024)} MB, "
"more than an archive import accepts — export fewer objects at a time."
)
blobs.append((h, path))
with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr(MANIFEST_NAME, json.dumps(manifest, ensure_ascii=False, indent=2))
for h in sorted(blob_hashes):
if store is None:
break
try:
path = store.blob_path(h)
except ValueError:
continue
if path.is_file():
zf.writestr(f"{BLOB_DIR}{h}", path.read_bytes())
else:
_LOGGER.warning("Documents archive: blob %s missing on disk, skipped", h[:12])
for h, path in blobs:
zf.write(path, arcname=f"{BLOB_DIR}{h}")
def build_documents_archive(hass: HomeAssistant, entry_ids: set[str] | None = None) -> bytes:
"""Build a documents ZIP in memory for the selected objects (None = all).
Blocking — call via the executor. The HTTP export streams through
:func:`async_build_documents_archive_file` instead; this in-memory form
stays for tooling and tests.
"""
store, manifest, blob_hashes = _gather_archive(hass, entry_ids)
buf = io.BytesIO()
_write_archive(store, manifest, blob_hashes, buf)
return buf.getvalue()
async def async_build_documents_archive_file(hass: HomeAssistant, entry_ids: set[str] | None = None) -> str:
"""Write the documents ZIP to a temporary file and return its path.
The caller streams it out and deletes it. Raises :class:`ArchiveTooLarge`
(nothing left behind) when the selection exceeds the import ceiling.
"""
import os
import tempfile
store, manifest, blob_hashes = _gather_archive(hass, entry_ids)
def _write() -> str:
fd, path = tempfile.mkstemp(prefix="maintenance-documents-", suffix=".zip")
os.close(fd)
try:
_write_archive(store, manifest, blob_hashes, path)
except BaseException:
os.unlink(path)
raise
return path
return await hass.async_add_executor_job(_write)
def _doc_key(doc: dict[str, Any]) -> tuple[str | None, str | None]:
"""A document's identity for the idempotent re-import: kind + content
hash (file) or URL (link). Only strings count — a list-valued URL in a
crafted manifest was unhashable and crashed the set lookup."""
kind = doc.get("kind")
ref = doc.get("hash") or doc.get("url")
return (kind if isinstance(kind, str) else None, ref if isinstance(ref, str) else None)
async def import_documents_archive(hass: HomeAssistant, data: bytes) -> dict[str, Any]:
"""Restore a documents ZIP: write blobs back, re-attach metadata.
@@ -203,67 +270,78 @@ async def import_documents_archive(hass: HomeAssistant, data: bytes) -> dict[str
except (zipfile.BadZipFile, ValueError, json.JSONDecodeError, KeyError) as err:
return {"error": f"invalid archive: {err}"}
# The manifest is untrusted JSON: a list at the top, an "objects" that is
# no list or a "documents" that is a number raised AttributeError /
# TypeError past the handler above — a bare 500 instead of the clean
# error (bug audit 2026-09-27). Wrong shapes are refused or skipped.
if not isinstance(manifest, dict):
return {"error": "invalid archive: the manifest is not an object"}
raw_objects = manifest.get("objects")
manifest_objects: list[tuple[dict[str, Any], list[Any]]] = [
(obj, docs)
for obj in (raw_objects if isinstance(raw_objects, list) else [])
if isinstance(obj, dict) and isinstance(docs := obj.get("documents", []), list)
]
# 1) Write back only blobs a manifest document actually references — an
# archive carrying extra blobs must not litter /config with orphans that
# ride every HA backup and are never refcounted (disk-fill hardening).
referenced: set[str] = set()
for obj in manifest.get("objects", []):
if not isinstance(obj, dict):
continue
for m in obj.get("documents", []):
for _obj, docs in manifest_objects:
for m in docs:
if isinstance(m, dict) and isinstance(m.get("hash"), str):
referenced.add(m["hash"])
import hashlib
written = 0
for digest, content in blobs.items():
if digest not in referenced:
_LOGGER.info("Documents archive: blob %s referenced by no document, skipped", digest[:12])
continue
if hashlib.sha256(content).hexdigest() != digest:
_LOGGER.warning("Documents archive: blob %s failed hash check, skipped", digest[:12])
continue
_, wrote_new = await hass.async_add_executor_job(store._store_blob_sync, content)
if wrote_new:
written += 1
store.notify_blob_added(digest)
# 2) Re-attach metadata to the matching object (id, then name).
ids, by_name = _object_name_map(hass)
docs_created = 0
objects_matched = 0
# old → new document ids across every restored object, so history photos
# and part doc links that pointed at the archived ids follow (the JSON
# importer does the same for its own restore).
doc_id_map: dict[str, str] = {}
for obj in manifest.get("objects", []):
if not isinstance(obj, dict):
continue
target = ids.get(str(obj.get("object_id") or "")) or by_name.get(str(obj.get("object_name") or ""))
if target is None:
_LOGGER.info("Documents archive: no object matches %r, its docs skipped", obj.get("object_name"))
continue
objects_matched += 1
# Skip docs already present on the target (idempotent re-import).
existing = store.for_object(target)
existing_keys = {(d.get("kind"), d.get("hash") or d.get("url")) for d in existing}
fresh = [
m
for m in obj.get("documents", [])
if isinstance(m, dict) and (m.get("kind"), m.get("hash") or m.get("url")) not in existing_keys
]
if fresh:
# Keep task links that still resolve on the target (a same-instance
# restore) via an identity map over the object's current task ids;
# a cross-instance restore has fresh task ids, so those links drop
# here and are re-established by the JSON import's remap instead.
valid_task_ids = _object_task_ids(hass, target)
identity = {tid: tid for tid in valid_task_ids}
part_identity = {pid: pid for pid in _object_part_ids(hass, target)}
docs_created += await store.async_import_documents(
target, fresh, task_id_map=identity, part_id_map=part_identity, id_map=doc_id_map
)
# Under the store's blob lock from the first blob write to the last
# refcount bump: a document delete running meanwhile could remove a blob
# file the restore had just written (or found) before the restored
# document registered it — a document pointing at nothing (bug audit
# 2026-09-27; the upload and delete paths took the lock, this one not).
async with store.blob_lock:
for digest, content in blobs.items():
if digest not in referenced:
_LOGGER.info("Documents archive: blob %s referenced by no document, skipped", digest[:12])
continue
if hashlib.sha256(content).hexdigest() != digest:
_LOGGER.warning("Documents archive: blob %s failed hash check, skipped", digest[:12])
continue
_, wrote_new = await hass.async_add_executor_job(store._store_blob_sync, content)
if wrote_new:
written += 1
store.notify_blob_added(digest)
# 2) Re-attach metadata to the matching object (id, then name).
ids, by_name = _object_name_map(hass)
for obj, docs in manifest_objects:
target = ids.get(str(obj.get("object_id") or "")) or by_name.get(str(obj.get("object_name") or ""))
if target is None:
_LOGGER.info("Documents archive: no object matches %r, its docs skipped", obj.get("object_name"))
continue
objects_matched += 1
# Skip docs already present on the target (idempotent re-import).
existing = store.for_object(target)
existing_keys = {_doc_key(d) for d in existing}
fresh = [m for m in docs if isinstance(m, dict) and _doc_key(m) not in existing_keys]
if fresh:
# Keep task links that still resolve on the target (a same-instance
# restore) via an identity map over the object's current task ids;
# a cross-instance restore has fresh task ids, so those links drop
# here and are re-established by the JSON import's remap instead.
valid_task_ids = _object_task_ids(hass, target)
identity = {tid: tid for tid in valid_task_ids}
part_identity = {pid: pid for pid in _object_part_ids(hass, target)}
docs_created += await store.async_import_documents(
target, fresh, task_id_map=identity, part_id_map=part_identity, id_map=doc_id_map
)
if doc_id_map:
await async_rewrite_doc_refs(hass, lambda old: doc_id_map.get(old, old))
@@ -14,6 +14,7 @@ serving view, sensor and frontend build on top of it.
from __future__ import annotations
import asyncio
import hashlib
import logging
import math
@@ -203,6 +204,18 @@ class DocumentStore:
# every new blob, told when the last reference goes. Optional so the
# store stays usable on its own (tests, tooling).
self.text_index: DocumentTextIndex | None = None
# Serialises every blob-registry transition that spans an await (the
# executor write/delete). Without it an upload of content whose last
# document was being deleted at the same moment registered the blob
# while the delete removed the file underneath it — a document
# pointing at nothing (bug audit 2026-09-26, SEC-6).
self._blob_lock = asyncio.Lock()
@property
def blob_lock(self) -> asyncio.Lock:
"""The blob-registry lock, for a caller that writes blobs AND
registers them itself (the documents-archive restore)."""
return self._blob_lock
# ------------------------------------------------------------------
# Paths
@@ -331,15 +344,18 @@ class DocumentStore:
if object_doc_count >= MAX_DOCS_PER_OBJECT:
raise ValueError("too_many_documents")
digest, wrote_new = await self.hass.async_add_executor_job(self._store_blob_sync, content)
async with self._blob_lock:
digest, wrote_new = await self.hass.async_add_executor_job(self._store_blob_sync, content)
# Register / adopt the blob and bump its refcount.
blob = self.blobs.get(digest)
deduped = blob is not None
if blob is None:
blob = {"size": len(content), "mime": mime, "refcount": 0}
self.blobs[digest] = blob
blob["refcount"] += 1
# Register / adopt the blob and bump its refcount — in the same
# critical section as the write, so a concurrent last-reference
# delete cannot remove the file between the two.
blob = self.blobs.get(digest)
deduped = blob is not None
if blob is None:
blob = {"size": len(content), "mime": mime, "refcount": 0}
self.blobs[digest] = blob
blob["refcount"] += 1
duplicate_in_object = next(
(did for did, d in self.documents.items() if d.get("object_id") == object_id and d.get("hash") == digest),
@@ -386,9 +402,16 @@ class DocumentStore:
if path.exists():
return digest, False
self._blobs_dir.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
tmp.write_bytes(content)
os.replace(tmp, path) # atomic
# A temp name of its own per write: two uploads of the same content
# shared "<digest>.tmp", and the second os.replace found it already
# moved away — a 500 for a perfectly good upload (bug audit
# 2026-09-26). Replacing an identical blob is harmless.
tmp = path.with_name(f"{path.name}.{uuid4().hex}.tmp")
try:
tmp.write_bytes(content)
os.replace(tmp, path) # atomic
finally:
tmp.unlink(missing_ok=True)
return digest, True
async def async_add_weblink(
@@ -716,24 +739,47 @@ class DocumentStore:
return freed
async def _deref_blob(self, doc: dict[str, Any]) -> int:
"""Decrement a file doc's blob refcount; delete the blob at 0."""
"""Decrement a file doc's blob refcount; delete the blob at 0.
Under the blob lock (see ``_blob_lock``): the file delete must not
interleave with an upload adopting the same content.
"""
if doc.get("kind") != KIND_FILE:
return 0
digest = doc.get("hash")
if not isinstance(digest, str):
return 0
blob = self.blobs.get(digest)
if blob is None:
async with self._blob_lock:
blob = self.blobs.get(digest)
if blob is None:
return 0
blob["refcount"] = blob.get("refcount", 1) - 1
if blob["refcount"] <= 0:
size = int(blob.get("size", 0))
self.blobs.pop(digest, None)
if self.text_index is not None:
await self.text_index.async_forget(digest)
await self.hass.async_add_executor_job(self._delete_blob_sync, digest)
return size
return 0
blob["refcount"] = blob.get("refcount", 1) - 1
if blob["refcount"] <= 0:
size = int(blob.get("size", 0))
self.blobs.pop(digest, None)
if self.text_index is not None:
await self.text_index.async_forget(digest)
await self.hass.async_add_executor_job(self._delete_blob_sync, digest)
return size
return 0
async def async_rehome(self, doc_ids: set[str], object_id: str) -> int:
"""Re-stamp documents onto another object. Returns the docs moved.
A shared spare-part pool that moves to a borrower when its owner is
deleted (#111) takes its manual along: the part keeps its ``doc_id``,
and the document must not go down with the old owner (bug audit
2026-09-26, SEC-10). Unknown ids are skipped.
"""
moved = 0
for doc_id in doc_ids:
doc = self.documents.get(doc_id)
if doc is not None and doc.get("object_id") != object_id:
doc["object_id"] = object_id
moved += 1
if moved:
await self._async_save()
return moved
def _delete_blob_sync(self, digest: str) -> None:
"""Delete a blob file and its extracted-text sidecar (executor)."""
@@ -848,27 +894,58 @@ class DocumentStore:
"""
issues = await self.async_find_issues()
freed = 0
orphans = zero = dangling = 0
# Every deletion re-checks its finding UNDER the blob lock: the scan
# above ran without it, and an upload of the same content (file
# written, registry entry pending) looked like an orphan — the
# cleanup deleted the file under the fresh document (bug audit
# 2026-09-27; uploads and deletes already serialised on the lock).
for digest in issues["orphan_blobs"]:
freed += await self.hass.async_add_executor_job(self._reclaim_blob_sync, digest)
async with self._blob_lock:
if digest in self.blobs:
continue # registered meanwhile — not an orphan any more
freed += await self.hass.async_add_executor_job(self._reclaim_blob_sync, digest)
orphans += 1
for digest in issues["zero_refcount"]:
blob = self.blobs.pop(digest, None)
freed += int(blob.get("size", 0)) if blob else 0
await self.hass.async_add_executor_job(self._delete_blob_sync, digest)
async with self._blob_lock:
blob = self.blobs.get(digest)
if blob is None or int(blob.get("refcount", 0)) > 0:
continue # adopted by an upload meanwhile
self.blobs.pop(digest, None)
freed += int(blob.get("size", 0))
await self.hass.async_add_executor_job(self._delete_blob_sync, digest)
zero += 1
for did in issues["dangling_docs"]:
doc = self.documents.pop(did, None)
if doc is not None:
# Reconcile the now over-counted blob refcount so no phantom
# registry entry lingers; the file is already gone (0 real bytes).
await self._deref_blob(doc)
doc = self.documents.get(did)
if doc is None:
continue
async with self._blob_lock:
doc_hash = doc.get("hash")
if isinstance(doc_hash, str) and doc_hash in self.blobs:
if await self.hass.async_add_executor_job(self._blob_file_exists, doc_hash):
continue # its content arrived meanwhile (upload / restore)
self.documents.pop(did, None)
# Reconcile the now over-counted blob refcount so no phantom
# registry entry lingers; the file is already gone (0 real bytes).
# Outside the lock: _deref_blob takes it itself.
await self._deref_blob(doc)
dangling += 1
if any(issues.values()):
await self._async_save()
return {
"orphans_deleted": len(issues["orphan_blobs"]),
"zero_refcount_cleared": len(issues["zero_refcount"]),
"dangling_removed": len(issues["dangling_docs"]),
"orphans_deleted": orphans,
"zero_refcount_cleared": zero,
"dangling_removed": dangling,
"bytes_freed": freed,
}
def _blob_file_exists(self, digest: str) -> bool:
"""Whether the blob file is on disk (executor; invalid digest = no)."""
try:
return self.blob_path(digest).is_file()
except ValueError:
return False
def _reclaim_blob_sync(self, digest: str) -> int:
"""Delete an orphan blob file, returning its size (0 if already gone)."""
try:
@@ -111,12 +111,10 @@ class EntityAnalyzer:
async def _async_fetch_statistics(self, entity_id: str) -> StatisticsInfo | None:
"""Fetch long-term statistics from the HA recorder."""
try:
from homeassistant.components.recorder import ( # type: ignore[attr-defined]
get_instance,
)
from homeassistant.components.recorder.statistics import (
statistics_during_period,
)
from homeassistant.helpers.recorder import get_instance
except ImportError:
_LOGGER.debug("Recorder statistics module not available")
return None
@@ -87,9 +87,39 @@ def rewrite_task(task_data: dict[str, Any], old_id: str, new_id: str) -> tuple[d
new_task["adaptive_config"] = {**ac, "environmental_entity": new_id}
changed = True
# A calendar-kind schedule (#187) names its HA calendar entity, and the
# to-do mirror (D#183) its target lists: a renamed calendar left the task
# without occurrences (never due again) and a renamed list orphaned the
# mirrored rows (bug audit 2026-09-27).
sched = new_task.get("schedule")
if isinstance(sched, dict) and sched.get("kind") == "calendar" and sched.get("entity_id") == old_id:
new_task["schedule"] = {**sched, "entity_id": new_id}
changed = True
mirrors = new_task.get("mirror_todo_entities")
if isinstance(mirrors, list) and old_id in mirrors:
new_task["mirror_todo_entities"] = [new_id if e == old_id else e for e in mirrors]
changed = True
return new_task, 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
into it (exclude) after an entity rename (bug audit 2026-09-27)."""
from ..const import BATTERY_FLEET_EXCLUDED, BATTERY_FLEET_INCLUDED
new_obj = dict(obj)
changed = False
for key in (BATTERY_FLEET_INCLUDED, BATTERY_FLEET_EXCLUDED):
ids = new_obj.get(key)
if isinstance(ids, list) and old_id in ids:
new_obj[key] = sorted({new_id if e == old_id else e for e in ids})
changed = True
return new_obj, changed
def rewrite_tasks(tasks: dict[str, dict[str, Any]], old_id: str, new_id: str) -> tuple[dict[str, dict[str, Any]], bool]:
"""Rewrite all tasks in an entry; return (new_tasks, any_changed)."""
new_tasks: dict[str, dict[str, Any]] = {}
@@ -124,6 +154,14 @@ def rewrite_store(store: Any, old_id: str, new_id: str) -> bool:
if isinstance(ac, dict) and ac.get("environmental_entity") == old_id:
state["adaptive_config"] = {**ac, "environmental_entity": new_id}
changed = True
# The to-do mirror's record of the rows it owns is keyed by list
# entity: the rows live on the renamed list under the same uid, so
# the record follows the rename instead of being re-added as
# duplicates (bug audit 2026-09-27).
mirror = state.get("todo_mirror")
if isinstance(mirror, dict) and old_id in mirror:
state["todo_mirror"] = {(new_id if k == old_id else k): v for k, v in mirror.items()}
changed = True
runtime = state.get("trigger_runtime")
if not isinstance(runtime, dict):
continue
@@ -53,11 +53,22 @@ def get_global_options(hass: HomeAssistant) -> Mapping[str, Any]:
return (entry.options or entry.data) if entry is not None else {}
def entry_option(entry: ConfigEntry, key: str) -> Any:
"""One setting of the GLOBAL entry at hand, falling back to the registry
default when unset — for the code that already holds that entry (its
setup, its update listener, the daily reminder ticks). Same read rule as
:func:`get_global_options` (``options``, else the creation ``data``).
The defaults used to be hand-typed next to each key (DRY audit
2026-09-26 B)."""
return (entry.options or entry.data).get(key, setting_default(key))
def global_option(hass: HomeAssistant, key: str) -> Any:
"""One global setting, falling back to the registry default when unset —
the runtime readers' accessor (coordinator, notification manager), so the
fallback is never hand-typed next to the key."""
return get_global_options(hass).get(key, setting_default(key))
entry = get_global_entry(hass)
return entry_option(entry, key) if entry is not None else setting_default(key)
def is_schedule_time_enabled(hass: HomeAssistant) -> bool:
@@ -67,7 +78,7 @@ def is_schedule_time_enabled(hass: HomeAssistant) -> bool:
Settings takes effect on the next refresh without a restart. Was copied
inline by the coordinator, calendar, and sensor before living here.
"""
return bool(get_global_options(hass).get(CONF_ADVANCED_SCHEDULE_TIME, False))
return bool(global_option(hass, CONF_ADVANCED_SCHEDULE_TIME))
def get_default_warning_days(hass: HomeAssistant) -> int:
@@ -77,7 +88,7 @@ def get_default_warning_days(hass: HomeAssistant) -> int:
back to the constant `DEFAULT_WARNING_DAYS` when the global entry does not
exist yet (during initial setup) or when no user value has been stored.
"""
raw = get_global_options(hass).get(CONF_DEFAULT_WARNING_DAYS, DEFAULT_WARNING_DAYS)
raw = global_option(hass, CONF_DEFAULT_WARNING_DAYS)
try:
value = int(raw)
except (TypeError, ValueError):
@@ -106,7 +117,7 @@ def get_panel_title(hass: HomeAssistant) -> str:
def _percent_option(hass: HomeAssistant, key: str, default: int) -> int:
"""A percent option within THRESHOLD_PERCENT_RANGE, else its default."""
raw = get_global_options(hass).get(key, default)
raw = global_option(hass, key)
try:
value = int(raw)
except (TypeError, ValueError):
@@ -2,8 +2,14 @@
from __future__ import annotations
import logging
from collections.abc import Mapping
from typing import Any
from homeassistant.core import HomeAssistant
_LOGGER = logging.getLogger(__name__)
def normalize_language(hass: HomeAssistant) -> str:
"""Return the HA UI language as a lowercase table key.
@@ -33,3 +39,36 @@ def normalize_language_code(code: str | None) -> str:
if lang.startswith("pt") and lang.endswith("br"):
return "pt-br"
return lang[:2]
def format_text(tables: Mapping[str, Mapping[str, str]], lang: str, key: str, **kwargs: Any) -> str:
"""Look *key* up in a per-language string table and fill its placeholders.
THE formatter behind the integration's Python string tables — the
notification, calendar, logbook and Assist copies each had their own
(bug audit 2026-09-26, DRY BR-A6). One rule set for all of them:
* a missing language or a missing key falls back to the English line
(per key), a key missing everywhere to the key itself;
* values are inserted verbatim. ``str.format`` never re-parses a
substituted value, so a task named "Filter {A}" needs no escaping —
the notification copy doubled the braces anyway and users read
"Filter {{A}}" on their phones;
* a translation with a stray placeholder falls back to the English
line instead of taking the whole message down.
"""
english = tables.get("en", {})
text = tables.get(lang, english).get(key)
if text is None:
text = english.get(key, key)
if not kwargs:
return text
try:
return text.format(**kwargs)
except (KeyError, IndexError, ValueError):
_LOGGER.warning("Malformed translation %s/%s — using the English text", lang, key)
fallback = english.get(key, key)
try:
return fallback.format(**kwargs)
except (KeyError, IndexError, ValueError):
return fallback
@@ -35,9 +35,31 @@ from ..const import (
MaintenanceFeedback,
)
from .history import completed_entries
from .least_squares import linear_fit
from .schedule import read_legacy_fields
def month_overrides(raw: Any) -> dict[int, float] | None:
"""Manual seasonal factors as ``{month 1-12: factor}``.
Stored with int keys, but the JSON store hands them back as strings
after a restart — ``1 <= "7"`` then raised in the analysis (swallowed by
the coordinator, so adaptive scheduling silently died for the task; bug
audit 2026-09-26). Invalid entries are dropped.
"""
if not isinstance(raw, dict):
return None
out: dict[int, float] = {}
for key, value in raw.items():
try:
month, factor = int(key), float(value)
except (TypeError, ValueError):
continue
if 1 <= month <= 12 and math.isfinite(factor):
out[month] = factor
return out or None
def hemisphere(hass: Any) -> str:
"""``"south"`` / ``"north"`` from HA's configured latitude — the seasonal
anchor the adaptive analysis takes. ``None`` (an un-onboarded HA) reads
@@ -189,7 +211,7 @@ class IntervalAnalyzer:
if recommended is not None and seasonal_enabled:
intervals_with_months = self._compute_intervals_with_months(history)
hemisphere = adaptive_config.get("hemisphere", "north")
manual_overrides = adaptive_config.get("seasonal_overrides")
manual_overrides = month_overrides(adaptive_config.get("seasonal_overrides"))
current_month = adaptive_config.get("_current_month") or dt_util.now().month
@@ -436,18 +458,13 @@ class IntervalAnalyzer:
# Least-squares regression: y = beta * x - beta * ln(eta)
# => y = m * x + b, where m = beta, b = -beta * ln(eta)
n_pts = len(x_vals)
sum_x = sum(x_vals)
sum_y = sum(y_vals)
sum_xy = sum(x * y for x, y in zip(x_vals, y_vals, strict=True))
sum_x2 = sum(x * x for x in x_vals)
denom = n_pts * sum_x2 - sum_x * sum_x
if abs(denom) < 1e-10:
# (the shared fit — the sensor predictor carried a second copy).
fit = linear_fit(x_vals, y_vals, min_denom=1e-10)
if fit is None:
return None
beta = (n_pts * sum_xy - sum_x * sum_y) / denom
b = (sum_y - beta * sum_x) / n_pts
beta, b = fit
n_pts = len(x_vals)
sum_y = sum(y_vals)
if beta <= 0:
return None
@@ -34,16 +34,26 @@ from ..const import (
CONF_TASKS,
DEFAULT_BUDGET_CURRENCY,
DEFAULT_CURRENCY_DECIMALS,
DEFAULT_TASK_PRIORITY,
DOMAIN,
NOTIFIABLE_STATUSES,
NOTIFICATION_TITLE_STYLES,
MaintenanceStatus,
TaskPriority,
)
from .global_options import get_global_options
from .i18n import normalize_language
from .aggregate import priority_rank
from .global_options import get_global_options, global_option
from .i18n import format_text, normalize_language
from .notification_gates import STATUS_ENABLED_KEYS, status_reminder_enabled, task_may_notify
from .notification_ids import (
DIGEST_TAG,
PANEL_PATH,
QUIET_END_TAG,
WARRANTY_TAG,
action_id,
budget_tag,
bundle_tag,
panel_url,
task_tag,
)
from .notify_hooks import (
KIND_BUDGET,
KIND_BUNDLE,
@@ -75,6 +85,14 @@ _NOTIFY_SERVICE_MISSING_ISSUE_ID = "notify_service_missing"
# adds noise; the sentinel is a singleton, not a real timestamp.
_SENT_ONCE = datetime.max # noqa: DTZ901 - intentional naive sentinel, see comment above
# A "notify once" status re-arms when the task leaves it — a threshold that
# recovered without a completion, an overdue task that was postponed — so the
# next time it gets there it is announced again (bug audit 2026-09-26: the
# freezer alarm fired once, ever). The stamp then holds the re-arm time and
# a new push waits at least this long: a sensor flapping around its limit
# cannot turn "once" into a stream.
_REARM_MIN_SECONDS = 3600
# The manager's bookkeeping survives a restart (2026-09-13): what was sent when
# (incl. the "once" marks), snoozes, the daily counter, the lead dedup, the
# fairness sets and the reminders quiet hours are holding. Without it every
@@ -973,7 +991,19 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
}
PRIORITY_RANK: dict[str, int] = {TaskPriority.HIGH: 0, TaskPriority.NORMAL: 1, TaskPriority.LOW: 2}
# The line of one task inside a bundled / held-reminder summary, per status
# (unknown statuses read as due soon). The bundle and the quiet-hours summary
# each spelled this map (DRY audit 2026-09-26 B).
_BUNDLED_LINE_KEYS: dict[str, str] = {
MaintenanceStatus.OVERDUE: "bundled_overdue",
MaintenanceStatus.DUE_SOON: "bundled_due_soon",
MaintenanceStatus.TRIGGERED: "bundled_triggered",
}
def _bundled_line(status: Any, lang: str, task_name: str) -> str:
"""One task's line in a bundle / quiet-hours summary."""
return _notif_t(_BUNDLED_LINE_KEYS.get(str(status or ""), "bundled_due_soon"), lang, task=task_name)
def task_key_of(entry_id: str, task_id: str) -> str:
@@ -981,7 +1011,7 @@ def task_key_of(entry_id: str, task_id: str) -> str:
return f"{entry_id}_{task_id}"
def _service_payload(title: str, message: str, *, tag: str, url: str = "/maintenance-supporter") -> dict[str, Any]:
def _service_payload(title: str, message: str, *, tag: str, url: str = PANEL_PATH) -> dict[str, Any]:
"""Notify payload with the deep link doubled into ``url`` (iOS) and
``clickAction`` (Android) — five hand-built copies each repeated the
link string twice."""
@@ -1016,7 +1046,7 @@ def build_action_buttons(
is_test = entry_id is None or task_id is None
def _id(verb: str) -> str:
return f"MS_TEST_{verb}" if is_test else f"MS_{verb}_{entry_id}_{task_id}"
return action_id(verb, None, None) if is_test else action_id(verb, entry_id, task_id)
actions: list[dict[str, str]] = []
if options.get(CONF_ACTION_COMPLETE_ENABLED, setting_default(CONF_ACTION_COMPLETE_ENABLED)):
@@ -1029,13 +1059,10 @@ def build_action_buttons(
def _notif_t(key: str, lang: str, **kwargs: str) -> str:
"""Get notification translation string."""
strings = _NOTIFICATION_STRINGS.get(lang, _NOTIFICATION_STRINGS["en"])
text = strings.get(key, _NOTIFICATION_STRINGS["en"].get(key, key))
if kwargs:
safe_kwargs = {k: str(v).replace("{", "{{").replace("}", "}}") for k, v in kwargs.items()}
text = text.format(**safe_kwargs)
return text
"""Get notification translation string (the shared i18n.format_text —
values go in verbatim; the brace doubling this copy did put "{{A}}" on
phones, bug audit 2026-09-26)."""
return format_text(_NOTIFICATION_STRINGS, lang, key, **kwargs)
async def get_user_notify_services(hass: HomeAssistant, user_id: str) -> list[str]:
@@ -1201,13 +1228,37 @@ class NotificationManager:
def _status_due(self, key: str, interval_hours: int) -> bool:
"""Whether a status notification for ``key`` is due now: never sent,
or the repeat interval has elapsed. A "notify once" status (interval
0) that was sent is never due again until the state is cleared."""
0) that was sent is not due again while the task stays in it; once
the task left the status (``rearm_left_statuses``) it is due again
after ``_REARM_MIN_SECONDS``."""
last = self._last_notified.get(key)
if last is None:
return True
if last == _SENT_ONCE:
return False
return not (interval_hours > 0 and (dt_util.now() - last).total_seconds() < interval_hours * 3600)
min_seconds = interval_hours * 3600 if interval_hours > 0 else _REARM_MIN_SECONDS
return (dt_util.now() - last).total_seconds() >= min_seconds
def rearm_left_statuses(self, entry_id: str, task_id: str, current_status: str | None) -> None:
"""Re-arm the "notify once" stamps of the statuses the task is no
longer in. Checked on every refresh (not on the transition), so a
status left while Home Assistant was down re-arms too."""
changed = False
for status in NOTIFIABLE_STATUSES:
if status == current_status:
continue
key = notification_key(entry_id, task_id, status)
if self._last_notified.get(key) == _SENT_ONCE:
self._last_notified[key] = dt_util.now()
changed = True
# A reminder held for the quiet-hours summary is stale once the
# task left that status (postponed, completed from elsewhere,
# the sensor recovered): the morning summary announced "overdue"
# for a task that no longer was (bug audit 2026-09-27).
if self._quiet_held.pop(key, None) is not None:
changed = True
if changed:
self._dirty()
def _stamp_status_sent(self, key: str, interval_hours: int) -> None:
self._last_notified[key] = _SENT_ONCE if interval_hours == 0 else dt_util.now()
@@ -1302,7 +1353,7 @@ class NotificationManager:
def _opt(self, key: str) -> Any:
"""A global setting, or its registry default when unset."""
return self._global_options.get(key, setting_default(key))
return global_option(self.hass, key)
@property
def enabled(self) -> bool:
@@ -1556,18 +1607,13 @@ class NotificationManager:
return False
held = list(self._quiet_held.values())
lang = self._lang
status_key_map: dict[str, str] = {
MaintenanceStatus.OVERDUE: "bundled_overdue",
MaintenanceStatus.DUE_SOON: "bundled_due_soon",
MaintenanceStatus.TRIGGERED: "bundled_triggered",
}
parts: list[str] = []
for f in held:
line = _notif_t(status_key_map.get(str(f.get("status") or ""), "bundled_due_soon"), lang, task=str(f.get("task_name") or ""))
line = _bundled_line(f.get("status"), lang, str(f.get("task_name") or ""))
parts.append(f"{f.get('object_name') or ''}: {line}" if f.get("object_name") else line)
title = _notif_t("quiet_end_title", lang, count=str(len(held)))
message = "; ".join(parts)
service_data = _service_payload(title, message, tag="maintenance_quiet_end", url="/maintenance-supporter?tab=today")
service_data = _service_payload(title, message, tag=QUIET_END_TAG, url=panel_url(tab="today"))
context = notification_context(
self.hass,
KIND_QUIET_END,
@@ -1604,7 +1650,7 @@ class NotificationManager:
@staticmethod
def _priority_rank(task_data: Mapping[str, Any] | None) -> int:
"""0 = high, 1 = normal, 2 = low (unknown reads as normal)."""
return PRIORITY_RANK.get(str((task_data or {}).get(CONF_TASK_PRIORITY) or DEFAULT_TASK_PRIORITY), 1)
return priority_rank((task_data or {}).get(CONF_TASK_PRIORITY))
def _admit(self, keys: list[str], rank: int) -> bool:
"""The daily-limit decision for a task (or a bundle's member tasks).
@@ -1843,13 +1889,8 @@ class NotificationManager:
if actor_name:
who = _notif_t("completed_by", lang, name=actor_name)
reason = _notif_t(f"reason_{src}" if f"reason_{src}" in _NOTIFICATION_STRINGS["en"] else "reason_unknown", lang)
title = _notif_t("completed_title", lang)
title = self._styled_title(_notif_t("completed_title", lang), task_name, object_name)
message = _notif_t("completed_message", lang, task=task_name, object=object_name, reason=reason, who=who)
style = self.title_style
if style == "object_name" and object_name:
title = object_name
elif style == "task_name" and task_name:
title = task_name
context = notification_context(
self.hass,
KIND_COMPLETED,
@@ -1865,7 +1906,7 @@ class NotificationManager:
# Same payload shape as every other kind: the per-task tag (the
# completion replaces the task's still-standing reminder on the phone)
# and the deep link the context already carries.
service_data = _service_payload(title, message, tag=f"maintenance_{task_id}", url=str(context["url"]))
service_data = _service_payload(title, message, tag=task_tag(task_id), url=str(context["url"]))
sent = await async_emit_and_dispatch(self.hass, self.notify_service, service_data, context)
if sent:
self._daily_count += 1
@@ -1913,15 +1954,18 @@ class NotificationManager:
title = "Maintenance"
message = f"{task_name} ({object_name})"
# v1.4.0 (#44): override title with object/task name so phone
# notification stacks remain distinguishable at a glance.
return self._styled_title(title, task_name, object_name), message
def _styled_title(self, title: str, task_name: str, object_name: str) -> str:
"""v1.4.0 (#44): the configured title style — the object or task name
instead of the generic title, so phone notification stacks stay
distinguishable at a glance. One place for every per-task push."""
style = self.title_style
if style == "object_name" and object_name:
title = object_name
elif style == "task_name" and task_name:
title = task_name
return title, message
return object_name
if style == "task_name" and task_name:
return task_name
return title
async def _async_send_notification_to_service(
self,
@@ -1957,8 +2001,8 @@ class NotificationManager:
service_data = _service_payload(
title,
message,
tag=f"maintenance_{task_id}",
url=f"/maintenance-supporter?entry_id={entry_id}&task_id={task_id}",
tag=task_tag(task_id),
url=panel_url(entry_id=entry_id, task_id=task_id),
)
if actions:
service_data["data"]["actions"] = actions
@@ -2027,16 +2071,7 @@ class NotificationManager:
return
lang = self._lang
status_key_map = {
MaintenanceStatus.OVERDUE: "bundled_overdue",
MaintenanceStatus.DUE_SOON: "bundled_due_soon",
MaintenanceStatus.TRIGGERED: "bundled_triggered",
}
task_parts: list[str] = []
for t in tasks:
key = status_key_map.get(t["status"], "bundled_due_soon")
task_parts.append(_notif_t(key, lang, task=t["task_name"]))
task_parts = [_bundled_line(t["status"], lang, t["task_name"]) for t in tasks]
title = _notif_t("bundled_title", lang, count=str(len(tasks)))
message = _notif_t("bundled_message", lang, object=object_name, task_list=", ".join(task_parts))
@@ -2050,8 +2085,8 @@ class NotificationManager:
service_data = _service_payload(
title,
message,
tag=f"maintenance_bundled_{entry_id}",
url=f"/maintenance-supporter?entry_id={entry_id}",
tag=bundle_tag(entry_id),
url=panel_url(entry_id=entry_id),
)
context = notification_context(
@@ -2091,7 +2126,7 @@ class NotificationManager:
service_data = _service_payload(
_notif_t("digest_title", lang),
_notif_t("digest_message", lang, overdue=str(overdue), due_soon=str(due_soon)),
tag="maintenance_weekly_digest",
tag=DIGEST_TAG,
)
context = notification_context(self.hass, KIND_DIGEST, overdue=overdue, due_soon=due_soon)
try:
@@ -2118,7 +2153,7 @@ class NotificationManager:
days=str(days),
names=", ".join(names),
),
tag="maintenance_warranty_reminder",
tag=WARRANTY_TAG,
)
context = notification_context(self.hass, KIND_WARRANTY, names=list(names), days=days)
try:
@@ -2174,16 +2209,11 @@ class NotificationManager:
if not self._admit([lead_task_key], self._priority_rank(self._task_config(entry_id, task_id))):
return
lang = self._lang
title = _notif_t("due_soon_title", lang)
message = _notif_t(
"due_soon_message",
lang,
task=task_name,
object=object_name,
days=str(days),
due=next_due if next_due is not None else "?",
)
# The due-soon message of the status path, #44 title style included
# — this copy built its own and always titled "due soon", so lead
# reminders were the one push a stack could not tell apart (bug
# audit 2026-09-26).
title, message = self._build_message(MaintenanceStatus.DUE_SOON, self._lang, task_name, object_name, days, next_due)
context = notification_context(
self.hass,
@@ -2248,7 +2278,7 @@ class NotificationManager:
budget=f"{budget:.{decimals}f}{currency_symbol}",
)
service_data = _service_payload(title, message, tag=f"maintenance_budget_{period}")
service_data = _service_payload(title, message, tag=budget_tag(period))
context = notification_context(self.hass, KIND_BUDGET, period=period, spent=spent, budget=budget, percent=pct)
try:
@@ -2296,6 +2326,35 @@ class NotificationManager:
self._last_notified.pop(key, None)
self._snoozed_until.pop(key, None)
self._quiet_held.pop(key, None)
# The daily-limit waiting lists too: a completed (or deleted) task is
# not waiting for a slot any more — left in, it kept reserving the
# "starved first" budget for a reminder that would never be sent
# (bug audit 2026-09-27).
tkey = task_key_of(entry_id, task_id)
self._deferred_today.discard(tkey)
self._starved.discard(tkey)
self._dirty()
def purge_entry(self, entry_id: str) -> None:
"""Forget every piece of bookkeeping about a deleted object's tasks —
the stamps, snoozes, held quiet-hours reminders, daily-limit lists and
lead-reminder dedup were persisted forever and a held reminder of a
deleted object still went out in the next summary (bug audit
2026-09-27). Keys all start with ``<entry_id>_``."""
prefix = f"{entry_id}_"
mappings: tuple[dict[str, Any], ...] = (
self._last_notified,
self._snoozed_until,
self._quiet_held,
self._delivered_at,
self._lead_sent,
)
for mapping in mappings:
for key in [k for k in mapping if k.startswith(prefix)]:
del mapping[key]
for keys in (self._served_today, self._deferred_today, self._starved):
keys.difference_update({k for k in keys if k.startswith(prefix)})
self._seeded_entries.discard(entry_id)
self._dirty()
async def async_dismiss_task_notification(self, task_id: str, responsible_user_id: str | None = None) -> None:
@@ -2313,7 +2372,7 @@ class NotificationManager:
services.extend(await get_user_notify_services(self.hass, responsible_user_id))
except Exception: # noqa: BLE001 - a lookup failure must not keep the action from finishing
_LOGGER.debug("User notify services unavailable for %s", responsible_user_id, exc_info=True)
tag = f"maintenance_{task_id}"
tag = task_tag(task_id)
for service in dict.fromkeys(s for s in services if s):
domain, _, name = service.partition(".")
if not (name and self.hass.services.has_service(domain, name)):
@@ -22,7 +22,7 @@ is passed in here as a plain ``{part_id: stock}`` map.
from __future__ import annotations
import re
from collections.abc import Callable, Mapping
from collections.abc import Callable, Iterator, Mapping
from datetime import date
from typing import Any
from urllib.parse import quote_plus
@@ -308,6 +308,73 @@ def sanitize_consumes_parts(
return list(out.values())
# ── Link traversal (task level AND per phase) ────────────────────────────────
def iter_part_links(task: Mapping[str, Any]) -> Iterator[dict[str, Any]]:
"""Every part link of a task: its ``consumes_parts`` AND each phase's (#139).
A phase may override which parts a completion consumes, so its links are
as real as the task's. Replace, the shared-pool owner hand-over and the
JSON import each walked the task level only — a phase link kept pointing
at the retired / deleted / pre-import part (bug audit 2026-09-27). Every
walker goes through this one.
"""
links = task.get("consumes_parts")
if isinstance(links, list):
yield from (link for link in links if isinstance(link, dict))
phases = task.get("phases")
if isinstance(phases, Mapping):
for pdef in phases.values():
plinks = pdef.get("consumes_parts") if isinstance(pdef, Mapping) else None
if isinstance(plinks, list):
yield from (link for link in plinks if isinstance(link, dict))
def map_part_links(
task: Mapping[str, Any], rewrite: Callable[[dict[str, Any]], dict[str, Any] | None]
) -> tuple[dict[str, Any], bool]:
"""A copy of ``task`` with every part link (task level and per phase, see
:func:`iter_part_links`) passed through ``rewrite``; ``None`` drops the
link, and a list left empty is removed. Returns ``(task, changed)`` —
the input is never mutated."""
changed = False
def _rewrite_list(links: list[Any]) -> list[dict[str, Any]]:
nonlocal changed
out: list[dict[str, Any]] = []
for link in links:
new = rewrite(dict(link)) if isinstance(link, dict) else None
if new != link:
changed = True
if new is not None:
out.append(new)
return out
new_task = dict(task)
links = task.get("consumes_parts")
if isinstance(links, list):
kept = _rewrite_list(links)
if kept:
new_task["consumes_parts"] = kept
else:
new_task.pop("consumes_parts", None)
phases = task.get("phases")
if isinstance(phases, Mapping):
new_phases: dict[str, Any] = {}
for pid, pdef in phases.items():
if isinstance(pdef, Mapping) and isinstance(pdef.get("consumes_parts"), list):
pdef = dict(pdef)
kept = _rewrite_list(pdef["consumes_parts"])
if kept:
pdef["consumes_parts"] = kept
else:
pdef.pop("consumes_parts", None)
new_phases[pid] = pdef
new_task["phases"] = new_phases
return new_task, changed
# ── Stock rules ──────────────────────────────────────────────────────────────
@@ -17,16 +17,17 @@ gated by ``global/update``) — write access cannot be self-granted.
from __future__ import annotations
from collections.abc import Callable, Coroutine
from functools import wraps
from typing import TYPE_CHECKING, Any
from homeassistant.components.websocket_api.connection import ActiveConnection
from homeassistant.components.websocket_api.const import WebSocketCommandHandler
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import Unauthorized
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse
from homeassistant.exceptions import Unauthorized, UnknownUser
from ..const import CONF_ADMIN_PANEL_USER_IDS, CONF_OPERATOR_WRITE_ENABLED
from .global_options import get_global_options
from .global_options import global_option
if TYPE_CHECKING:
from homeassistant.auth.models import User
@@ -34,7 +35,7 @@ if TYPE_CHECKING:
def operator_user_ids(hass: HomeAssistant) -> list[str]:
"""Return the operator allowlist (``admin_panel_user_ids``) as string ids."""
raw = get_global_options(hass).get(CONF_ADMIN_PANEL_USER_IDS, []) or []
raw = global_option(hass, CONF_ADMIN_PANEL_USER_IDS) or []
return [uid for uid in raw if isinstance(uid, str)]
@@ -46,7 +47,7 @@ def operator_write_enabled(hass: HomeAssistant) -> bool:
is read-only; an admin must explicitly enable this for allowlisted
non-admins to gain content CRUD.
"""
return get_global_options(hass).get(CONF_OPERATOR_WRITE_ENABLED, False) is True
return global_option(hass, CONF_OPERATOR_WRITE_ENABLED) is True
def user_can_write(hass: HomeAssistant, user: User | None) -> bool:
@@ -92,3 +93,33 @@ def require_write(func: WebSocketCommandHandler) -> WebSocketCommandHandler:
func(hass, connection, msg)
return with_write
ServiceHandler = Callable[[ServiceCall], Coroutine[Any, Any, ServiceResponse]]
def service_tier(hass: HomeAssistant, handler: ServiceHandler, *, admin: bool = False) -> ServiceHandler:
"""The service-call twin of ``require_write`` (``admin=True``: of
``require_admin``).
Home Assistant does not gate custom services by user — any account can
call them over the WebSocket ``call_service`` command or REST, so a
plain user could delete tasks through ``delete_task`` although the
panel's commands refuse it (bug audit 2026-09-26). A call carrying a
user is held to the tier of the matching WS command, the way HA's own
admin services check it; calls without one (automations, scripts
started by the system) pass.
"""
@wraps(handler)
async def guarded(call: ServiceCall) -> ServiceResponse:
user_id = call.context.user_id
if user_id is not None:
user = await hass.auth.async_get_user(user_id)
if user is None:
raise UnknownUser(context=call.context)
if not (user.is_admin if admin else user_can_write(hass, user)):
raise Unauthorized(context=call.context)
return await handler(call)
return guarded
@@ -27,10 +27,10 @@ from ..const import (
CONF_OBJECT,
CONF_TASKS,
DOMAIN,
GLOBAL_UNIQUE_ID,
MAX_ADOPTED_NOTES,
)
from .aggregate import object_name
from .adopt_match import candidate_object, covering_task
from .aggregate import get_object_entries, object_name
PROBLEM_DEVICE_CLASS = "problem"
# safety (NAS disk-health / lifespan thresholds) and tamper alarms behave like
@@ -77,9 +77,7 @@ def _adopted_entity_ids(hass: HomeAssistant) -> set[str]:
from ..entity.triggers import normalize_entity_ids
watched: set[str] = set()
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
continue
for entry in get_object_entries(hass):
for task in entry.data.get(CONF_TASKS, {}).values():
tc = task.get("trigger_config")
if isinstance(tc, dict):
@@ -90,9 +88,7 @@ def _adopted_entity_ids(hass: HomeAssistant) -> set[str]:
def _object_by_device(hass: HomeAssistant) -> dict[str, dict[str, str]]:
"""{ha_device_id: {entry_id, name}} for objects already attached to a device."""
out: dict[str, dict[str, str]] = {}
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
continue
for entry in get_object_entries(hass):
obj = entry.data.get(CONF_OBJECT, {})
dev = obj.get("ha_device_id")
if dev:
@@ -100,6 +96,14 @@ def _object_by_device(hass: HomeAssistant) -> dict[str, dict[str, str]]:
return out
def sensor_covered_by(hass: HomeAssistant, entity_id: str, name: str, entry: Any, device_name: str = "") -> dict[str, str] | None:
"""The target's task that probably already watches this problem under
another name (helpers.adopt_match.covering_task), or None."""
if entry is None:
return None
return covering_task(hass, [name], [entity_id], entry.data.get(CONF_TASKS, {}), ignore=(device_name, entry.title))
def discover_problem_sensors(hass: HomeAssistant) -> list[dict[str, Any]]:
"""Propose adoptable problem sensors (not already watched by a task).
@@ -139,6 +143,12 @@ def discover_problem_sensors(hass: HomeAssistant) -> list[dict[str, Any]]:
area_name = area.name
# Suggested target: existing object on this device, else a fresh one.
suggested = by_device.get(device_id) if device_id else None
# 2.94: no object linked to the device — the existing object it most
# likely is (model number / name in the device name, same area …)
# becomes the default, like in the suggested setups.
candidate = candidate_object(hass, device_id) if device_id and suggested is None else None
target_id = suggested["entry_id"] if suggested else (candidate["entry_id"] if candidate else None)
target = hass.config_entries.async_get_entry(target_id) if target_id else None
# Suggested spare part: when the target object already exists and has a
# part whose name matches the sensor's (toner-low ↔ "Toner cartridge"),
# adoption can pre-link it so completing the task consumes/restocks it.
@@ -161,6 +171,9 @@ def discover_problem_sensors(hass: HomeAssistant) -> list[dict[str, Any]]:
"suggested_object_name": suggested["name"] if suggested else (device_name or name),
"suggested_part_id": suggested_part[0] if suggested_part else None,
"suggested_part_name": suggested_part[1] if suggested_part else None,
"candidate": candidate,
"target_entry_id": target.entry_id if target is not None else None,
"covered_by": sensor_covered_by(hass, state.entity_id, name, target, device_name),
}
)
out.sort(key=lambda c: (c["device_name"] or "", c["name"]))
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from .notification_ids import PANEL_PATH
from .qrcodegen import QrCode
_LOGGER = logging.getLogger(__name__)
@@ -69,7 +70,8 @@ def build_qr_url(
params["action"] = action
query = urllib.parse.urlencode(params)
return f"{base}/maintenance-supporter?{query}"
# The panel route every notification deep link uses too (notification_ids).
return f"{base}{PANEL_PATH}?{query}"
def _icon_elements(icon: str, cx: float, cy: float, r: float, fill: str) -> str:
@@ -123,6 +123,45 @@ def _merged_tasks(entry: Any) -> dict[str, Any]:
return merged_tasks(entry)
async def async_sweep_unattached_photos(hass: HomeAssistant) -> int:
"""Remove completion photos that never became part of a record.
A document tagged exactly ``photo``, pointed at by nothing (no history
entry of any task, no part, no task link) and uploaded more than
``UNATTACHED_PHOTO_MAX_AGE_HOURS`` ago: the dialog it was taken in was
cancelled, the photo removed again, or the upload finished after the
save. Those orphans used to stay forever and count against the object's
document cap (bug audit 2026-09-27, R SEC-3). Skipped entirely while an
object's Store is not loaded — its history could reference a photo.
Returns the number removed.
"""
from datetime import timedelta
from ..const import DOCUMENT_STORE_KEY, DOMAIN
from .completion_requirements import UNATTACHED_PHOTO_MAX_AGE_HOURS, unattached_photo_ids
from .dates import parse_persisted_utc
store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
if store is None:
return 0
candidates = unattached_photo_ids(hass)
if not candidates:
return 0
cutoff = dt_util.utcnow() - timedelta(hours=UNATTACHED_PHOTO_MAX_AGE_HOURS)
removed = 0
for doc_id in candidates:
doc = store.documents.get(doc_id)
added = parse_persisted_utc(doc.get("added_at")) if doc is not None else None
# No readable upload time = no age: kept (the hygiene scan's business).
if added is None or added > cutoff:
continue
await store.async_remove(doc_id)
removed += 1
if removed:
_LOGGER.info("Removed %d completion photo(s) that never became part of a record", removed)
return removed
async def async_run_retention_sweep(hass: HomeAssistant) -> None:
"""Auto-archive overdue-done one-offs and auto-delete aged auto-archives.
@@ -141,6 +180,10 @@ async def async_run_retention_sweep(hass: HomeAssistant) -> None:
)
from .aggregate import get_object_entries
# Independent of the archive / delete thresholds: a completion photo that
# never became part of a record is not a retention choice but an orphan.
await async_sweep_unattached_photos(hass)
opts = _global_options(hass)
archive_days = _coerce_int(
opts.get(CONF_ARCHIVE_ONEOFF_DAYS, DEFAULT_ARCHIVE_ONEOFF_DAYS),
@@ -40,6 +40,7 @@ from ..const import (
MAX_URL_LENGTH,
)
from .task_fields import EARLIEST_COMPLETION_RANGE
from .url_safety import is_safe_url
# Per-field cap for task dicts. Values mirror the voluptuous schemas in
# websocket/tasks.py so an admin who reaches the same field through the UI
@@ -121,6 +122,11 @@ _FRESH_COPY_STRIP_KEYS = (
"archived_reason",
# #170: a copy is a new task — it gets its own reference number.
"ref_no",
# Dynamic state like the history (storage._DYNAMIC_TASK_FIELDS, pinned by
# tests/test_dry_round4_runtime.py): a copy starts its phase cycle and its
# checklist from the beginning (DRY audit 2026-09-26 B).
"phase_cursor",
"checklist_progress",
)
@@ -144,7 +150,7 @@ def strip_task_runtime_state(task: dict[str, Any]) -> dict[str, Any]:
return task
def cap_task_fields(task_data: dict[str, Any]) -> dict[str, Any]:
def cap_task_fields(task_data: dict[str, Any], *, keep_action_owner: bool = False) -> dict[str, Any]:
"""Truncate user-controllable strings + numerics on a task dict in-place.
Returns the same dict for fluent use. Mirrors the WS schema caps:
@@ -152,8 +158,19 @@ def cap_task_fields(task_data: dict[str, Any]) -> dict[str, Any]:
- `interval_days` → 1..MAX_INTERVAL_DAYS (negative/zero coerced to 1)
- `warning_days` → 0..365
- `checklist` → list of strings, each ≤ 500 chars, list ≤ 100 items
``keep_action_owner``: the completion action's ``configured_by`` (the
user it runs as) survives. Only for RE-WRITES of a stored task whose
action the caller cannot change (the ``update_task`` service, the
options-flow task edit) and for the config flow's websocket step, whose
callers are server-side copies (object duplicate / replace) or an import
that stamped the owner itself. Stripping it there made an operator's
action run with system rights again after any unrelated edit (bug audit
2026-09-27, the SEC-2 regression). A client-supplied owner is never
trusted: every client write path stamps the connection user instead.
"""
_cap_strings(task_data, _TASK_STR_LIMITS)
_drop_unsafe_url(task_data)
iv = task_data.get("interval_days")
if isinstance(iv, int):
@@ -221,11 +238,13 @@ def cap_task_fields(task_data: dict[str, Any]) -> dict[str, Any]:
if task_data.get("assignee_pool") is not None:
task_data["assignee_pool"] = sanitize_assignee_pool(task_data["assignee_pool"])
rs = task_data.get("rotation_strategy")
if rs is not None:
# Absence means "no rotation": a stored None / "" (the panel dialog sends
# null for "none") is dropped too — as a select default it made the
# options-flow task form unsaveable (bug audit 2026-09-26).
if "rotation_strategy" in task_data:
from ..const import ROTATION_STRATEGIES
if rs not in ROTATION_STRATEGIES:
if task_data["rotation_strategy"] not in ROTATION_STRATEGIES:
task_data.pop("rotation_strategy", None)
if task_data.get("required_completion_fields") is not None:
@@ -241,7 +260,7 @@ def cap_task_fields(task_data: dict[str, Any]) -> dict[str, Any]:
# Strict shape: {service: "domain.name", target?: dict, data?: dict}.
# Drops the field entirely on any structural problem; the action layer
# treats absence as "no action configured" (not an error).
cap_action_field(task_data)
cap_action_field(task_data, keep_owner=keep_action_owner)
# v1.3.0: per-task quick_complete_defaults — pre-fill values used when
# the user scans the "quick complete" QR code. Schema mirrors the
@@ -364,11 +383,14 @@ _MAX_TARGET_FIELD_LENGTH = 200
_FORBIDDEN_ACTION_DOMAINS = frozenset({"shell_command", "python_script", "hassio", "homeassistant", "recorder", "backup"})
def cap_action_field(task_data: dict[str, Any]) -> None:
def cap_action_field(task_data: dict[str, Any], *, keep_owner: bool = False) -> None:
"""Validate + truncate task_data['on_complete_action'] in-place.
Drops the entire field on any structural problem. Passes silently when
not present (it's optional).
not present (it's optional). ``configured_by`` — the user the action runs
as (``action_listener``) — is server-stamped: kept only for the WS update
path's stored action (``keep_owner``), dropped from anything else a
client or an import could have written.
"""
import re
@@ -421,9 +443,45 @@ def cap_action_field(task_data: dict[str, Any]) -> None:
if serialised is not None and len(serialised) <= _MAX_ACTION_DATA_BYTES:
cleaned["data"] = data
owner = action.get(ACTION_OWNER_KEY)
if keep_owner and isinstance(owner, str) and 0 < len(owner) <= 64:
cleaned[ACTION_OWNER_KEY] = owner
task_data["on_complete_action"] = cleaned
# The user a completion action runs as (see helpers.action_listener).
ACTION_OWNER_KEY = "configured_by"
def stamp_action_owner(task_data: dict[str, Any], user_id: str | None) -> None:
"""Record who configured the task's completion action — it then runs
with that user's rights, so an operator cannot schedule an admin-only
service (bug audit 2026-09-26). Call after :func:`cap_action_field`."""
action = task_data.get("on_complete_action")
if isinstance(action, dict) and user_id:
action[ACTION_OWNER_KEY] = user_id
def settle_action_owner(task_data: dict[str, Any], stored_action: Any, user_id: str | None) -> None:
"""Owner of an EDITED task's completion action, after :func:`cap_action_field`.
An unchanged action (the panel dialog sends it back on every save) keeps
the owner it was stored with — an operator editing the notes must not
re-author an admin's action, nor may an edit drop the owner (the action
would then run with system rights). A changed action belongs to
``user_id``, the saving user (bug audit 2026-09-26 / 2026-09-27).
"""
stored_owner = stored_action.get(ACTION_OWNER_KEY) if isinstance(stored_action, dict) else None
new_action = task_data.get("on_complete_action")
unchanged = isinstance(stored_action, dict) and new_action == {
k: v for k, v in stored_action.items() if k != ACTION_OWNER_KEY
}
if isinstance(new_action, dict):
new_action.pop(ACTION_OWNER_KEY, None)
stamp_action_owner(task_data, stored_owner if unchanged else user_id)
def cap_quick_complete_defaults_field(task_data: dict[str, Any]) -> None:
"""Validate + truncate task_data['quick_complete_defaults'] in-place.
@@ -467,9 +525,23 @@ def cap_quick_complete_defaults_field(task_data: dict[str, Any]) -> None:
def cap_object_fields(obj_data: dict[str, Any]) -> dict[str, Any]:
"""Truncate user-controllable strings on an object dict in-place."""
_cap_strings(obj_data, _OBJECT_STR_LIMITS)
_drop_unsafe_url(obj_data)
return obj_data
def _drop_unsafe_url(data: dict[str, Any]) -> None:
"""Drop a ``documentation_url`` that is not http(s)/path-relative —
every create and edit path runs through the cap helpers, so this is the
one place a ``javascript:`` link from a flow or an import stops (bug
audit 2026-09-26; the WS layer refuses it with an error before)."""
url = data.get("documentation_url")
if isinstance(url, str) and url and not is_safe_url(url):
import logging
logging.getLogger(__name__).warning("Dropped an unsafe documentation link (%.40s)", url)
data["documentation_url"] = None
def cap_group_fields(group_data: dict[str, Any]) -> dict[str, Any]:
"""Truncate user-controllable strings on a group dict in-place."""
_cap_strings(group_data, _GROUP_STR_LIMITS)
@@ -24,6 +24,7 @@ so the engine stays pure and sync.
from __future__ import annotations
import logging
import statistics
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
@@ -31,7 +32,9 @@ from datetime import date, timedelta
from itertools import pairwise
from typing import Any
from ..const import MAX_INTERVAL_DAYS, SCHEDULE_OFFSET_MAX_DAYS
from .dates import (
INTERVAL_UNITS,
add_interval,
interval_span_days,
next_day_of_month,
@@ -41,6 +44,8 @@ from .dates import (
roll_back_to_business_day,
)
_LOGGER = logging.getLogger(__name__)
# Recurrence kinds. Phase 2 covers the v2.6.x set; the calendar kinds
# (weekdays / nth_weekday / day_of_month) arrive with the roadmap feature.
KIND_INTERVAL = "interval"
@@ -70,12 +75,14 @@ _CALENDAR_DEFAULT_SPAN_DAYS = 7
# runtime is up) every calendar-entity schedule simply has no occurrences.
CalendarOccurrenceProvider = Callable[[str], Sequence[date]]
_calendar_provider: CalendarOccurrenceProvider | None = None
_provider_failures_logged: set[str] = set()
def set_calendar_occurrence_provider(provider: CalendarOccurrenceProvider | None) -> None:
"""Install (or, with ``None``, remove) the calendar-entity occurrence source."""
global _calendar_provider # process-wide hook by design
_calendar_provider = provider
_provider_failures_logged.clear()
def calendar_occurrences(entity_id: str) -> tuple[date, ...]:
@@ -89,6 +96,10 @@ def calendar_occurrences(entity_id: str) -> tuple[date, ...]:
try:
raw = _calendar_provider(entity_id)
except Exception: # noqa: BLE001 — a broken provider must never take next_due down
# Once per entity: next_due is recomputed on every refresh.
if entity_id not in _provider_failures_logged:
_provider_failures_logged.add(entity_id)
_LOGGER.warning("Calendar dates of %s could not be read", entity_id, exc_info=True)
return ()
return tuple(sorted({d for d in raw if isinstance(d, date)}))
@@ -98,7 +109,7 @@ _MAX_PLANNED_STEPS = 2000
# (#83) offset bound: ±15 days covers every sensible "N days before/after the
# pattern date" case without letting a bogus payload shift schedules by years.
_MAX_OFFSET_DAYS = 15
_MAX_OFFSET_DAYS = SCHEDULE_OFFSET_MAX_DAYS # const: one source for flows, WS and TS
def _coerce_int(raw: object) -> int | None:
@@ -125,10 +136,32 @@ def _coerce_int(raw: object) -> int | None:
return None
def _sanitize_every(raw: object) -> int | None:
"""Interval count >= 1, or None."""
# Longest interval per unit: MAX_INTERVAL_DAYS (10 years) in that unit. The
# flat interval_days was capped on write, the nested ``schedule.every`` was
# not — "every 8000 years" reached the refresh and died on year 10026, every
# refresh, every restart (bug audit 2026-09-27). Clamped on READ, so stored
# garbage from any write path or an old backup is harmless too.
_MAX_EVERY_BY_UNIT = {
"days": MAX_INTERVAL_DAYS,
"weeks": MAX_INTERVAL_DAYS // 7,
"months": (MAX_INTERVAL_DAYS // 365) * 12,
"years": MAX_INTERVAL_DAYS // 365,
}
def _sanitize_unit(raw: object) -> str:
"""One of ``INTERVAL_UNITS``; anything else (a list, "fortnights", None)
reads as days — the historical fallback of ``add_interval`` — instead of
being carried into storage and every consumer (bug audit 2026-09-27)."""
return raw if isinstance(raw, str) and raw in INTERVAL_UNITS else "days"
def _sanitize_every(raw: object, unit: str = "days") -> int | None:
"""Interval count >= 1 (clamped to the unit's maximum), or None."""
val = _coerce_int(raw)
return val if val is not None and val >= 1 else None
if val is None or val < 1:
return None
return min(val, _MAX_EVERY_BY_UNIT.get(unit, MAX_INTERVAL_DAYS))
def _sanitize_nth(raw: object) -> int | None:
@@ -255,13 +288,14 @@ class Schedule:
return cls(kind=KIND_ONE_TIME, due_date=parse_iso_date(due_date))
# Coerce — an imported flat payload can carry interval_days as a
# string, and `"30" <= 0` raises TypeError on every refresh.
every = _sanitize_every(interval_days)
unit = _sanitize_unit(interval_unit)
every = _sanitize_every(interval_days, unit)
if every is None:
return cls(kind=KIND_MANUAL)
return cls(
kind=KIND_INTERVAL,
every=every,
unit=interval_unit or "days",
unit=unit,
anchor=interval_anchor or "completion",
)
@@ -269,6 +303,12 @@ class Schedule:
"""True when the recurrence has an end condition (count and/or until)."""
return self.ends_count is not None or self.ends_until is not None
@property
def is_calendar_kind(self) -> bool:
"""A fixed calendar (weekdays, nth weekday, day of month, calendar
entity) rather than an interval — completions cover occurrences."""
return self.kind in _CALENDAR_KINDS
def next_due(
self,
*,
@@ -286,7 +326,36 @@ class Schedule:
- ``season_months`` rolls an off-season date to the next active month;
- the finite-series end (``ends_count`` / ``ends_until``) stops re-arming.
A one_time task keeps its fixed date and ignores season/finite.
Never raises: a date past the calendar's end (year 9999 — a
far-future reset date, an absurd interval, a season roll in 9999)
reads as "no due date". It used to raise OverflowError / ValueError
from inside the coordinator refresh, which took the whole object
down on every refresh and every restart (bug audit 2026-09-27).
"""
try:
return self._next_due(
last_performed=last_performed,
created_at=created_at,
last_planned_due=last_planned_due,
today=today,
times_performed=times_performed,
due_override=due_override,
)
except (OverflowError, ValueError):
return None
def _next_due(
self,
*,
last_performed: date | None,
created_at: date | None,
last_planned_due: date | None,
today: date,
times_performed: int,
due_override: date | None,
) -> date | None:
""":meth:`next_due` without the end-of-calendar guard."""
# Finite series exhausted by completion count → terminally done. Checked
# BEFORE the override so postponing a finished series can't resurrect it
# (there is no current cycle left to postpone).
@@ -310,6 +379,29 @@ class Schedule:
if self.kind == KIND_ONE_TIME:
return result
result = self._roll_to_season(result)
# Calendar kinds: completing or skipping ahead of the date covers that
# occurrence — complete()/skip() remember it in ``last_planned_due``.
# The next occurrence strictly after the completion day was the very
# date just done, so a Monday task completed on Saturday was due again
# two days later and overdue on Tuesday (bug audit 2026-09-26).
# EVERY occurrence up to that covered one is consumed, not just an
# exact match: a second early completion/skip (Saturday: done for
# Monday; Sunday: skip the next Monday too) re-anchored on the
# following occurrence while the next one after the new completion
# day was still the Monday already covered — the due date jumped
# back a week (bug audit 2026-09-27, R SCH-1). Only a
# last_planned_due that IS an occurrence of the current schedule
# counts: a stale value (e.g. from a former interval schedule, or a
# changed weekday set) never swallows an occurrence.
if (
self.kind in _CALENDAR_KINDS
and result is not None
and last_performed is not None
and last_planned_due is not None
and last_performed < result <= last_planned_due
and self._calendar_occurrence(last_planned_due, inclusive=True) == last_planned_due
):
result = self._roll_to_season(self._calendar_occurrence(last_planned_due, inclusive=False))
# Finite series ends once the next occurrence would fall past until.
if self.ends_until is not None and result is not None and result > self.ends_until:
@@ -539,10 +631,11 @@ class Schedule:
season = _sanitize_months(d.get("season_months"))
ends_count, ends_until = _parse_ends(d.get("ends"))
if kind == KIND_INTERVAL:
unit = _sanitize_unit(d.get("unit"))
return cls(
kind=KIND_INTERVAL,
every=_sanitize_every(d.get("every")),
unit=d.get("unit") or "days",
every=_sanitize_every(d.get("every"), unit),
unit=unit,
anchor=d.get("anchor") or "completion",
season_months=season,
ends_count=ends_count,
@@ -35,7 +35,9 @@ from ..const import (
DEGRADATION_MIN_CYCLE_HOURS,
)
from .history import completed_entries
from .least_squares import linear_fit
from .schedule import read_legacy_fields
from .trigger_fallback import counter_baseline
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
@@ -172,9 +174,15 @@ class SensorPredictor:
return None
trigger_config = task_data.get("trigger_config") or {}
entity_id = trigger_config.get("entity_id")
if not entity_id:
# Every trigger shape: a catalog-adopted task names its sensor(s) in
# ``entity_ids`` only and was never predicted (bug audit 2026-09-26,
# SCH-9). The forecast follows the first sensor.
from ..entity.triggers import normalize_entity_ids
entity_ids = normalize_entity_ids(trigger_config) if trigger_config.get("type") != "compound" else []
if not entity_ids:
return None
entity_id = entity_ids[0]
trigger_type = trigger_config.get("type", "threshold")
if trigger_type not in ("threshold", "counter"):
@@ -508,11 +516,14 @@ class SensorPredictor:
# Counter: predict when delta reaches target
target = trigger_config.get("trigger_target_value")
delta_mode = trigger_config.get("trigger_delta_mode", False)
baseline = trigger_config.get("trigger_baseline_value", 0)
if target is None:
return None
if delta_mode:
# Current delta = current - baseline
# Current delta = current - baseline, against the entity's
# LIVING baseline (moved on every service) — the initial
# config value made a serviced counter look long overdue
# (bug audit 2026-09-26, SCH-8).
baseline = counter_baseline(trigger_config, degradation.entity_id)
current_delta = current - (baseline or 0)
threshold_value = float(target)
current = current_delta
@@ -738,12 +749,10 @@ class SensorPredictor:
Returns sorted list of (timestamp_seconds, value) tuples.
"""
try:
from homeassistant.components.recorder import ( # type: ignore[attr-defined]
get_instance,
)
from homeassistant.components.recorder.statistics import (
statistics_during_period,
)
from homeassistant.helpers.recorder import get_instance
except ImportError:
_LOGGER.debug("Recorder statistics module not available")
return []
@@ -813,29 +822,16 @@ class SensorPredictor:
Returns (slope, intercept, r_squared) or None if insufficient data.
Uses least squares method.
"""
# The shared fit (helpers/least_squares — it shifts x by the first
# timestamp, so Unix-epoch x values don't cancel catastrophically).
fit = linear_fit([p[0] for p in points], [p[1] for p in points], min_denom=1e-15)
if fit is None:
return None
slope, intercept = fit
n = len(points)
if n < 2:
return None
# Normalize X-values to avoid catastrophic cancellation.
# Raw Unix timestamps (~1.7e9) squared exceed Float64 precision,
# causing the denominator (n*Σx²−(Σx)²) to lose significant digits.
# Translation does not change the slope; intercept is adjusted below.
x0 = points[0][0]
sum_x = sum(p[0] - x0 for p in points)
sum_y = sum(p[1] for p in points)
sum_xy = sum((p[0] - x0) * p[1] for p in points)
sum_x2 = sum((p[0] - x0) ** 2 for p in points)
sum_y2 = sum(p[1] ** 2 for p in points)
denom = n * sum_x2 - sum_x**2
if abs(denom) < 1e-15:
return None
slope = (n * sum_xy - sum_x * sum_y) / denom
intercept = (sum_y - slope * sum_x) / n - slope * x0
# R-squared (coefficient of determination)
ss_tot = sum_y2 - (sum_y**2) / n
if abs(ss_tot) < 1e-15:
@@ -58,6 +58,8 @@ from ..const import (
CONF_DEFAULT_WARNING_DAYS,
CONF_DELETE_ARCHIVED_ONEOFF_DAYS,
CONF_DISABLED_TEMPLATE_IDS,
CONF_HOME_REGION,
CONF_HOME_TYPE,
CONF_INSTALL_ASSIST_SENTENCES,
CONF_MAX_NOTIFICATIONS_PER_DAY,
CONF_MEMBER_DISPLAY,
@@ -178,6 +180,10 @@ SETTING_SPECS: tuple[SettingSpec, ...] = (
SettingSpec(CONF_MEMBER_DISPLAY, dict, {}),
# v2.21: hidden template ids (bespoke known-id sanitiser in the handler)
SettingSpec(CONF_DISABLED_TEMPLATE_IDS, list, []),
# v2.93: home profile dwelling type (enum-checked in dashboard.py).
SettingSpec(CONF_HOME_TYPE, str, "auto", max_len=16),
# 2.94: state / province / region override (format-checked in dashboard.py).
SettingSpec(CONF_HOME_REGION, str, "auto", max_len=8),
# Archive automation
SettingSpec(CONF_ARCHIVE_ONEOFF_DAYS, int, DEFAULT_ARCHIVE_ONEOFF_DAYS, int_range=(0, 3650)),
SettingSpec(CONF_DELETE_ARCHIVED_ONEOFF_DAYS, int, DEFAULT_DELETE_ARCHIVED_ONEOFF_DAYS, int_range=(0, 3650)),
@@ -25,14 +25,13 @@ from ._registry import SIGNATURES
def _entity_watchers(hass: HomeAssistant) -> dict[str, set[str]]:
"""entity_id → lowercased names of the tasks watching it via a trigger."""
from ...const import CONF_TASKS, DOMAIN, GLOBAL_UNIQUE_ID
from ...const import CONF_TASKS
from ...entity.triggers import normalize_entity_ids
from ..aggregate import get_object_entries
out: dict[str, set[str]] = {}
known = _catalog_name_variants()
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
continue
for entry in get_object_entries(hass):
for task in entry.data.get(CONF_TASKS, {}).values():
tc = task.get("trigger_config")
if isinstance(tc, dict):
@@ -58,7 +57,7 @@ def _catalog_name_variants() -> set[str]:
return variants
def _matches_catalog_key(entry: er.RegistryEntry, key: str, catalog_keys: set[str]) -> bool:
def _matches_catalog_key(entry: er.RegistryEntry, key: str, catalog_keys: set[str], *, tk_authoritative: bool = False) -> bool:
"""``_entity_matches`` where a LONGER key of the same catalog that ends
in ``key`` owns the entity.
@@ -67,17 +66,66 @@ def _matches_catalog_key(entry: er.RegistryEntry, key: str, catalog_keys: set[st
its own "Replace Secondary Filter" duty — the secondary filter's wear
then fired the main-filter task (bug review 2026-09-04). An exact
translation_key match is the integration's own word and needs no guard.
That guard only knows catalog keys; an integration flagged
``translation_keys_authoritative`` also keeps the fallbacks off entities
whose own (uncataloged) translation_key differs.
"""
if entry.translation_key == key:
return True
if not _entity_matches(entry, key):
if not _entity_matches(entry, key, tk_authoritative=tk_authoritative):
return False
return not any(
other != key and other.endswith(f"_{key}") and _entity_matches(entry, other) for other in catalog_keys
other != key and other.endswith(f"_{key}") and _entity_matches(entry, other, tk_authoritative=tk_authoritative)
for other in catalog_keys
)
def discover_integration_setups(hass: HomeAssistant) -> list[dict[str, Any]]:
def _appliance_type(hass: HomeAssistant, device: Any, integration: str, key: str | None) -> str | None:
"""The appliance type the integration's own config entry names for this
device (WashData: ``device_type``) — options first, then data."""
if not key or device is None:
return None
for entry_id in getattr(device, "config_entries", ()) or ():
entry = hass.config_entries.async_get_entry(entry_id)
if entry is not None and entry.domain == integration:
value = entry.options.get(key, entry.data.get(key))
return str(value) if value else None
return None
def annotate_for_target(
hass: HomeAssistant, proposals: list[dict[str, Any]], entry: Any, *, device_name: str = ""
) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
"""Split proposals against the target object's tasks (2.94).
A duty the target already has BY NAME (any language) goes to ``already``
with the existing task's name — shown, never adopted. The rest keeps
``covered_by`` (helpers.adopt_match.covering_task): a likely duplicate
under another name, which the dialog shows unticked.
"""
from ...const import CONF_TASKS
from ..adopt_match import covering_task
tasks = dict(entry.data.get(CONF_TASKS, {})) if entry is not None else {}
ignore = (device_name, entry.title if entry is not None else "")
by_lower = {str(t.get("name", "")).lower(): str(t.get("name", "")) for t in tasks.values()}
keep: list[dict[str, Any]] = []
already: list[dict[str, str]] = []
for proposal in proposals:
hit = set(by_lower) & proposal_name_variants(proposal["catalog_task_name"], proposal["entity_label"])
if hit:
already.append({"task_name": proposal["task_name"], "task_name_localized": proposal["task_name_localized"], "existing_name": by_lower[sorted(hit)[0]]})
continue
covered = (
covering_task(hass, (proposal["task_name_localized"], proposal["task_name"]), proposal["entity_ids"], tasks, ignore=ignore)
if tasks
else None
)
keep.append({**proposal, "covered_by": covered})
return keep, already
def discover_integration_setups(hass: HomeAssistant, *, targets: dict[str, str] | None = None) -> list[dict[str, Any]]:
"""Devices of catalogued integrations with their matchable task wiring.
Groups matched entities per device; carries the maintenance object already
@@ -88,8 +136,15 @@ def discover_integration_setups(hass: HomeAssistant) -> list[dict[str, Any]]:
"Clean Undercarriage" after "Replace Mower Blades" was adopted. A watcher
with a custom/renamed name conservatively claims the whole entity —
re-running discovery never re-proposes against a rename.
2.94: a device no object is linked to gets a ``candidate`` — the existing
object it most likely is (helpers.adopt_match.candidate_object) — which
becomes the default target, and every proposal is annotated against the
target (:func:`annotate_for_target`). ``targets`` overrides the target
per device for the dialog's preview (``""`` = a new object).
"""
from ...templates import localize_template_text
from ..adopt_match import candidate_object
from ..i18n import normalize_language
from ..problem_sensors import _object_by_device
@@ -127,7 +182,11 @@ def discover_integration_setups(hass: HomeAssistant) -> list[dict[str, Any]]:
else ""
).lower()
catalog_keys = {key for s in catalog.tasks for key in s.keys}
tk_auth = catalog.translation_keys_authoritative
appliance = _appliance_type(hass, device, integration, catalog.appliance_type_key)
for sig in catalog.tasks:
if appliance and appliance in sig.exclude_appliance_types:
continue
# Device-type gates: registry model substring and/or a
# type-identifying sibling entity (watched siblings still count —
# only the match TARGET must be unwatched).
@@ -136,7 +195,7 @@ def discover_integration_setups(hass: HomeAssistant) -> list[dict[str, Any]]:
if sig.models_exclude and any(m.lower() in model for m in sig.models_exclude):
continue
if sig.require_sibling_keys and not any(
any(_entity_matches(e, key) for key in sig.require_sibling_keys) for e in entries
any(_entity_matches(e, key, tk_authoritative=tk_auth) for key in sig.require_sibling_keys) for e in entries
):
continue
variants = task_name_variants(sig.task_name)
@@ -147,7 +206,9 @@ def discover_integration_setups(hass: HomeAssistant) -> list[dict[str, Any]]:
continue
# Empty keys (non-sensor domains only, tripwire-enforced) match
# the device's single entity of that domain — THE lawn_mower.
if sig.keys and not any(_matches_catalog_key(entry, key, catalog_keys) for key in sig.keys):
if sig.keys and not any(
_matches_catalog_key(entry, key, catalog_keys, tk_authoritative=tk_auth) for key in sig.keys
):
continue
group = matched.setdefault(device_id, {}).setdefault(
(integration, sig.task_name, sig.direction),
@@ -183,15 +244,15 @@ def discover_integration_setups(hass: HomeAssistant) -> list[dict[str, Any]]:
integration = next(iter(sig_map))[0]
catalog = SIGNATURES[integration]
suggested = by_device.get(device_id)
# Duties already present on the bound object BY NAME (any language) are
# not re-proposed — covers manually created calendar tasks whose
# trigger watches no entity (the entity-watched exclusion misses them).
existing_names: set[str] = set()
if suggested and (target := hass.config_entries.async_get_entry(suggested["entry_id"])):
from ...const import CONF_TASKS
existing_names = {str(t.get("name", "")).lower() for t in target.data.get(CONF_TASKS, {}).values()}
tasks = []
candidate = None if suggested else candidate_object(hass, device_id)
# The object the proposals are judged against: the linked one, else
# the candidate, else none (a new object) — or the dialog's choice.
if targets is not None and device_id in targets:
target_id = targets[device_id] or None
else:
target_id = suggested["entry_id"] if suggested else (candidate["entry_id"] if candidate else None)
target = hass.config_entries.async_get_entry(target_id) if target_id else None
raw: list[dict[str, Any]] = []
for (_integ, task_name, direction), group in sig_map.items():
entity_ids = sorted(group["entity_ids"])
if not entity_ids:
@@ -209,9 +270,7 @@ def discover_integration_setups(hass: HomeAssistant) -> list[dict[str, Any]]:
else:
proposals = [(entity_ids, None)]
for ids, label in proposals:
if existing_names and existing_names & proposal_name_variants(task_name, label):
continue
tasks.append(
raw.append(
{
# task_name stays the EN catalog key (adopt selections
# match on it) — suffixed with the entity label for
@@ -226,9 +285,16 @@ def discover_integration_setups(hass: HomeAssistant) -> list[dict[str, Any]]:
"direction": direction,
}
)
if not tasks:
# Duties already present on the target BY NAME (any language) are not
# proposed — covers manually created calendar tasks whose trigger
# watches no entity (the entity-watched exclusion misses them); since
# 2.94 they are listed as ``already`` so the dialog can say so.
tasks, already = annotate_for_target(hass, raw, target, device_name=device_name)
if not tasks and targets is None:
continue
tasks.sort(key=lambda t: (t["task_name"], t["direction"]))
from ...const import CONF_TASKS
out.append(
{
"device_id": device_id,
@@ -238,7 +304,11 @@ def discover_integration_setups(hass: HomeAssistant) -> list[dict[str, Any]]:
"integration_name": catalog.name,
"suggested_entry_id": suggested["entry_id"] if suggested else None,
"suggested_object_name": suggested["name"] if suggested else device_name,
"candidate": candidate,
"target_entry_id": target.entry_id if target is not None else None,
"target_task_count": len(target.data.get(CONF_TASKS, {})) if target is not None else 0,
"tasks": tasks,
"already": already,
}
)
out.sort(key=lambda s: (s["integration_name"], s["device_name"]))
@@ -31,7 +31,13 @@ Direction semantics:
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.
— 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
@@ -98,6 +104,14 @@ class ConsumableSignature:
# 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 = ""
@@ -118,6 +132,11 @@ class ConsumableSignature:
# 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)
@@ -130,6 +149,19 @@ class IntegrationSignature:
# 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]:
@@ -184,7 +216,7 @@ def entity_label(hass: HomeAssistant, entry: er.RegistryEntry, device_name: str)
return label.strip()[:60]
def _entity_matches(entry: er.RegistryEntry, key: str) -> bool:
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.
@@ -192,9 +224,16 @@ def _entity_matches(entry: er.RegistryEntry, key: str) -> bool:
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."""
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:
@@ -260,11 +299,16 @@ def _threshold_for(sig: ConsumableSignature, hass: HomeAssistant, entity_id: str
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)
@@ -288,22 +332,44 @@ def build_setup_trigger(sig: ConsumableSignature, hass: HomeAssistant, entity_id
counters, resetting the counter drops it back below the threshold), which
resolves the task just like a cleared problem sensor.
``event_present`` signatures build a single-entity state-change LATCH on the
``present`` state (Home Connect salt/rinse-aid/descale/clean events): the
task activates while the event is present and auto-completes when the
appliance clears it (to ``off``/``confirmed``).
``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":
return {
latch: dict[str, Any] = {
"type": "state_change",
"entity_id": entity_ids[0], # state latch watches a single entity
"entity_id": entity_ids[0], # legacy mirror; entity_ids is authoritative
"entity_ids": list(entity_ids),
# Home Connect events use "present"; other integrations latch on
# their own alert state (Dolphin filter bag: "full").
"trigger_to_state": sig.on_states[0] if sig.on_states else "present",
"trigger_target_changes": 1,
"auto_complete_on_recovery": True,
}
if 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.
@@ -8,6 +8,7 @@ integration's source; drift-probed weekly)."""
from __future__ import annotations
from ._model import ConsumableSignature, IntegrationSignature
from ._shared import FILTER_LIFE_PERCENT, HOOD_GREASE_FILTER_USAGE, VENTILATION_FILTER_OPERATING_TIME, VENTILATION_FILTER_REMAIN
SIGNATURES: dict[str, IntegrationSignature] = {
"hass_dyson": IntegrationSignature(
@@ -18,7 +19,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"(translation_key 'filter_life' for BOTH hepa and carbon "
"instances, PERCENTAGE) — one any-low task covers both filters."
),
tasks=(ConsumableSignature(("filter_life",), "Replace Filter", "percent_left"),),
tasks=(FILTER_LIFE_PERCENT,),
),
"dreo": IntegrationSignature(
name="Dreo",
@@ -26,22 +27,29 @@ SIGNATURES: dict[str, IntegrationSignature] = {
source=(
"JeffSteinbok/hass-dreo sensor.py (translation_key 'filter_life', unit '%', humidifiers with FILTERTIME support)."
),
tasks=(ConsumableSignature(("filter_life",), "Replace Filter", "percent_left"),),
tasks=(FILTER_LIFE_PERCENT,),
),
"vesync": IntegrationSignature(
name="VeSync (Levoit)",
verified="2026-07-19 @ core/dev vesync/sensor.py",
source="core vesync: tk 'filter_life', PERCENTAGE, MEASUREMENT (Levoit purifiers).",
tasks=(ConsumableSignature(("filter_life",), "Replace Filter", "percent_left"),),
tasks=(FILTER_LIFE_PERCENT,),
),
"daikin": IntegrationSignature(
name="Daikin AC",
verified="2026-07-18 @ home-assistant/core dev",
verified="2026-09-25 @ home-assistant/core dev (+ tag 2026.7.0)",
source=(
"home-assistant/core homeassistant/components/daikin/climate.py "
"(climate platform verified present; AC-only integration, so the "
"climate entity IS an air conditioner). Runtime on the hvac_action "
"ATTRIBUTE — the state only reports the standby mode. Interval per Daikin's official guidance (clean filters every 2 weeks; ≈100 runtime-hours at typical in-season use)."
"(AC-only integration, so the climate entity IS an air "
"conditioner). hvac_action is only mapped for COOL/HEAT/OFF "
"(HA_STATE_TO_CURRENT_HVAC) and is None in fan_only, dry and "
"heat_cool — the former hvac_action-attribute runtime missed "
"every hour in those modes and never reached its 'fan'/'drying' "
"states. Runtime on the climate STATE instead: DAIKIN_TO_HA_STATE "
"maps fan/dry/cool/hot/auto to fan_only/dry/cool/heat/heat_cool — "
"every mode except off moves air through the filter. Interval per "
"Daikin's official guidance (clean filters every 2 weeks; ≈100 "
"runtime-hours at typical in-season use)."
),
tasks=(
ConsumableSignature(
@@ -50,19 +58,25 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"runtime_hours",
delta_units=100,
entity_domain="climate",
attribute="hvac_action",
on_states=("cooling", "heating", "fan", "drying"),
on_states=("cool", "dry", "fan_only", "heat", "heat_cool"),
),
),
),
"gree": IntegrationSignature(
name="Gree AC",
verified="2026-07-18 @ home-assistant/core dev",
verified="2026-09-25 @ home-assistant/core dev (+ tag 2026.7.0)",
source=(
"home-assistant/core homeassistant/components/gree/climate.py "
"(climate platform verified present; AC-only integration, so the "
"climate entity IS an air conditioner). Runtime on the hvac_action "
"ATTRIBUTE — the state only reports the standby mode. Interval per Daikin's official guidance (clean filters every 2 weeks; ≈100 runtime-hours at typical in-season use)."
"(AC-only integration, so the climate entity IS an air "
"conditioner). The entity NEVER sets hvac_action (0 hits at "
"2026.7.0 and dev) — the former hvac_action-attribute runtime "
"never accumulated. Runtime on the climate STATE instead: "
"HVAC_MODES maps Mode.Auto/Cool/Dry/Fan/Heat to "
"auto/cool/dry/fan_only/heat and _attr_hvac_modes = "
"[*HVAC_MODES_INVERSE, HVACMode.OFF] — every mode except off "
"moves air through the filter. Interval per Daikin's official "
"guidance (clean filters every 2 weeks; ≈100 runtime-hours at "
"typical in-season use)."
),
tasks=(
ConsumableSignature(
@@ -71,8 +85,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"runtime_hours",
delta_units=100,
entity_domain="climate",
attribute="hvac_action",
on_states=("cooling", "heating", "fan", "drying"),
on_states=("auto", "cool", "dry", "fan_only", "heat"),
),
),
),
@@ -144,7 +157,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"A filter_expired problem binary also exists (adoption path)."
),
tasks=(
ConsumableSignature(("filter_life",), "Replace Filter", "percent_left"),
FILTER_LIFE_PERCENT,
ConsumableSignature(("wick_life",), "Replace Wick", "percent_left"),
ConsumableSignature(("water_refresher_life",), "Replace Water Refresher", "percent_left"),
),
@@ -171,7 +184,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"from filter hours vs the model's max filter life "
"(wrapper.filter_max_life; was filter_alarm_duration before 2026-09)."
),
tasks=(ConsumableSignature(("filter_life",), "Replace Filter", "percent_left"),),
tasks=(FILTER_LIFE_PERCENT,),
),
"duco": IntegrationSignature(
name="Duco ventilation",
@@ -190,12 +203,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"(adoption path)."
),
tasks=(
ConsumableSignature(
("air_filter_operating_time",),
"Replace Ventilation Filter",
"usage_above",
above_hours=4380,
),
VENTILATION_FILTER_OPERATING_TIME,
),
),
"tradfri": IntegrationSignature(
@@ -253,4 +261,161 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
tasks=(ConsumableSignature(("filter_install_time",), "Replace Filter", "usage_above", above_hours=300),),
),
# ─── Round 14 (2026-09-25): purifier/HRV filters from the HACS + ────
# ─── core 2026.9/dev sweep ───────────────────────────────────────────
"meross_lan": IntegrationSignature(
name="Meross LAN (MAP100 purifier)",
verified="2026-09-25 @ krahabb/meross_lan master",
source=(
"HACS meross_lan sensor.py MLFilterMaintenanceSensor "
"(Appliance.Control.FilterMaintenance; entitykey mc.KEY_FILTER "
"'filter', PERCENTAGE, DIAGNOSTIC, no translation_key). "
"helpers/entity.py names it entitykey.replace('_', ' ')"
".capitalize() = 'Filter' (channel 0 is dropped) under "
"has_entity_name → suffix _filter. The value is the payload's "
"'life' — REMAINING: the emulator (emulator/mixins/fan.py) starts "
"it at 100 and counts it down, and a real MAP100 push quoted in "
"issue #388 reads life: 100 on a fresh filter. No other "
"meross_lan entity key ends in 'filter' (the match is "
"platform-scoped)."
),
tasks=(ConsumableSignature(("filter",), "Replace Filter", "percent_left"),),
),
"tuya_local": IntegrationSignature(
name="Tuya Local",
verified="2026-09-25 @ make-all/tuya-local main",
source=(
"HACS tuya_local (not in the HACS default store) entity.py: "
"_attr_translation_key = config.translation_key; devices/*.yaml "
"carry 'translation_key: filter_life' with unit '%' in 57 device "
"configs (purifiers, humidifiers, dehumidifiers, robot vacuums, "
"fans) — the Tuya 'filter' DP, REMAINING life (it sits next to a "
"'Filter remaining' days DP on the Honeywell/Stadler Form "
"configs; a used-hours DP was moved off filter_life in #6202). "
"The duration-unit filter_life variants (d/h/min/s) are NOT "
"signed: some count UP without the 'invert' mapping "
"(fresco_hydrateultra_petfountain v1 vs v2). 'entity: lock' in "
"these configs is the CHILD lock — no lock signature."
),
tasks=(FILTER_LIFE_PERCENT,),
),
"govee": IntegrationSignature(
name="Govee (purifiers)",
verified="2026-09-25 @ lasswellt/govee-homeassistant main",
source=(
"HACS govee (lasswellt) sensor.py GoveeFilterLifeSensor: tk "
"'sensor_filter_life', PERCENTAGE, MEASUREMENT, DIAGNOSTIC — "
"'remaining filter life %' from the devices.capabilities.property "
"instance 'filterLifeTime' (models/state.py; H7124/H7126). "
"LaggAt/hacs-govee shares the 'govee' domain but ships lights "
"only — no conflicting entity."
),
tasks=(ConsumableSignature(("sensor_filter_life",), "Replace Filter", "percent_left"),),
),
"duux": IntegrationSignature(
name="Duux",
verified="2026-09-25 @ SSmale/Duux-Home-Assistant master",
source=(
"HACS duux sensor.py DuuxFilterLifeSensor (Bright 2 purifier): "
"key 'filter', tk 'filter_life', PERCENTAGE, MEASUREMENT — "
"translations name it 'HEPA Filter remaining lifespan' "
"(REMAINING; fixtures/devices.json reports filter: 80)."
),
tasks=(FILTER_LIFE_PERCENT,),
),
"komfovent": IntegrationSignature(
name="Komfovent ventilation",
verified="2026-09-25 @ lnagel/hass-komfovent main",
source=(
"HACS komfovent sensor.py: key 'filter_clogging' (Modbus register "
"917, PERCENTAGE, MEASUREMENT; KomfoventSensor sets "
"translation_key = key). Clogging climbs to 100 % (warning 0x81 "
"'Change Air Filter'); the 'Clean Filters Calibration' button "
"resets it after a change → alert_above 90 % with auto-resolve."
),
tasks=(ConsumableSignature(("filter_clogging",), "Replace Ventilation Filter", "alert_above", delta_units=90),),
),
"pluggit": IntegrationSignature(
name="Pluggit ventilation",
verified="2026-09-25 @ Tvalley71/pluggit main",
source=(
"HACS pluggit device_map.py: key ATTR_FILTER_REMAIN "
"'filter_remain' (DURATION, unit 'd'; entity.py's translation_key "
"property returns the key) — Modbus register 554 = filter days "
"REMAINING (device.py derives the replace level from lifetime − "
"remain; the translated state 0 reads 'Replace the filter now'). "
"Absent on Servo_flow units (not_component_class)."
),
tasks=(VENTILATION_FILTER_REMAIN,),
),
"dantherm": IntegrationSignature(
name="Dantherm ventilation",
verified="2026-09-25 @ Tvalley71/dantherm main",
source=(
"HACS dantherm — the same code base as pluggit (device_map.py "
"key 'filter_remain', DURATION, unit 'd', Modbus register 554 = "
"filter days REMAINING; translation_key = key)."
),
tasks=(VENTILATION_FILTER_REMAIN,),
),
"ha_carrier": IntegrationSignature(
name="Carrier Infinity",
verified="2026-09-25 @ dahlb/ha_carrier main",
source=(
"HACS ha_carrier sensor.py FilterUsedSensor: entity_name 'Filter "
"Remaining' (carrier_entity.py sets _attr_name = entity_name "
"under has_entity_name, no translation_key → suffix "
"_filter_remaining), PERCENTAGE = 100 − status.filter_used → "
"REMAINING."
),
tasks=(ConsumableSignature(("filter_remaining",), "Replace Filter", "percent_left"),),
),
"localthings": IntegrationSignature(
name="Samsung (Local Things)",
verified="2026-09-25 @ mbillow/localthings main",
source=(
"HACS localthings registry/capabilities: every % filter sensor "
"is Samsung's filterUsage = % USED (common.filter_usage_percent: "
"filterStatus flips to 'wash' at 100); entity.py's "
"translation_key defaults to the descriptor key. "
"airconditioner.AIR_FILTER 'air_filter_usage' (AC + dehumidifier "
"washable dust filter) → Filter Cleaning, gated on its AC-only "
"sibling 'air_filter_usage_hours' because fridge.AIR_FILTER "
"reuses the 'air_filter_usage' key for the fridge's deodorizing "
"filter; air_purifier.HEPA_FILTER 'hepa_filter_usage' + "
"air_purifier.FILTER 'filter_progress' (counts UP, 100 = change) "
"→ Replace Filter; range_hood.HOOD_FILTER 'hood_filter_usage' → "
"Clean Grease Filter (mirrors the core smartthings entry). "
"Skipped: the shared water-filter key 'filter_usage' (fridge, "
"dishwasher, water purifier AND the AMF microfiber lint unit on "
"the washer registry — one key, different duties)."
),
tasks=(
ConsumableSignature(
("air_filter_usage",),
"Filter Cleaning",
"alert_above",
delta_units=90,
require_sibling_keys=("air_filter_usage_hours",),
),
ConsumableSignature(("hepa_filter_usage", "filter_progress"), "Replace Filter", "alert_above", delta_units=90),
HOOD_GREASE_FILTER_USAGE,
),
),
"flexit": IntegrationSignature(
name="Flexit (Modbus)",
verified="2026-09-25 @ home-assistant/core dev",
source=(
"core flexit (Modbus CI66; config flow since 2026.9, the sensor "
"and binary_sensor platforms are on dev = 2026.10): tk "
"'air_filter_operating_time' (DURATION, HOURS, TOTAL_INCREASING; "
"flexit_modbus Measurements.filter_running_hours, input register "
"8 — the filter timer behind the filter alarm, not a unit "
"runtime) → usage_above at 4380 h, mirroring flexit_bacnet. A "
"'filter_alarm' problem binary also exists (adoption path)."
),
tasks=(
VENTILATION_FILTER_OPERATING_TIME,
),
),
}
@@ -8,6 +8,18 @@ integration's source; drift-probed weekly)."""
from __future__ import annotations
from ._model import ConsumableSignature, IntegrationSignature
from ._shared import (
ANNUAL_SERVICE_DISTANCE_TO_SERVICE,
ANNUAL_SERVICE_MILEAGE,
ANNUAL_SERVICE_ODOMETER,
ANNUAL_SERVICE_TESLA_ODOMETER,
BIKE_CHAIN_ODOMETER,
BIKE_CHAIN_TOTAL_DISTANCE,
BIKE_SERVICE_TOTAL_DISTANCE,
TIRE_ROTATION_MILEAGE,
TIRE_ROTATION_ODOMETER,
TIRE_ROTATION_TESLA_ODOMETER,
)
SIGNATURES: dict[str, IntegrationSignature] = {
"kia_uvo": IntegrationSignature(
@@ -20,8 +32,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"(target vs remaining) are unverified — odometer delta instead."
),
tasks=(
ConsumableSignature(("odometer",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("odometer",), "Tire Rotation", "usage_delta", delta_units=10000),
ANNUAL_SERVICE_ODOMETER,
TIRE_ROTATION_ODOMETER,
),
),
"tesla_custom": IntegrationSignature(
@@ -33,8 +45,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"translation_key; DISTANCE, TOTAL_INCREASING, native miles)."
),
tasks=(
ConsumableSignature(("odometer",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("odometer",), "Tire Rotation", "usage_delta", delta_units=10000),
ANNUAL_SERVICE_ODOMETER,
TIRE_ROTATION_ODOMETER,
),
),
"renault": IntegrationSignature(
@@ -45,8 +57,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"(translation_key 'mileage', DISTANCE, TOTAL_INCREASING, km)."
),
tasks=(
ConsumableSignature(("mileage",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("mileage",), "Tire Rotation", "usage_delta", delta_units=10000),
ANNUAL_SERVICE_MILEAGE,
TIRE_ROTATION_MILEAGE,
),
),
"mbapi2020": IntegrationSignature(
@@ -58,8 +70,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"serviceintervaldays/distance) — lifetime km counter."
),
tasks=(
ConsumableSignature(("odometer",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("odometer",), "Tire Rotation", "usage_delta", delta_units=10000),
ANNUAL_SERVICE_ODOMETER,
TIRE_ROTATION_ODOMETER,
),
),
"vw_eu_data_act": IntegrationSignature(
@@ -72,8 +84,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"unofficial WeConnect APIs were locked down upstream)."
),
tasks=(
ConsumableSignature(("mileage",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("mileage",), "Tire Rotation", "usage_delta", delta_units=10000),
ANNUAL_SERVICE_MILEAGE,
TIRE_ROTATION_MILEAGE,
),
),
"subaru": IntegrationSignature(
@@ -81,8 +93,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
verified="2026-07-18 @ home-assistant/core dev",
source=("home-assistant/core homeassistant/components/subaru/sensor.py (key sc.ODOMETER, translation_key 'odometer')."),
tasks=(
ConsumableSignature(("odometer",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("odometer",), "Tire Rotation", "usage_delta", delta_units=10000),
ANNUAL_SERVICE_ODOMETER,
TIRE_ROTATION_ODOMETER,
),
),
"volvo": IntegrationSignature(
@@ -90,8 +102,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
verified="2026-07-18 @ home-assistant/core dev",
source=("home-assistant/core homeassistant/components/volvo/sensor.py (key 'odometer', api_field 'odometer')."),
tasks=(
ConsumableSignature(("odometer",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("odometer",), "Tire Rotation", "usage_delta", delta_units=10000),
ANNUAL_SERVICE_ODOMETER,
TIRE_ROTATION_ODOMETER,
),
),
"polestar_api": IntegrationSignature(
@@ -112,8 +124,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
verified="2026-07-19 @ itchannel/fordpass-ha master sensor.py",
source="HACS fordpass: dict-key 'odometer' sensor (name-style, suffix match).",
tasks=(
ConsumableSignature(("odometer",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("odometer",), "Tire Rotation", "usage_delta", delta_units=10000),
ANNUAL_SERVICE_ODOMETER,
TIRE_ROTATION_ODOMETER,
),
),
"toyota": IntegrationSignature(
@@ -121,20 +133,23 @@ SIGNATURES: dict[str, IntegrationSignature] = {
verified="2026-07-19 @ DurgNomis-drol/ha_toyota master sensor.py",
source="HACS toyota: tk 'odometer', DISTANCE, TOTAL_INCREASING.",
tasks=(
ConsumableSignature(("odometer",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("odometer",), "Tire Rotation", "usage_delta", delta_units=10000),
ANNUAL_SERVICE_ODOMETER,
TIRE_ROTATION_ODOMETER,
),
),
"mg_saic": IntegrationSignature(
name="MG/SAIC iSMART",
verified="2026-07-19 @ ad-ha/mg-saic-ha main sensor.py (HACS default)",
verified="2026-09-25 @ townsmcp/mg-saic-ha main sensor.py (HACS default)",
source=(
"HACS mg_saic: 'Mileage' sensor (suffix _mileage; the sibling "
"HACS mg_saic (repo moved ad-ha/mg-saic-ha → townsmcp/mg-saic-ha, "
"hacs/default #9319; code identical): SAICMGMileageSensor "
"'Mileage' (field 'mileage', KILOMETERS, total_increasing; name "
"'{brand} {model} Mileage' → suffix _mileage; the sibling "
"'Mileage Since Last Charge' does not end in _mileage — no clash)."
),
tasks=(
ConsumableSignature(("mileage",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("mileage",), "Tire Rotation", "usage_delta", delta_units=10000),
ANNUAL_SERVICE_MILEAGE,
TIRE_ROTATION_MILEAGE,
),
),
"myskoda": IntegrationSignature(
@@ -149,7 +164,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"editorial 15000 km interval here."
),
tasks=(
ConsumableSignature(("mileage",), "Tire Rotation", "usage_delta", delta_units=10000),
TIRE_ROTATION_MILEAGE,
ConsumableSignature(("inspection",), "Annual Service", "duration_left", below_hours=336),
ConsumableSignature(("inspection_in_km",), "Annual Service", "value_below", delta_units=1000),
ConsumableSignature(("oil_service_in_days",), "Oil Service", "duration_left", below_hours=336),
@@ -168,7 +183,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"Countdowns replace the generic odometer service duty."
),
tasks=(
ConsumableSignature(("mileage",), "Tire Rotation", "usage_delta", delta_units=10000),
TIRE_ROTATION_MILEAGE,
ConsumableSignature(("service_inspection_time",), "Annual Service", "duration_left", below_hours=336),
ConsumableSignature(("service_inspection_distance",), "Annual Service", "value_below", delta_units=1000),
ConsumableSignature(("oil_change_time",), "Oil Service", "duration_left", below_hours=336),
@@ -184,8 +199,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
verified="2026-07-20 @ home-assistant/core dev",
source=("core tesla_fleet: key/tk 'vehicle_state_odometer' (TOTAL_INCREASING, MILES, DISTANCE)."),
tasks=(
ConsumableSignature(("vehicle_state_odometer",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("vehicle_state_odometer",), "Tire Rotation", "usage_delta", delta_units=10000),
ANNUAL_SERVICE_TESLA_ODOMETER,
TIRE_ROTATION_TESLA_ODOMETER,
),
),
"teslemetry": IntegrationSignature(
@@ -193,8 +208,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
verified="2026-07-20 @ home-assistant/core dev",
source=("core teslemetry: key/tk 'vehicle_state_odometer' (TOTAL_INCREASING, MILES, DISTANCE)."),
tasks=(
ConsumableSignature(("vehicle_state_odometer",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("vehicle_state_odometer",), "Tire Rotation", "usage_delta", delta_units=10000),
ANNUAL_SERVICE_TESLA_ODOMETER,
TIRE_ROTATION_TESLA_ODOMETER,
),
),
"tessie": IntegrationSignature(
@@ -202,8 +217,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
verified="2026-07-20 @ home-assistant/core dev",
source=("core tessie: key/tk 'vehicle_state_odometer' (TOTAL_INCREASING, MILES, DISTANCE)."),
tasks=(
ConsumableSignature(("vehicle_state_odometer",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("vehicle_state_odometer",), "Tire Rotation", "usage_delta", delta_units=10000),
ANNUAL_SERVICE_TESLA_ODOMETER,
TIRE_ROTATION_TESLA_ODOMETER,
),
),
"ituran": IntegrationSignature(
@@ -211,8 +226,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
verified="2026-07-20 @ home-assistant/core dev",
source=("core ituran: tk 'mileage' (KILOMETERS, DISTANCE) — fleet-tracker odometer."),
tasks=(
ConsumableSignature(("mileage",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("mileage",), "Tire Rotation", "usage_delta", delta_units=10000),
ANNUAL_SERVICE_MILEAGE,
TIRE_ROTATION_MILEAGE,
),
),
"starline": IntegrationSignature(
@@ -220,8 +235,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
verified="2026-07-20 @ home-assistant/core dev",
source=("core starline: tk 'mileage' (KILOMETERS, TOTAL_INCREASING) — alarm-system odometer."),
tasks=(
ConsumableSignature(("mileage",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("mileage",), "Tire Rotation", "usage_delta", delta_units=10000),
ANNUAL_SERVICE_MILEAGE,
TIRE_ROTATION_MILEAGE,
),
),
"bosch_ebike": IntegrationSignature(
@@ -236,8 +251,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"re-baselines on completion, like the car duties."
),
tasks=(
ConsumableSignature(("total_distance",), "Lubricate Chain", "usage_delta", delta_units=250),
ConsumableSignature(("total_distance",), "Bike Service", "usage_delta", delta_units=2000),
BIKE_CHAIN_TOTAL_DISTANCE,
BIKE_SERVICE_TOTAL_DISTANCE,
),
),
"stromer": IntegrationSignature(
@@ -248,8 +263,210 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"— the eBike's odometer. Same drivetrain duties as Bosch eBike."
),
tasks=(
ConsumableSignature(("total_distance",), "Lubricate Chain", "usage_delta", delta_units=250),
ConsumableSignature(("total_distance",), "Bike Service", "usage_delta", delta_units=2000),
BIKE_CHAIN_TOTAL_DISTANCE,
BIKE_SERVICE_TOTAL_DISTANCE,
),
),
# --- Round 14 (2026-09-25) --------------------------------------------
"stellantis_vehicles": IntegrationSignature(
name="Stellantis (Peugeot/Citroën/DS/Opel/Fiat…)",
verified="2026-09-25 @ andreadegiovine/homeassistant-stellantis-vehicles develop",
source=(
"HACS stellantis_vehicles const.py SENSORS_DEFAULT (sensor.py sets "
"translation_key = key): 'mileage' (odometer.mileage, KILOMETERS, "
"TOTAL_INCREASING), 'mileage_before_maintenance' (maintenance."
"mileageBeforeMaintenance, KILOMETERS remaining) and "
"'days_before_maintenance' (maintenance.daysBeforeMaintenance, "
"DURATION DAYS remaining) — the car's own service countdowns, so "
"no editorial 15000 km interval (myskoda precedent)."
),
tasks=(
TIRE_ROTATION_MILEAGE,
ConsumableSignature(("days_before_maintenance",), "Annual Service", "duration_left", below_hours=336),
ConsumableSignature(("mileage_before_maintenance",), "Annual Service", "value_below", delta_units=1000),
),
),
"cardata": IntegrationSignature(
name="BMW CarData (bmw-cardata-ha)",
verified="2026-09-25 @ kvanbiesen/bmw-cardata-ha main",
source=(
"HACS cardata (not in the default store): descriptor "
"'vehicle.vehicle.travelledDistance' titled 'Vehicle mileage' "
"(descriptor_titles.py; entity.py names '{vehicle} {title}', no "
"translation_key → suffix _vehicle_mileage); sensor.py forces "
"TOTAL_INCREASING for DESC_TRAVELLED_DISTANCE; the unit is the "
"stream's own (units.py normalises to km/mi) — the unit-aware "
"threshold converts."
),
tasks=(
ConsumableSignature(("vehicle_mileage",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("vehicle_mileage",), "Tire Rotation", "usage_delta", delta_units=10000),
),
),
"bavariandata": IntegrationSignature(
name="BavarianData (BMW CarData)",
verified="2026-09-25 @ JustChr/BavarianData main",
source=(
"HACS bavariandata: catalogue descriptor 'vehicle.vehicle."
"travelledDistance' (descriptor_metadata.py: distance, "
"total_increasing, km) → keys.translation_key() = "
"'vehicle_vehicle_travelleddistance' (entity.py). Cars streaming "
"'vehicle.vehicle.mileage' instead get an uncatalogued name-only "
"sensor without a fixed unit — deliberately not matched."
),
tasks=(
ConsumableSignature(("vehicle_vehicle_travelleddistance",), "Annual Service", "usage_delta", delta_units=15000),
ConsumableSignature(("vehicle_vehicle_travelleddistance",), "Tire Rotation", "usage_delta", delta_units=10000),
),
),
"uconnect": IntegrationSignature(
name="Uconnect (Jeep/Fiat/Chrysler/Dodge/Ram/Alfa Romeo)",
verified="2026-09-25 @ hass-uconnect/hass-uconnect main",
source=(
"HACS uconnect sensor.py (no translation_key; name '{make} "
"{nickname|model} <name>' → entity_id suffix): 'Odometer' (key "
"odometer, TOTAL_INCREASING, API unit km/mi), 'Distance to "
"Service' (distanceToService, remaining), 'Days till service "
"needed' (daysToService, DAYS remaining) and 'Oil Life' (key "
"oil_level = py_uconnect oilLevel, PERCENTAGE remaining). The "
"service countdowns replace the generic odometer service duty."
),
tasks=(
TIRE_ROTATION_ODOMETER,
ConsumableSignature(("days_till_service_needed",), "Annual Service", "duration_left", below_hours=336),
ANNUAL_SERVICE_DISTANCE_TO_SERVICE,
ConsumableSignature(("oil_life",), "Oil Service", "percent_left"),
),
),
"porscheconnect": IntegrationSignature(
name="Porsche Connect",
verified="2026-09-25 @ CJNE/ha-porscheconnect main",
source=(
"HACS porscheconnect sensor.py: tk 'mileage' (MILEAGE.kilometers, "
"TOTAL_INCREASING), 'main_service_range'/'main_service_time' "
"('Next service in', km resp. DAYS remaining) and "
"'oil_service_range'/'oil_service_time' ('Next oil change in'). "
"The car's own countdowns replace the generic odometer service "
"duty; the intermediate-service pair is left out (no duty name)."
),
tasks=(
TIRE_ROTATION_MILEAGE,
ConsumableSignature(("main_service_time",), "Annual Service", "duration_left", below_hours=336),
ConsumableSignature(("main_service_range",), "Annual Service", "value_below", delta_units=1000),
ConsumableSignature(("oil_service_time",), "Oil Service", "duration_left", below_hours=336),
ConsumableSignature(("oil_service_range",), "Oil Service", "value_below", delta_units=1000),
),
),
"nissan_connect": IntegrationSignature(
name="Nissan Connect",
verified="2026-09-25 @ dan-r/HomeAssistant-NissanConnect main",
source=(
"HACS nissan_connect sensor.py OdometerSensor (_attr_translation_key "
"'odometer', DISTANCE, TOTAL_INCREASING, native KILOMETERS with "
"suggested MILES for imperial accounts)."
),
tasks=(
ANNUAL_SERVICE_ODOMETER,
TIRE_ROTATION_ODOMETER,
),
),
"smartcar": IntegrationSignature(
name="Smartcar",
verified="2026-09-25 @ wbyoung/smartcar main",
source=(
"HACS smartcar sensor.py: 'Odometer' (key odometer, "
"odometer-traveleddistance, DISTANCE, TOTAL_INCREASING, native km "
"with imperial conversion; has_entity_name, no translation_key → "
"suffix _odometer). 'Engine Oil Life' deliberately NOT matched: "
"the API's lifeRemaining is passed through without the ×100 the "
"sibling percent sensors apply — its scale is unverified."
),
tasks=(
ANNUAL_SERVICE_ODOMETER,
TIRE_ROTATION_ODOMETER,
),
),
"lucidmotors": IntegrationSignature(
name="Lucid Motors",
verified="2026-09-25 @ borski/ha-lucidmotors main",
source=(
"HACS lucidmotors sensor.py: key 'odometer_km' (state.chassis) with "
"translation_key 'mileage', DISTANCE, KILOMETERS — the lifetime "
"odometer (declared MEASUREMENT, but only ever grows)."
),
tasks=(
ANNUAL_SERVICE_MILEAGE,
TIRE_ROTATION_MILEAGE,
),
),
"abrp": IntegrationSignature(
name="ABRP (A Better Routeplanner)",
verified="2026-09-25 @ MichelFR/ha-abrp main",
source=("HACS abrp sensor.py: tk 'odometer' (telemetry odometer_km, KILOMETERS, DISTANCE, TOTAL_INCREASING)."),
tasks=(
ANNUAL_SERVICE_ODOMETER,
TIRE_ROTATION_ODOMETER,
),
),
# Separate domain from pypolestar's polestar_api above.
"polestar": IntegrationSignature(
name="Unofficial Polestar",
verified="2026-09-25 @ kildahldev/unofficial-polestar-api main",
source=(
"HACS polestar (kildahldev; domain 'polestar', not polestar_api) "
"sensor.py, name-based ids (no translation_key): 'Odometer' "
"(odometer_km, KILOMETERS, TOTAL_INCREASING), 'Distance to "
"service' (health.distance_to_service_km, remaining) and 'Days to "
"service' (health.days_to_service, unit 'd'). Countdowns replace "
"the generic odometer service duty."
),
tasks=(
TIRE_ROTATION_ODOMETER,
ConsumableSignature(("days_to_service",), "Annual Service", "duration_left", below_hours=336),
ANNUAL_SERVICE_DISTANCE_TO_SERVICE,
),
),
"specialized_turbo": IntegrationSignature(
name="Specialized Turbo eBike",
verified="2026-09-25 @ home-assistant/core dev",
source=(
"core specialized_turbo sensor.py (new in 2026.9): tk 'odometer' "
"(motor.odometer_km, KILOMETERS, DISTANCE, TOTAL_INCREASING). Same "
"drivetrain duties as Bosch eBike."
),
tasks=(
BIKE_CHAIN_ODOMETER,
ConsumableSignature(("odometer",), "Bike Service", "usage_delta", delta_units=2000),
),
),
"cowboy": IntegrationSignature(
name="Cowboy eBike",
verified="2026-09-25 @ elsbrock/cowboy-ha main",
source=(
"HACS cowboy sensor.py: tk 'total_distance' (KILOMETERS, DISTANCE, "
"TOTAL_INCREASING). Cowboy bikes run a carbon belt drive — no "
"chain to lubricate, so only the drivetrain service interval."
),
tasks=(BIKE_SERVICE_TOTAL_DISTANCE,),
),
"ha_bosch_ebike": IntegrationSignature(
name="Bosch eBike (Smart System & eBike System 2)",
verified="2026-09-25 @ Xunil99/ha-bosch-ebike main",
source=(
"HACS ha_bosch_ebike (separate domain from bosch_ebike) sensor.py "
"BoschServiceDueSensor: tk 'service_due_in_km' (km remaining to "
"the dealer-set/overridden service odometer) and "
"'service_due_in_days' (unit 'd', days remaining) — created for "
"every bike, BES2 included. tk 'odometer' (km, driveUnit.odometer "
"/ 1000) drives the chain duty; translation_keys_authoritative keeps "
"the suffix fallback from also claiming next_service_odometer and "
"last_ride_start_odometer (their own translation_keys)."
),
translation_keys_authoritative=True,
tasks=(
ConsumableSignature(("service_due_in_days",), "Bike Service", "duration_left", below_hours=336),
ConsumableSignature(("service_due_in_km",), "Bike Service", "value_below", delta_units=100),
BIKE_CHAIN_ODOMETER,
),
),
}
@@ -1,4 +1,4 @@
"""Robot lawn mowers.
"""Robot lawn mowers, irrigation and pool/spa water care.
Data module of the suggested-setups signature catalog — see
``helpers/signatures/_model.py`` for the direction semantics and the
@@ -8,6 +8,13 @@ integration's source; drift-probed weekly)."""
from __future__ import annotations
from ._model import ConsumableSignature, IntegrationSignature
from ._shared import (
LANDROID_BLADE_RUNTIME,
LANDROID_UNDERCARRIAGE_RUNTIME,
MOWER_BLADES_RUNTIME,
MOWER_UNDERCARRIAGE_RUNTIME,
POOL_SALT_LOW,
)
SIGNATURES: dict[str, IntegrationSignature] = {
"husqvarna_automower": IntegrationSignature(
@@ -39,8 +46,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"(translation_key 'blade_runtime_current' — since last reset, DURATION min→h)"
),
tasks=(
ConsumableSignature(("blade_runtime_current",), "Replace Blades", "usage_above"),
ConsumableSignature(("mower_runtime_total",), "Clean Undercarriage", "usage_delta", delta_units=25),
LANDROID_BLADE_RUNTIME,
LANDROID_UNDERCARRIAGE_RUNTIME,
),
),
"gardena_smart_system": IntegrationSignature(
@@ -72,22 +79,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"runtime trigger on the lawn_mower entity."
),
tasks=(
ConsumableSignature(
(),
"Replace Blades",
"runtime_hours",
delta_units=100,
entity_domain="lawn_mower",
on_states=("mowing",),
),
ConsumableSignature(
(),
"Clean Undercarriage",
"runtime_hours",
delta_units=25,
entity_domain="lawn_mower",
on_states=("mowing",),
),
MOWER_BLADES_RUNTIME,
MOWER_UNDERCARRIAGE_RUNTIME,
),
),
"sunseeker": IntegrationSignature(
@@ -138,22 +131,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"counter) — the ENGINE accumulates mowing time."
),
tasks=(
ConsumableSignature(
(),
"Replace Blades",
"runtime_hours",
delta_units=100,
entity_domain="lawn_mower",
on_states=("mowing",),
),
ConsumableSignature(
(),
"Clean Undercarriage",
"runtime_hours",
delta_units=25,
entity_domain="lawn_mower",
on_states=("mowing",),
),
MOWER_BLADES_RUNTIME,
MOWER_UNDERCARRIAGE_RUNTIME,
),
),
"rainbird": IntegrationSignature(
@@ -194,6 +173,123 @@ SIGNATURES: dict[str, IntegrationSignature] = {
name="Ondilo ICO",
verified="2026-07-20 @ home-assistant/core dev",
source=("core ondilo_ico: tk 'salt' (mg/L ≡ ppm numerically) — pool salt concentration."),
tasks=(ConsumableSignature(("salt",), "Refill Pool Salt", "value_below", delta_units=2700),),
tasks=(POOL_SALT_LOW,),
),
# --- Round 14 (2026-09-25) --------------------------------------------
"mammotion": IntegrationSignature(
name="Mammotion (Luba)",
verified="2026-09-25 @ mikey0000/Mammotion-HA main",
source=(
"HACS mammotion (not in the default store) sensor.py "
"LUBA_SENSOR_ONLY_TYPES (MammotionSensorEntity sets translation_key "
"= key): 'blade_used_time' (maintenance.blade_used_time, SECONDS, "
"suggested h — counts since the blade reset; lawn_mower.py service "
"reset_blade_time) and 'maintenance_work_time' ('Total work "
"time', SECONDS, lifetime). Yuka models carry no blade sensors."
),
tasks=(
ConsumableSignature(("blade_used_time",), "Replace Blades", "usage_above"),
ConsumableSignature(("maintenance_work_time",), "Clean Undercarriage", "usage_delta", delta_units=25),
),
),
"dreame_mower": IntegrationSignature(
name="Dreame Mower",
verified="2026-09-25 @ bhuebschen/dreame-mower main",
source=(
"HACS dreame_mower sensor.py DreameMowerProperty.BLADES_LEFT → "
"key/translation_key 'blades_left' (dreame/const.py; siid 9 piid "
"2, UNIT_PERCENT — device.blades_life 'blade remaining life in "
"percent'; only created when the mower reports it, exists_fn)."
),
tasks=(ConsumableSignature(("blades_left",), "Replace Blades", "percent_left"),),
),
# Two HACS forks share the domain and the keys (SmartServicePL and
# ADNPolymerase) — mirrors landroid_cloud.
"worx_vision_cloud": IntegrationSignature(
name="Worx Landroid Vision",
verified=("2026-09-25 @ SmartServicePL/worx_vision_cloud_plus_github main + ADNPolymerase/ha-landroid-vision main"),
source=(
"HACS worx_vision_cloud sensor.py (both forks): tk "
"'blade_runtime_current' (blades.current_on or blade_work_time "
"minus the reset marker — since the last blade reset, MINUTES) "
"and 'mower_runtime_total' (worktime_total, MINUTES, "
"TOTAL_INCREASING lifetime)."
),
tasks=(
LANDROID_BLADE_RUNTIME,
LANDROID_UNDERCARRIAGE_RUNTIME,
),
),
"intellicenter": IntegrationSignature(
name="Pentair IntelliCenter",
verified="2026-09-25 @ joyfulhouse/intellicenter main",
source=(
"HACS intellicenter sensor.py: chlorinator (CHLORINATOR_SUBTYPE) "
"PoolSensor on SALT_ATTR, name '+ (Salt)' → '<IntelliChlor> "
"(Salt)', CONCENTRATION_PPM (no translation_key → suffix _salt). "
"Same IntelliChlor band as ScreenLogic."
),
tasks=(POOL_SALT_LOW,),
),
"hotspring": IntegrationSignature(
name="Hot Spring spas",
verified="2026-09-25 @ home-assistant/core dev",
source=(
"core hotspring sensor.py (new in 2026.9): tk "
"'water_care_120_day_timer' ('Salt cartridge age' — per the "
"integration docs 'number of days the FreshWater Salt System "
"cartridge has been in use', i.e. counts UP; DURATION DAYS; only "
"created while a cartridge is installed; python-hotspring passes "
"the spa's raw 120DayTimer). Hot Spring rates the cartridge for "
"up to four months → 120 days = 2,880 h."
),
tasks=(ConsumableSignature(("water_care_120_day_timer",), "Replace Salt Cartridge", "usage_above", above_hours=2880),),
),
"neopool": IntegrationSignature(
name="Sugar Valley NeoPool",
verified="2026-09-25 @ home-assistant/core dev",
source=(
"core neopool binary_sensor.py (platform on dev, i.e. 2026.10): tk "
"'uv_lamp' (RUNNING; neopool_modbus decodes the UV relay bit, "
"created only for a valid MBF_PAR_UV_RELAY_GPIO). No lamp-hours "
"counter — the ENGINE accumulates lamp-on time. Sugar Valley "
"UVScenic manual (routine maintenance): UV lamps last '1 year or "
"8,000 hours'. NOTE: 'measure_cl' is chlorine ppm despite its "
"'Salt level' label and is never mapped to salt; "
"cell_runtime_part has no documented cleaning interval (cells "
"self-clean by polarity reversal) and is not used."
),
tasks=(
ConsumableSignature(
("uv_lamp",),
"Replace UV Lamp",
"runtime_hours",
delta_units=8000,
entity_domain="binary_sensor",
on_states=("on",),
),
),
),
"bestway": IntegrationSignature(
name="Bestway (Lay-Z-Spa / Flowclear)",
verified="2026-09-25 @ cdpuk/ha-bestway main",
source=(
"HACS bestway binary_sensor.py PoolFilterChangeRequiredSensor "
"(key 'pool_filter_change_required', name 'Pool Filter Change "
"Required', no device class → not adoptable as a problem sensor; "
"status.filter_change_required from the pump's 'filter' "
"attribute). No has_entity_name → entity_id "
"'binary_sensor.pool_filter_change_required' (exact object-id "
"match). State latch on 'on'; clears after the change."
),
tasks=(
ConsumableSignature(
("pool_filter_change_required",),
"Replace Filter",
"event_present",
entity_domain="binary_sensor",
on_states=("on",),
),
),
),
}
@@ -8,6 +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
SIGNATURES: dict[str, IntegrationSignature] = {
"vicare": IntegrationSignature(
@@ -63,7 +64,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
name="Plugwise (Anna/Adam)",
verified="2026-07-19 @ core/dev plugwise/sensor.py",
source="core plugwise: tk 'water_pressure', BAR, MEASUREMENT (boiler loop).",
tasks=(ConsumableSignature(("water_pressure",), "Refill Heating Water", "value_below", delta_units=1),),
tasks=(HEATING_WATER_PRESSURE_LOW,),
),
"incomfort": IntegrationSignature(
name="Intergas InComfort",
@@ -171,7 +172,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"suffix _salt_level). 'Out of salt estimated day' is a DATE "
"sensor - parked for the date direction."
),
tasks=(ConsumableSignature(("salt_level",), "Refill Softener Salt", "percent_left"),),
tasks=(SOFTENER_SALT_LEVEL,),
),
"fumis": IntegrationSignature(
name="Fumis (pellet stoves)",
@@ -211,4 +212,110 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
),
),
# ─── Round 14 (2026-09-25): boiler pressure, softener salt, ─────────
# ─── generator engine hours ──────────────────────────────────────────
"de_dietrich": IntegrationSignature(
name="De Dietrich (Diematic)",
verified="2026-09-25 @ home-assistant/core dev",
source=(
"core de_dietrich (new on dev = 2026.10, Diematic Modbus): tk "
"'water_pressure', UnitOfPressure.BAR, MEASUREMENT (boiler loop)."
),
tasks=(HEATING_WATER_PRESSURE_LOW,),
),
"remeha_home": IntegrationSignature(
name="Remeha Home",
verified="2026-09-25 @ msvisser/remeha_home main",
source=(
"HACS remeha_home const.py APPLIANCE_SENSOR_TYPES: key "
"'waterPressure', name 'Water Pressure' (BAR, MEASUREMENT; "
"has_entity_name without translation_key → suffix "
"_water_pressure)."
),
tasks=(HEATING_WATER_PRESSURE_LOW,),
),
"syr_connect": IntegrationSignature(
name="SYR Connect (softeners)",
verified="2026-09-25 @ alexhass/syr_connect main",
source=(
"HACS syr_connect sensor.py sets _attr_translation_key = "
"sensor_key.lower() → tk 'getss1' ('Salt container stock 1'; "
"const.py: getSS1 = salt supply of container 1 in "
"UnitOfTime.WEEKS, MEASUREMENT, no device class). Weeks are not a "
"unit the duration conversion knows, so the duty is a "
"value_below in the entity's own unit: below 2 weeks (= 336 h). "
"getSS2/getSS3 (extra containers, disabled by default, absent on "
"most devices) are left out."
),
tasks=(ConsumableSignature(("getss1",), "Refill Softener Salt", "value_below", delta_units=2),),
),
"salt_sentry": IntegrationSignature(
name="Salt Sentry",
verified="2026-09-25 @ Lemcke-solutions/Salt-sentry-ha-integration main",
source=(
"HACS salt_sentry sensor.py SaltPercentageSensor: tk "
"'salt_level', '%' = (empty − distance) / (empty − full) × 100 — "
"the salt REMAINING in the brine tank (ultrasonic level sensor)."
),
tasks=(SOFTENER_SALT_LEVEL,),
),
"unique_waterontharder": IntegrationSignature(
name="Unique Waterontharder",
verified="2026-09-25 @ mirkin-pixel/ha-unique-waterontharders main",
source=(
"HACS unique_waterontharder sensor.py: tk 'salt_level', "
"PERCENTAGE, MEASUREMENT — the cloud API's 'zout_niveau' (salt "
"level)."
),
tasks=(SOFTENER_SALT_LEVEL,),
),
"bwt_aqa_perla_ble": IntegrationSignature(
name="BWT AQA Perla (BLE)",
verified="2026-09-25 @ Micka41/bwt-aqa-perla-ble main",
source=(
"HACS bwt_aqa_perla_ble sensor.py: tk 'salt_pct', PERCENTAGE, "
"MEASUREMENT — coordinator.py decodes it as remaining salt × 100 "
"// tank capacity ('qte_sel_restant'). A 'salt_alarm' problem "
"binary also exists (adoption path)."
),
tasks=(ConsumableSignature(("salt_pct",), "Refill Softener Salt", "percent_left"),),
),
"generac": IntegrationSignature(
name="Generac (Mobile Link)",
verified="2026-09-25 @ binarydev/ha-generac main",
source=(
"HACS generac sensor.py RunTimeSensor: name sensor_name(self, "
"'run_time') = 'generac_<device_id>_run_time' → suffix _run_time, "
"DURATION, 'h'; value = apparatus property type 71, which the "
"Mobile Link API labels 'Engine Hours' (payload quoted in issue "
"#202) — the LIFETIME engine-hours counter → usage_delta every "
"200 h (Generac air-cooled home-standby schedule: oil and filter "
"every 200 h or 2 years). The pjordanandrsn fork shares the "
"domain and the same property-71 sensor."
),
tasks=(ConsumableSignature(("run_time",), "Oil Service", "usage_delta", delta_units=200),),
),
"energytrak": IntegrationSignature(
name="EnergyTrak (generators)",
verified="2026-09-25 @ brentb2529/ha-energytrak main",
source=(
"HACS energytrak sensor.py: tk 'engine_hours' (DURATION, HOURS, "
"TOTAL_INCREASING; normalize.py: 'a monotonic counter that is "
"always > 0 on an installed unit') — LIFETIME → usage_delta every "
"200 h (standby-generator oil cadence, as for Generac)."
),
tasks=(ConsumableSignature(("engine_hours",), "Oil Service", "usage_delta", delta_units=200),),
),
"himoinsa_c4lan": IntegrationSignature(
name="Himoinsa C4LAN generators",
verified="2026-09-25 @ spiri439/himoinsa-c4lan main",
source=(
"HACS himoinsa_c4lan sensor.py: key 'engine_hours', name 'Engine "
"hours' (Modbus input register 42 = total engine hours per "
"const.py; HOURS, DURATION, TOTAL_INCREASING; has_entity_name "
"without translation_key → suffix _engine_hours) — LIFETIME → "
"usage_delta every 250 h (typical diesel-genset oil interval)."
),
tasks=(ConsumableSignature(("engine_hours",), "Oil Service", "usage_delta", delta_units=250),),
),
}
@@ -8,6 +8,7 @@ integration's source; drift-probed weekly)."""
from __future__ import annotations
from ._model import ConsumableSignature, IntegrationSignature
from ._shared import NAS_VOLUME_USAGE_HIGH, UNRAID_ARRAY_USAGE_HIGH
SIGNATURES: dict[str, IntegrationSignature] = {
"synology_dsm": IntegrationSignature(
@@ -19,7 +20,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"PERCENTAGE). Disk-health thresholds ship as device_class: safety "
"binaries → covered by problem-sensor adoption (widened to safety)."
),
tasks=(ConsumableSignature(("volume_percentage_used",), "Storage Cleanup", "alert_above", delta_units=85),),
tasks=(NAS_VOLUME_USAGE_HIGH,),
),
"qnap": IntegrationSignature(
name="QNAP NAS",
@@ -29,6 +30,82 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"(translation_key 'volume_percentage_used', PERCENTAGE — same key "
"shape as synology_dsm)."
),
tasks=(ConsumableSignature(("volume_percentage_used",), "Storage Cleanup", "alert_above", delta_units=85),),
tasks=(NAS_VOLUME_USAGE_HIGH,),
),
# Unraid / UniFi UNAS / MOS below mirror the Synology/QNAP duty: storage
# usage fires ABOVE 85 % and a cleanup that lowers it auto-resolves.
"unraid": IntegrationSignature(
name="Unraid",
verified="2026-09-25 @ ruaan-deysel/ha-unraid main (2026.9.1, HACS default)",
source=(
"HACS unraid sensor.py ArrayUsageSensor: _attr_translation_key "
"'array_usage', unit '%' (capacity.usage_percent of the array)."
),
tasks=(UNRAID_ARRAY_USAGE_HIGH,),
),
"unraid_api": IntegrationSignature(
name="Unraid API",
verified="2026-09-25 @ chris-mc1/unraid_api main (1.5.1, HACS default)",
source=(
"HACS unraid_api sensor.py description key 'array_usage' "
"(PERCENTAGE, calc_array_usage_percentage = used / total * 100); "
"entity.py sets translation_key = description.key."
),
tasks=(UNRAID_ARRAY_USAGE_HIGH,),
),
"unraid_management_agent": IntegrationSignature(
name="Unraid Management Agent",
verified="2026-09-25 @ ruaan-deysel/ha-unraid-management-agent main (2026.6.5, HACS default)",
source=(
"HACS unraid_management_agent sensor.py ARRAY_SENSOR_DESCRIPTIONS "
"translation_key 'array_usage' (PERCENTAGE, "
"array.computed_used_percent)."
),
tasks=(UNRAID_ARRAY_USAGE_HIGH,),
),
"unifi_unas_rest": IntegrationSignature(
name="UniFi UNAS (REST)",
verified="2026-09-25 @ LayerTM/unifi-unas-ha main (1.8.7, HACS default)",
source=(
"HACS unifi_unas_rest sensor.py hub sensor translation_key "
"'storage_usage' (PERCENTAGE, storage.usage_percent — the whole "
"NAS). The per-pool 'pool_usage' sub-device sensors are left out "
"on purpose: on a single-pool NAS they would duplicate the duty."
),
tasks=(ConsumableSignature(("storage_usage",), "Storage Cleanup", "alert_above", delta_units=85),),
),
"unifi_unas": IntegrationSignature(
name="UniFi UNAS (MQTT)",
verified="2026-09-25 @ cardouken/homeassistant-unifi-unas main (0.9.4, HACS default)",
source=(
"HACS unifi_unas sensor.py: no translation_key — pool sensors are "
"named f'Storage Pool {pool_num} {name}' (STORAGE_POOL_SENSORS "
"'usage' → 'Usage', PERCENTAGE; pools numbered from 1 by "
"scripts/unas_monitor.py) on the one NAS device → entity-id "
"suffix _storage_pool_<n>_usage, pools 1-4 cataloged (any-high)."
),
tasks=(
ConsumableSignature(
(
"storage_pool_1_usage",
"storage_pool_2_usage",
"storage_pool_3_usage",
"storage_pool_4_usage",
),
"Storage Cleanup",
"alert_above",
delta_units=85,
),
),
),
"mos": IntegrationSignature(
name="MOS NAS",
verified="2026-09-25 @ anym001/ha-mos main (0.3.3, HACS default)",
source=(
"HACS mos sensor/pools.py ENTITY_DESCRIPTIONS translation_key "
"'pool_usage' (PERCENTAGE, status.usagePercent) — one sub-device "
"per storage pool, so each pool gets its own cleanup duty."
),
tasks=(ConsumableSignature(("pool_usage",), "Storage Cleanup", "alert_above", delta_units=85),),
),
}
@@ -8,6 +8,7 @@ integration's source; drift-probed weekly)."""
from __future__ import annotations
from ._model import ConsumableSignature, IntegrationSignature
from ._shared import FILTER_LIFE_PERCENT
SIGNATURES: dict[str, IntegrationSignature] = {
"lg_thinq": IntegrationSignature(
@@ -102,21 +103,116 @@ SIGNATURES: dict[str, IntegrationSignature] = {
# has NO desiccant compartment and is excluded.
"home_connect": IntegrationSignature(
name="Home Connect",
verified="2026-07-17 @ home-assistant/core dev",
verified="2026-07-17 / extended 2026-09-25 @ home-assistant/core dev (2026.9 dishwasher events)",
source=(
"home-assistant/core homeassistant/components/home_connect/sensor.py "
"EVENT_SENSORS (HomeConnectEventSensor, device_class ENUM, "
"EVENT_OPTIONS ['confirmed','off','present']; translation_key per "
"EventKey). No percent/countdown consumables exist (coffee counters "
"are lifetime, no reset) — these actionable events are the only "
"maintenance-usable signal, matched as a state latch on 'present'."
"EventKey; the class sets _attr_entity_registry_enabled_default = "
"False — every event sensor must be enabled first). No "
"percent/countdown consumables exist (coffee counters are "
"lifetime, no reset) — these actionable events are the only "
"maintenance-usable signal, matched as a state latch on 'present'. "
"2026.9 added the dishwasher events salt_lack / "
"program_blocked_salt_lack / rinse_aid_lack (API: 'supply is "
"empty' resp. 'program blocked due to lack of salt'), "
"machine_care_reminder ('Machine Clean program without dishes is "
"needed'), machine_care_and_filter_cleaning_reminder ('Machine "
"Clean program AND cleaning the filter and spray arm'), "
"machine_care_and_low_maintenance_filter_cleaning_reminder (filter "
"cleaning optional → machine care only) and "
"smart_filter_cleaning_reminder ('filter system may be blocked'). "
"Escalation keys of one condition share ONE any-latch (refilling "
"salt clears all three salt events); coffee Calc'N'Clean "
"(descale + clean in one program) joins the descale duty; the "
"two i-Dos tanks are refilled independently → per-entity duties. "
"Semantics per api-docs.home-connect.com/events. Skipped: the "
"'descaling/calc_n_clean in N cups' pre-warnings and "
"grease_filter_max_saturation_nearly_reached (pre-warnings of the "
"duties below, not the duty itself)."
),
tasks=(
ConsumableSignature(("salt_nearly_empty",), "Refill Salt", "event_present"),
ConsumableSignature(("rinse_aid_nearly_empty",), "Refill Rinse Aid", "event_present"),
ConsumableSignature(("device_should_be_descaled",), "Descale Appliance", "event_present"),
ConsumableSignature(("device_should_be_cleaned",), "Clean Appliance", "event_present"),
ConsumableSignature(
("salt_nearly_empty", "salt_lack", "program_blocked_salt_lack"),
"Refill Salt",
"event_present",
),
ConsumableSignature(("rinse_aid_nearly_empty", "rinse_aid_lack"), "Refill Rinse Aid", "event_present"),
ConsumableSignature(
(
"device_should_be_descaled",
"device_descaling_overdue",
"device_descaling_blockage",
"device_should_be_calc_n_cleaned",
"device_calc_n_clean_overdue",
"device_calc_n_clean_blockage",
),
"Descale Appliance",
"event_present",
),
# Coffee-maker cleaning + dishwasher Machine Care (a cleaning
# program run without dishes) — one duty name, disjoint appliance
# types, so ONE signature (same name + direction would collide in
# the per-device dedupe).
ConsumableSignature(
(
"device_should_be_cleaned",
"device_cleaning_overdue",
"machine_care_reminder",
"machine_care_and_filter_cleaning_reminder",
"machine_care_and_low_maintenance_filter_cleaning_reminder",
),
"Clean Appliance",
"event_present",
),
# The combined machine-care-AND-filter reminder backs both duties.
ConsumableSignature(
("smart_filter_cleaning_reminder", "machine_care_and_filter_cleaning_reminder"),
"Filter Cleaning",
"event_present",
),
ConsumableSignature(("grease_filter_max_saturation_reached",), "Clean Grease Filter", "event_present"),
ConsumableSignature(
("poor_i_dos_1_fill_level", "poor_i_dos_2_fill_level"),
"Refill Detergent",
"event_present",
per_entity=True,
),
# Roxxter robot vacuum: 'empty the dust box and clean the filter'.
ConsumableSignature(("empty_dust_box_and_clean_filter",), "Empty Dustbin", "event_present"),
),
),
"homeconnect_ws": IntegrationSignature(
name="Home Connect Local",
verified="2026-09-25 @ chris-mc1/homeconnect_local_hass main (HACS default)",
source=(
"HACS homeconnect_ws: entity.py sets translation_key = description "
"key. entity_descriptions/dishcare.py 'sensor_salt' / "
"'sensor_rinse_aid' (HCEventSensor, ENUM options ['empty', "
"'nearly_empty', 'full'] — sensor.py returns 'empty' while "
"SaltLack/RinseAidLack is Present/Confirmed, 'nearly_empty' while "
"the NearlyEmpty event is, else 'full'; never None) → ok-state "
"latch on 'full'. cooking.py 'sensor_grease_filter_saturation' / "
"'sensor_carbon_filter_saturation' (Cooking.Hood.Status.*Saturation, "
"%, counts UP, reset by the hood's filter-reset buttons) → "
"alert_above 90 (the Samsung hood_filter_usage precedent). "
"consumer_products.py 'sensor_countdown_descaling' / "
"'_cleaning' / '_water_filter' (Status.BeverageCountdown*, "
"unitless beverages left until due, disabled by default) → "
"value_below 10. Skipped: 'sensor_countdown_calc_n_clean' (its "
"relation to the separate descale countdown is undocumented), "
"'sensor_machinecare_remaining_runs' and the dishwasher/washer/"
"dryer reminder binaries (device_class problem → problem-sensor "
"adoption)."
),
tasks=(
ConsumableSignature(("sensor_salt",), "Refill Salt", "event_present", ok_state="full"),
ConsumableSignature(("sensor_rinse_aid",), "Refill Rinse Aid", "event_present", ok_state="full"),
ConsumableSignature(("sensor_grease_filter_saturation",), "Clean Grease Filter", "alert_above", delta_units=90),
ConsumableSignature(("sensor_carbon_filter_saturation",), "Replace Filter", "alert_above", delta_units=90),
ConsumableSignature(("sensor_countdown_descaling",), "Descale Appliance", "value_below", delta_units=10),
ConsumableSignature(("sensor_countdown_cleaning",), "Clean Appliance", "value_below", delta_units=10),
ConsumableSignature(("sensor_countdown_water_filter",), "Replace Water Filter", "value_below", delta_units=10),
),
),
"miele": IntegrationSignature(
@@ -163,23 +259,71 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
"midea_ac_lan": IntegrationSignature(
name="Midea (LAN)",
verified="2026-07-18 @ wuwentao/midea_ac_lan master",
verified="2026-07-18 / re-audited 2026-09-25 @ wuwentao/midea_ac_lan master",
source=(
"wuwentao/midea_ac_lan midea_devices.py + midea_entity.py "
"(_attr_translation_key from the per-attribute config; entity_id = "
"f'{device_id}_{entity_key}'). 0xED water purifier: filter1/2/3_life "
"PERCENTAGE; 0xC2: filter_life PERCENTAGE. The filterN_days "
"countdowns describe the SAME filters — percent only, no duplicate "
"tasks. Filter cleaning/change reminders (A1/CE/AC full_dust) are "
"device_class problem binaries — covered by problem-sensor adoption."
"f'{device_id}_{entity_key}'; device model = "
"f\"{MIDEA_DEVICES[type]['name']} {model}\"). 0xED 'Water Drinking "
"Appliance': filter1/2/3_life PERCENTAGE; 0xC2 'Toilet': "
"filter_life PERCENTAGE. 2026-09-25 fix: 0xFC 'Air Purifier' "
"reuses translation_key filter1_life/filter2_life (PERCENTAGE) — "
"it proposed 'Replace Water Filter' on a purifier; the water "
"duty now excludes the purifier model, which gets 'Replace "
"Filter' instead. The filterN_days countdowns describe the SAME "
"filters — percent only, no duplicate tasks. Filter "
"cleaning/change reminders (A1/CE/AC full_dust) are device_class "
"problem binaries — covered by problem-sensor adoption."
),
tasks=(
ConsumableSignature(
("filter1_life", "filter2_life", "filter3_life"),
"Replace Water Filter",
"percent_left",
models_exclude=("Air Purifier",),
),
ConsumableSignature(
("filter_life", "filter1_life", "filter2_life"),
"Replace Filter",
"percent_left",
models=("Toilet", "Air Purifier"),
),
),
),
"midea": IntegrationSignature(
name="Midea (core)",
verified="2026-09-25 @ home-assistant/core dev (new in 2026.8) + midea-local 12.1.0",
source=(
"home-assistant/core homeassistant/components/midea/sensor.py "
"SENSOR_ENTITIES (created only when the device reports the "
"attribute): tk 'salt_available' (key left_salt, PERCENTAGE — ED "
"soft-water body 09); tk 'filter_life_level' (PERCENTAGE) is shared "
"by the ED water purifier (keys life1/life2/life3, next to "
"'filter_available_days') and the FC air purifier (keys "
"filter1_life/filter2_life) — routed by the device model, which "
"entity.py sets to device_catalog.MIDEA_DEVICE_NAMES "
"('Water Drinking Appliance' / 'Air Purifier' / 'Toilet'); tk "
"'filter_life' (C2 toilet, midea-local c2/message.py "
"filter_life = 100 - body[19] → remaining %). The 15 virtual "
"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."
),
tasks=(
ConsumableSignature(("salt_available",), "Refill Softener Salt", "percent_left"),
ConsumableSignature(
("filter_life_level",),
"Replace Water Filter",
"percent_left",
models=("Water Drinking Appliance",),
),
ConsumableSignature(
("filter_life", "filter_life_level"),
"Replace Filter",
"percent_left",
models=("Air Purifier", "Toilet"),
),
ConsumableSignature(("filter_life",), "Replace Filter", "percent_left"),
),
),
"lamarzocco": IntegrationSignature(
@@ -206,7 +350,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"tub-clean cadence reuses LG's manufacturer value of 30 cycles."
),
tasks=(
ConsumableSignature(("filter_life",), "Replace Filter", "percent_left"),
FILTER_LIFE_PERCENT,
ConsumableSignature(("filter_cleaning",), "Filter Cleaning", "percent_left"),
ConsumableSignature(("cycles_total",), "Clean Tub", "usage_delta", delta_units=30),
),
@@ -254,10 +398,29 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"itself (washer, dryer, dishwasher, air fryer, …)."
),
tasks=(
ConsumableSignature(("cycle_count",), "Descaling", "usage_delta", delta_units=30),
ConsumableSignature(("cycle_count",), "Filter Cleaning", "usage_delta", delta_units=50),
ConsumableSignature(("cycle_count",), "Clean Tub", "usage_delta", delta_units=100),
# 2.94: by WashData's own device_type (const.py DEVICE_TYPE_*,
# stored in the entry's options): no descaling for appliances
# without a water circuit (a heat-pump dryer — reported), no tub
# cleaning where there is no tub. Unknown/generic/other keep all.
ConsumableSignature(
("cycle_count",),
"Descaling",
"usage_delta",
delta_units=30,
exclude_appliance_types=("dryer", "air_fryer", "bread_maker", "pump"),
),
ConsumableSignature(
("cycle_count",), "Filter Cleaning", "usage_delta", delta_units=50, exclude_appliance_types=("bread_maker",)
),
ConsumableSignature(
("cycle_count",),
"Clean Tub",
"usage_delta",
delta_units=100,
exclude_appliance_types=("air_fryer", "bread_maker", "pump"),
),
),
appliance_type_key="device_type",
),
"traeger": IntegrationSignature(
name="Traeger grill",
@@ -277,4 +440,78 @@ SIGNATURES: dict[str, IntegrationSignature] = {
ConsumableSignature(("cook_cycle",), "Clean Appliance", "usage_delta", delta_units=20),
),
),
"electrolux": IntegrationSignature(
name="Electrolux (OCP API)",
verified="2026-09-25 @ TTLucian/ha-electrolux main (HACS default; not the electrolux_status domain)",
source=(
"HACS electrolux: entity.py sets translation_key = the lowercased "
"capability name (fppn prefix stripped) → 'filterlife', "
"'filterlife_1', 'filterlife_2'. catalogs/catalog_ap.py "
"'FilterLife' (PERCENTAGE, air purifiers) and the UltimateHome 500 "
"pair 'FilterLife_1' / 'FilterLife_2' (int 0-100 %, two slots with "
"their own FilterType_1/_2) → one duty per filter (per_entity). "
"Skipped: AC/fridge/hood filter lifetimes and timers (seconds or "
"minutes with the maintainer's own 'unit is an educated guess' "
"caveat — remaining vs used unclear) and the filterState ENUMs "
"(rendered state strings not verified)."
),
tasks=(
ConsumableSignature(
("filterlife", "filterlife_1", "filterlife_2"),
"Replace Filter",
"percent_left",
per_entity=True,
),
),
),
"ge_home": IntegrationSignature(
name="GE Home (SmartHQ)",
verified="2026-09-25 @ simbaja/ha_gehome master + simbaja/gehome (gehomesdk 2026.8.0; custom repository, not in the HACS default store)",
source=(
"HACS ge_home: no translation_key — entity names are "
"f'{serial} {ERD title}' (+ ' {Property Title}' for property "
"sensors, entities/common/ge_erd_entity.py + "
"ge_erd_property_sensor.py), matched by entity-id suffix. "
"devices/fridge.py WATER_FILTER_STATUS 'percent_remaining' "
"(uom_override PERCENTAGE → _water_filter_status_percent_remaining); "
"devices/water_filter.py WH_FILTER_LIFE_REMAINING 'life_remaining' "
"(ErdCodeClass.PERCENTAGE → '%', gehomesdk "
"ErdWaterFilterLifeRemainingConverter pct → "
"_wh_filter_life_remaining_life_remaining). Skipped: the water "
"softener binary WH_SOFTENER_LOW_SALT — gehomesdk "
"ErdWaterSoftenerSaltLevel.boolify() returns self == OK, so the "
"'low salt' binary is ON while the salt is fine (inverted; a "
"latch would fire backwards). AC/dehumidifier/ice-maker filter "
"binaries are device_class problem → problem-sensor adoption."
),
tasks=(
ConsumableSignature(
("water_filter_status_percent_remaining", "wh_filter_life_remaining_life_remaining"),
"Replace Water Filter",
"percent_left",
),
),
),
"candy": IntegrationSignature(
name="Candy Simply-Fi",
verified="2026-09-25 @ bigmoby/home-assistant-candy main (1.5.1, HACS default)",
source=(
"HACS candy sensor.py: tk 'wash_total_cycles' (TOTAL_INCREASING; "
"client/model.py sums the wide Temp* wash counters — no wrap, no "
"reset) → Clean Tub every 30 cycles (the hOn/LG cadence). With the "
"integration's opt-in maintenance counters (Simply-Fi app parity) "
"it also exposes tk 'wash_maint_limescale' / 'wash_maint_filter' "
"— cycles REMAINING until due (helpers.cycles_remaining: 0 when "
"due or overdue, back to the full interval after the "
"integration's reset button) → value_below 1 fires exactly when "
"Candy itself reports due. Skipped: 'wash_maint_full_checkup' "
"(what the Simply-Fi 'Full Check-up' asks the user to do is "
"undocumented)."
),
tasks=(
ConsumableSignature(("wash_total_cycles",), "Clean Tub", "usage_delta", delta_units=30),
ConsumableSignature(("wash_maint_limescale",), "Descaling", "value_below", delta_units=1),
ConsumableSignature(("wash_maint_filter",), "Filter Cleaning", "value_below", delta_units=1),
),
),
}
@@ -12,6 +12,7 @@ integration's source; drift-probed weekly)."""
from __future__ import annotations
from ._model import ConsumableSignature, IntegrationSignature
from ._shared import LOCK_CYLINDER_CYCLES
SIGNATURES: dict[str, IntegrationSignature] = {
"nuki": IntegrationSignature(
@@ -23,14 +24,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"the ENGINE counts locking cycles on the lock entity."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
LOCK_CYLINDER_CYCLES,
),
),
"tedee": IntegrationSignature(
@@ -42,14 +36,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"the ENGINE counts locking cycles; entity_domain-gated to locks."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
LOCK_CYLINDER_CYCLES,
),
),
"august": IntegrationSignature(
@@ -61,14 +48,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"the ENGINE counts locking cycles; entity_domain-gated to locks."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
LOCK_CYLINDER_CYCLES,
),
),
"yale": IntegrationSignature(
@@ -80,14 +60,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"the ENGINE counts locking cycles; entity_domain-gated to locks."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
LOCK_CYLINDER_CYCLES,
),
),
"switchbot": IntegrationSignature(
@@ -99,14 +72,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"the ENGINE counts locking cycles; entity_domain-gated to locks."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
LOCK_CYLINDER_CYCLES,
),
),
"loqed": IntegrationSignature(
@@ -118,14 +84,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"the ENGINE counts locking cycles; entity_domain-gated to locks."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
LOCK_CYLINDER_CYCLES,
),
),
"homematicip_cloud": IntegrationSignature(
@@ -137,14 +96,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"the ENGINE counts locking cycles; entity_domain-gated to locks."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
LOCK_CYLINDER_CYCLES,
),
),
"schlage": IntegrationSignature(
@@ -155,14 +107,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"(lock platform verified present) — engine-counted locking cycles."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
LOCK_CYLINDER_CYCLES,
),
),
"sesame": IntegrationSignature(
@@ -173,14 +118,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"(lock platform verified present) — engine-counted locking cycles."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
LOCK_CYLINDER_CYCLES,
),
),
"yalexs_ble": IntegrationSignature(
@@ -191,14 +129,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"(lock platform verified present) — engine-counted locking cycles."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
LOCK_CYLINDER_CYCLES,
),
),
"dormakaba_dkey": IntegrationSignature(
@@ -209,14 +140,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"(lock platform verified present) — engine-counted locking cycles."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
LOCK_CYLINDER_CYCLES,
),
),
"homematic": IntegrationSignature(
@@ -226,6 +150,56 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"home-assistant/core homeassistant/components/homematic/lock.py "
"(lock platform verified present) — engine-counted locking cycles, entity_domain-gated so the hub's other device types are untouched."
),
tasks=(
LOCK_CYLINDER_CYCLES,
),
),
# --- Round 14 (2026-09-25) --------------------------------------------
"wyzeapi": IntegrationSignature(
name="Wyze Lock",
verified="2026-09-25 @ SecKatie/ha-wyzeapi master",
source=(
"HACS wyzeapi lock.py (WyzeLock for DeviceTypes.LOCK, WyzeLockBolt "
"for YD_BT1 — one lock entity per lock device). Engine-counted "
"locking cycles; entity_domain-gated so the hub's cameras/plugs "
"are untouched."
),
tasks=(
LOCK_CYLINDER_CYCLES,
),
),
"ttlock": IntegrationSignature(
name="TTLock",
verified="2026-09-25 @ jbergler/hass-ttlock develop",
source=(
"HACS ttlock lock.py (one Lock entity per lock coordinator, "
"is_locked from the lock state) — engine-counted locking cycles."
),
tasks=(
LOCK_CYLINDER_CYCLES,
),
),
"kwikset": IntegrationSignature(
name="Kwikset Smart Locks",
verified="2026-09-25 @ explosivo22/kwikset-ha main",
source=(
"HACS kwikset (not in the default store) lock.py: 'Each physical "
"lock device exposes a single LockEntity' (KwiksetLock) — "
"engine-counted locking cycles."
),
tasks=(
LOCK_CYLINDER_CYCLES,
),
),
"nuki_web": IntegrationSignature(
name="Nuki Web",
verified="2026-09-25 @ ArnyminerZ/ha-nuki-web master",
source=(
"HACS nuki_web lock.py: one NukiLockEntity per smartlock_id — Nuki "
"OPENERS (type 2) are lock entities too and report 'locked' "
"whenever the opener is merely online, so they are excluded via "
"the device model entity.py derives from the type ('Opener')."
),
tasks=(
ConsumableSignature(
(),
@@ -234,6 +208,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
models_exclude=("Opener",),
),
),
),
@@ -30,4 +30,29 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
),
),
"oralb_live": IntegrationSignature(
name="Oral-B (live BLE)",
verified="2026-09-25 @ thomasgregg/oralb-ha main",
source=(
"thomasgregg/oralb-ha custom_components/oralb_live/sensor.py: key/tk "
"'refill_days' ('Brush head remaining', UnitOfTime.DAYS, DURATION, "
"entity_registry_enabled_default=False — discovered once the user "
"enables it); protocol.parse_refill_remainder decodes the handle's own "
"brush-head countdown (ff2d). The sibling 'refill_brushing_time' "
"(hours) is the same countdown in another unit — one signal per duty."
),
tasks=(ConsumableSignature(("refill_days",), "Replace Brush Head", "duration_left", below_hours=48),),
),
"philips_shaver": IntegrationSignature(
name="Philips shaver",
verified="2026-09-25 @ mtheli/philips_shaver main",
source=(
"mtheli/philips_shaver custom_components/philips_shaver/sensor.py "
"PhilipsHeadRemainingSensor: _attr_translation_key 'head_remaining', "
"PERCENTAGE (coordinator: CHAR_HEAD_REMAINING byte — the shaver's own "
"head-life counter). 'cleaning_cycles_remaining' is skipped: an "
"integration-side evaporation estimate, not a device value."
),
tasks=(ConsumableSignature(("head_remaining",), "Replace Shaver Head", "percent_left"),),
),
}
@@ -39,6 +39,29 @@ SIGNATURES: dict[str, IntegrationSignature] = {
ConsumableSignature(("total_cycles",), "Wash Litter Box", "usage_delta", delta_units=150),
),
),
"petlibro": IntegrationSignature(
name="PETLIBRO",
verified="2026-09-25 @ jjjonesjr33/petlibro dev",
source=(
"jjjonesjr33/petlibro custom_components/petlibro/sensor.py: tk "
"'remaining_desiccant' (feeders, native 'd', DURATION — "
"remainingDesiccantDays), tk 'remaining_filter_days' (Dockstream "
"fountains, 'd' — realInfo.remainingReplacementDays), tk "
"'remaining_cleaning_days' (fountains + Polar wet-food feeder + Luma "
"litter box, 'd' — remainingCleaningDays), tk "
"'remaining_replacement_days' (Luma litter box 'Filter Replacement "
"Days', same API field as the fountain filter); button.py "
"desiccant_reset / filter_reset / cleaning_reset restart the countdowns. "
"Luma's remaining_mat_days skipped (minor accessory, no catalog duty)."
),
tasks=(
# 48 canonical hours = warn at 2 days left, like PetKit's desiccant.
ConsumableSignature(("remaining_desiccant",), "Replace Desiccant", "duration_left", below_hours=48),
ConsumableSignature(("remaining_filter_days",), "Replace Water Filter", "duration_left", below_hours=48),
ConsumableSignature(("remaining_cleaning_days",), "Clean Appliance", "duration_left", below_hours=48),
ConsumableSignature(("remaining_replacement_days",), "Replace Filter", "duration_left", below_hours=48),
),
),
"eheimdigital": IntegrationSignature(
name="EHEIM Digital (aquarium)",
verified="2026-07-20 @ home-assistant/core dev",
@@ -120,6 +120,72 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
tasks=(ConsumableSignature(("totals_filament_used",), "Replace Nozzle", "usage_delta", delta_units=1000),),
),
"ha_creality_ws": IntegrationSignature(
name="Creality (WebSocket)",
verified="2026-09-25 @ 3dg1luk43/ha_creality_ws main",
source=(
"3dg1luk43/ha_creality_ws custom_components/ha_creality_ws/sensor.py "
"PrintStatusSensor: _attr_translation_key 'print_status', no unit, states "
"off/unknown/error/self-testing/completed/paused/stopped/printing/"
"processing/idle ('printing' = st == 1 with a file loaded). No lifetime "
"print-hours counter exists — the ENGINE accumulates print time. K1/K2/"
"Hi/3V3 are all FDM machines (Prusa's 200 print-hour lubrication cadence)."
),
tasks=(
ConsumableSignature(
("print_status",),
"Lubricate Rails and Rods",
"runtime_hours",
delta_units=200,
on_states=("printing",),
),
),
),
"elegoo_printer": IntegrationSignature(
name="Elegoo printer",
verified="2026-09-25 @ danielcherubini/elegoo-homeassistant main",
source=(
"danielcherubini/elegoo-homeassistant custom_components/elegoo_printer/"
"definitions.py _print_status_sensor: key/translation_key 'print_status', "
"ENUM of ElegooPrintStatus names (incl. 'printing'). Resin printers carry "
"the same tk (PRINTER_STATUS_RESIN), so the duty is gated on the FDM-only "
"sibling 'Nozzle Temperature' (key nozzle_temp, has_entity_name, no tk → "
"suffix _nozzle_temperature; only in PRINTER_STATUS_FDM, sensor.py adds it "
"for PrinterType.FDM). The 'Total Print Time' sensor is per job, not "
"lifetime — the ENGINE accumulates print time."
),
tasks=(
ConsumableSignature(
("print_status",),
"Lubricate Rails and Rods",
"runtime_hours",
delta_units=200,
on_states=("printing",),
require_sibling_keys=("nozzle_temperature",),
),
),
),
"anycubic_cloud": IntegrationSignature(
name="Anycubic Cloud",
verified="2026-09-25 @ Nino6689/hass-anycubic main",
source=(
"Nino6689/hass-anycubic custom_components/anycubic_cloud/sensor.py: "
"key/translation_key 'print_time_total_hrs' (UnitOfTime.HOURS, "
"TOTAL_INCREASING, printer.total_print_time_hrs — lifetime print hours, "
"no reset) → usage_delta. Resin (LCD) printers share the sensor, so the "
"duty is gated on the FDM-only sibling tk 'curr_nozzle_temp' "
"(PrinterEntityType.FDM, helpers.check_descriptor_status_not_fdm)."
),
tasks=(
ConsumableSignature(
("print_time_total_hrs",),
"Lubricate Rails and Rods",
"usage_delta",
delta_units=200,
require_sibling_keys=("curr_nozzle_temp",),
),
),
),
"ipp": IntegrationSignature(
name="IPP printer",
verified="2026-07-16 @ home-assistant/core dev",
@@ -134,9 +200,36 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
"brother": IntegrationSignature(
name="Brother printer",
verified="2026-07-16 @ home-assistant/core dev",
source="home-assistant/core homeassistant/components/brother/sensor.py (*_toner_remaining / *_remaining_life, %)",
verified="2026-09-25 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/brother/sensor.py "
"(*_toner_remaining / *_ink_remaining / *_remaining_life, PERCENTAGE). "
"Inkjets: black/cyan/magenta/yellow_ink_remaining (since 2026.7). "
"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)."
),
tasks=(
ConsumableSignature(
(
"black_ink_remaining",
"cyan_ink_remaining",
"magenta_ink_remaining",
"yellow_ink_remaining",
),
"Replace Ink or Toner",
"percent_left",
per_entity=True, # inkjets: one task per cartridge (#145)
),
ConsumableSignature(("ink_capture_box_remaining_life",), "Replace Maintenance Box", "percent_left"),
ConsumableSignature(("laser_remaining_life",), "Replace Laser Unit", "percent_left"),
ConsumableSignature(
("pf_kit_1_remaining_life", "pf_kit_mp_remaining_life"),
"Replace Paper Feed Kit",
"percent_left",
per_entity=True, # the tray-1 and MP-tray kits wear independently
),
ConsumableSignature(
(
"black_toner_remaining",
@@ -164,4 +257,55 @@ SIGNATURES: dict[str, IntegrationSignature] = {
ConsumableSignature(("fuser_remaining_life",), "Replace Fuser", "percent_left"),
),
),
"hpprinter": IntegrationSignature(
name="HP printer",
verified="2026-09-25 @ elad-bar/ha-hpprinter master",
source=(
"elad-bar/ha-hpprinter custom_components/hpprinter/parameters/"
"data_points.json 'consumable_percentage_level_remaining' "
"(ConsumablePercentageLevelRemaining, unit %, excluded for printheads); "
"managers/ha_config_manager.py sets translation_key = property_key. Each "
"cartridge is its OWN device (ha_coordinator.create_consumable_device, "
"via_device = the printer), so every cartridge device carries one level "
"sensor and gets its own plain duty."
),
tasks=(
ConsumableSignature(
("consumable_percentage_level_remaining",),
"Replace Ink or Toner",
"percent_left",
per_entity=True,
),
),
),
"epson_workforce": IntegrationSignature(
name="Epson WorkForce",
verified="2026-09-25 @ lymanepp/ha-epson-workforce master",
source=(
"lymanepp/ha-epson-workforce custom_components/epson_workforce/sensor.py "
"SENSOR_TYPES ink_bk/ink_pb/ink_gy/ink_m/ink_c/ink_y/ink_lc/ink_lm named "
"'Ink level <Colour>' (PERCENTAGE, _attr_has_entity_name, no tk → "
"entity-id suffixes _ink_level_black … _ink_level_light_magenta); "
"parser.py reads the tank bar height = ink left. The 'clean' maintenance-"
"box sensor ('Cleaning level') is SKIPPED: the parser only reads a bar "
"height, whether it shows capacity used or left is not established."
),
tasks=(
ConsumableSignature(
(
"ink_level_black",
"ink_level_photoblack",
"ink_level_gray",
"ink_level_cyan",
"ink_level_magenta",
"ink_level_yellow",
"ink_level_light_cyan",
"ink_level_light_magenta",
),
"Replace Ink or Toner",
"percent_left",
per_entity=True, # one task per tank
),
),
),
}
@@ -7,7 +7,14 @@ integration's source; drift-probed weekly)."""
from __future__ import annotations
from ._model import ConsumableSignature, IntegrationSignature
from ._model import IntegrationSignature
from ._shared import (
LOCK_CYLINDER_CYCLES,
MOWER_BLADES_RUNTIME,
MOWER_UNDERCARRIAGE_RUNTIME,
VACUUM_FILTER_CLEANING_RUNTIME,
VACUUM_MAIN_BRUSH_RUNTIME,
)
SIGNATURES: dict[str, IntegrationSignature] = {
"matter": IntegrationSignature(
@@ -20,14 +27,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"to locks; every transition to 'locked' is one mechanical cycle."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
LOCK_CYLINDER_CYCLES,
),
),
"zwave_js": IntegrationSignature(
@@ -39,14 +39,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"the ENGINE counts locking cycles; entity_domain-gated to locks, so the bridge's other device types are untouched."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
LOCK_CYLINDER_CYCLES,
),
),
"zha": IntegrationSignature(
@@ -58,14 +51,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"the ENGINE counts locking cycles; entity_domain-gated to locks, so the bridge's other device types are untouched."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
LOCK_CYLINDER_CYCLES,
),
),
"mqtt": IntegrationSignature(
@@ -77,46 +63,11 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"the ENGINE counts locking cycles; entity_domain-gated to locks, so the bridge's other device types are untouched."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
ConsumableSignature(
(),
"Filter Cleaning",
"runtime_hours",
delta_units=15,
entity_domain="vacuum",
on_states=("cleaning",),
),
ConsumableSignature(
(),
"Clean Main Brush",
"runtime_hours",
delta_units=30,
entity_domain="vacuum",
on_states=("cleaning",),
),
ConsumableSignature(
(),
"Replace Blades",
"runtime_hours",
delta_units=100,
entity_domain="lawn_mower",
on_states=("mowing",),
),
ConsumableSignature(
(),
"Clean Undercarriage",
"runtime_hours",
delta_units=25,
entity_domain="lawn_mower",
on_states=("mowing",),
),
LOCK_CYLINDER_CYCLES,
VACUUM_FILTER_CLEANING_RUNTIME,
VACUUM_MAIN_BRUSH_RUNTIME,
MOWER_BLADES_RUNTIME,
MOWER_UNDERCARRIAGE_RUNTIME,
),
),
# MQTT vacuums (Valetudo!) and mowers (OpenMower) expose only
@@ -130,14 +81,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"the ENGINE counts locking cycles; entity_domain-gated to locks, so the bridge's other device types are untouched."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
LOCK_CYLINDER_CYCLES,
),
),
"deconz": IntegrationSignature(
@@ -148,14 +92,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"(lock platform verified present) — engine-counted locking cycles, entity_domain-gated so the bridge's other device types are untouched."
),
tasks=(
ConsumableSignature(
(),
"Lubricate Cylinder",
"cycle_count",
delta_units=2000,
entity_domain="lock",
on_states=("locked",),
),
LOCK_CYLINDER_CYCLES,
),
),
}
@@ -12,17 +12,48 @@ integration's source; drift-probed weekly)."""
from __future__ import annotations
from ._model import ConsumableSignature, IntegrationSignature
from ._shared import (
FILTER_LIFE_PERCENT,
HOOD_GREASE_FILTER_USAGE,
SIDE_BRUSH_LIFE_PERCENT,
VACUUM_FILTER_CLEANING_RUNTIME,
VACUUM_MAIN_BRUSH_RUNTIME,
)
SIGNATURES: dict[str, IntegrationSignature] = {
"roborock": IntegrationSignature(
name="Roborock",
verified="2026-07-16 @ home-assistant/core dev",
source="home-assistant/core homeassistant/components/roborock/sensor.py (translation_key, duration s→h)",
verified="2026-09-25 @ home-assistant/core dev + Python-roborock/python-roborock main",
source=(
"home-assistant/core homeassistant/components/roborock/sensor.py (translation_key, "
"duration s→h). 2026-09 round: dock tks 'strainer_time_left' / "
"'cleaning_brush_time_left' (HOURS; python-roborock v1_containers: "
"STRAINER_REPLACE_TIME 150 / CLEANING_BRUSH_REPLACE_TIME 300 minus work "
"count — countdowns to replacement, HA docs: 'replace your dock's "
"strainer/maintenance brush'); Q7 (B01) tk 'mop_life_time_left' (MINUTES, "
"b01_q7_containers: max(0, 10800 - mop_life) = remaining); Dyad tk "
"'brush_remaining' ('Roller left', SECONDS, DyadDataProtocol.BRUSH_LEFT) joins "
"the main-brush duty; Zeo washer tk 'times_after_clean' (unitless "
"RoborockZeoProtocol.TIMES_AFTER_CLEAN, washes since the last drum clean — "
"usage_above like LG's tub_clean_counter). Q10 tks main_brush_life/"
"side_brush_life/filter_life/sensor_life are SKIPPED: HA names them 'time "
"used' while python-roborock's Q10Consumable calls them remaining life."
),
tasks=(
ConsumableSignature(("main_brush_time_left",), "Replace Main Brush", "duration_left"),
# Dyad wet-dry vacuums report their roller brush as 'brush_remaining'
# — same duty, same direction → one signature (per-device dedupe).
ConsumableSignature(("main_brush_time_left", "brush_remaining"), "Replace Main Brush", "duration_left"),
ConsumableSignature(("side_brush_time_left",), "Replace Side Brush", "duration_left"),
ConsumableSignature(("filter_time_left",), "Replace Filter", "duration_left"),
ConsumableSignature(("sensor_time_left",), "Clean Sensors", "duration_left"),
ConsumableSignature(("mop_life_time_left",), "Replace Mop Pads", "duration_left"),
# Dock consumables: the 'hours' are the dock's wash-count budget
# (150 / 300); 24 left ≈ the last 8-16 % of the part's life.
ConsumableSignature(("strainer_time_left",), "Replace Dock Strainer", "duration_left"),
ConsumableSignature(("cleaning_brush_time_left",), "Replace Maintenance Brush", "duration_left"),
# Zeo washer: washes since the last drum-clean program (unitless;
# above_hours is the wash count, ~monthly like LG's tub counter).
ConsumableSignature(("times_after_clean",), "Clean Tub", "usage_above", above_hours=30),
),
),
"xiaomi_miio": IntegrationSignature(
@@ -61,20 +92,42 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
"ecovacs": IntegrationSignature(
name="Ecovacs",
verified="2026-07-17 @ home-assistant/core dev + DeebotUniverse/client.py main",
verified="2026-09-25 @ home-assistant/core dev + DeebotUniverse/client.py main",
source=(
"home-assistant/core homeassistant/components/ecovacs/sensor.py "
"(translation_key f'lifespan_{component.name.lower()}', PERCENTAGE) + "
"DeebotUniverse/client.py deebot_client/events LifeSpan enum members"
"(translation_key f'lifespan_{component.name.lower()}' over const.py "
"SUPPORTED_LIFESPANS, PERCENTAGE; LEGACY_LIFESPAN_SENSORS "
"f'lifespan_{component}' for main_brush/side_brush/filter) + "
"DeebotUniverse/client.py deebot_client/events LifeSpan enum members "
"(LifeSpanEvent.percent = remaining). 2026.8 added cleaning_solution, "
"sewage_box (the dirty-water box), water_sink (the station's cleaning "
"sink), trimmer_brush and weed_rope (GOAT edge trimmer). "
"total_stats_time_mower = the mower override of total_stats_time "
"(SECONDS, suggested h, TOTAL_INCREASING lifetime). unit_care is "
"SKIPPED (which parts it covers is not established from source)."
),
tasks=(
ConsumableSignature(("lifespan_brush",), "Replace Main Brush", "percent_left"),
ConsumableSignature(("lifespan_brush", "lifespan_main_brush"), "Replace Main Brush", "percent_left"),
ConsumableSignature(("lifespan_side_brush",), "Replace Side Brush", "percent_left"),
ConsumableSignature(("lifespan_filter",), "Replace Filter", "percent_left"),
# The robot's filter and the handheld unit's filter (combo models)
# are separate parts — one task per entity when a device has both.
ConsumableSignature(("lifespan_filter", "lifespan_hand_filter"), "Replace Filter", "percent_left", per_entity=True),
ConsumableSignature(("lifespan_station_filter",), "Replace Secondary Filter", "percent_left"),
ConsumableSignature(("lifespan_dust_bag",), "Replace Dust Bag", "percent_left"),
ConsumableSignature(("lifespan_round_mop",), "Replace Mop Pads", "percent_left"),
# GOAT robotic mowers report blade lifespan through the same platform.
ConsumableSignature(("lifespan_cleaning_solution",), "Refill Detergent", "percent_left"),
ConsumableSignature(("lifespan_sewage_box",), "Empty Dirty Water Tank", "percent_left"),
ConsumableSignature(("lifespan_water_sink",), "Clean Mop Tray", "percent_left"),
ConsumableSignature(("lifespan_air_freshener",), "Replace Air Freshener", "percent_left"),
ConsumableSignature(("lifespan_uv_sanitizer",), "Replace UV Lamp", "percent_left"),
# GOAT robotic mowers report their wear parts through the same platform.
ConsumableSignature(("lifespan_blade",), "Replace Blades", "percent_left"),
ConsumableSignature(("lifespan_lens_brush",), "Replace Lens Brush", "percent_left"),
ConsumableSignature(("lifespan_trimmer_brush",), "Replace Trimmer Brush", "percent_left"),
ConsumableSignature(("lifespan_weed_rope",), "Replace Trimmer Line", "percent_left"),
# Lifetime mowing time — undercarriage wash every 25 h like the
# automower/landroid signatures.
ConsumableSignature(("total_stats_time_mower",), "Clean Undercarriage", "usage_delta", delta_units=25),
),
),
"weback_vacuum": IntegrationSignature(
@@ -86,22 +139,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"the ENGINE accumulates cleaning time on the vacuum entity."
),
tasks=(
ConsumableSignature(
(),
"Filter Cleaning",
"runtime_hours",
delta_units=15,
entity_domain="vacuum",
on_states=("cleaning",),
),
ConsumableSignature(
(),
"Clean Main Brush",
"runtime_hours",
delta_units=30,
entity_domain="vacuum",
on_states=("cleaning",),
),
VACUUM_FILTER_CLEANING_RUNTIME,
VACUUM_MAIN_BRUSH_RUNTIME,
),
),
"roomba": IntegrationSignature(
@@ -113,22 +152,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"ENGINE accumulates cleaning time."
),
tasks=(
ConsumableSignature(
(),
"Filter Cleaning",
"runtime_hours",
delta_units=15,
entity_domain="vacuum",
on_states=("cleaning",),
),
ConsumableSignature(
(),
"Clean Main Brush",
"runtime_hours",
delta_units=30,
entity_domain="vacuum",
on_states=("cleaning",),
),
VACUUM_FILTER_CLEANING_RUNTIME,
VACUUM_MAIN_BRUSH_RUNTIME,
ConsumableSignature(
("bin_full",),
"Empty Dustbin",
@@ -149,22 +174,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"ENGINE accumulates cleaning time."
),
tasks=(
ConsumableSignature(
(),
"Filter Cleaning",
"runtime_hours",
delta_units=15,
entity_domain="vacuum",
on_states=("cleaning",),
),
ConsumableSignature(
(),
"Clean Main Brush",
"runtime_hours",
delta_units=30,
entity_domain="vacuum",
on_states=("cleaning",),
),
VACUUM_FILTER_CLEANING_RUNTIME,
VACUUM_MAIN_BRUSH_RUNTIME,
),
),
"romy": IntegrationSignature(
@@ -176,49 +187,39 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"ENGINE accumulates cleaning time."
),
tasks=(
ConsumableSignature(
(),
"Filter Cleaning",
"runtime_hours",
delta_units=15,
entity_domain="vacuum",
on_states=("cleaning",),
),
ConsumableSignature(
(),
"Clean Main Brush",
"runtime_hours",
delta_units=30,
entity_domain="vacuum",
on_states=("cleaning",),
),
VACUUM_FILTER_CLEANING_RUNTIME,
VACUUM_MAIN_BRUSH_RUNTIME,
),
),
"tuya": IntegrationSignature(
name="Tuya vacuum",
verified="2026-07-18 @ home-assistant/core dev",
verified="2026-09-25 @ home-assistant/core dev",
source=(
"home-assistant/core homeassistant/components/tuya/vacuum.py "
"(vacuum platform verified present; no consumable sensors) — the "
"ENGINE accumulates cleaning time, entity_domain-gated so the bridge's other device types are untouched."
"home-assistant/core homeassistant/components/tuya/vacuum.py (vacuum "
"platform) — the ENGINE accumulates cleaning time, entity_domain-gated so "
"the bridge's other device types are untouched. sensor.py category SD "
"(robot vacuum) ALSO has consumable sensors (present since 2026.7): tks "
"'duster_cloth_life' (DP duster_cloth), 'side_brush_life' (DP edge_brush), "
"'filter_life' (DPCode.FILTER_LIFE = DP 'filter', const.py: 'Filter life "
"(percentage)'), 'rolling_brush_life' (DP roll_brush). No unit in the "
"description — TuyaSensorEntity takes the device's DP unit; Tuya's sd "
"standard status set defines all four as 'life' Integer 0-100 %, reset via "
"reset_* DPs → percent_left. Devices whose DP reports 'min'/'h' instead are "
"NOT matched (unit gate): real devices mislabel the unit (Neatsvor X600 "
"reports 'min' with a 200-max range), so no duration signature. SKIPPED: "
"KJ purifier 'filter_utilization' (Tuya: 'Filter cartridge utilization', "
"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."
),
tasks=(
ConsumableSignature(
(),
"Filter Cleaning",
"runtime_hours",
delta_units=15,
entity_domain="vacuum",
on_states=("cleaning",),
),
ConsumableSignature(
(),
"Clean Main Brush",
"runtime_hours",
delta_units=30,
entity_domain="vacuum",
on_states=("cleaning",),
),
ConsumableSignature(("rolling_brush_life",), "Replace Main Brush", "percent_left"),
SIDE_BRUSH_LIFE_PERCENT,
FILTER_LIFE_PERCENT,
ConsumableSignature(("duster_cloth_life",), "Replace Mop Pads", "percent_left"),
VACUUM_FILTER_CLEANING_RUNTIME,
VACUUM_MAIN_BRUSH_RUNTIME,
),
),
"switchbot_cloud": IntegrationSignature(
@@ -230,22 +231,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"ENGINE accumulates cleaning time, entity_domain-gated so the bridge's other device types are untouched."
),
tasks=(
ConsumableSignature(
(),
"Filter Cleaning",
"runtime_hours",
delta_units=15,
entity_domain="vacuum",
on_states=("cleaning",),
),
ConsumableSignature(
(),
"Clean Main Brush",
"runtime_hours",
delta_units=30,
entity_domain="vacuum",
on_states=("cleaning",),
),
VACUUM_FILTER_CLEANING_RUNTIME,
VACUUM_MAIN_BRUSH_RUNTIME,
),
),
"smartthings": IntegrationSignature(
@@ -261,26 +248,12 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"(SAMSUNG_CE_HOOD_FILTER, PERCENTAGE) — same up-counting shape."
),
tasks=(
ConsumableSignature(
(),
"Filter Cleaning",
"runtime_hours",
delta_units=15,
entity_domain="vacuum",
on_states=("cleaning",),
),
ConsumableSignature(
(),
"Clean Main Brush",
"runtime_hours",
delta_units=30,
entity_domain="vacuum",
on_states=("cleaning",),
),
VACUUM_FILTER_CLEANING_RUNTIME,
VACUUM_MAIN_BRUSH_RUNTIME,
# Samsung fridge water filter / hood grease filter: usage counts
# UP in percent; replacing/cleaning resets to 0 (auto-resolve).
ConsumableSignature(("water_filter_usage",), "Replace Water Filter", "alert_above", delta_units=90),
ConsumableSignature(("hood_filter_usage",), "Clean Grease Filter", "alert_above", delta_units=90),
HOOD_GREASE_FILTER_USAGE,
),
),
"sharkiq": IntegrationSignature(
@@ -292,22 +265,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"ENGINE accumulates cleaning time."
),
tasks=(
ConsumableSignature(
(),
"Filter Cleaning",
"runtime_hours",
delta_units=15,
entity_domain="vacuum",
on_states=("cleaning",),
),
ConsumableSignature(
(),
"Clean Main Brush",
"runtime_hours",
delta_units=30,
entity_domain="vacuum",
on_states=("cleaning",),
),
VACUUM_FILTER_CLEANING_RUNTIME,
VACUUM_MAIN_BRUSH_RUNTIME,
),
),
"tplink": IntegrationSignature(
@@ -319,22 +278,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"ENGINE accumulates cleaning time, entity_domain-gated so the bridge's other device types are untouched."
),
tasks=(
ConsumableSignature(
(),
"Filter Cleaning",
"runtime_hours",
delta_units=15,
entity_domain="vacuum",
on_states=("cleaning",),
),
ConsumableSignature(
(),
"Clean Main Brush",
"runtime_hours",
delta_units=30,
entity_domain="vacuum",
on_states=("cleaning",),
),
VACUUM_FILTER_CLEANING_RUNTIME,
VACUUM_MAIN_BRUSH_RUNTIME,
),
),
"mydolphin_plus": IntegrationSignature(
@@ -356,4 +301,107 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
),
),
"robovac_mqtt": IntegrationSignature(
name="Eufy Clean",
verified="2026-09-25 @ jeppesens/eufy-clean main",
source=(
"jeppesens/eufy-clean custom_components/robovac_mqtt/sensor.py accessory "
"sensors: RoboVacSensor(coordinator, attr.replace('_usage', '_remaining'), "
"name, …) with _attr_has_entity_name and names 'Filter Remaining' / "
"'Rolling Brush Remaining' / 'Side Brush Remaining' / 'Sensor Remaining' / "
"'Cleaning Tray Remaining' / 'Mopping Cloth Remaining' → entity-id "
"suffixes; unit 'h', value = max life − usage (const.py "
"ACCESSORY_MAX_LIFE / SCALAR_ACCESSORY_MAX_LIFE) → hours REMAINING, reset "
"buttons in button.py. Tray and mop only on mop-capable (novel) models."
),
tasks=(
ConsumableSignature(("filter_remaining",), "Replace Filter", "duration_left"),
ConsumableSignature(("rolling_brush_remaining",), "Replace Main Brush", "duration_left"),
ConsumableSignature(("side_brush_remaining",), "Replace Side Brush", "duration_left"),
ConsumableSignature(("mopping_cloth_remaining",), "Replace Mop Pads", "duration_left"),
# Short cleaning intervals (sensors 60/35 h, tray 30 h): warn in
# the last 6 h instead of the 24 h default, which would fire
# almost right after the previous cleaning.
ConsumableSignature(("sensor_remaining",), "Clean Sensors", "duration_left", below_hours=6),
ConsumableSignature(("cleaning_tray_remaining",), "Clean Mop Tray", "duration_left", below_hours=6),
),
),
"robovac": IntegrationSignature(
name="Eufy RoboVac",
verified="2026-09-25 @ damacus/robovac main",
source=(
"damacus/robovac custom_components/robovac/vacuum.py (vacuum platform, "
"VacuumActivity.CLEANING) — the ENGINE accumulates cleaning time on every "
"model. sensor.py RobovacConsumableSensor (proto models with DPS 168 only): "
"_attr_has_entity_name, _attr_name = label from _PROTO_CONSUMABLES ('Side "
"Brush' / 'Rolling Brush' / 'Filter' / 'Scraper' / 'Sensor' / 'Mop') → "
"entity-id suffixes; unit 'h' = 'cumulative hours since the last manual "
"reset' (proto_decode.decode_consumable_response) → usage_above. Limits = "
"Eufy's accessory lifetimes as encoded in jeppesens/eufy-clean const.py "
"ACCESSORY_MAX_LIFE (same DPS 168 ConsumableResponse). 'Dust Bag' skipped "
"(no lifetime reference)."
),
tasks=(
VACUUM_FILTER_CLEANING_RUNTIME,
VACUUM_MAIN_BRUSH_RUNTIME,
ConsumableSignature(("filter",), "Replace Filter", "usage_above", above_hours=360),
ConsumableSignature(("rolling_brush",), "Replace Main Brush", "usage_above", above_hours=360),
ConsumableSignature(("side_brush",), "Replace Side Brush", "usage_above", above_hours=180),
ConsumableSignature(("mop",), "Replace Mop Pads", "usage_above", above_hours=180),
ConsumableSignature(("sensor",), "Clean Sensors", "usage_above", above_hours=60),
ConsumableSignature(("scraper",), "Clean Mop Tray", "usage_above", above_hours=30),
),
),
"tineco": IntegrationSignature(
name="Tineco",
verified="2026-09-25 @ wheeller123/Tineco-Integration main",
source=(
"wheeller123/Tineco-Integration custom_components/tineco/sensor.py "
"TinecoBrushRollerSensor: _attr_translation_key 'brush_roller', ENUM "
"options normal/tangled/stuck/needs_cleaning (br code 3) — latch on "
"'needs_cleaning'; the device reporting 'normal' again auto-resolves."
),
tasks=(
ConsumableSignature(
("brush_roller",),
"Clean Main Brush",
"event_present",
on_states=("needs_cleaning",),
),
),
),
"xiaomi_vacuum": IntegrationSignature(
name="Xiaomi Vacuum (cloud)",
verified="2026-09-25 @ roquerodrigo/ha-xiaomi-vacuum main",
source=(
"roquerodrigo/ha-xiaomi-vacuum custom_components/xiaomi_vacuum/sensor/"
"{filter,main_brush,side_brush,mop}_life.py: _attr_translation_key "
"'filter_life' / 'main_brush_life' / 'side_brush_life' / 'mop_life', "
"life_base.py PERCENTAGE 'remaining life 0-100' (MIoT life-level)."
),
tasks=(
ConsumableSignature(("main_brush_life",), "Replace Main Brush", "percent_left"),
SIDE_BRUSH_LIFE_PERCENT,
FILTER_LIFE_PERCENT,
ConsumableSignature(("mop_life",), "Replace Mop Pads", "percent_left"),
),
),
"ilife": IntegrationSignature(
name="ILIFE",
verified="2026-09-25 @ maximedeprince/ha-ilife main",
source=(
"maximedeprince/ha-ilife custom_components/ilife/sensor.py SENSORS "
"(ILIFE-cloud backend): translation_key 'main_brush' / 'side_brush' / "
"'filter', PERCENTAGE from PartsStatus MainBrushLife/SideBrushLife/"
"FilterLife; api.py reset_part sets the field back to 100 → remaining. "
"Gated on the sibling tk 'history' (ILifeHistorySensor, cloud backend "
"only): the Tuya backend's TuyaGenericSensor names raw DP codes (a bare "
"'filter' DP of unknown unit/direction would otherwise suffix-match)."
),
tasks=(
ConsumableSignature(("main_brush",), "Replace Main Brush", "percent_left", require_sibling_keys=("history",)),
ConsumableSignature(("side_brush",), "Replace Side Brush", "percent_left", require_sibling_keys=("history",)),
ConsumableSignature(("filter",), "Replace Filter", "percent_left", require_sibling_keys=("history",)),
),
),
}
@@ -8,6 +8,7 @@ integration's source; drift-probed weekly)."""
from __future__ import annotations
from ._model import ConsumableSignature, IntegrationSignature
from ._shared import CHARGER_CABLE_ETO, CHARGER_CABLE_LIFETIME_ENERGY, CHARGER_CABLE_TOTAL_ENERGY
SIGNATURES: dict[str, IntegrationSignature] = {
"easee": IntegrationSignature(
@@ -20,12 +21,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"Core `wallbox` verified NEGATIVE: its added_energy is per-session."
),
tasks=(
ConsumableSignature(
("lifetime_energy",),
"Inspect Cable and Plug",
"usage_delta",
delta_units=5000,
),
CHARGER_CABLE_LIFETIME_ENERGY,
),
),
"keba": IntegrationSignature(
@@ -36,7 +32,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"('E total' description, name 'Total Energy' → entity_id suffix "
"_total_energy, kWh, TOTAL_INCREASING lifetime)."
),
tasks=(ConsumableSignature(("total_energy",), "Inspect Cable and Plug", "usage_delta", delta_units=5000),),
tasks=(CHARGER_CABLE_TOTAL_ENERGY,),
),
"goecharger_api2": IntegrationSignature(
name="go-e Charger",
@@ -47,7 +43,7 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"lifetime energy) — the unit map converts the 5,000 kWh target "
"into the live display unit."
),
tasks=(ConsumableSignature(("eto",), "Inspect Cable and Plug", "usage_delta", delta_units=5000),),
tasks=(CHARGER_CABLE_ETO,),
),
"openevse": IntegrationSignature(
name="OpenEVSE",
@@ -59,4 +55,82 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
tasks=(ConsumableSignature(("usage_total",), "Inspect Cable and Plug", "usage_delta", delta_units=5000),),
),
# --- Round 14 (2026-09-25) --------------------------------------------
"tesla_wall_connector": IntegrationSignature(
name="Tesla Wall Connector",
verified="2026-09-25 @ home-assistant/core dev",
source=(
"core tesla_wall_connector sensor.py: key 'energy_kWh' with "
"translation_key 'energy_kwh' since 2026.8 ('Lifetime energy', "
"lifetime.energy_wh, native WATT_HOUR, suggested kWh, ENERGY, "
"TOTAL_INCREASING). The per-session 'session_energy_wh' is not "
"used."
),
tasks=(ConsumableSignature(("energy_kwh",), "Inspect Cable and Plug", "usage_delta", delta_units=5000),),
),
"nexblue": IntegrationSignature(
name="NexBlue",
verified="2026-09-25 @ home-assistant/core dev",
source=(
"core nexblue sensor.py (new in 2026.9): tk 'lifetime_energy' "
"(status.lifetime_energy_kwh, KILO_WATT_HOUR, TOTAL_INCREASING); "
"the sibling 'energy' (state_class TOTAL) is the session value."
),
tasks=(CHARGER_CABLE_LIFETIME_ENERGY,),
),
"silla_prism": IntegrationSignature(
name="Silla Prism",
verified="2026-09-25 @ home-assistant/core dev",
source=(
"core silla_prism sensor.py (new in 2026.9): tk 'total_energy' "
"(port total_energy = pysillaprism 'wh_total' topic, 'lifetime "
"energy delivered, in watt-hours', WATT_HOUR, TOTAL_INCREASING); "
"'session_energy' ('wh' topic) is per session."
),
tasks=(CHARGER_CABLE_TOTAL_ENERGY,),
),
"besen": IntegrationSignature(
name="Besen EV charger",
verified="2026-09-25 @ home-assistant/core dev",
source=(
"core besen sensor.py (sensor platform on dev, i.e. 2026.10): tk "
"'total_energy' (charge.total_energy, KILO_WATT_HOUR, "
"TOTAL_INCREASING — the besen library README: 'Total energy for "
"cumulative consumption'); 'session_energy' resets per session."
),
tasks=(CHARGER_CABLE_TOTAL_ENERGY,),
),
"peblar": IntegrationSignature(
name="Peblar",
verified="2026-09-25 @ home-assistant/core dev",
source=(
"core peblar sensor.py: tk 'energy_total' ('Lifetime energy', "
"meter.energy_total, native WATT_HOUR, suggested kWh, "
"TOTAL_INCREASING, diagnostic); 'energy_session' is per session."
),
tasks=(ConsumableSignature(("energy_total",), "Inspect Cable and Plug", "usage_delta", delta_units=5000),),
),
"zaptec": IntegrationSignature(
name="Zaptec EV charger",
verified="2026-09-25 @ custom-components/zaptec master",
source=(
"HACS zaptec sensor.py: key 'signed_meter_value_kwh' with tk "
"'signed_meter_value' ('Energy meter', ZaptecEnengySensor — the "
"max OCMF meter reading (RV) of signed_meter_value/completed_"
"session, KILO_WATT_HOUR, TOTAL_INCREASING): the charger's own "
"metering register, i.e. lifetime delivered energy."
),
tasks=(ConsumableSignature(("signed_meter_value",), "Inspect Cable and Plug", "usage_delta", delta_units=5000),),
),
"goecharger_mqtt": IntegrationSignature(
name="go-eCharger (MQTT)",
verified="2026-09-25 @ syssi/homeassistant-goecharger-mqtt main",
source=(
"HACS goecharger_mqtt definitions/sensor.py key 'eto' ('Total "
"energy', WATT_HOUR, TOTAL_INCREASING); entity.py derives "
"translation_key = key.lower() (attribute '') and entity_id "
"'<topic>_eto'. The disabled 'etop' (persisted) is not used."
),
tasks=(CHARGER_CABLE_ETO,),
),
}
@@ -7,7 +7,8 @@ integration's source; drift-probed weekly)."""
from __future__ import annotations
from ._model import ConsumableSignature, IntegrationSignature
from ._model import IntegrationSignature
from ._shared import MIOT_BRUSH_LIFE_LEVEL, MIOT_FILTER_LIFE_LEVEL
SIGNATURES: dict[str, IntegrationSignature] = {
"xiaomi_miot": IntegrationSignature(
@@ -26,8 +27,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
# Matched via the entity_id suffix (translation_key is the noisier
# 'filter-filter_life_level' form). One % task per filter; the side
# brush collides to a '_2' suffix and is intentionally not matched.
ConsumableSignature(("filter_life_level",), "Replace Filter", "percent_left"),
ConsumableSignature(("brush_life_level",), "Replace Main Brush", "percent_left"),
MIOT_FILTER_LIFE_LEVEL,
MIOT_BRUSH_LIFE_LEVEL,
),
),
"xiaomi_home": IntegrationSignature(
@@ -40,8 +41,8 @@ SIGNATURES: dict[str, IntegrationSignature] = {
"'_<key>_p_' infix. Same MIoT spec properties as hass-xiaomi-miot."
),
tasks=(
ConsumableSignature(("filter_life_level",), "Replace Filter", "percent_left"),
ConsumableSignature(("brush_life_level",), "Replace Main Brush", "percent_left"),
MIOT_FILTER_LIFE_LEVEL,
MIOT_BRUSH_LIFE_LEVEL,
),
),
}
@@ -24,10 +24,13 @@ from .dates import parse_hhmm
def effective_warning_days(warning_days: int, span_days: int | None) -> int:
"""The warning window, capped at one interval measured in real days
"""The warning window, capped below one interval measured in real days
(issue #58: a 6-*month* task must not collapse a 14-day warning to
``min(14, 6)``). ``span_days`` ``None``/0 = no cap (manual, one-off)."""
return min(warning_days, span_days) if span_days else warning_days
``min(14, 6)``). The cap is ``span - 1``: a weekly task with the default
7-day warning was "due soon" from the day it was completed — for its
whole cycle, with a due-soon push right after the completion (bug audit
2026-09-26). ``span_days`` ``None``/0 = no cap (manual, one-off)."""
return min(warning_days, max(span_days - 1, 0)) if span_days else warning_days
def is_past_schedule_time(schedule_time: str | None) -> bool:
@@ -15,13 +15,14 @@ every rule is individually testable.
from __future__ import annotations
import math
from collections.abc import Callable
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from homeassistant.util import dt as dt_util
from ..const import UNAVAILABLE_STATES
from .dates import parse_persisted_utc
if TYPE_CHECKING:
from homeassistant.core import State
@@ -147,17 +148,77 @@ def evaluate_threshold(
return FallbackResult(current_value=last_value, active=active)
def _finite(raw: Any) -> float | None:
if isinstance(raw, bool):
return None
try:
value = float(raw)
except (TypeError, ValueError):
return None
return value if math.isfinite(value) else None
def counter_baseline(trigger_config: Mapping[str, Any], entity_id: str | None) -> float | None:
"""THE baseline a delta counter measures *entity_id* against.
The entity's own living baseline from the Store
(``_trigger_state[entity].baseline_value`` — moved on every completion),
else the configured initial ``trigger_baseline_value``. Four copies of
this lookup had drifted: the coordinator took the FIRST entity's
baseline for whatever entity's reading it showed, and the sensor
predictor ignored the Store and forecast from the initial baseline, so a
serviced odometer task predicted "due now" (bug audit 2026-09-26, DRY
BR-A10 / SCH-8).
"""
state = trigger_config.get("_trigger_state")
if entity_id and isinstance(state, Mapping):
own = state.get(entity_id)
if isinstance(own, Mapping) and own.get("baseline_value") is not None:
return _finite(own["baseline_value"])
return _finite(trigger_config.get("trigger_baseline_value"))
def counter_progress(
get_state: StateGetter,
trigger_config: Mapping[str, Any],
entity_ids: list[str],
) -> tuple[float | None, float | None]:
"""``(reading, baseline)`` of ONE delta-counter entity — the one furthest
along — so a progress display never subtracts one entity's baseline from
another entity's reading. Entities without a live reading only count when
none has one (then the first with a baseline, reading ``None``)."""
attribute = trigger_config.get("attribute")
best: tuple[float, float] | None = None
fallback_baseline: float | None = None
for eid in entity_ids:
baseline = counter_baseline(trigger_config, eid)
if baseline is None:
continue
if fallback_baseline is None:
fallback_baseline = baseline
value = _numeric_entity_value(get_state, eid, attribute)
if value is not None and (best is None or value - baseline > best[0] - best[1]):
best = (value, baseline)
if best is not None:
return best
return None, fallback_baseline
def evaluate_counter(
get_state: StateGetter,
trigger_config: dict[str, Any],
entity_ids: list[str],
) -> FallbackResult:
"""Counter: value (or delta from a per-entity baseline) reaches a target."""
"""Counter: value (or delta from a per-entity baseline) reaches a target.
In delta mode the reported value is the reading of the entity furthest
along (:func:`counter_progress`), so the coordinator's delta pairs the
reading with ITS baseline.
"""
attribute = trigger_config.get("attribute")
entity_logic = trigger_config.get("entity_logic", "any")
target = trigger_config.get("trigger_target_value", 0) # pragma: no mutate (WS-required; default only guards hand-edited data)
delta_mode = trigger_config.get("trigger_delta_mode", False)
trigger_state = trigger_config.get("_trigger_state", {})
per_entity: list[bool] = []
last_value: float | None = None
@@ -168,13 +229,16 @@ def evaluate_counter(
continue
last_value = value
if delta_mode:
baseline = trigger_state.get(eid, {}).get("baseline_value")
if baseline is None:
baseline = trigger_config.get("trigger_baseline_value")
baseline = counter_baseline(trigger_config, eid)
per_entity.append(baseline is not None and (value - baseline) >= target)
else:
per_entity.append(value >= target)
if delta_mode:
reading, _baseline = counter_progress(get_state, trigger_config, entity_ids)
if reading is not None:
last_value = reading
active = _aggregate(per_entity, entity_logic) if per_entity else None
return FallbackResult(current_value=last_value, active=active)
@@ -245,15 +309,11 @@ def evaluate_runtime(
total = float(seconds)
on_since = es.get("on_since")
if on_since:
on_dt = dt_util.parse_datetime(on_since)
# The live runtime trigger's own parser (naive payloads read as
# UTC so the subtraction below can't raise) — this fallback
# re-implemented it (DRY audit 2026-09-26 B).
on_dt = parse_persisted_utc(on_since)
if on_dt is not None:
# Older payloads may be naive — assume UTC (live writes are
# TZ-aware) so the subtraction below can't raise. Mirrors
# threshold.py's exceeded_since handling.
if on_dt.tzinfo is None:
from datetime import UTC
on_dt = on_dt.replace(tzinfo=UTC)
ongoing = max(0.0, (dt_util.utcnow() - on_dt).total_seconds()) # pragma: no mutate (a 1s floor shift vanishes in the 2-decimal rounding)
# #149: per-session cap — the open window contributes at most
# what the session may still book (mirrors the live tracker).
@@ -29,13 +29,17 @@ from ..const import (
DEFAULT_WARNING_DAYS,
DOMAIN,
GLOBAL_UNIQUE_ID,
ScheduleType,
)
from .dates import add_interval, parse_iso_date
from .schedule import _CALENDAR_KINDS, Schedule
from .dates import parse_iso_date, try_add_interval
from .pause import is_task_inert
from .status import effective_warning_days
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from ..models.maintenance_task import MaintenanceTask
@dataclass(frozen=True)
class VacationState:
@@ -56,7 +60,14 @@ class VacationState:
"""Last day on which suppression still applies (inclusive)."""
if self.end is None:
return None
return self.end + timedelta(days=max(0, self.buffer_days))
try:
return self.end + timedelta(days=max(0, self.buffer_days))
except OverflowError:
# An end date of 9999-12-31 plus the buffer overflowed, and
# is_silent_for runs inside every object's refresh — the object
# went unavailable for the whole vacation (bug audit 2026-09-27).
# The window simply never ends.
return date.max
def is_active(self, at: datetime | None = None) -> bool:
"""True if today falls within [start, end + buffer] and the toggle is on."""
@@ -204,16 +215,51 @@ def _project_time_based(
window_end: date,
interval_unit: str | None = None,
) -> list[PreviewEvent]:
"""Project DUE_SOON / OVERDUE dates for a time-based task."""
"""Project DUE_SOON / OVERDUE dates for a bare interval (no task model).
Kept for callers that only hold the flat numbers; the preview itself goes
through :func:`task_next_due` so postpones, seasons and the planned anchor
are honoured.
"""
if not interval_days or interval_days <= 0:
return []
anchor = last_performed or created_at or today
# Unit-aware (weeks/months/years), not raw days — else a 6-month task would
# preview as due in 6 days during vacation planning.
next_due = add_interval(anchor, interval_days, interval_unit or "days")
# preview as due in 6 days during vacation planning. A due date past the
# calendar's end is no event (bug audit 2026-09-27).
next_due = try_add_interval(anchor, interval_days, interval_unit or "days")
if next_due is None:
return []
return _events_from_next_due(next_due, warning_days, today, window_start, window_end)
def task_next_due(task: MaintenanceTask, today: date) -> date | None:
"""``MaintenanceTask.next_due`` evaluated for an explicit *today*.
The preview used to re-derive the due date from the flat interval fields
and so ignored a postpone (``due_override``), the seasonal window, the
planned anchor, a finite series and every one-time task (bug audit
2026-09-26, DRY BR-A1). It now asks the task's own Schedule with exactly
the inputs the model property uses; only *today* (the first-time anchor)
is injectable. ``tests/test_audit_2026_09_26_t2_runtime.py`` pins this to
the property for today == now.
"""
last: date | None = None
if task.last_performed:
try:
last = date.fromisoformat(task.last_performed)
except (ValueError, TypeError):
return None # the model's "malformed anchor = no next_due" rule
return task._schedule().next_due(
last_performed=last,
created_at=parse_iso_date(task.created_at),
last_planned_due=parse_iso_date(task.last_planned_due),
today=today,
times_performed=task.times_performed,
due_override=parse_iso_date(task.due_override),
)
def compute_preview(
state: VacationState,
tasks: Iterable[Mapping[str, Any]],
@@ -221,15 +267,18 @@ def compute_preview(
) -> list[dict[str, Any]]:
"""Project status changes for each task during [start, end+buffer].
Each input *task* dict must carry: ``task_id``, ``entry_id``,
``object_name``, ``task_name``, ``schedule_type``, plus the dynamic
fields ``last_performed``, ``created_at``, ``interval_days``,
``warning_days``, ``enabled`` (all optional).
Each input *task* is the task's merged storage dict (static config +
Store state, exactly what ``MaintenanceTask.from_dict`` reads) plus the
row identity ``task_id``, ``entry_id``, ``object_name``, ``task_name``.
The caller drops tasks of paused objects (it holds the object dict);
archived and disabled tasks are dropped here as well.
Returns a list of preview rows; tasks with no projected events in the
window are omitted (caller may still want to display the task list
elsewhere).
"""
from ..models.maintenance_task import MaintenanceTask # models import helpers
if state.start is None or state.end is None:
return []
today = today or dt_util.now().date()
@@ -240,59 +289,41 @@ def compute_preview(
rows: list[dict[str, Any]] = []
for t in tasks:
if not t.get("enabled", True):
# Archived / disabled tasks never fire — the preview listed archived
# ones as "will be due" (bug audit 2026-09-26). Paused objects are the
# caller's half of the same predicate.
if is_task_inert(dict(t), {}):
continue
task_id = str(t.get("task_id") or "")
if not task_id:
continue
schedule_type = t.get("schedule_type") or "time_based"
task = MaintenanceTask.from_dict(dict(t))
schedule_type = task.schedule_type
events: list[PreviewEvent] = []
kind: str
confidence: str
if schedule_type == "time_based":
events = _project_time_based(
last_performed=_coerce_date(t.get("last_performed")),
created_at=_coerce_date(t.get("created_at")),
interval_days=t.get("interval_days"),
warning_days=_task_warning_days(t),
today=today,
window_start=window_start,
window_end=window_end,
interval_unit=t.get("interval_unit"),
)
kind = "time_based"
confidence = "deterministic"
elif schedule_type == "sensor_based":
if schedule_type == ScheduleType.SENSOR_BASED:
# Sensor triggers are non-deterministic. Surface every sensor task
# in the window with a single "may fire anytime" event so the user
# can decide per-task whether to exempt or pre-complete.
events = [PreviewEvent(date=window_start, status="triggered_est")]
kind = "sensor_based"
confidence = "unpredictable"
elif schedule_type in _CALENDAR_KINDS:
# Calendar kinds (weekdays / nth_weekday / day_of_month / calendar
# entity): project the next occurrence via the Schedule (the flat
# fields can't express it).
raw = t.get("schedule")
sched = Schedule.from_dict(raw) if isinstance(raw, dict) else None
nd = (
sched.next_due(
last_performed=_coerce_date(t.get("last_performed")),
created_at=_coerce_date(t.get("created_at")),
last_planned_due=None,
today=today,
)
if sched
else None
)
events = _events_from_next_due(nd, _task_warning_days(t), today, window_start, window_end) if nd else []
else:
# Interval, calendar kinds and one-time tasks: the task's own next
# due date and its span-capped warning window — the same inputs
# the status ladder uses, so a weekly task with a 14-day warning
# doesn't preview as "due soon" for two weeks. Manual tasks (and a
# finished series / done one-off) have no next due and drop out.
nd = task_next_due(task, today)
if nd is None:
continue
warning = effective_warning_days(_task_warning_days(t), task._schedule().span_days())
events = _events_from_next_due(nd, warning, today, window_start, window_end)
kind = schedule_type
confidence = "deterministic"
else:
# Manual / one-time tasks have no auto-due — never appear here.
continue
if not events:
continue
@@ -92,7 +92,12 @@ def build_provider_from_workday_options(
if not workdays:
return None # a config with no working days can't drive roll-back
excludes = set(options.get("excludes") or ("sat", "sun", "holiday"))
# Only a MISSING key means Workday's default; an explicit empty list is
# the user's "exclude nothing" (holidays count as working days) — the
# `or` fallback read it as the default and skipped every holiday (bug
# audit 2026-09-26, SCH-12). Same rule as ``workdays`` above.
raw_excludes = options.get("excludes")
excludes = set(("sat", "sun", "holiday") if raw_excludes is None else raw_excludes)
holidays_excluded = "holiday" in excludes
calendar: Any = None