updated apps
This commit is contained in:
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user