257 files

This commit is contained in:
Home Assistant Version Control
2026-10-02 16:06:24 +00:00
parent 7c55603942
commit c29f5cc088
258 changed files with 24621 additions and 3136 deletions
@@ -529,6 +529,36 @@ LOW_LATCH_KEY = "battery_low_latch"
_LATCH_MEMORY_KEY = "battery_fleet_low_latch_memory"
def sanitize_low_latch(raw: Any) -> dict[str, dict[str, Any]]:
"""An imported latch map ({entity_id: {at, last_replaced}}) reduced to
valid entries, so a restore keeps an open low episode open instead of
dropping a battery that sits between the low and the recovered level
(round-trip audit 2026-09-29)."""
from homeassistant.core import valid_entity_id
out: dict[str, dict[str, Any]] = {}
if not isinstance(raw, dict):
return out
for key, entry in raw.items():
if len(out) >= 2000:
break
if not isinstance(key, str) or not valid_entity_id(key) or not isinstance(entry, dict):
continue
at = entry.get("at")
if not isinstance(at, str):
continue
try:
# parse_datetime raises (not None) for a well-formed but
# impossible date such as February 30.
if dt_util.parse_datetime(at.strip()) is None:
continue
except ValueError:
continue
last = entry.get("last_replaced")
out[key] = {"at": at.strip()[:40], "last_replaced": last[:40] if isinstance(last, str) else None}
return out
def _replaced_since(last_replaced: str | None, entry: dict[str, Any]) -> bool:
"""Whether the battery's current last-replaced date is NEWER than the one
seen when it latched (a recorded replacement)."""
@@ -92,6 +92,38 @@ def parse_persisted_utc(value: str | None) -> datetime | None:
return parsed
def due_instant(raw: Any) -> datetime | None:
"""The instant a sensor's REPORTED due date stands for, or None.
Not a persisted value (``parse_persisted_utc`` reads our own stamps, naive
= UTC): a ``timestamp`` sensor reports an ISO datetime (aware, or naive =
local time), a ``date`` sensor an ISO date, which falls due at local
midnight that day, and an attribute may carry the Python objects
themselves (the due_date trigger).
"""
if isinstance(raw, datetime):
parsed: datetime | None = raw
elif isinstance(raw, date):
return dt_util.start_of_local_day(raw)
elif isinstance(raw, str) and raw.strip():
text = raw.strip()
if len(text) == 10 and (day := dt_util.parse_date(text)) is not None:
return dt_util.start_of_local_day(day)
parsed = dt_util.parse_datetime(text)
else:
return None
if parsed is None:
return None
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=dt_util.get_default_time_zone())
def days_until(due: datetime, now: datetime) -> float:
"""Days from ``now`` to ``due`` — negative once it has passed. Rounded to
three decimals (about a minute and a half) so a timer that fires a hair
early still reads the due instant as reached."""
return round((due - now).total_seconds() / 86400, 3)
def _add_months(anchor: date, months: int) -> date:
"""Advance ``anchor`` by ``months`` calendar months, clamping the day."""
total = anchor.month - 1 + months
@@ -3,16 +3,27 @@
The JSON/YAML backup deliberately keeps document metadata only; the binary
blobs ride the HA backup. That leaves a portable JSON export with dangling
file docs on a fresh instance. This module adds a dedicated, self-contained
documents archive:
documents archive, laid out so a person can browse it too (version 2):
manifest.json {"version":1, "objects":[{object_id, object_name,
documents:[<metadata>]}]}
blobs/<sha256> the raw file contents (content-addressed, dedup'd)
README.txt
manifest.json {"version":2, "objects":[{object_id,
object_name, documents:[<metadata>
+ "path">]}]}
Family Car/Manuals/owners-manual.pdf
Family Car/Links.txt the object's web links
Utility Meters/Meter reading/2026-09-12 hot-water.jpg
completion photos: task + day done
Export gathers the selected objects' documents + their unique blobs. Import
writes every blob back, then re-attaches metadata to the matching object
(by id first, then by name for a cross-instance restore), skipping documents
that already exist so a repeated import is idempotent. Weblinks travel too
One folder per object; completion photos sit in their task's folder named
by the day of the completion, every other file in a folder for its category.
Version 1 stored the files as ``blobs/<sha256>`` — no names, no extensions,
useless outside a restore (round-trip audit 2026-09-29); its archives still
import.
Import reads every file the manifest names (checked against its hash),
writes it back, then re-attaches metadata to the matching object (by id
first, then by name for a cross-instance restore), skipping documents that
already exist so a repeated import is idempotent. Weblinks travel too
(0 bytes) so the archive is a complete documents backup on its own.
"""
@@ -21,19 +32,26 @@ from __future__ import annotations
import io
import json
import logging
import mimetypes
import re
import zipfile
from pathlib import PurePosixPath
from typing import Any
from homeassistant.core import HomeAssistant
from ..const import DOMAIN, GLOBAL_UNIQUE_ID
from .completion_photos import history_photo_ids
from .documents import KIND_WEBLINK, async_rewrite_doc_refs, doc_wire_dict
_LOGGER = logging.getLogger(__name__)
MANIFEST_NAME = "manifest.json"
README_NAME = "README.txt"
LINKS_NAME = "Links.txt"
# Version 1's content-addressed layout — still read on import.
BLOB_DIR = "blobs/"
ARCHIVE_VERSION = 1
ARCHIVE_VERSION = 2
# Cap a single archive import so a crafted ZIP can't exhaust memory/disk. A
# real documents backup is dominated by the blobs, already capped at 25 MB
# each × 100 docs/object — this is a coarse whole-archive ceiling on top.
@@ -63,14 +81,15 @@ def _get_store(hass: HomeAssistant) -> Any:
return hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
def _object_name_map(hass: HomeAssistant) -> tuple[dict[str, str], dict[str, str]]:
def _object_name_map(hass: HomeAssistant) -> tuple[dict[str, str], dict[str, list[tuple[str, bool]]]]:
"""(object_id → entry_id-object_id) is identity; return the maps the import
needs: existing object_ids (set) and name → object_id for cross-instance
matching."""
needs: existing object_ids (set) and name → [(object_id, archived)] for
cross-instance matching — a list, since a replaced object and its
successor share the name by default."""
from ..const import CONF_OBJECT
ids: dict[str, str] = {}
by_name: dict[str, str] = {}
by_name: dict[str, list[tuple[str, bool]]] = {}
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
continue
@@ -81,10 +100,29 @@ def _object_name_map(hass: HomeAssistant) -> tuple[dict[str, str], dict[str, str
ids[oid] = oid
name = obj.get("name")
if name:
by_name.setdefault(name, oid)
by_name.setdefault(name, []).append((oid, bool(obj.get("archived_at"))))
return ids, by_name
def _match_object(
obj: dict[str, Any],
ids: dict[str, str],
by_name: dict[str, list[tuple[str, bool]]],
claimed: set[str],
) -> str | None:
"""The object an archive's manifest entry belongs to: the same id (a
restore on the same instance), else a same-named object no other manifest
entry took yet, the one with the same archive state first. Matching by
the first name alone attached a successor's documents to the retired
object it replaced (bug audit 2026-09-29)."""
if (target := ids.get(str(obj.get("object_id") or ""))) is not None:
return target
free = [(oid, archived) for oid, archived in by_name.get(str(obj.get("object_name") or ""), []) if oid not in claimed]
if isinstance(obj.get("archived"), bool):
free = [c for c in free if c[1] == obj["archived"]] or free
return free[0][0] if free else None
def _object_task_ids(hass: HomeAssistant, object_id: str) -> set[str]:
"""The current task ids of the object whose id is ``object_id`` (empty if
none). Used to keep a same-instance archive restore's task links valid."""
@@ -115,6 +153,88 @@ class ArchiveTooLarge(ValueError):
"""The selection's files exceed what an import accepts (MAX_ARCHIVE_BYTES)."""
# Document category (its first known tag) → folder inside the object's folder.
_CATEGORY_FOLDERS = {
"manual": "Manuals",
"warranty": "Warranty",
"invoice": "Invoices",
"spare_parts": "Spare parts",
"photo": "Photos",
"other": "Other",
}
_UNSAFE_CHARS = re.compile(r'[\\/:*?"<>|\x00-\x1f]+')
_WINDOWS_RESERVED = re.compile(r"^(con|prn|aux|nul|com\d|lpt\d)$", re.IGNORECASE)
_README = """Maintenance Supporter - documents archive
One folder per maintenance object. Completion photos sit in the folder of
their task, named by the day the task was done; every other file sits in a
folder for its category (Manuals, Invoices, Photos, ...). Links.txt lists an
object's web links. A file several objects share (a replaced appliance and
its successor) is stored once, in the folder of the first.
To restore: Maintenance panel > Settings > Import / Export > Restore
documents ZIP. manifest.json describes every file for the restore - keep
it next to the folders.
"""
def _safe_part(name: Any, fallback: str) -> str:
"""One path segment that every OS unpacks: no separators or reserved
characters, no trailing dots, bounded length."""
text = _UNSAFE_CHARS.sub(" ", str(name or ""))
text = re.sub(r"\s+", " ", text).strip().strip(".").strip()[:80].strip()
if not text:
return fallback
return f"{text}_" if _WINDOWS_RESERVED.match(PurePosixPath(text).stem) else text
def _file_name(doc: dict[str, Any]) -> str:
"""A document's file name with an extension (from its MIME type when the
stored name has none)."""
raw = str(doc.get("filename") or doc.get("title") or "document").replace(chr(92), "/")
name = PurePosixPath(raw).name or "document"
suffix = re.sub(r"[^A-Za-z0-9]", "", PurePosixPath(name).suffix)
if suffix and len(suffix) <= 10:
stem = PurePosixPath(name).stem
else:
stem = name
suffix = (mimetypes.guess_extension(str(doc.get("mime") or "")) or "").lstrip(".")
return _safe_part(stem, "document") + (f".{suffix.lower()}" if suffix else "")
class _Paths:
"""Hands out unique archive paths (case-insensitive, like the file
systems the archive is unpacked on)."""
def __init__(self) -> None:
# The archive's own root entries are not available as folder names.
self._taken: set[str] = {f"/{MANIFEST_NAME}".casefold(), f"/{README_NAME}".casefold(), "/blobs"}
def claim(self, folder: str, name: str) -> str:
stem, suffix = PurePosixPath(name).stem, PurePosixPath(name).suffix
candidate, n = f"{folder}/{name}", 2
while candidate.casefold() in self._taken:
candidate, n = f"{folder}/{stem} ({n}){suffix}", n + 1
self._taken.add(candidate.casefold())
return candidate
def _completion_photo_refs(entry: Any) -> dict[str, tuple[str, str]]:
"""{document id: (task name, day done)} — the first completion each photo
belongs to."""
from .aggregate import merged_tasks
refs: dict[str, tuple[str, str]] = {}
for task in merged_tasks(entry).values():
for item in task.get("history") or []:
if not isinstance(item, dict):
continue
day = str(item.get("timestamp") or "")[:10]
for doc_id in history_photo_ids(item):
refs.setdefault(doc_id, (str(task.get("name") or ""), day))
return refs
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).
@@ -128,21 +248,61 @@ def _gather_archive(hass: HomeAssistant, entry_ids: set[str] | None) -> tuple[An
manifest_objects: list[dict[str, Any]] = []
blob_hashes: set[str] = set()
paths = _Paths()
# One file per content: a replaced object shares its predecessor's
# documents, and writing each at every path doubled the archive towards
# the import ceiling (bug audit 2026-09-29). Later documents point at the
# first copy's path.
first_path: dict[str, str] = {}
for entry in entries:
obj = entry.data.get(CONF_OBJECT, {})
object_id = obj.get("id", "")
if not object_id or store is None:
continue
stored = store.for_object(object_id)
if not stored:
continue
# A readable place for every file (version 2): the object's folder
# (made unique, two objects may share a name), then the task + day
# for a completion photo, else the category.
folder = PurePosixPath(paths.claim("", _safe_part(obj.get("name"), "Object"))).name
photo_refs = _completion_photo_refs(entry)
docs = []
for d in store.for_object(object_id):
for d in stored:
# The same record the JSON export writes (id included, so a
# restore can re-point completion photos / part doc links).
docs.append(doc_wire_dict(d, include_id=True))
record = doc_wire_dict(d, include_id=True)
h = d.get("hash")
if d.get("kind") != KIND_WEBLINK and isinstance(h, str):
blob_hashes.add(h)
if docs:
manifest_objects.append({"object_id": object_id, "object_name": obj.get("name", ""), "documents": docs})
if h in first_path:
record["path"] = first_path[h]
elif (ref := photo_refs.get(str(d.get("id")))) is not None:
task_name, day = ref
name = _file_name(d)
record["path"] = paths.claim(
f"{folder}/{_safe_part(task_name, 'Task')}", f"{day} {name}" if day else name
)
else:
tags = [t for t in d.get("tags") or [] if t in _CATEGORY_FOLDERS]
category = _CATEGORY_FOLDERS[tags[0]] if tags else "Documents"
record["path"] = paths.claim(f"{folder}/{category}", _file_name(d))
first_path.setdefault(h, record["path"])
docs.append(record)
links = [r for r in docs if r.get("kind") == KIND_WEBLINK]
if links:
paths.claim(folder, LINKS_NAME)
manifest_objects.append(
{
"object_id": object_id,
"object_name": obj.get("name", ""),
# Tells a retired object from its same-named successor on a
# restore into another instance (new object ids there).
"archived": bool(obj.get("archived_at")),
"folder": folder,
"documents": docs,
}
)
return store, {"version": ARCHIVE_VERSION, "objects": manifest_objects}, sorted(blob_hashes)
@@ -150,35 +310,49 @@ def _gather_archive(hass: HomeAssistant, entry_ids: set[str] | None) -> tuple[An
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).
Each file is streamed from disk into the archive (``ZipFile.write``) at
its readable path, so memory stays flat however large the documents
are. A file several documents share is written once, at the path the
manifest gives all of them. 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). ``blob_hashes`` is the set the manifest references.
"""
blobs: list[tuple[str, Any]] = []
wanted = set(blob_hashes)
files: list[tuple[str, Any]] = []
written: set[str] = set()
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))
for obj in manifest.get("objects") or []:
for doc in obj.get("documents") or []:
h, arcname = doc.get("hash"), doc.get("path")
if store is None or h not in wanted or not isinstance(arcname, str) or arcname in written:
continue
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])
doc.pop("path", None)
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."
)
files.append((arcname, path))
written.add(arcname)
with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr(README_NAME, _README)
zf.writestr(MANIFEST_NAME, json.dumps(manifest, ensure_ascii=False, indent=2))
for h, path in blobs:
zf.write(path, arcname=f"{BLOB_DIR}{h}")
for obj in manifest.get("objects") or []:
links = [d for d in obj.get("documents") or [] if d.get("kind") == KIND_WEBLINK]
if links and obj.get("folder"):
text = "".join(f"{d.get('title') or d.get('url')}\n{d.get('url')}\n\n" for d in links)
zf.writestr(f"{obj['folder']}/{LINKS_NAME}", text)
for arcname, path in files:
zf.write(path, arcname=arcname)
def build_documents_archive(hass: HomeAssistant, entry_ids: set[str] | None = None) -> bytes:
@@ -218,6 +392,14 @@ async def async_build_documents_archive_file(hass: HomeAssistant, entry_ids: set
return await hass.async_add_executor_job(_write)
def _blob_on_disk(store: Any, digest: str) -> bool:
"""Whether the store holds this content (blocking — a file check)."""
try:
return bool(store.blob_path(digest).is_file())
except ValueError:
return False
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
@@ -235,40 +417,88 @@ async def import_documents_archive(hass: HomeAssistant, data: bytes) -> dict[str
already present on the target object are skipped so a repeat import is
idempotent. Returns counts.
"""
import hashlib
store = _get_store(hass)
if store is None:
return {"error": "documents store unavailable"}
def _read() -> tuple[dict[str, Any], dict[str, bytes]]:
def _read() -> tuple[Any, dict[str, bytes]]:
"""(manifest — None when the archive has none, verified blobs by
content hash). Everything here runs in the executor, the hashing
included: it covers up to 500 MB (it ran on the event loop)."""
import unicodedata
def nfc(text: str) -> str:
return unicodedata.normalize("NFC", text)
blobs: dict[str, bytes] = {}
manifest: dict[str, Any] = {}
manifest: Any = None
total = 0
with zipfile.ZipFile(io.BytesIO(data)) as zf:
names = zf.namelist()
if len(names) > MAX_ARCHIVE_MEMBERS:
raise ValueError("archive_too_many_members")
for name in names:
if name == MANIFEST_NAME:
# Bound the metadata member too (was read uncapped).
manifest = json.loads(_read_member_bounded(zf, name, MAX_MANIFEST_BYTES).decode("utf-8"))
# An archive browsed and zipped again: macOS stores names
# decomposed ("u" + combining diaeresis), and zipping the unpacked
# folder wraps everything in one more folder. Match NFC names
# below that folder (bug audit 2026-09-29: such an archive
# imported nothing, silently).
prefix = ""
if not any(nfc(n) == MANIFEST_NAME for n in names):
nested = {nfc(n)[: -len(MANIFEST_NAME)] for n in names if nfc(n).endswith("/" + MANIFEST_NAME) and nfc(n).count("/") == 1}
if len(nested) == 1:
prefix = nested.pop()
members = {nfc(n)[len(prefix) :]: n for n in names if nfc(n).startswith(prefix)}
if MANIFEST_NAME in members:
# Bound the metadata member too (was read uncapped).
manifest = json.loads(_read_member_bounded(zf, members[MANIFEST_NAME], MAX_MANIFEST_BYTES).decode("utf-8"))
# Version 2: the files sit at the readable paths the manifest
# names; keyed by their content hash, which the documents' hash
# then has to match like a version-1 blob name.
listed: set[str] = set()
raw_objects = manifest.get("objects") if isinstance(manifest, dict) else None
for obj in raw_objects if isinstance(raw_objects, list) else []:
docs_in = obj.get("documents") if isinstance(obj, dict) else None
for doc in docs_in if isinstance(docs_in, list) else []:
if isinstance(doc, dict) and isinstance(doc.get("path"), str):
listed.add(nfc(doc["path"]))
for name, member in members.items():
digest = None
if name in listed:
pass
elif name.startswith(BLOB_DIR) and not name.endswith("/"):
digest = name[len(BLOB_DIR) :]
if len(digest) == 64 and all(c in "0123456789abcdef" for c in digest):
remaining = MAX_ARCHIVE_BYTES - total
if remaining <= 0:
raise ValueError("archive_too_large")
# Read bounded by the remaining budget so a bomb can't
# inflate past the whole-archive ceiling (checked DURING
# the read, not after materialising the full member).
content = _read_member_bounded(zf, name, remaining)
total += len(content)
blobs[digest] = content
if not (len(digest) == 64 and all(c in "0123456789abcdef" for c in digest)):
continue
else:
continue
remaining = MAX_ARCHIVE_BYTES - total
if remaining <= 0:
raise ValueError("archive_too_large")
# Read bounded by the remaining budget so a bomb can't
# inflate past the whole-archive ceiling (checked DURING the
# read, not after materialising the full member).
content = _read_member_bounded(zf, member, remaining)
total += len(content)
actual = hashlib.sha256(content).hexdigest()
if digest is not None and actual != digest:
_LOGGER.warning("Documents archive: blob %s failed hash check, skipped", digest[:12])
continue
blobs.setdefault(actual, content)
return manifest, blobs
try:
manifest, blobs = await hass.async_add_executor_job(_read)
except (zipfile.BadZipFile, ValueError, json.JSONDecodeError, KeyError) as err:
return {"error": f"invalid archive: {err}"}
if manifest is None:
# Zipped without it (only the folders): nothing says which document a
# file belongs to — said, instead of "0 restored".
return {
"error": f"invalid archive: no {MANIFEST_NAME} — keep it next to the folders when zipping again",
"code": "docs_archive_no_manifest",
}
# 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 /
@@ -292,8 +522,6 @@ async def import_documents_archive(hass: HomeAssistant, data: bytes) -> dict[str
if isinstance(m, dict) and isinstance(m.get("hash"), str):
referenced.add(m["hash"])
import hashlib
written = 0
docs_created = 0
objects_matched = 0
@@ -311,26 +539,40 @@ async def import_documents_archive(hass: HomeAssistant, data: bytes) -> dict[str
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)
# A file document whose file neither came with the archive nor is on
# disk would point at nothing — an archive zipped again without some
# of its files created such documents without a word.
file_hashes = {
m["hash"]
for _obj, docs in manifest_objects
for m in docs
if isinstance(m, dict) and m.get("kind") != KIND_WEBLINK and isinstance(m.get("hash"), str)
}
on_disk = await hass.async_add_executor_job(lambda: {h for h in file_hashes if _blob_on_disk(store, h)})
files_missing = 0
# 2) Re-attach metadata to the matching object (id, then name).
ids, by_name = _object_name_map(hass)
claimed: set[str] = set()
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 ""))
target = _match_object(obj, ids, by_name, claimed)
if target is None:
_LOGGER.info("Documents archive: no object matches %r, its docs skipped", obj.get("object_name"))
continue
claimed.add(target)
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]
restorable = [m for m in fresh if m.get("kind") == KIND_WEBLINK or m.get("hash") in on_disk]
files_missing += len(fresh) - len(restorable)
fresh = restorable
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;
@@ -345,8 +587,11 @@ async def import_documents_archive(hass: HomeAssistant, data: bytes) -> dict[str
if doc_id_map:
await async_rewrite_doc_refs(hass, lambda old: doc_id_map.get(old, old))
return {
result: dict[str, Any] = {
"blobs_written": written,
"documents_created": docs_created,
"objects_matched": objects_matched,
}
if files_missing:
result["files_missing"] = files_missing
return result
@@ -91,9 +91,24 @@ def doc_wire_dict(doc: dict[str, Any], *, include_id: bool) -> dict[str, Any]:
pages = doc.get("task_pages")
if isinstance(pages, dict) and pages:
out["task_pages"] = dict(pages)
# When it was added — a restore used to stamp every document with the
# import time (round-trip audit 2026-09-29).
if isinstance(doc.get("added_at"), str):
out["added_at"] = doc["added_at"]
return out
def imported_added_at(meta: dict[str, Any]) -> str:
"""The ``added_at`` of an imported record: the exported moment when it
parses and is not in the future, else now."""
now = dt_util.utcnow()
raw = meta.get("added_at")
parsed = dt_util.parse_datetime(raw) if isinstance(raw, str) else None
if parsed is not None and parsed.tzinfo is not None and parsed <= now:
return dt_util.as_utc(parsed).isoformat()
return now.isoformat()
async def async_rewrite_doc_refs(hass: HomeAssistant, rewrite: Callable[[str], str | None]) -> int:
"""Apply ``rewrite`` to every document reference held OUTSIDE the
document store: completion photos on history entries (the per-object
@@ -443,7 +458,7 @@ class DocumentStore:
async def async_import_documents(
self,
object_id: str,
docs: list[dict[str, Any]],
docs: list[Any], # untrusted import records, shape-checked below
task_id_map: dict[str, str] | None = None,
part_id_map: dict[str, str] | None = None,
id_map: dict[str, str] | None = None,
@@ -533,7 +548,7 @@ class DocumentStore:
"task_ids": remap(meta),
"part_ids": remap_parts(meta),
**({"task_pages": pages} if pages else {}),
"added_at": dt_util.utcnow().isoformat(),
"added_at": imported_added_at(meta),
}
return 1
if meta.get("kind") == KIND_FILE:
@@ -562,7 +577,7 @@ class DocumentStore:
"task_ids": remap(meta),
"part_ids": remap_parts(meta),
**({"task_pages": pages} if pages else {}),
"added_at": dt_util.utcnow().isoformat(),
"added_at": imported_added_at(meta),
}
return 1
return 0
@@ -15,7 +15,7 @@ import re
from typing import Any
def rewrite_trigger_config(config: dict[str, Any], old_id: str, new_id: str) -> tuple[dict[str, Any], bool]:
def rewrite_trigger_config(config: Any, old_id: str, new_id: str) -> tuple[Any, bool]:
"""Return (new_config, changed) with all entity_id references rewritten.
Handles:
@@ -1030,6 +1030,7 @@ def build_action_buttons(
entry_id: str | None,
task_id: str | None,
skip_allowed: bool,
complete_allowed: bool = True,
) -> list[dict[str, str]]:
"""The Companion-app action buttons a reminder carries, per the three
action toggles — translated labels, at most three (Android's cap).
@@ -1049,7 +1050,7 @@ def build_action_buttons(
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)):
if options.get(CONF_ACTION_COMPLETE_ENABLED, setting_default(CONF_ACTION_COMPLETE_ENABLED)) and complete_allowed:
actions.append({"action": _id("COMPLETE"), "title": f"✅ {_notif_t('action_complete', lang)}"})
if options.get(CONF_ACTION_SKIP_ENABLED, setting_default(CONF_ACTION_SKIP_ENABLED)) and skip_allowed:
actions.append({"action": _id("SKIP"), "title": f"⏭️ {_notif_t('action_skip', lang)}"})
@@ -1341,6 +1342,31 @@ class NotificationManager:
Unknown entries/tasks keep the button (nothing to check against)."""
return self._task_config(entry_id, task_id).get("allow_skip") is not False
def _complete_allowed(self, entry_id: str, task_id: str) -> bool:
"""A "Complete" button the action handler would refuse is not offered
(audit 2026-09-29): the tap only logged the refusal. Same rules as
``coordinator.complete_maintenance`` for an unattended surface — a
task only a tag scan may complete, one that demands details (nobody
is asked on a notification tap; phase-aware), and one whose completion
window has not opened yet (a lead-time reminder). Unknown entries or
tasks keep the button (nothing to check against)."""
from ..models.maintenance_task import MaintenanceTask
from .completion_requirements import required_completion_fields
entry = self.hass.config_entries.async_get_entry(entry_id)
coordinator = getattr(getattr(entry, "runtime_data", None), "coordinator", None)
if coordinator is None:
return True
task = coordinator._get_merged_tasks_data().get(task_id)
if not task:
return True
if task.get("require_tag_scan") or required_completion_fields(task):
return False
try:
return bool(MaintenanceTask.from_dict(task).can_complete_now)
except (KeyError, TypeError, ValueError):
return True
def _task_config(self, entry_id: str, task_id: str) -> Mapping[str, Any]:
"""The task's static config dict (``entry.data``) — carries the flags
the computed coordinator payload drops (``notify_enabled``,
@@ -1723,8 +1749,27 @@ class NotificationManager:
per-task gate's view of the snooze state — notification_gates.)"""
return self._is_snoozed(notification_key(entry_id, task_id, status))
def snooze_task(self, entry_id: str, task_id: str) -> None:
"""Snooze all notifications for a task."""
def task_snooze_state(self, entry_id: str, task_id: str) -> dict[str, datetime]:
"""The task's active snoozes, per status — what a voice undo restores."""
state: dict[str, datetime] = {}
for status in NOTIFIABLE_STATUSES:
until = self._snoozed_until.get(notification_key(entry_id, task_id, status))
if until is not None:
state[status] = until
return state
def restore_task_snooze_state(self, entry_id: str, task_id: str, state: dict[str, datetime]) -> None:
"""Put a task's snoozes back to *state* (from :meth:`task_snooze_state`)."""
for status in NOTIFIABLE_STATUSES:
key = notification_key(entry_id, task_id, status)
if status in state:
self._snoozed_until[key] = state[status]
else:
self._snoozed_until.pop(key, None)
self._dirty()
def snooze_task(self, entry_id: str, task_id: str) -> datetime:
"""Snooze all notifications for a task; returns when it ends."""
hours = self._opt(CONF_SNOOZE_DURATION_HOURS)
until = dt_util.now() + timedelta(hours=hours)
# Snooze for all status types
@@ -1732,6 +1777,7 @@ class NotificationManager:
self._snoozed_until[notification_key(entry_id, task_id, status)] = until
self._dirty()
_LOGGER.debug("Snoozed task %s for %s hours (until %s)", task_id, hours, until)
return until
async def async_task_status_changed(
self,
@@ -1996,6 +2042,7 @@ class NotificationManager:
entry_id=entry_id,
task_id=task_id,
skip_allowed=self._skip_allowed(entry_id, task_id),
complete_allowed=self._complete_allowed(entry_id, task_id),
)
service_data = _service_payload(
@@ -40,9 +40,11 @@ MAX_PART_NOTES = 500
MAX_PART_UNIT = 16
MAX_PART_URL = 500
MAX_PART_COST = 100_000.0
MAX_PART_STOCK = 9_999
# Stock counts the part's unit — with a package size (#98 follow-up) that is
# millilitres or grams, where 25 kg of salt is 25 000.
MAX_PART_STOCK = 100_000
MAX_CONSUMES_PER_TASK = 10
MAX_CONSUME_QUANTITY = 999
MAX_CONSUME_QUANTITY = 10_000
# Marker on auto-created buy tasks: {"part_id": "..."} — the reconciler
# exclusively owns tasks carrying it. Detached (popped) from a COMPLETED buy
@@ -199,6 +201,43 @@ def round_qty(v: float) -> float | int:
return int(r) if r.is_integer() else r
def _clean_package_size(raw: Any) -> float | int | None:
"""How many units one bought package holds (a 500 ml can), or None.
With a package size a part is BOUGHT in packages and USED in its unit:
stock, consumption and the reorder threshold count units (ml), while the
restock amount counts packages and the price is per package — the split
Discussion #98 asked for, so a 500 ml can at 4.99 prices a 30 ml job
without a per-millilitre price nobody can type.
"""
size = _clean_stock(raw, "package_size")
if size is None or size == 0:
return None
return size
def restock_units(part: Mapping[str, Any], packages: float | None = None) -> float:
"""What a purchase adds to the stock, in the part's unit.
``packages`` is what was actually bought (a completion's
``restock_quantity``), else the part's own restock amount, else one.
Without a package size a "package" is one unit — the old behaviour.
"""
count = float(packages) if packages and packages > 0 else float(part.get("restock_quantity") or 1)
size = part.get("package_size")
return round_qty(count * float(size)) if size else round_qty(count)
def unit_price(part: Mapping[str, Any]) -> float | None:
"""The price of ONE stock unit: the package price spread over its
contents, or the plain price without a package size."""
cost = part.get("cost")
if cost is None:
return None
size = part.get("package_size")
return float(cost) / float(size) if size else float(cost)
def _clean_stock(raw: Any, field: str) -> float | int | None:
"""Stock-ish number or None. ``stock: None`` = inventory not tracked.
@@ -241,6 +280,8 @@ def normalize_part(raw: Mapping[str, Any]) -> dict[str, Any]:
"cost": _clean_cost(raw.get("cost")),
"reorder_threshold": _clean_stock(raw.get("reorder_threshold"), "reorder_threshold"),
"restock_quantity": _clean_stock(raw.get("restock_quantity"), "restock_quantity"),
# #98 follow-up: bought in packages, used and stocked in the unit.
"package_size": _clean_package_size(raw.get("package_size")),
"auto_buy_task": bool(raw.get("auto_buy_task")),
# Receipt/datasheet via the refcounted DocumentStore (not inline files).
"doc_id": _clean_str(raw.get("doc_id"), "doc_id", 64) or None,
@@ -470,7 +511,11 @@ def buy_task_notes(part: Mapping[str, Any], stock: float | None, decimals: int =
lines: list[str] = []
qty = part.get("restock_quantity") or 1
unit = f" {part['unit']}" if part.get("unit") else ""
lines.append(f"{qty}×{unit} {part['name']}".replace("× ", "× ").strip())
if part.get("package_size"):
# Bought in packages: "2 × Glass cleaner (500 ml)".
lines.append(f"{qty} × {part['name']} ({part['package_size']}{unit})")
else:
lines.append(f"{qty}×{unit} {part['name']}".replace("× ", "× ").strip())
idents = " · ".join(
p
for p in (
@@ -485,7 +530,7 @@ def buy_task_notes(part: Mapping[str, Any], stock: float | None, decimals: int =
if part.get("cost") is not None:
lines.append(f"≈ {part['cost']:.{decimals}f} × {qty}")
if stock is not None:
lines.append(f"◎ {stock}")
lines.append(f"◎ {stock}{unit}")
if part.get("storage_location"):
lines.append(f"→ {part['storage_location']}")
return "\n".join(lines)
@@ -35,6 +35,12 @@ def is_object_paused(obj: dict[str, Any]) -> bool:
return obj.get("paused_at") is not None
def is_task_paused(task_data: dict[str, Any]) -> bool:
"""True when the task itself is paused (#193) — the same marker as an
object's: ``paused_at`` set = paused, ``paused_until`` optionally ends it."""
return task_data.get("paused_at") is not None
def is_task_inert(task_data: dict[str, Any], obj: dict[str, Any]) -> bool:
"""True when nothing may happen to the task: it is archived, disabled or
its object is paused. The one predicate behind the coordinator's
@@ -42,7 +48,12 @@ def is_task_inert(task_data: dict[str, Any], obj: dict[str, Any]) -> bool:
refusal — three hand-copied spellings before, and reset/postpone had
none (a stale notification button or an old NFC sticker could restart
a retired task's cycle)."""
return task_data.get("archived_at") is not None or task_data.get("enabled") is False or is_object_paused(obj)
return (
task_data.get("archived_at") is not None
or task_data.get("enabled") is False
or is_task_paused(task_data)
or is_object_paused(obj)
)
def pause_due_for_auto_resume(obj: dict[str, Any], today: date) -> bool:
@@ -147,8 +158,29 @@ def build_resumed_entry_data(
new_tasks: dict[str, Any] = {}
for tid, td in dict(new_data.get(CONF_TASKS, {})).items():
td = dict(td)
if td.get("archived_at") is None and is_recurring(td):
# A task paused on its own (#193) stays paused — resuming the object
# does not end a pause somebody set for that one task.
if td.get("archived_at") is None and not is_task_paused(td) and is_recurring(td):
reanchor_recurring_task(tid, store=store, today_iso=today_iso, task_data=td)
new_tasks[tid] = td
new_data[CONF_TASKS] = new_tasks
return new_data
def resume_task_data(task_id: str, task_data: dict[str, Any], store: MaintenanceStore | None, today_iso: str) -> dict[str, Any]:
"""A paused task's dict with the pause cleared (#193).
The object resume's rule for one task: an active recurring task restarts
a fresh cycle from today — the filter that went back into use counts its
three weeks from now, not from before the pause. One-time and manual
tasks keep their dates. Mutates the Store's anchor when there is one
(the caller saves).
"""
from .schedule import is_recurring
td = dict(task_data)
td.pop("paused_at", None)
td.pop("paused_until", None)
if td.get("archived_at") is None and is_recurring(td):
reanchor_recurring_task(task_id, store=store, today_iso=today_iso, task_data=td)
return td
@@ -86,12 +86,18 @@ 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."""
"""{ha_device_id: {entry_id, name}} for objects already attached to a device.
Archived objects are skipped: a replaced object stays archived on the old
unit's device next to its successor, and adopting into — or reporting as
"already linked" — a retired object is never what discovery means (it
depended on the entries' order which of the two won).
"""
out: dict[str, dict[str, str]] = {}
for entry in get_object_entries(hass):
obj = entry.data.get(CONF_OBJECT, {})
dev = obj.get("ha_device_id")
if dev:
if dev and obj.get("archived_at") is None:
out[dev] = {"entry_id": entry.entry_id, "name": object_name(entry)}
return out
@@ -22,6 +22,8 @@
#
# Vendored from: https://github.com/nayuki/QR-Code-generator/blob/master/python/qrcodegen.py
# Used by maintenance_supporter for QR code generation (helpers/qr_generator.py).
# Local change: the byte helpers are annotated bytearray (what they build and
# return) — mypy --strict no longer treats bytearray as bytes. Code unchanged.
from __future__ import annotations
import collections, itertools, re
@@ -165,7 +167,7 @@ class QrCode:
self._isfunction = [[False] * self._size for _ in range(self._size)]
self._draw_function_patterns()
allcodewords: bytes = self._add_ecc_and_interleave(bytearray(datacodewords))
allcodewords: bytearray = self._add_ecc_and_interleave(bytearray(datacodewords))
self._draw_codewords(allcodewords)
if msk == -1:
@@ -300,7 +302,7 @@ class QrCode:
# ---- Private helper methods for constructor: Codewords and masking ----
def _add_ecc_and_interleave(self, data: bytearray) -> bytes:
def _add_ecc_and_interleave(self, data: bytearray) -> bytearray:
version: int = self._version
assert len(data) == QrCode._get_num_data_codewords(version, self._errcorlvl)
numblocks: int = QrCode._NUM_ERROR_CORRECTION_BLOCKS[self._errcorlvl.ordinal][version]
@@ -308,13 +310,13 @@ class QrCode:
rawcodewords: int = QrCode._get_num_raw_data_modules(version) // 8
numshortblocks: int = numblocks - rawcodewords % numblocks
shortblocklen: int = rawcodewords // numblocks
blocks: list[bytes] = []
rsdiv: bytes = QrCode._reed_solomon_compute_divisor(blockecclen)
blocks: list[bytearray] = []
rsdiv: bytearray = QrCode._reed_solomon_compute_divisor(blockecclen)
k: int = 0
for i in range(numblocks):
dat: bytearray = data[k : k + shortblocklen - blockecclen + (0 if i < numshortblocks else 1)]
k += len(dat)
ecc: bytes = QrCode._reed_solomon_compute_remainder(dat, rsdiv)
ecc: bytearray = QrCode._reed_solomon_compute_remainder(dat, rsdiv)
if i < numshortblocks:
dat.append(0)
blocks.append(dat + ecc)
@@ -327,7 +329,7 @@ class QrCode:
assert len(result) == rawcodewords
return result
def _draw_codewords(self, data: bytes) -> None:
def _draw_codewords(self, data: bytearray) -> None:
assert len(data) == QrCode._get_num_raw_data_modules(self._version) // 8
i: int = 0
for right in range(self._size - 1, 0, -2):
@@ -435,7 +437,7 @@ class QrCode:
* QrCode._NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]
@staticmethod
def _reed_solomon_compute_divisor(degree: int) -> bytes:
def _reed_solomon_compute_divisor(degree: int) -> bytearray:
if not (1 <= degree <= 255):
raise ValueError("Degree out of range")
result = bytearray([0] * (degree - 1) + [1])
@@ -449,7 +451,7 @@ class QrCode:
return result
@staticmethod
def _reed_solomon_compute_remainder(data: bytes, divisor: bytes) -> bytes:
def _reed_solomon_compute_remainder(data: bytearray, divisor: bytearray) -> bytearray:
result = bytearray([0] * len(divisor))
for b in data:
factor: int = b ^ result.pop(0)
@@ -65,6 +65,9 @@ _TASK_STR_LIMITS: dict[str, int] = {
# crafted backup can't smuggle oversized strings past the importer.
"archived_at": MAX_META_LENGTH,
"archived_reason": MAX_META_LENGTH,
# #193: a single task's pause (the object's markers).
"paused_at": MAX_META_LENGTH,
"paused_until": MAX_DATE_LENGTH,
"schedule_time": MAX_SCHEDULE_TIME_LENGTH,
"priority": MAX_TYPE_LENGTH,
"reading_unit": MAX_READING_UNIT_LENGTH,
@@ -120,6 +123,9 @@ _FRESH_COPY_STRIP_KEYS = (
"adaptive_config",
"archived_at",
"archived_reason",
# #193: a copy starts un-paused.
"paused_at",
"paused_until",
# #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
@@ -127,6 +133,10 @@ _FRESH_COPY_STRIP_KEYS = (
# checklist from the beginning (DRY audit 2026-09-26 B).
"phase_cursor",
"checklist_progress",
# The fleet's replacement log / low latch ride entry.data only on an
# import (storage._SPLIT_ONLY_TASK_FIELDS); a copy never carries them.
"battery_replacements",
"battery_low_latch",
)
@@ -333,7 +343,8 @@ def seed_rotation_assignee(task_data: dict[str, Any]) -> None:
pool member when the assignee is missing — or no longer in the pool
(the pool was edited out from under the current assignee).
"""
pool = [u for u in task_data.get("assignee_pool") or [] if u]
raw_pool = task_data.get("assignee_pool")
pool = [u for u in raw_pool if isinstance(u, str) and u] if isinstance(raw_pool, list) else []
if not pool or not task_data.get("rotation_strategy"):
return
current = task_data.get("responsible_user_id")
@@ -545,8 +545,6 @@ class SensorPredictor:
else:
return None
if threshold_value is None:
return None
# Calculate days until threshold
delta = threshold_value - current
@@ -783,7 +781,9 @@ class SensorPredictor:
for row in rows:
# HA Python API (statistics_during_period) returns start as
# epoch seconds (float), not milliseconds or datetime objects.
start = row.get("start")
# Any: the recorder's rows are typed with float starts, but a
# datetime start is handled too (older recorders / other callers).
start: Any = row.get("start")
if start is None:
continue
@@ -40,7 +40,6 @@ from ..const import (
CONF_ADVANCED_COMPLETION_ACTIONS,
CONF_ADVANCED_ENVIRONMENTAL,
CONF_ADVANCED_GROUPS,
CONF_ADVANCED_PREDICTIONS,
CONF_ADVANCED_SCHEDULE_TIME,
CONF_ADVANCED_SEASONAL,
CONF_ARCHIVE_ONEOFF_DAYS,
@@ -83,6 +82,7 @@ from ..const import (
CONF_PANEL_ENABLED,
CONF_PANEL_TITLE,
CONF_PART_SEARCH_URL_TEMPLATE,
CONF_PARTS_COST_MODE,
CONF_QUIET_HOURS_ENABLED,
CONF_QUIET_HOURS_END,
CONF_QUIET_HOURS_START,
@@ -164,7 +164,6 @@ SETTING_SPECS: tuple[SettingSpec, ...] = (
SettingSpec(CONF_PANEL_TITLE, str, ""),
# Advanced-feature toggles
SettingSpec(CONF_ADVANCED_ADAPTIVE, bool, False),
SettingSpec(CONF_ADVANCED_PREDICTIONS, bool, False),
SettingSpec(CONF_ADVANCED_SEASONAL, bool, False),
SettingSpec(CONF_ADVANCED_ENVIRONMENTAL, bool, False),
SettingSpec(CONF_ADVANCED_BUDGET, bool, False),
@@ -229,6 +228,8 @@ SETTING_SPECS: tuple[SettingSpec, ...] = (
SettingSpec(CONF_BUDGET_ALERT_THRESHOLD, int, 80, int_range=(10, 100)),
SettingSpec(CONF_BUDGET_CURRENCY, str, DEFAULT_BUDGET_CURRENCY, max_len=5),
SettingSpec(CONF_CURRENCY_DECIMALS, int, DEFAULT_CURRENCY_DECIMALS, int_range=(0, 3)),
# #104: spare parts count when bought or when used (enum-checked in dashboard.py).
SettingSpec(CONF_PARTS_COST_MODE, str, "purchase", max_len=16),
)
_SPEC_BY_KEY: dict[str, SettingSpec] = {s.key: s for s in SETTING_SPECS}
@@ -127,7 +127,9 @@ def _appliance_type(hass: HomeAssistant, device: Any, integration: str, key: str
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 ():
from ..device_link import device_owner_ids
for entry_id in device_owner_ids(device):
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))
@@ -64,6 +64,11 @@ Direction semantics:
no wear sensor, but every transition to ``locked`` is one mechanical cycle.
state_change trigger with ``trigger_target_changes = N``; completing the
task resets the counter. No auto-complete (cycles don't recover).
* ``due_date`` — the device REPORTS when the maintenance is due (a
``timestamp``/``date`` sensor: Vitesy's "Filter change due"). Trigger: the
due_date type, active from ``days_before`` days before that date; the
device's own "done" button moves the date forward, which clears the
trigger and auto-completes the task.
"""
from __future__ import annotations
@@ -95,11 +100,14 @@ class ConsumableSignature:
keys: tuple[str, ...] # translation_key values (also matched as _<key> entity-id suffix)
task_name: str # EN task name; localized through templates_i18n
direction: str # duration_left | percent_left | usage_above | event_present | usage_delta | runtime_hours
direction: str # duration_left | percent_left | usage_above | event_present | usage_delta | runtime_hours | due_date
below_hours: int = _DEFAULT_BELOW_HOURS
below_percent: int | None = _DEFAULT_BELOW_PERCENT
above_hours: int = _DEFAULT_ABOVE_HOURS
delta_units: int = _DEFAULT_DELTA_UNITS
# due_date only: how many days before the reported date the task fires
# (time to order a filter); 0 = on the date itself.
days_before: int = 0
# runtime_hours signatures target a non-sensor STATE entity; empty keys
# then mean "the device's single entity of this domain".
entity_domain: str = "sensor"
@@ -271,8 +279,8 @@ def _unit_compatible(direction: str, unit: str | None) -> bool:
key match, so existing single-shape signatures are unaffected. The one
strict case is ``event_present``: ENUM event sensors carry no unit, so a
unit-bearing entity that happens to share the key is NOT an event."""
if direction in ("event_present", "runtime_hours", "cycle_count"):
return unit is None # ENUM events and state entities carry no unit
if direction in ("event_present", "runtime_hours", "cycle_count", "due_date"):
return unit is None # ENUM events, state entities and dates carry no unit
if direction in ("alert_above", "value_below"):
return True # measurement alert in the entity's own unit (any unit)
if unit is None:
@@ -290,6 +298,8 @@ def _threshold_for(sig: ConsumableSignature, hass: HomeAssistant, entity_id: str
"""
if sig.direction == "event_present":
return 0.0 # ENUM event latch — no numeric threshold
if sig.direction == "due_date":
return float(sig.days_before) # days before the reported date
if sig.direction in ("runtime_hours", "alert_above", "value_below", "cycle_count"):
# Engine-accumulated hours resp. a raw measurement threshold in the
# entity's own unit — no conversion in either case.
@@ -392,6 +402,21 @@ def build_setup_trigger(sig: ConsumableSignature, hass: HomeAssistant, entity_id
# their own alert state (Dolphin filter bag: "full").
latch["trigger_to_state"] = sig.on_states[0] if sig.on_states else "present"
return latch
if sig.direction == "due_date":
# The device keeps the date itself; its own "done" button (wired as
# the completion action) moves it forward, which clears the trigger.
due: dict[str, Any] = {
"type": "due_date",
"entity_id": entity_ids[0],
"entity_ids": list(entity_ids),
"trigger_days_before": sig.days_before,
"auto_complete_on_recovery": True,
}
if len(entity_ids) > 1:
due["entity_logic"] = "any"
if sig.attribute:
due["attribute"] = sig.attribute
return due
if sig.direction == "cycle_count":
# Engine-counted mechanical cycles: every transition into on_states[0]
# increments; the task fires at N and completing it resets the count.
@@ -444,6 +469,7 @@ def build_setup_trigger(sig: ConsumableSignature, hass: HomeAssistant, entity_id
) # value_below + consumables use trigger_below
return {
"type": "threshold",
"entity_id": entity_ids[0], # legacy mirror (#106); entity_ids is authoritative
"entity_ids": list(entity_ids),
threshold_key: _threshold_for(sig, hass, entity_ids[0]),
"entity_logic": "any",
@@ -36,7 +36,10 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
"bosch": IntegrationSignature(
name="Bosch/Buderus heating",
verified="2026-07-18 @ bosch-thermostat/home-assistant-bosch-custom-component master + live RC300 registry",
verified=(
"2026-07-18 @ bosch-thermostat/home-assistant-bosch-custom-component master + live RC300 registry; "
"re-verified 2026-09-30 after the coordinator refactor (21d1924): names unchanged"
),
source=(
"bosch-thermostat custom component: sensors are DYNAMIC (named "
"from the device's XMPP data, sensor/base.py builds names without "
@@ -244,15 +247,9 @@ 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,),
),
# (De Dietrich left again on 2026-09-30: core removed the integration
# from dev before any release, #183545 — it returns as a joint Remeha /
# De Dietrich Modbus integration.)
"remeha_home": IntegrationSignature(
name="Remeha Home",
verified="2026-09-25 @ msvisser/remeha_home main",
@@ -773,4 +773,35 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
translation_keys_authoritative=True,
),
# ─── 2026-09-30: dates the appliance reports itself (due_date engine) ─
"vitesy": IntegrationSignature(
name="Vitesy (Shelfy)",
verified="2026-09-30 @ home-assistant/core dev (new in 2026.10)",
source=(
"core vitesy sensor.py: tk 'filter_change_due' / 'fridge_cleaning_due' "
"(device_class TIMESTAMP, EntityCategory.DIAGNOSTIC; the value is the "
"device's maintenance[component].due_date, created only when the device "
"reports one) + button.py: tk 'filter_changed' / 'fridge_cleaned' ('Mark "
"filter as changed' / 'Mark fridge as cleaned', api.reset_maintenance → "
"the next maintenance period, i.e. a later due date). Every entity "
"description sets a translation_key."
),
translation_keys_authoritative=True,
tasks=(
# A week's lead time to order the cartridge.
ConsumableSignature(
("filter_change_due",),
"Replace Filter",
"due_date",
days_before=7,
resets=(("filter_change_due", "filter_changed"),),
),
ConsumableSignature(
("fridge_cleaning_due",),
"Clean Refrigerator",
"due_date",
resets=(("fridge_cleaning_due", "fridge_cleaned"),),
),
),
),
}
@@ -144,10 +144,11 @@ SIGNATURES: dict[str, IntegrationSignature] = {
),
"ha_creality_ws": IntegrationSignature(
name="Creality (WebSocket)",
verified="2026-09-25 @ 3dg1luk43/ha_creality_ws main",
verified="2026-09-25 @ 3dg1luk43/ha_creality_ws main; re-verified 2026-09-30 (states now from utils.derive_print_state)",
source=(
"3dg1luk43/ha_creality_ws custom_components/ha_creality_ws/sensor.py "
"PrintStatusSensor: _attr_translation_key 'print_status', no unit, states "
"(utils.derive_print_state since 2026-09-26) "
"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/"
@@ -17,12 +17,13 @@ from __future__ import annotations
import math
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from datetime import datetime
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
from .dates import days_until, due_instant, parse_persisted_utc
if TYPE_CHECKING:
from homeassistant.core import State
@@ -285,6 +286,36 @@ def evaluate_state_change(
return FallbackResult(current_value=best_count, active=active)
def evaluate_due_date(
get_state: StateGetter,
trigger_config: dict[str, Any],
entity_ids: list[str],
now: datetime | None = None,
) -> FallbackResult:
"""Due date: active from ``trigger_days_before`` days before the date the
entity reports; the reading is the days left (the soonest one)."""
attribute = trigger_config.get("attribute")
entity_logic = trigger_config.get("entity_logic", "any")
days_before = float(trigger_config.get("trigger_days_before") or 0)
now = now or dt_util.utcnow()
per_entity: list[bool] = []
soonest: float | None = None
for eid in entity_ids:
state = get_state(eid)
if state is None or state.state in UNAVAILABLE_STATES:
continue
due = due_instant(state.attributes.get(attribute) if attribute else state.state)
if due is None:
continue
left = days_until(due, now)
soonest = left if soonest is None else min(soonest, left)
per_entity.append(left <= days_before)
if not per_entity:
# No readable date — keep the event-driven latch as it is.
return FallbackResult(current_value=None, active=None)
return FallbackResult(current_value=soonest, active=_aggregate(per_entity, entity_logic))
def evaluate_runtime(
trigger_config: dict[str, Any],
entity_ids: list[str],