updated apps

This commit is contained in:
2026-07-14 23:57:03 -04:00
parent 6cc7212cef
commit 010e828e9c
797 changed files with 45153 additions and 4246 deletions
@@ -15,8 +15,6 @@ from ..const import (
CONF_OBJECT,
CONF_TASKS,
DEFAULT_WARNING_DAYS,
DOMAIN,
GLOBAL_UNIQUE_ID,
MAX_CHECKLIST_ITEM_LENGTH,
MAX_CHECKLIST_ITEMS,
)
@@ -69,12 +67,14 @@ def _csv_safe(val: str) -> str:
return val
def export_objects_csv(hass: HomeAssistant) -> str:
"""Export all maintenance objects and tasks as CSV.
def export_objects_csv(hass: HomeAssistant, entry_ids: set[str] | None = None) -> str:
"""Export maintenance objects and tasks as CSV (all, or a selection).
Each row represents one task, with the parent object info repeated.
"""
entries = [entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.unique_id != GLOBAL_UNIQUE_ID]
from ..export import object_entries
entries = object_entries(hass, entry_ids)
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=_COLUMNS, extrasaction="ignore")
@@ -101,9 +101,9 @@ def export_objects_csv(hass: HomeAssistant) -> str:
"object_manufacturer": _csv_safe(obj_data.get("manufacturer", "")),
"object_model": _csv_safe(obj_data.get("model", "")),
"object_serial_number": _csv_safe(obj_data.get("serial_number", "")),
"object_area_id": obj_data.get("area_id", ""),
"object_installation_date": obj_data.get("installation_date", ""),
"object_warranty_expiry": obj_data.get("warranty_expiry", ""),
"object_area_id": _csv_safe(obj_data.get("area_id", "")),
"object_installation_date": _csv_safe(obj_data.get("installation_date", "")),
"object_warranty_expiry": _csv_safe(obj_data.get("warranty_expiry", "")),
"task_name": _csv_safe(tdata.get("name", "")),
"task_type": tdata.get("type", "custom"),
"enabled": tdata.get("enabled", True),
@@ -113,7 +113,7 @@ def export_objects_csv(hass: HomeAssistant) -> str:
"due_date": sched["due_date"] or "",
"interval_anchor": sched["interval_anchor"],
"schedule_time": tdata.get("schedule_time", ""),
"reading_unit": tdata.get("reading_unit", ""),
"reading_unit": _csv_safe(tdata.get("reading_unit", "")),
"warning_days": tdata.get("warning_days", DEFAULT_WARNING_DAYS),
"last_performed": tdata.get("last_performed", ""),
"notes": _csv_safe(tdata.get("notes", "")),
@@ -150,15 +150,18 @@ _OBJECT_RECORD_COLUMNS = [
]
def export_object_records_csv(hass: HomeAssistant) -> str:
def export_object_records_csv(hass: HomeAssistant, entry_ids: set[str] | None = None) -> str:
"""Export one row per maintenance object (the objects-table download, #67).
Unlike ``export_objects_csv`` (one row per task, object fields repeated),
this emits exactly one row per object — including objects that have no
tasks, which the per-task export skips entirely — and carries the full
asset field set used by the objects table.
asset field set used by the objects table. ``entry_ids`` narrows to a
selection (None = all).
"""
entries = [entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.unique_id != GLOBAL_UNIQUE_ID]
from ..export import object_entries
entries = object_entries(hass, entry_ids)
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=_OBJECT_RECORD_COLUMNS, extrasaction="ignore")
@@ -176,9 +179,9 @@ def export_object_records_csv(hass: HomeAssistant) -> str:
"object_manufacturer": _csv_safe(obj_data.get("manufacturer") or ""),
"object_model": _csv_safe(obj_data.get("model") or ""),
"object_serial_number": _csv_safe(obj_data.get("serial_number") or ""),
"object_area_id": obj_data.get("area_id") or "",
"object_installation_date": obj_data.get("installation_date") or "",
"object_warranty_expiry": obj_data.get("warranty_expiry") or "",
"object_area_id": _csv_safe(obj_data.get("area_id") or ""),
"object_installation_date": _csv_safe(obj_data.get("installation_date") or ""),
"object_warranty_expiry": _csv_safe(obj_data.get("warranty_expiry") or ""),
"object_documentation_url": _csv_safe(obj_data.get("documentation_url") or ""),
"object_notes": _csv_safe(obj_data.get("notes") or ""),
"task_count": len(tasks_data),
@@ -224,6 +227,10 @@ def import_objects_csv(
"area_id": (row.get("object_area_id") or "").strip() or None,
"installation_date": (row.get("object_installation_date") or "").strip() or None,
"warranty_expiry": (row.get("object_warranty_expiry") or "").strip() or None,
# In the object-CSV export columns but never read back on
# import until now (round-trip gap, audit 2026-07-11).
"documentation_url": (row.get("object_documentation_url") or "").strip() or None,
"notes": (row.get("object_notes") or "").strip() or None,
"task_ids": [],
},
"tasks": {},
@@ -0,0 +1,289 @@
"""Documents archive (ZIP) — the one export that carries file *contents*.
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:
manifest.json {"version":1, "objects":[{object_id, object_name,
documents:[<metadata>]}]}
blobs/<sha256> the raw file contents (content-addressed, dedup'd)
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
(0 bytes) so the archive is a complete documents backup on its own.
"""
from __future__ import annotations
import io
import json
import logging
import zipfile
from typing import Any
from homeassistant.core import HomeAssistant
from ..const import DOMAIN, GLOBAL_UNIQUE_ID
from .documents import KIND_FILE, KIND_WEBLINK
_LOGGER = logging.getLogger(__name__)
MANIFEST_NAME = "manifest.json"
BLOB_DIR = "blobs/"
ARCHIVE_VERSION = 1
# 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.
MAX_ARCHIVE_BYTES = 500 * 1024 * 1024 # 500 MB uncompressed-blob budget
MAX_MANIFEST_BYTES = 16 * 1024 * 1024 # the manifest is metadata only
MAX_ARCHIVE_MEMBERS = 20000 # ceiling on ZIP entry count (blobs cap at 100/obj)
def _read_member_bounded(zf: zipfile.ZipFile, name: str, limit: int) -> bytes:
"""Read a ZIP member but never materialise more than ``limit`` bytes.
``ZipFile.read`` inflates the WHOLE member before returning, so a crafted
member (small compressed, huge inflated — a "zip bomb") would exhaust memory
before any post-hoc size check. Reading through ``open()`` with a hard byte
ceiling bounds the decompression regardless of the declared/actual size.
"""
with zf.open(name) as fh:
data = fh.read(limit + 1)
if len(data) > limit:
raise ValueError("archive_member_too_large")
return data
def _get_store(hass: HomeAssistant) -> Any:
from .. import DOCUMENT_STORE_KEY
return hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
def _object_name_map(hass: HomeAssistant) -> tuple[dict[str, str], dict[str, str]]:
"""(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."""
from ..const import CONF_OBJECT
ids: dict[str, str] = {}
by_name: dict[str, str] = {}
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
continue
obj = entry.data.get(CONF_OBJECT, {})
oid = obj.get("id")
if not oid:
continue
ids[oid] = oid
name = obj.get("name")
if name:
by_name.setdefault(name, oid)
return ids, by_name
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."""
from ..const import CONF_OBJECT, CONF_TASKS
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
continue
if entry.data.get(CONF_OBJECT, {}).get("id") == object_id:
return set(entry.data.get(CONF_TASKS, {}))
return set()
def _object_part_ids(hass: HomeAssistant, object_id: str) -> set[str]:
"""The current spare-part ids of the object (same role as
``_object_task_ids``, for a doc's part links)."""
from ..const import CONF_OBJECT, CONF_PARTS
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
continue
if entry.data.get(CONF_OBJECT, {}).get("id") == object_id:
return set(entry.data.get(CONF_PARTS) or {})
return set()
def build_documents_archive(hass: HomeAssistant, entry_ids: set[str] | None = None) -> bytes:
"""Build a documents ZIP for the selected objects (None = all).
Runs on the event loop for the metadata gather (reads config entries) but
the heavy blob reads happen here synchronously — call via the executor.
"""
from ..const import CONF_OBJECT
from ..export import object_entries
store = _get_store(hass)
entries = object_entries(hass, entry_ids)
manifest_objects: list[dict[str, Any]] = []
blob_hashes: set[str] = set()
for entry in entries:
obj = entry.data.get(CONF_OBJECT, {})
object_id = obj.get("id", "")
if not object_id or store is None:
continue
docs = []
for d in store.for_object(object_id):
if d.get("kind") == KIND_WEBLINK:
docs.append(
{
"kind": KIND_WEBLINK,
"url": d.get("url"),
"title": d.get("title"),
"tags": d.get("tags") or [],
"task_ids": d.get("task_ids") or [],
"part_ids": d.get("part_ids") or [],
}
)
else:
h = d.get("hash")
docs.append(
{
"kind": KIND_FILE,
"hash": h,
"title": d.get("title"),
"filename": d.get("filename"),
"mime": d.get("mime"),
"size": d.get("size"),
"tags": d.get("tags") or [],
"task_ids": d.get("task_ids") or [],
"part_ids": d.get("part_ids") or [],
}
)
if isinstance(h, str):
blob_hashes.add(h)
if docs:
manifest_objects.append({"object_id": object_id, "object_name": obj.get("name", ""), "documents": docs})
manifest = {"version": ARCHIVE_VERSION, "objects": manifest_objects}
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr(MANIFEST_NAME, json.dumps(manifest, ensure_ascii=False, indent=2))
for h in sorted(blob_hashes):
if store is None:
break
try:
path = store.blob_path(h)
except ValueError:
continue
if path.is_file():
zf.writestr(f"{BLOB_DIR}{h}", path.read_bytes())
else:
_LOGGER.warning("Documents archive: blob %s missing on disk, skipped", h[:12])
return buf.getvalue()
async def import_documents_archive(hass: HomeAssistant, data: bytes) -> dict[str, Any]:
"""Restore a documents ZIP: write blobs back, re-attach metadata.
Objects are matched by id first (same instance), then by name (a
cross-instance restore after a JSON import created fresh ids). Documents
already present on the target object are skipped so a repeat import is
idempotent. Returns counts.
"""
store = _get_store(hass)
if store is None:
return {"error": "documents store unavailable"}
def _read() -> tuple[dict[str, Any], dict[str, bytes]]:
blobs: dict[str, bytes] = {}
manifest: dict[str, Any] = {}
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"))
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
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}"}
# 1) Write back only blobs a manifest document actually references — an
# archive carrying extra blobs must not litter /config with orphans that
# ride every HA backup and are never refcounted (disk-fill hardening).
referenced: set[str] = set()
for obj in manifest.get("objects", []):
if not isinstance(obj, dict):
continue
for m in obj.get("documents", []):
if isinstance(m, dict) and isinstance(m.get("hash"), str):
referenced.add(m["hash"])
import hashlib
written = 0
for digest, content in blobs.items():
if digest not in referenced:
_LOGGER.info("Documents archive: blob %s referenced by no document, skipped", digest[:12])
continue
if hashlib.sha256(content).hexdigest() != digest:
_LOGGER.warning("Documents archive: blob %s failed hash check, skipped", digest[:12])
continue
_, wrote_new = await hass.async_add_executor_job(store._store_blob_sync, content)
if wrote_new:
written += 1
# 2) Re-attach metadata to the matching object (id, then name).
ids, by_name = _object_name_map(hass)
docs_created = 0
objects_matched = 0
for obj in manifest.get("objects", []):
if not isinstance(obj, dict):
continue
target = ids.get(str(obj.get("object_id") or "")) or by_name.get(str(obj.get("object_name") or ""))
if target is None:
_LOGGER.info("Documents archive: no object matches %r, its docs skipped", obj.get("object_name"))
continue
objects_matched += 1
# Skip docs already present on the target (idempotent re-import).
existing = store.for_object(target)
existing_keys = {(d.get("kind"), d.get("hash") or d.get("url")) for d in existing}
fresh = [
m
for m in obj.get("documents", [])
if isinstance(m, dict) and (m.get("kind"), m.get("hash") or m.get("url")) not in existing_keys
]
if fresh:
# Keep task links that still resolve on the target (a same-instance
# restore) via an identity map over the object's current task ids;
# a cross-instance restore has fresh task ids, so those links drop
# here and are re-established by the JSON import's remap instead.
valid_task_ids = _object_task_ids(hass, target)
identity = {tid: tid for tid in valid_task_ids}
part_identity = {pid: pid for pid in _object_part_ids(hass, target)}
docs_created += await store.async_import_documents(
target, fresh, task_id_map=identity, part_id_map=part_identity
)
return {
"blobs_written": written,
"documents_created": docs_created,
"objects_matched": objects_matched,
}
@@ -26,7 +26,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.storage import Store
from homeassistant.util import dt as dt_util
from ..const import DOMAIN, SIGNAL_DOCUMENTS_UPDATED
from ..const import DOMAIN, MAX_DOCS_PER_OBJECT, SIGNAL_DOCUMENTS_UPDATED
_LOGGER = logging.getLogger(__name__)
@@ -146,6 +146,12 @@ class DocumentStore:
if len(content) > MAX_DOC_BYTES:
raise ValueError("file_too_large")
# Per-object document cap — a runaway upload loop must not be able to
# bloat the (single, global) documents store without bound.
object_doc_count = sum(1 for d in self.documents.values() if d.get("object_id") == object_id)
if object_doc_count >= MAX_DOCS_PER_OBJECT:
raise ValueError("too_many_documents")
digest, wrote_new = await self.hass.async_add_executor_job(self._store_blob_sync, content)
# Register / adopt the blob and bump its refcount.
@@ -172,6 +178,7 @@ class DocumentStore:
"size": len(content),
"tags": list(tags or []),
"task_ids": [],
"part_ids": [],
"added_at": dt_util.utcnow().isoformat(),
}
self.documents[doc_id] = doc
@@ -214,21 +221,41 @@ class DocumentStore:
"title": title or url,
"tags": list(tags or []),
"task_ids": [],
"part_ids": [],
"added_at": dt_util.utcnow().isoformat(),
}
self.documents[doc_id] = doc
await self._async_save()
return {"id": doc_id, **doc}
async def async_import_documents(self, object_id: str, docs: list[dict[str, Any]]) -> int:
async def async_import_documents(
self,
object_id: str,
docs: list[dict[str, Any]],
task_id_map: dict[str, str] | None = None,
part_id_map: dict[str, str] | None = None,
) -> int:
"""Recreate document metadata for an imported object (P6).
Web-links round-trip fully. File docs are restored as metadata + a blob
refcount; the binary itself is not in the JSON export (it rides the
/config backup), so unless a matching backup was restored the blob is
absent and the hygiene scan flags the doc as dangling. task_ids are
dropped (tasks get fresh ids on import). Returns the number created.
absent and the hygiene scan flags the doc as dangling. ``task_ids`` /
``part_ids`` are remapped through their old→new id maps so a doc's
task and spare-part links survive the import; ids with no mapping are
dropped. Returns the number created.
"""
def _remap(meta: dict[str, Any]) -> list[str]:
if not task_id_map:
return []
return [task_id_map[t] for t in (meta.get("task_ids") or []) if t in task_id_map]
def _remap_parts(meta: dict[str, Any]) -> list[str]:
if not part_id_map:
return []
return [part_id_map[p] for p in (meta.get("part_ids") or []) if p in part_id_map]
created = 0
for meta in docs:
if not isinstance(meta, dict):
@@ -248,7 +275,8 @@ class DocumentStore:
"url": url,
"title": title or url,
"tags": tags,
"task_ids": [],
"task_ids": _remap(meta),
"part_ids": _remap_parts(meta),
"added_at": dt_util.utcnow().isoformat(),
}
created += 1
@@ -272,7 +300,8 @@ class DocumentStore:
"mime": mime,
"size": size,
"tags": tags,
"task_ids": [],
"task_ids": _remap(meta),
"part_ids": _remap_parts(meta),
"added_at": dt_util.utcnow().isoformat(),
}
created += 1
@@ -292,13 +321,15 @@ class DocumentStore:
tags: list[str] | None = None,
task_ids: list[str] | None = None,
task_pages: dict[str, int] | None = None,
part_ids: list[str] | None = None,
) -> bool:
"""Update editable metadata (title / tags / task links / per-task page).
"""Update editable metadata (title / tags / task+part links / per-task page).
``task_pages`` is a ``{task_id: page}`` map merged into the doc: a page
``>= 1`` sets the jump-to page for that task's link, ``0`` clears it. Page
hints are always pruned to the currently linked tasks so an unlink also
forgets its page, and an empty map is dropped to keep the record clean.
``part_ids`` (v2.26) links the doc to spare parts, mirroring task links.
"""
doc = self.documents.get(doc_id)
if doc is None:
@@ -309,6 +340,8 @@ class DocumentStore:
doc["tags"] = list(tags)
if task_ids is not None:
doc["task_ids"] = list(task_ids)
if part_ids is not None:
doc["part_ids"] = list(part_ids)
if task_pages is not None:
merged = dict(doc.get("task_pages") or {})
for tid, page in task_pages.items():
@@ -14,6 +14,7 @@ from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from ..const import (
@@ -27,12 +28,22 @@ from ..const import (
)
def get_global_options(hass: HomeAssistant) -> Mapping[str, Any]:
"""Return the options dict from the global config entry, or empty mapping."""
def get_global_entry(hass: HomeAssistant) -> ConfigEntry | None:
"""Return the single global config entry (`unique_id == GLOBAL_UNIQUE_ID`).
The one place that resolves it — the ~inline `for entry in async_entries(...)
if unique_id == GLOBAL_UNIQUE_ID` loop was copy-pasted across many modules.
"""
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
return entry.options or entry.data
return {}
return entry
return None
def get_global_options(hass: HomeAssistant) -> Mapping[str, Any]:
"""Return the options dict from the global config entry, or empty mapping."""
entry = get_global_entry(hass)
return (entry.options or entry.data) if entry is not None else {}
def get_default_warning_days(hass: HomeAssistant) -> int:
@@ -0,0 +1,429 @@
"""Spare parts & consumables — pure domain logic.
Maintenance consumes things (filters, seals, descaler, salt). This module owns
the part model and every rule around it, hass-free so it is trivially
unit-testable:
- normalization/validation of the part dict stored in ``entry.data["parts"]``
- GTIN validation (GS1 check digit; the GTIN family covers EAN-13/UPC-A/EAN-8)
- edge-triggered stock transitions (``low`` / ``out`` / ``restocked``)
- the **declarative buy-task reconciler**: an auto "Buy {part}" task exists
exactly while its part opts in AND is low. Computing the desired set and
diffing against reality makes idempotence and self-healing fall out as
properties instead of special cases.
- the shopping-search URL resolver (product_url wins; otherwise a configurable
``{q}`` template with query precedence GTIN → "{vendor} {mpn}" → name).
Static part definitions live in ``entry.data["parts"]`` (like ``tasks``); the
mutable stock count lives in the per-entry Store (like ``last_performed``) and
is passed in here as a plain ``{part_id: stock}`` map.
"""
from __future__ import annotations
import re
from collections.abc import Mapping
from datetime import date
from typing import Any
from urllib.parse import quote_plus
from uuid import uuid4
# ── Limits (mirrored in the WS schemas + panel dialog) ──────────────────────
MAX_PARTS_PER_OBJECT = 50
MAX_PART_NAME = 100
MAX_PART_MPN = 64
MAX_PART_VENDOR = 64
MAX_PART_STORAGE_LOCATION = 120
MAX_PART_NOTES = 500
MAX_PART_UNIT = 16
MAX_PART_URL = 500
MAX_PART_COST = 100_000.0
MAX_PART_STOCK = 9_999
MAX_CONSUMES_PER_TASK = 10
MAX_CONSUME_QUANTITY = 999
# Marker on auto-created buy tasks: {"part_id": "..."} — the reconciler
# exclusively owns tasks carrying it. Detached (popped) from a COMPLETED buy
# task once its part is restocked, so the task survives as a plain done
# one-off (its cost history stays in the statistics) while the next low
# episode is free to create a fresh reminder.
PART_REF_FIELD = "part_ref"
# Label + icon stamped on auto-created buy tasks (design decision: existing
# ``custom`` task type + label instead of a new task-type enum).
BUY_TASK_LABEL = "shopping"
BUY_TASK_ICON = "mdi:cart"
# Localized "Buy {name}" templates (HA config language; EN fallback). Kept as
# a flat table like templates_i18n — the backend can't reach strings.json for
# runtime-generated names.
_BUY_NAME_TEMPLATES = {
"en": "Buy {name}",
"de": "{name} kaufen",
"fr": "Acheter {name}",
"es": "Comprar {name}",
"it": "Acquistare {name}",
"nl": "{name} kopen",
"pl": "Kup {name}",
"pt": "Comprar {name}",
"cs": "Koupit {name}",
"da": "Køb {name}",
"fi": "Osta {name}",
"hi": "{name} खरीदें",
"ja": "{name}を購入",
"nb": "Kjøp {name}",
"ru": "Купить {name}",
"sv": "Köp {name}",
"uk": "Купити {name}",
"zh": "购买{name}",
}
# Default shopping-search templates by UI language (the "Amazon as fallback"
# decision); overridable via the global ``part_search_url_template`` setting.
_DEFAULT_SEARCH_TEMPLATES = {
"de": "https://www.amazon.de/s?k={q}",
"fr": "https://www.amazon.fr/s?k={q}",
"it": "https://www.amazon.it/s?k={q}",
"es": "https://www.amazon.es/s?k={q}",
"nl": "https://www.amazon.nl/s?k={q}",
}
_FALLBACK_SEARCH_TEMPLATE = "https://www.amazon.com/s?k={q}"
class PartValidationError(ValueError):
"""A part payload failed validation."""
# ── GTIN ─────────────────────────────────────────────────────────────────────
def validate_gtin(raw: Any) -> str | None:
"""Normalize + validate a GTIN (EAN-13 / UPC-A / EAN-8 / GTIN-14).
The GS1 GTIN family is the worldwide standard — EAN-13 is GTIN-13, the
North-American UPC-A is GTIN-12 (an EAN-13 with a leading zero). Accepts
8/12/13/14 digits (spaces/dashes tolerated), verifies the GS1 mod-10 check
digit, and returns the normalized digit string. ``None`` for empty input;
raises :class:`PartValidationError` for malformed input.
"""
if raw is None:
return None
s = re.sub(r"[\s-]", "", str(raw))
if not s:
return None
if not s.isdigit() or len(s) not in (8, 12, 13, 14):
raise PartValidationError("gtin must be 8, 12, 13 or 14 digits (EAN/UPC/GTIN)")
digits = [int(c) for c in s]
check = digits[-1]
total = 0
# GS1: number positions from the RIGHT starting at 1 (the check digit);
# even positions weigh 3, odd weigh 1.
for i, d in enumerate(reversed(digits[:-1]), start=2):
total += d * (3 if i % 2 == 0 else 1)
if (10 - total % 10) % 10 != check:
raise PartValidationError("gtin check digit is invalid")
return s
# ── Part normalization ───────────────────────────────────────────────────────
def _clean_str(raw: Any, field: str, max_len: int) -> str:
s = str(raw or "").strip()
if len(s) > max_len:
raise PartValidationError(f"{field} must be at most {max_len} characters")
return s
def _clean_url(raw: Any) -> str:
s = str(raw or "").strip()
if not s:
return ""
if len(s) > MAX_PART_URL or not re.match(r"^https?://", s):
raise PartValidationError("product_url must be an http(s) URL")
return s
def _clean_cost(raw: Any) -> float | None:
if raw in (None, ""):
return None
try:
v = round(float(raw), 2)
except (TypeError, ValueError) as err:
raise PartValidationError("cost must be a number") from err
if not 0 <= v <= MAX_PART_COST:
raise PartValidationError("cost out of range")
return v
def _clean_stock(raw: Any, field: str) -> int | None:
"""Stock-ish int or None. ``stock: None`` = inventory not tracked."""
if raw in (None, ""):
return None
try:
v = int(raw)
except (TypeError, ValueError) as err:
raise PartValidationError(f"{field} must be an integer") from err
if not 0 <= v <= MAX_PART_STOCK:
raise PartValidationError(f"{field} out of range (0-{MAX_PART_STOCK})")
return v
def normalize_part(raw: Mapping[str, Any]) -> dict[str, Any]:
"""Validate + normalize one part definition (the stored static shape).
``stock`` is intentionally NOT part of this dict — the mutable count lives
in the per-entry Store. A part with no tracked stock is a catalog-only
entry (identifiers + links), which is a valid, useful state.
"""
if not isinstance(raw, Mapping):
raise PartValidationError("part must be an object")
name = _clean_str(raw.get("name"), "name", MAX_PART_NAME)
if not name:
raise PartValidationError("part name must not be empty")
part: dict[str, Any] = {
"id": str(raw.get("id") or uuid4().hex),
"name": name,
"mpn": _clean_str(raw.get("mpn"), "mpn", MAX_PART_MPN),
"gtin": validate_gtin(raw.get("gtin")),
"vendor": _clean_str(raw.get("vendor"), "vendor", MAX_PART_VENDOR),
"storage_location": _clean_str(raw.get("storage_location"), "storage_location", MAX_PART_STORAGE_LOCATION),
"product_url": _clean_url(raw.get("product_url")),
"notes": _clean_str(raw.get("notes"), "notes", MAX_PART_NOTES),
"unit": _clean_str(raw.get("unit"), "unit", MAX_PART_UNIT),
"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"),
"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,
}
return part
def sanitize_consumes_parts(raw: Any, valid_part_ids: set[str] | None = None) -> list[dict[str, Any]]:
"""Cap/clean a task's ``consumes_parts`` list ([{part_id, quantity}]).
Unknown part ids are dropped when ``valid_part_ids`` is given; quantity is
clamped to 1..MAX_CONSUME_QUANTITY; duplicates collapse (last wins).
"""
if not isinstance(raw, list):
return []
out: dict[str, dict[str, Any]] = {}
for item in raw[:MAX_CONSUMES_PER_TASK]:
if not isinstance(item, Mapping):
continue
part_id = str(item.get("part_id") or "").strip()
if not part_id or (valid_part_ids is not None and part_id not in valid_part_ids):
continue
try:
qty = int(item.get("quantity", 1))
except (TypeError, ValueError):
qty = 1
out[part_id] = {"part_id": part_id, "quantity": max(1, min(qty, MAX_CONSUME_QUANTITY))}
return list(out.values())
# ── Stock rules ──────────────────────────────────────────────────────────────
def part_is_low(part: Mapping[str, Any], stock: int | None) -> bool:
"""Tracked stock at/below the reorder threshold."""
threshold = part.get("reorder_threshold")
return stock is not None and threshold is not None and stock <= int(threshold)
def part_wants_buy_task(part: Mapping[str, Any]) -> bool:
return bool(part.get("auto_buy_task")) and part.get("reorder_threshold") is not None
def stock_transition(part: Mapping[str, Any], old: int | None, new: int | None) -> str | None:
"""The edge this stock change crossed, if any: ``low`` / ``out`` / ``restocked``.
Edge-triggered on purpose — a further decrease while already low never
re-nags, and automations get exactly one event per crossing.
"""
if new is None:
return None
was_low = part_is_low(part, old)
is_low = part_is_low(part, new)
if new == 0 and (old is None or old > 0):
return "out"
if is_low and not was_low:
return "low"
if was_low and not is_low:
return "restocked"
return None
# ── Shopping-search URL ──────────────────────────────────────────────────────
def default_search_template(lang: str) -> str:
return _DEFAULT_SEARCH_TEMPLATES.get((lang or "en")[:2].lower(), _FALLBACK_SEARCH_TEMPLATE)
def search_query(part: Mapping[str, Any]) -> str:
"""Query precedence: GTIN (most precise) → "{vendor} {mpn}" → name."""
if part.get("gtin"):
return str(part["gtin"])
mpn = str(part.get("mpn") or "").strip()
if mpn:
vendor = str(part.get("vendor") or "").strip()
return f"{vendor} {mpn}".strip()
return str(part.get("name") or "")
def resolve_shopping_url(part: Mapping[str, Any], template: str | None, lang: str) -> str:
"""The link to buy this part: ``product_url`` wins; else the search template."""
if part.get("product_url"):
return str(part["product_url"])
tpl = (template or "").strip() or default_search_template(lang)
if "{q}" not in tpl:
tpl = tpl.rstrip("/") + "?q={q}"
return tpl.replace("{q}", quote_plus(search_query(part)))
# ── Buy-task construction + declarative reconcile ────────────────────────────
def buy_task_name(part_name: str, lang: str) -> str:
tpl = _BUY_NAME_TEMPLATES.get((lang or "en")[:2].lower(), _BUY_NAME_TEMPLATES["en"])
return tpl.replace("{name}", part_name)
def buy_task_notes(part: Mapping[str, Any], stock: int | None) -> str:
"""Self-contained purchase notes: identifiers, qty, price, storage spot.
Deliberately mostly language-neutral (labels are identifiers like MPN/GTIN;
the storage location is the user's own text).
"""
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())
idents = " · ".join(
p
for p in (
f"{part['vendor']}" if part.get("vendor") else "",
f"MPN: {part['mpn']}" if part.get("mpn") else "",
f"GTIN: {part['gtin']}" if part.get("gtin") else "",
)
if p
)
if idents:
lines.append(idents)
if part.get("cost") is not None:
lines.append(f"{part['cost']:.2f} × {qty}")
if stock is not None:
lines.append(f"{stock}")
if part.get("storage_location"):
lines.append(f"{part['storage_location']}")
return "\n".join(lines)
def build_buy_task(
part: Mapping[str, Any],
stock: int | None,
*,
object_id: str,
lang: str,
search_template: str | None,
today: date,
) -> dict[str, Any]:
"""The one-off shopping reminder for a low part (due today, actionable now)."""
return {
"id": uuid4().hex,
"object_id": object_id,
"name": buy_task_name(str(part["name"]), lang),
"type": "custom",
"enabled": True,
"schedule": {"kind": "one_time", "due_date": today.isoformat()},
"warning_days": 0,
"created_at": today.isoformat(),
"labels": [BUY_TASK_LABEL],
"custom_icon": BUY_TASK_ICON,
"notes": buy_task_notes(part, stock),
"documentation_url": resolve_shopping_url(part, search_template, lang),
PART_REF_FIELD: {"part_id": str(part["id"])},
}
def reconcile_buy_tasks(
parts: Mapping[str, Mapping[str, Any]],
stocks: Mapping[str, int | None],
tasks: Mapping[str, Mapping[str, Any]],
*,
object_id: str,
lang: str,
search_template: str | None,
today: date,
is_task_done: Any,
) -> tuple[dict[str, dict[str, Any]], list[str], list[str], bool]:
"""Compute the task map with auto "buy" reminders synced to low parts.
Declarative: a buy task is **desired** for every part that opts in
(:func:`part_wants_buy_task`) and is low (:func:`part_is_low`). Diffing
desired vs. the tasks carrying a ``part_ref`` yields exactly the creates
and removals — idempotence and self-healing are properties, not bookkeeping.
Episode semantics: an existing buy task — open OR already completed —
occupies its part's low episode, so completing the reminder (when the
restock didn't lift the stock above the threshold) never respawns a
duplicate. When the part leaves the desired set (restocked / opted out /
deleted):
- an **open** reminder is removed (orphan cleanup), while
- a **completed** one keeps its cost history: its ``part_ref`` is detached
so it survives as a plain done one-off (retention applies normally) and
the next low episode starts fresh.
``is_task_done(task_dict) -> bool`` is injected so this module stays free
of the task model. Returns ``(new_tasks, created_ids, removed_ids, changed)``.
"""
desired: set[str] = {
part_id
for part_id, part in parts.items()
if part_wants_buy_task(part) and part_is_low(part, stocks.get(part_id))
}
result: dict[str, dict[str, Any]] = {tid: dict(t) for tid, t in tasks.items()}
existing_by_part: dict[str, str] = {}
for tid, task in result.items():
ref = task.get(PART_REF_FIELD)
if isinstance(ref, Mapping) and ref.get("part_id"):
existing_by_part[str(ref["part_id"])] = tid
created: list[str] = []
removed: list[str] = []
for part_id, tid in list(existing_by_part.items()):
if part_id in desired:
continue
if is_task_done(result[tid]):
# Keep the completed reminder (and its cost history); detach the
# marker so the next low episode can create a fresh one.
result[tid].pop(PART_REF_FIELD, None)
else:
removed.append(tid)
del result[tid]
existing_by_part.pop(part_id, None)
for part_id in sorted(desired):
if part_id in existing_by_part:
continue
task = build_buy_task(
parts[part_id],
stocks.get(part_id),
object_id=object_id,
lang=lang,
search_template=search_template,
today=today,
)
result[task["id"]] = task
created.append(task["id"])
changed = bool(created or removed) or any(
tasks[tid].get(PART_REF_FIELD) != result[tid].get(PART_REF_FIELD) for tid in tasks if tid in result
)
return result, created, removed, changed
@@ -0,0 +1,237 @@
"""Adopt HA problem sensors as sensor-triggered maintenance tasks.
Many integrations expose ``binary_sensor`` entities with
``device_class: problem`` — printer errors, filter warnings, low-battery
alerts. This turns a *selected* set of them into maintenance tasks that use the
existing sensor-trigger pipeline: the task triggers while the problem is active
(``state_change`` to ``on``) and auto-completes when it clears
(``auto_complete_on_recovery``), so a one-off appliance fault lands in the same
inbox, history and reminders as planned maintenance.
Opt-in by design: discovery only *proposes*, and adoption acts on an explicit
selection — a chatty integration can never flood the task list on its own. The
pure discovery/build logic lives here; the WS layer wires it to hass.
"""
from __future__ import annotations
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers import area_registry as ar
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from ..const import (
CONF_ADOPTED_NOTES,
CONF_OBJECT,
CONF_TASKS,
DOMAIN,
GLOBAL_UNIQUE_ID,
MAX_ADOPTED_NOTES,
)
PROBLEM_DEVICE_CLASS = "problem"
# Words too generic to establish a sensor↔part relationship on their own
# ("Printer problem" must not match a part just because it's ON the printer).
_MATCH_STOPWORDS = frozenset(
{"problem", "low", "empty", "sensor", "status", "warning", "error", "alert", "the", "and"}
)
def _name_tokens(name: str) -> set[str]:
"""Meaningful lowercase tokens (≥3 chars, stopwords removed) of a name."""
import re
return {
tok
for tok in re.split(r"[^a-z0-9]+", name.lower())
if len(tok) >= 3 and tok not in _MATCH_STOPWORDS
}
def match_part_for_sensor(sensor_name: str, parts: dict[str, Any]) -> tuple[str, str] | None:
"""The object's spare part that best matches a problem sensor's name.
A toner-low sensor on a printer should suggest the "Toner cartridge" part:
match = shared meaningful name token (case-insensitive, stopwords ignored).
Returns ``(part_id, part_name)`` of the best (most-overlapping) match, or
``None`` — deliberately conservative: no token overlap, no suggestion.
"""
sensor_tokens = _name_tokens(sensor_name)
if not sensor_tokens or not isinstance(parts, dict):
return None
best: tuple[int, str, str] | None = None
for part_id, part in parts.items():
if not isinstance(part, dict):
continue
part_name = str(part.get("name") or "")
overlap = len(_name_tokens(part_name) & sensor_tokens)
if overlap and (best is None or overlap > best[0]):
best = (overlap, str(part_id), part_name)
return (best[1], best[2]) if best else None
def _adopted_entity_ids(hass: HomeAssistant) -> set[str]:
"""Every entity id already watched by some task's trigger — so discovery
hides sensors that are already adopted (or manually wired to a trigger)."""
from ..entity.triggers import normalize_entity_ids
watched: set[str] = set()
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
continue
for task in entry.data.get(CONF_TASKS, {}).values():
tc = task.get("trigger_config")
if isinstance(tc, dict):
watched.update(normalize_entity_ids(tc))
return watched
def _object_by_device(hass: HomeAssistant) -> dict[str, dict[str, str]]:
"""{ha_device_id: {entry_id, name}} for objects already attached to a device."""
out: dict[str, dict[str, str]] = {}
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
continue
obj = entry.data.get(CONF_OBJECT, {})
dev = obj.get("ha_device_id")
if dev:
out[dev] = {"entry_id": entry.entry_id, "name": obj.get("name", entry.title)}
return out
def discover_problem_sensors(hass: HomeAssistant) -> list[dict[str, Any]]:
"""Propose adoptable problem sensors (not already watched by a task).
Each candidate carries what the picker needs to render + a suggested target
object: the maintenance object already attached to the sensor's HA device,
if any, else a name derived from the device/entity for a fresh object.
"""
adopted = _adopted_entity_ids(hass)
by_device = _object_by_device(hass)
ent_reg = er.async_get(hass)
dev_reg = dr.async_get(hass)
area_reg = ar.async_get(hass)
out: list[dict[str, Any]] = []
for state in hass.states.async_all("binary_sensor"):
if state.attributes.get("device_class") != PROBLEM_DEVICE_CLASS:
continue
if state.entity_id in adopted:
continue
name = state.attributes.get("friendly_name") or state.entity_id
ent = ent_reg.async_get(state.entity_id)
# Skip our OWN per-task "overdue" binary sensors — they carry
# device_class: problem too, and adopting them would be circular.
if ent is not None and ent.platform == DOMAIN:
continue
device_id = ent.device_id if ent else None
device_name = ""
area_name = ""
if device_id and (dev := dev_reg.async_get(device_id)):
device_name = dev.name_by_user or dev.name or ""
area_id = dev.area_id
if area_id and (area := area_reg.async_get_area(area_id)):
area_name = area.name
# Suggested target: existing object on this device, else a fresh one.
suggested = by_device.get(device_id) if device_id else None
# Suggested spare part: when the target object already exists and has a
# part whose name matches the sensor's (toner-low ↔ "Toner cartridge"),
# adoption can pre-link it so completing the task consumes/restocks it.
suggested_part: tuple[str, str] | None = None
if suggested is not None:
from ..const import CONF_PARTS
target_entry = hass.config_entries.async_get_entry(suggested["entry_id"])
if target_entry is not None:
suggested_part = match_part_for_sensor(name, target_entry.data.get(CONF_PARTS) or {})
out.append(
{
"entity_id": state.entity_id,
"name": name,
"state": state.state, # "on" = problem active right now
"device_id": device_id,
"device_name": device_name,
"area_name": area_name,
"suggested_entry_id": suggested["entry_id"] if suggested else None,
"suggested_object_name": suggested["name"] if suggested else (device_name or name),
"suggested_part_id": suggested_part[0] if suggested_part else None,
"suggested_part_name": suggested_part[1] if suggested_part else None,
}
)
out.sort(key=lambda c: (c["device_name"] or "", c["name"]))
return out
def stash_task_notes_for_readopt(hass: HomeAssistant, task: dict[str, Any]) -> None:
"""Preserve a deleted adopted task's notes for a later re-adopt.
Un-adopting a problem sensor = deleting its task, which used to drop the
accumulated notes ("needs part X", "reset via service menu"). For tasks
carrying the adopted signature (``auto_complete_on_recovery`` on watched
``entity_ids``), non-empty notes are stashed on the global entry keyed by
the watched sensor, and restored (consumed) when the sensor is re-adopted.
FIFO-capped at ``MAX_ADOPTED_NOTES`` so the global entry can't grow
unbounded. Called from the shared task-delete path; a no-op for everything
that isn't an adopted task with notes.
"""
tc = task.get("trigger_config")
if not isinstance(tc, dict) or not tc.get("auto_complete_on_recovery"):
return
entity_ids = tc.get("entity_ids") or []
notes = task.get("notes")
if not entity_ids or not isinstance(notes, str) or not notes.strip():
return
from .global_options import get_global_entry
entry = get_global_entry(hass)
if entry is None:
return
options = dict(entry.options or entry.data)
stash = dict(options.get(CONF_ADOPTED_NOTES) or {})
key = str(entity_ids[0])
stash.pop(key, None) # re-insert as newest (dict order = age)
stash[key] = notes
while len(stash) > MAX_ADOPTED_NOTES:
stash.pop(next(iter(stash)))
options[CONF_ADOPTED_NOTES] = stash
hass.config_entries.async_update_entry(entry, options=options)
def pop_stashed_notes(hass: HomeAssistant, entity_id: str) -> str | None:
"""Consume (return + remove) stashed notes for ``entity_id``, if any."""
from .global_options import get_global_entry
entry = get_global_entry(hass)
if entry is None:
return None
options = dict(entry.options or entry.data)
stash = dict(options.get(CONF_ADOPTED_NOTES) or {})
notes = stash.pop(entity_id, None)
if notes is None:
return None
options[CONF_ADOPTED_NOTES] = stash
hass.config_entries.async_update_entry(entry, options=options)
return notes if isinstance(notes, str) and notes.strip() else None
def build_problem_task(entity_id: str, name: str) -> dict[str, Any]:
"""The task payload for an adopted problem sensor: manual schedule (no
calendar), triggered while the problem is on, auto-completed on recovery."""
# A concise task title; the sensor's friendly name often already reads like
# "Printer problem", so keep it as-is rather than double-prefixing.
return {
"name": name,
"task_type": "inspection",
"schedule": {"kind": "manual"},
"trigger_config": {
"type": "state_change",
"entity_ids": [entity_id],
"trigger_to_state": "on",
"trigger_target_changes": 1,
"auto_complete_on_recovery": True,
},
}
@@ -13,7 +13,9 @@ from __future__ import annotations
from typing import Any
from ..const import (
MAX_COST,
MAX_DATE_LENGTH,
MAX_DURATION_MINUTES,
MAX_ENTITY_SLUG_LENGTH,
MAX_ICON_LENGTH,
MAX_ID_LENGTH,
@@ -43,6 +45,11 @@ _TASK_STR_LIMITS: dict[str, int] = {
"responsible_user_id": MAX_META_LENGTH,
"entity_slug": MAX_ENTITY_SLUG_LENGTH,
"created_at": MAX_DATE_LENGTH,
# Lifecycle metadata that now round-trips through JSON import (audit
# 2026-07-11): an ISO timestamp + a short reason code. Length-capped so a
# crafted backup can't smuggle oversized strings past the importer.
"archived_at": MAX_META_LENGTH,
"archived_reason": MAX_META_LENGTH,
"schedule_time": MAX_SCHEDULE_TIME_LENGTH,
"priority": MAX_TYPE_LENGTH,
"reading_unit": 32,
@@ -60,6 +67,12 @@ _OBJECT_STR_LIMITS: dict[str, int] = {
"notes": MAX_TEXT_LENGTH, # v1.4.10 #46
"ha_device_id": MAX_ID_LENGTH, # 2.19: link to an existing HA device
"parent_entry_id": MAX_ID_LENGTH, # 2.19: parent object (via_device)
# 2.20 pause + replace lineage — capped so an imported backup can't smuggle
# oversized strings into these (import copies them verbatim).
"paused_at": MAX_META_LENGTH, # ISO timestamp marker (presence = paused)
"paused_until": MAX_DATE_LENGTH, # auto-resume date
"predecessor_entry_id": MAX_ID_LENGTH,
"replaced_by_entry_id": MAX_ID_LENGTH,
}
_GROUP_STR_LIMITS: dict[str, int] = {
@@ -291,15 +304,19 @@ def cap_quick_complete_defaults_field(task_data: dict[str, Any]) -> None:
cleaned["notes"] = notes[:MAX_TEXT_LENGTH]
cost = defaults.get("cost")
if isinstance(cost, (int, float)) and 0 <= cost <= 1_000_000:
if isinstance(cost, (int, float)) and 0 <= cost <= MAX_COST:
cleaned["cost"] = float(cost)
duration = defaults.get("duration")
if isinstance(duration, int) and 0 <= duration <= 525_600:
if isinstance(duration, int) and 0 <= duration <= MAX_DURATION_MINUTES:
cleaned["duration"] = duration
from ..const import MaintenanceFeedback
feedback = defaults.get("feedback")
if feedback in ("needed", "not_needed"):
# Use the enum (needed / not_needed / not_sure) — a bare ("needed",
# "not_needed") literal silently dropped a valid not_sure feedback.
if feedback in tuple(MaintenanceFeedback):
cleaned["feedback"] = feedback
if cleaned:
@@ -0,0 +1,150 @@
"""Saved filter views — named, shared combinations of the panel list's filters.
A "view" bundles the panel task-list's filter state (status / responsible user /
archived toggle) plus its sort + group-by mode under a user-given name, so a
household or team can reapply "Kitchen overdue" or "Unassigned this week" from
the toolbar in one tap instead of re-picking every control.
Views are **shared/global**: one list stored on the global config entry, listed
by any authenticated user (read) and created/deleted with write permission. A
view's ``id`` is a stable handle a later feature can reference (e.g. notification
routing — "only notify me about view X").
This module is the pure shape + sanitiser; the WS layer (``websocket/saved_views``)
reads/writes the global entry's options. Everything stored is re-sanitised on the
way in, so a hand-edited or legacy entry can never inject unknown keys/values.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from uuid import uuid4
from homeassistant.core import HomeAssistant
from ..const import CONF_SAVED_FILTER_VIEWS, MAX_SAVED_VIEWS, MAX_VIEW_NAME_LENGTH
from .global_options import get_global_options
# Closed value sets mirrored from the panel's filter controls. Anything outside
# these coerces to the permissive default ("" / "due_date" / "none"), so an
# unknown value degrades to "show all / natural order" rather than being stored.
VALID_STATUSES = frozenset({"", "ok", "due_soon", "overdue", "triggered", "paused", "archived"})
VALID_SORT_MODES = frozenset({"due_date", "object", "type", "task_name", "area", "assigned_user", "group"})
VALID_GROUP_BY = frozenset({"none", "area", "group", "user"})
def _clean_filters(raw: Any) -> dict[str, Any]:
"""Sanitise a view's ``filters`` sub-dict against the closed value sets."""
src: Mapping[str, Any] = raw if isinstance(raw, Mapping) else {}
status = src.get("status", "")
status = status if isinstance(status, str) and status in VALID_STATUSES else ""
sort_mode = src.get("sort_mode", "due_date")
sort_mode = sort_mode if isinstance(sort_mode, str) and sort_mode in VALID_SORT_MODES else "due_date"
group_by = src.get("group_by", "none")
group_by = group_by if isinstance(group_by, str) and group_by in VALID_GROUP_BY else "none"
user_id = src.get("user_id")
user_id = user_id.strip() if isinstance(user_id, str) and user_id.strip() else None
if isinstance(user_id, str) and len(user_id) > 64:
user_id = None
# v2.26: a label filter ("only tasks tagged 'garden'") — free text like the
# labels themselves (capped to their length), None = no label filter.
from ..const import MAX_LABEL_LENGTH
label = src.get("label")
label = label.strip() if isinstance(label, str) and label.strip() else None
if isinstance(label, str) and len(label) > MAX_LABEL_LENGTH:
label = None
return {
"status": status,
"user_id": user_id,
"label": label,
"archived": bool(src.get("archived")),
"sort_mode": sort_mode,
"group_by": group_by,
}
def view_matches_task(filters: Mapping[str, Any], task: Mapping[str, Any]) -> bool:
"""Does a task match a view's TASK-SELECTING filters (label + user)?
Used by notification routing ("only notify about view X"). Deliberately
ignores the DISPLAY dimensions: ``status`` (the per-status notify toggles
own that), ``archived`` (archived tasks never notify anyway) and
sort/group. A ``user_id`` of ``current_user`` is a client-side sentinel
that cannot be resolved server-side — treated as "no user filter".
"""
label = filters.get("label")
if label and label not in (task.get("labels") or []):
return False
user_id = filters.get("user_id")
if user_id and user_id != "current_user" and task.get("responsible_user_id") != user_id:
return False
return True
def sanitize_view(raw: Any, *, view_id: str | None = None) -> dict[str, Any] | None:
"""Return a clean view dict, or ``None`` if it has no usable name.
``view_id`` overrides the incoming id (used on save to preserve an existing
id or mint a fresh one); otherwise the raw id is kept when valid.
"""
if not isinstance(raw, Mapping):
return None
name = raw.get("name")
if not isinstance(name, str) or not name.strip():
return None
resolved_id = view_id
if resolved_id is None:
rid = raw.get("id")
resolved_id = rid if isinstance(rid, str) and rid.strip() else uuid4().hex
return {
"id": resolved_id,
"name": name.strip()[:MAX_VIEW_NAME_LENGTH],
"filters": _clean_filters(raw.get("filters")),
}
def list_saved_views(hass: HomeAssistant) -> list[dict[str, Any]]:
"""The sanitised saved views stored on the global entry (empty if none)."""
raw = get_global_options(hass).get(CONF_SAVED_FILTER_VIEWS)
if not isinstance(raw, list):
return []
# Do NOT truncate here: save/delete re-persist this list, so truncating a
# (hand-edited) >MAX list on read would permanently drop the tail on the
# next write. Growth past the cap is prevented on the way IN by upsert_view.
out: list[dict[str, Any]] = []
for item in raw:
clean = sanitize_view(item)
if clean is not None:
out.append(clean)
return out
def upsert_view(views: list[dict[str, Any]], incoming: dict[str, Any]) -> tuple[list[dict[str, Any]], str]:
"""Insert or replace ``incoming`` (matched by id) in ``views``.
Returns the new list and the saved view's id. A new view is rejected past
``MAX_SAVED_VIEWS`` by raising ``ValueError('too_many_views')``; updating an
existing one is always allowed.
"""
result = [dict(v) for v in views]
for i, v in enumerate(result):
if v.get("id") == incoming["id"]:
result[i] = incoming
return result, incoming["id"]
if len(result) >= MAX_SAVED_VIEWS:
raise ValueError("too_many_views")
result.append(incoming)
return result, incoming["id"]
def remove_view(views: list[dict[str, Any]], view_id: str) -> list[dict[str, Any]]:
"""Return ``views`` without the entry whose id is ``view_id``."""
return [dict(v) for v in views if v.get("id") != view_id]
@@ -168,15 +168,20 @@ class Schedule:
- the finite-series end (``ends_count`` / ``ends_until``) stops re-arming.
A one_time task keeps its fixed date and ignores season/finite.
"""
# A postponed occurrence wins for the current cycle — until it's been
# completed past the override date (then fall through to normal cadence).
if due_override is not None and (last_performed is None or due_override > last_performed):
return due_override
# Finite series exhausted by completion count → terminally done.
# Finite series exhausted by completion count → terminally done. Checked
# BEFORE the override so postponing a finished series can't resurrect it
# (there is no current cycle left to postpone).
if self.ends_count is not None and times_performed >= self.ends_count:
return None
# A postponed occurrence wins for the current cycle — until it's been
# completed past the override date (then fall through to normal cadence).
# It still respects the series end date.
if due_override is not None and (last_performed is None or due_override > last_performed):
if self.ends_until is not None and due_override > self.ends_until:
return None
return due_override
result = self._compute_next_due(
last_performed=last_performed,
created_at=created_at,
@@ -251,9 +256,16 @@ class Schedule:
periods = 1 if days_gap < 0 else (days_gap // step) + 1
return anchor + timedelta(days=periods * step)
# Calendar units (months/years): step until past last_performed.
# Multiply from the ORIGINAL anchor instead of iterating on the
# clamped result — iterative adds let February permanently drag a
# day-31 anchor to day 28 (Jan 31 → Feb 28 → Mar 28 …); n×interval
# from the anchor recovers the intended day per target month
# (Jan 31 + 2 months = Mar 31). The clamp can still stick across
# CYCLES when a February due itself becomes the next anchor — the
# day_of_month kind is the tool for hard month-end pinning.
candidate = anchor
for _ in range(_MAX_PLANNED_STEPS):
candidate = add_interval(candidate, every, self.unit)
for n in range(1, _MAX_PLANNED_STEPS + 1):
candidate = add_interval(anchor, n * every, self.unit)
if candidate > last_performed:
return candidate
return candidate
@@ -51,6 +51,7 @@ from ..const import (
CONF_NOTIFY_DUE_SOON_INTERVAL,
CONF_NOTIFY_OVERDUE_ENABLED,
CONF_NOTIFY_OVERDUE_INTERVAL,
CONF_NOTIFY_SCOPE_VIEW_ID,
CONF_NOTIFY_SERVICE,
CONF_NOTIFY_TRIGGERED_ENABLED,
CONF_NOTIFY_TRIGGERED_INTERVAL,
@@ -127,6 +128,7 @@ SETTING_SPECS: tuple[SettingSpec, ...] = (
SettingSpec(CONF_NOTIFICATION_BUNDLE_THRESHOLD, int, int_range=(2, 20)),
# title_style is enum-validated by a bespoke rule in the handler.
SettingSpec(CONF_NOTIFICATION_TITLE_STYLE, str),
SettingSpec(CONF_NOTIFY_SCOPE_VIEW_ID, str, max_len=64),
# Actions
SettingSpec(CONF_ACTION_COMPLETE_ENABLED, bool),
SettingSpec(CONF_ACTION_SKIP_ENABLED, bool),