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