393 files
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user