New apps Added

This commit is contained in:
2026-07-08 10:43:39 -04:00
parent 3b1f4bbd75
commit fefc2c8b5c
1114 changed files with 406637 additions and 154 deletions
@@ -0,0 +1,3 @@
"""Helper modules for the Maintenance Supporter integration."""
from __future__ import annotations
@@ -0,0 +1,110 @@
"""On-complete action listener (v1.3.0).
Subscribes to the integration's own EVENT_TASK_COMPLETED event bus topic
and dispatches the per-task `on_complete_action` service-call when set.
This is deliberately implemented as an event listener rather than a
direct call from the coordinator — that way Layer A (the event) is the
single source of truth, and Layer B (this listener) is just one of many
possible subscribers (other subscribers being user-written automations).
Adding more action-points later (skip, reset) is one extra listener
each, no coordinator changes.
Errors during the service-call are logged but do not propagate — a
broken `on_complete_action` config must not block the task from being
recorded as completed.
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from typing import Any
from homeassistant.core import Event, HomeAssistant, callback
from ..const import (
CONF_TASKS,
DOMAIN,
EVENT_TASK_COMPLETED,
GLOBAL_UNIQUE_ID,
)
from .sanitize import _FORBIDDEN_ACTION_DOMAINS
_LOGGER = logging.getLogger(__name__)
def _resolve_task_action(hass: HomeAssistant, entry_id: str, task_id: str) -> dict[str, Any] | None:
"""Look up `on_complete_action` from the task's config entry.
Returns the action dict or None if the entry/task/action isn't there.
"""
entry = hass.config_entries.async_get_entry(entry_id)
if entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_ID:
return None
task = entry.data.get(CONF_TASKS, {}).get(task_id) or {}
action = task.get("on_complete_action")
if not isinstance(action, dict) or not action.get("service"):
return None
return action
def _split_service(spec: str) -> tuple[str, str] | None:
"""Split `domain.service` into a tuple, or None if malformed."""
if not isinstance(spec, str) or "." not in spec:
return None
domain, name = spec.split(".", 1)
if not domain or not name:
return None
return domain, name
async def _dispatch_action(hass: HomeAssistant, action: dict[str, Any]) -> None:
"""Run the configured service-call with HA's standard call signature."""
parts = _split_service(action.get("service", ""))
if parts is None:
_LOGGER.warning(
"on_complete_action.service must be 'domain.service', got %r",
action.get("service"),
)
return
domain, name = parts
# Defense-in-depth: refuse privileged domains at dispatch too, so an action
# stored before the write-time denylist existed (or via any path that skips
# cap_action_field) can never run shell/scripts/host control on completion.
if domain in _FORBIDDEN_ACTION_DOMAINS:
_LOGGER.warning("on_complete_action refused: %s is a privileged service domain", domain)
return
data = action.get("data") if isinstance(action.get("data"), dict) else None
target = action.get("target") if isinstance(action.get("target"), dict) else None
try:
await hass.services.async_call(domain, name, service_data=data, target=target, blocking=False)
except Exception:
_LOGGER.exception(
"on_complete_action service-call failed: %s.%s data=%r target=%r",
domain,
name,
data,
target,
)
@callback
def register_action_listener(hass: HomeAssistant) -> Callable[[], None]:
"""Register the EVENT_TASK_COMPLETED listener.
Returns the unsubscribe callback so callers can clean up on integration
teardown.
"""
async def _on_task_completed(event: Event) -> None:
entry_id = event.data.get("entry_id")
task_id = event.data.get("task_id")
if not entry_id or not task_id:
return
action = _resolve_task_action(hass, entry_id, task_id)
if action is None:
return
await _dispatch_action(hass, action)
return hass.bus.async_listen(EVENT_TASK_COMPLETED, _on_task_completed)
@@ -0,0 +1,85 @@
"""Single source of truth for cross-entry status aggregation.
Both the ``maintenance_supporter/statistics`` WebSocket endpoint (which feeds
the panel KPI chips and the Lovelace card header) and the global summary
sensors compute their counts here, so the numbers can never diverge.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from ..const import CONF_TASKS, DOMAIN, GLOBAL_UNIQUE_ID, MaintenanceStatus
if TYPE_CHECKING:
from .. import MaintenanceSupporterData
# The status buckets we count. `ok` is included so the dashboard strategy
# headline and a future summary sensor have a single source for it too.
_COUNTED_STATUSES = (
MaintenanceStatus.OVERDUE,
MaintenanceStatus.DUE_SOON,
MaintenanceStatus.TRIGGERED,
MaintenanceStatus.OK,
)
def get_object_entries(hass: HomeAssistant) -> list[ConfigEntry]:
"""Return all non-global config entries for this domain."""
return [entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.unique_id != GLOBAL_UNIQUE_ID]
def get_runtime_data(hass: HomeAssistant, entry_id: str) -> MaintenanceSupporterData | None:
"""Get runtime data for a config entry."""
entry = hass.config_entries.async_get_entry(entry_id)
if entry is None:
return None
return getattr(entry, "runtime_data", None)
def compute_status_counts(hass: HomeAssistant) -> dict[str, Any]:
"""Aggregate task status counts across every maintenance object.
Status counts come from the live coordinator data (``_status``), which
already forces disabled tasks to OK. ``total_tasks`` is the configured
task count (static), matching the historical statistics-endpoint shape.
"""
counts = {str(s): 0 for s in _COUNTED_STATUSES}
total_objects = 0
total_tasks = 0
total_cost = 0.0
for entry in get_object_entries(hass):
total_objects += 1
# Archived tasks are inert: excluded from the task total and (via their
# ARCHIVED _status, which isn't a counted bucket) from every status
# count. Their cost still counts — budget is retained on archive.
total_tasks += sum(1 for td in entry.data.get(CONF_TASKS, {}).values() if td.get("archived_at") is None)
rd = get_runtime_data(hass, entry.entry_id)
coord_data = rd.coordinator.data if rd and rd.coordinator else None
for task in (coord_data or {}).get(CONF_TASKS, {}).values():
status = str(task.get("_status", MaintenanceStatus.OK))
if status in counts:
counts[status] += 1
total_cost += task.get("_total_cost", 0.0) or 0.0
needs_attention = (
counts[str(MaintenanceStatus.OVERDUE)]
+ counts[str(MaintenanceStatus.DUE_SOON)]
+ counts[str(MaintenanceStatus.TRIGGERED)]
)
return {
"total_objects": total_objects,
"total_tasks": total_tasks,
"overdue": counts[str(MaintenanceStatus.OVERDUE)],
"due_soon": counts[str(MaintenanceStatus.DUE_SOON)],
"triggered": counts[str(MaintenanceStatus.TRIGGERED)],
"ok": counts[str(MaintenanceStatus.OK)],
"needs_attention": needs_attention,
"total_cost": round(total_cost, 2),
}
@@ -0,0 +1,326 @@
"""CSV import/export for maintenance objects and tasks."""
from __future__ import annotations
import csv
import io
import logging
import re
from typing import Any
from uuid import uuid4
from homeassistant.core import HomeAssistant
from ..const import (
CONF_OBJECT,
CONF_TASKS,
DEFAULT_WARNING_DAYS,
DOMAIN,
GLOBAL_UNIQUE_ID,
MAX_CHECKLIST_ITEM_LENGTH,
MAX_CHECKLIST_ITEMS,
)
from .dates import INTERVAL_UNITS
from .global_options import get_default_warning_days
from .schedule import read_legacy_fields
_LOGGER = logging.getLogger(__name__)
# CSV column order
_COLUMNS = [
"object_name",
"object_manufacturer",
"object_model",
"object_serial_number",
"object_area_id",
"object_installation_date",
"object_warranty_expiry",
"task_name",
"task_type",
"enabled",
"schedule_type",
"interval_days",
"interval_unit",
"due_date",
"interval_anchor",
"schedule_time",
"reading_unit",
"warning_days",
"last_performed",
"notes",
"documentation_url",
"custom_icon",
"nfc_tag_id",
"responsible_user_id",
"trigger_type",
"status",
"times_performed",
"total_cost",
# Checklist exported as a single cell with steps separated by literal "\n".
# The csv module handles the embedded newlines via RFC 4180 field quoting.
"checklist",
]
def _csv_safe(val: str) -> str:
"""Prefix cells that start with formula-triggering characters to mitigate CSV injection."""
if val and val[0] in ("=", "+", "-", "@"):
return "\t" + val
return val
def export_objects_csv(hass: HomeAssistant) -> str:
"""Export all maintenance objects and tasks as CSV.
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]
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=_COLUMNS, extrasaction="ignore")
writer.writeheader()
for entry in entries:
obj_data = entry.data.get(CONF_OBJECT, {})
# Merge static + Store dynamic data
rd = getattr(entry, "runtime_data", None)
store = getattr(rd, "store", None) if rd else None
static_tasks = entry.data.get(CONF_TASKS, {})
tasks_data = store.merge_all_tasks(static_tasks) if store is not None else static_tasks
rd = getattr(entry, "runtime_data", None)
coord_data = rd.coordinator.data if rd and rd.coordinator else None
ct_tasks = (coord_data or {}).get(CONF_TASKS, {})
for tid, tdata in tasks_data.items():
ct = ct_tasks.get(tid, {})
sched = read_legacy_fields(tdata)
writer.writerow(
{
"object_name": _csv_safe(obj_data.get("name", "")),
"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", ""),
"task_name": _csv_safe(tdata.get("name", "")),
"task_type": tdata.get("type", "custom"),
"enabled": tdata.get("enabled", True),
"schedule_type": sched["schedule_type"],
"interval_days": sched["interval_days"] if sched["interval_days"] is not None else "",
"interval_unit": sched["interval_unit"],
"due_date": sched["due_date"] or "",
"interval_anchor": sched["interval_anchor"],
"schedule_time": tdata.get("schedule_time", ""),
"reading_unit": 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", "")),
"documentation_url": _csv_safe(tdata.get("documentation_url", "")),
"custom_icon": _csv_safe(tdata.get("custom_icon", "")),
"nfc_tag_id": _csv_safe(tdata.get("nfc_tag_id", "")),
"responsible_user_id": _csv_safe(tdata.get("responsible_user_id", "")),
"trigger_type": (tdata.get("trigger_config") or {}).get("type", ""),
"status": ct.get("_status", "ok"),
"times_performed": ct.get("_times_performed", 0),
"total_cost": ct.get("_total_cost", 0.0),
# Each item is _csv_safe()-prefixed individually so a step
# starting with "=" can't trigger a formula in Excel after
# the cell is unpacked.
"checklist": "\n".join(_csv_safe(item) for item in (tdata.get("checklist") or []) if item),
}
)
return output.getvalue()
# (#67) Per-object CSV columns — one row per maintenance object (asset record).
_OBJECT_RECORD_COLUMNS = [
"object_name",
"object_manufacturer",
"object_model",
"object_serial_number",
"object_area_id",
"object_installation_date",
"object_warranty_expiry",
"object_documentation_url",
"object_notes",
"task_count",
]
def export_object_records_csv(hass: HomeAssistant) -> 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.
"""
entries = [entry for entry in hass.config_entries.async_entries(DOMAIN) if entry.unique_id != GLOBAL_UNIQUE_ID]
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=_OBJECT_RECORD_COLUMNS, extrasaction="ignore")
writer.writeheader()
for entry in entries:
obj_data = entry.data.get(CONF_OBJECT, {})
static_tasks = entry.data.get(CONF_TASKS, {})
rd = getattr(entry, "runtime_data", None)
store = getattr(rd, "store", None) if rd else None
tasks_data = store.merge_all_tasks(static_tasks) if store is not None else static_tasks
writer.writerow(
{
"object_name": _csv_safe(obj_data.get("name", "")),
"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_documentation_url": _csv_safe(obj_data.get("documentation_url") or ""),
"object_notes": _csv_safe(obj_data.get("notes") or ""),
"task_count": len(tasks_data),
}
)
return output.getvalue()
def import_objects_csv(
csv_content: str,
hass: HomeAssistant | None = None,
) -> list[dict[str, Any]]:
"""Parse CSV content into a list of object dicts ready for creation.
When *hass* is supplied, missing per-row ``warning_days`` columns fall back
to the integration-wide default from the global config entry. Without
*hass* (e.g. in unit tests that exercise the parser in isolation), the
bare constant ``7`` is used.
Returns a list of objects, each with 'object' and 'tasks' dicts
matching the format expected by the config flow.
"""
default_warning_days = get_default_warning_days(hass) if hass is not None else 7
reader = csv.DictReader(io.StringIO(csv_content))
# Group rows by object name
objects_map: dict[str, dict[str, Any]] = {}
for row in reader:
obj_name = (row.get("object_name") or "").strip()
if not obj_name:
continue
if obj_name not in objects_map:
objects_map[obj_name] = {
"object": {
"id": uuid4().hex,
"name": obj_name,
"manufacturer": (row.get("object_manufacturer") or "").strip() or None,
"model": (row.get("object_model") or "").strip() or None,
"serial_number": (row.get("object_serial_number") or "").strip() or None,
"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,
"task_ids": [],
},
"tasks": {},
}
task_name = (row.get("task_name") or "").strip()
if not task_name:
continue
task_id = uuid4().hex
task_data: dict[str, Any] = {
"id": task_id,
"object_id": objects_map[obj_name]["object"]["id"],
"name": task_name,
"type": (row.get("task_type") or "custom").strip(),
"enabled": True,
"schedule_type": (row.get("schedule_type") or "time_based").strip(),
"warning_days": _safe_int(row.get("warning_days"), default_warning_days),
"history": [],
}
interval = row.get("interval_days", "").strip()
if interval:
task_data["interval_days"] = _safe_int(interval, None)
interval_unit = (row.get("interval_unit") or "").strip().lower()
if interval_unit in INTERVAL_UNITS:
task_data["interval_unit"] = interval_unit
due_date = (row.get("due_date") or "").strip()
if due_date and re.fullmatch(r"\d{4}-\d{2}-\d{2}", due_date):
task_data["due_date"] = due_date
anchor = (row.get("interval_anchor") or "").strip()
if anchor in ("planned", "completion"):
task_data["interval_anchor"] = anchor
# schedule_time round-trip with strict HH:MM validation; malformed
# values are dropped silently (consistent with other CSV import fields).
sched_time = (row.get("schedule_time") or "").strip()
if sched_time and re.fullmatch(r"^([01]\d|2[0-3]):[0-5]\d$", sched_time):
task_data["schedule_time"] = sched_time
reading_unit = (row.get("reading_unit") or "").strip()
if reading_unit:
task_data["reading_unit"] = reading_unit[:32]
last_performed = (row.get("last_performed") or "").strip()
if last_performed:
task_data["last_performed"] = last_performed
notes = (row.get("notes") or "").strip()
if notes:
task_data["notes"] = notes
# Optional fields (backwards-compatible — missing columns default to empty)
if (row.get("enabled") or "").strip().lower() == "false":
task_data["enabled"] = False
doc_url = (row.get("documentation_url") or "").strip()
if doc_url:
from urllib.parse import urlparse
scheme = urlparse(doc_url).scheme.lower()
if scheme in ("", "http", "https"):
task_data["documentation_url"] = doc_url
custom_icon = (row.get("custom_icon") or "").strip()
if custom_icon:
task_data["custom_icon"] = custom_icon
nfc_tag = (row.get("nfc_tag_id") or "").strip()
if nfc_tag:
task_data["nfc_tag_id"] = nfc_tag
resp_user = (row.get("responsible_user_id") or "").strip()
if resp_user:
task_data["responsible_user_id"] = resp_user
# Checklist round-trips via a single cell with "\n" between items.
# Apply the same hard caps as the WebSocket schema so a malicious or
# accidental CSV can't bloat the entry.
checklist_raw = row.get("checklist") or ""
if checklist_raw:
items = [line.strip()[:MAX_CHECKLIST_ITEM_LENGTH] for line in checklist_raw.splitlines() if line.strip()][
:MAX_CHECKLIST_ITEMS
]
if items:
task_data["checklist"] = items
objects_map[obj_name]["tasks"][task_id] = task_data
objects_map[obj_name]["object"]["task_ids"].append(task_id)
return list(objects_map.values())
def _safe_int(value: str | None, default: int | None) -> int | None:
"""Safely convert a string to int."""
if value is None:
return default
try:
return int(float(value))
except (ValueError, TypeError):
return default
@@ -0,0 +1,177 @@
"""Calendar-aware interval arithmetic (no external dependencies).
Used by the scheduling model so an interval can be expressed in days, weeks,
months, or years. Month/year steps are calendar-aware and clamp the day to the
target month's last day (e.g. Jan 31 + 1 month -> Feb 28/29), avoiding the
drift you get from approximating "monthly" as 30 days.
"""
from __future__ import annotations
import calendar
from collections.abc import Iterator
from datetime import date, timedelta
from .workday import is_business_day
INTERVAL_UNITS = ("days", "weeks", "months", "years")
def parse_iso_date(value: str | None) -> date | None:
"""ISO date string → ``date``; ``None`` when absent or malformed."""
if not value:
return None
try:
return date.fromisoformat(value)
except (ValueError, TypeError):
return None
def _add_months(anchor: date, months: int) -> date:
"""Advance ``anchor`` by ``months`` calendar months, clamping the day."""
total = anchor.month - 1 + months
year = anchor.year + total // 12
month = total % 12 + 1
last_day = calendar.monthrange(year, month)[1]
return date(year, month, min(anchor.day, last_day))
def add_interval(anchor: date, n: int, unit: str = "days") -> date:
"""Return ``anchor`` advanced by ``n`` of ``unit``.
``unit`` is one of days / weeks / months / years; anything else (or None)
falls back to days, which keeps existing day-based tasks working unchanged.
"""
if unit == "weeks":
return anchor + timedelta(weeks=n)
if unit == "months":
return _add_months(anchor, n)
if unit == "years":
return _add_months(anchor, n * 12)
return anchor + timedelta(days=n)
def nth_weekday_of_month(year: int, month: int, nth: int, weekday: int) -> date | None:
"""The ``nth`` ``weekday`` of ``(year, month)``; ``None`` if it doesn't exist.
``weekday`` is 0=Mon … 6=Sun (``date.weekday()``). ``nth`` is 1..5, or -1 for
the last occurrence. A 5th occurrence that the month doesn't have → ``None``.
"""
last_day = calendar.monthrange(year, month)[1]
if nth == -1:
anchor = date(year, month, last_day)
return anchor - timedelta(days=(anchor.weekday() - weekday) % 7)
first = date(year, month, 1)
day = 1 + (weekday - first.weekday()) % 7 + (nth - 1) * 7
if day > last_day:
return None
return date(year, month, day)
def next_weekday_in_set(ref: date, weekdays: tuple[int, ...], *, inclusive: bool) -> date | None:
"""Next date on/after ``ref`` whose weekday is in ``weekdays`` (0=Mon…6=Sun).
``inclusive`` includes ``ref`` itself; otherwise the search starts the next
day. ``None`` when ``weekdays`` is empty.
"""
if not weekdays:
return None
start = ref if inclusive else ref + timedelta(days=1)
for offset in range(7):
candidate = start + timedelta(days=offset)
if candidate.weekday() in weekdays:
return candidate
return None # pragma: no cover (unreachable: a non-empty weekday set always matches within 7 days)
def _iter_months(year: int, month: int, limit: int = 60) -> Iterator[tuple[int, int]]:
"""Yield ``(year, month)`` forward from the given month, ``limit`` times."""
for _ in range(limit):
yield year, month
month += 1
if month > 12:
month = 1
year += 1
def next_nth_weekday(
ref: date,
nth: int,
weekday: int,
months: tuple[int, ...] | None = None,
*,
inclusive: bool,
) -> date | None:
"""Next ``nth``-``weekday``-of-month occurrence on/after ``ref``.
Optionally restricted to ``months`` (1=Jan…12=Dec). ``None`` if no occurrence
is found within a bounded horizon (e.g. a 5th weekday restricted to a month
that never has one).
"""
for year, month in _iter_months(ref.year, ref.month):
if months and month not in months:
continue
occ = nth_weekday_of_month(year, month, nth, weekday)
if occ is not None and (occ >= ref if inclusive else occ > ref):
return occ
return None
def next_day_of_month(
ref: date,
day: int,
months: tuple[int, ...] | None = None,
*,
inclusive: bool,
) -> date | None:
"""Next ``day``-of-month occurrence on/after ``ref`` (day clamped to month).
``day`` 1..31 is clamped to the month's length (e.g. 31 → Feb 28/29);
``day == -1`` means the LAST day of the month (#83). Optionally restricted
to ``months``.
"""
for year, month in _iter_months(ref.year, ref.month):
if months and month not in months:
continue
month_len = calendar.monthrange(year, month)[1]
clamped = month_len if day == -1 else min(day, month_len)
candidate = date(year, month, clamped)
if candidate >= ref if inclusive else candidate > ref:
return candidate
return None
def roll_back_to_business_day(d: date) -> date:
"""Roll a date back to the preceding business day (business days unchanged).
Used by the day-of-month schedule's ``business`` flag (#83): "last business
day of the month" = last day rolled back past non-business days. What
counts as a business day comes from :mod:`.workday`: plain Mon-Fri by
default, or the user's Workday integration configuration (public holidays,
custom working weekdays) when one is set up.
Bounded: a pathological provider that never yields a business day within
two weeks returns *d* unchanged rather than walking off into the past.
"""
candidate = d
for _ in range(14):
if is_business_day(candidate):
return candidate
candidate -= timedelta(days=1)
return d
def interval_span_days(n: int | None, unit: str = "days") -> int:
"""Length of one ``n``-``unit`` interval in days, calendar-aware.
Measured as the distance from a fixed reference date to that date advanced
by the interval, so months/years reflect real calendar lengths instead of
the raw count. Used so things like the warning window aren't capped by the
bare interval *count* for month/year tasks (issue #58/#59 — a 6-month task
must not collapse a 14-day warning to ``min(14, 6)``). Returns 0 when there
is no positive interval.
"""
if not n or n <= 0:
return 0
ref = date(2001, 1, 1) # common (non-leap) year; representative span
return (add_interval(ref, n, unit) - ref).days
@@ -0,0 +1,513 @@
"""Document storage for maintenance objects — manuals/PDFs + web-links.
Files are **content-addressed** (SHA-256) under
``<config>/maintenance_supporter/docs/blobs/<hash>`` so identical uploads dedupe
to a single reference-counted blob. All document metadata + the blob registry
live in one global Store (``.storage/maintenance_supporter.documents``); the
binaries live on disk under ``/config`` so they're included in Home Assistant
backups (unlike ``/media``/``/share`` which are separate backup toggles).
Design notes: see the project memory ``project_documents_feature_concept``.
This is the v1 backend foundation — pure storage + dedup + lifecycle; WS,
serving view, sensor and frontend build on top of it.
"""
from __future__ import annotations
import hashlib
import logging
import os
from pathlib import Path
from typing import Any
from uuid import uuid4
from homeassistant.core import HomeAssistant
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
_LOGGER = logging.getLogger(__name__)
DOC_STORE_VERSION = 1
DOC_STORE_KEY = f"{DOMAIN}.documents"
DOCS_SUBDIR = "docs"
BLOBS_SUBDIR = "blobs"
# Predefined document categories (localized in the frontend; stored as keys).
DOC_CATEGORIES = ("manual", "warranty", "invoice", "spare_parts", "photo", "other")
# Per-file cap — guards against runaway backup growth (docs live in /config and
# are therefore duplicated into every retained backup).
MAX_DOC_BYTES = 25 * 1024 * 1024 # 25 MB
KIND_FILE = "file"
KIND_WEBLINK = "weblink"
class DocumentStore:
"""Global store for maintenance-object documents (metadata + blob registry).
``documents``: ``{doc_id: {object_id, kind, hash|url, title, filename, mime,
size, tags, task_ids, added_at}}``.
``blobs``: ``{sha256: {size, mime, refcount}}`` — the content-addressed
registry backing ``kind == "file"`` documents (shared across objects).
"""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the document store."""
self.hass = hass
self._store: Store[dict[str, Any]] = Store(hass, DOC_STORE_VERSION, DOC_STORE_KEY)
self._data: dict[str, Any] = {"documents": {}, "blobs": {}}
# ------------------------------------------------------------------
# Paths
# ------------------------------------------------------------------
@property
def _blobs_dir(self) -> Path:
return Path(self.hass.config.path(DOMAIN, DOCS_SUBDIR, BLOBS_SUBDIR))
def blob_path(self, digest: str) -> Path:
"""Absolute path to a blob file (validated hex digest — no traversal)."""
if not digest or not all(c in "0123456789abcdef" for c in digest):
raise ValueError(f"invalid blob digest: {digest!r}")
return self._blobs_dir / digest
# ------------------------------------------------------------------
# Load / save
# ------------------------------------------------------------------
async def async_load(self) -> None:
"""Load persisted metadata + blob registry."""
raw = await self._store.async_load()
if raw is not None:
self._data = raw
self._data.setdefault("documents", {})
self._data.setdefault("blobs", {})
async def _async_save(self) -> None:
# Doc mutations are infrequent → immediate save keeps metadata and the
# on-disk blobs consistent (a crash between the two only ever leaves an
# orphan blob, which the hygiene scan reclaims).
await self._store.async_save(self._data)
# Nudge the storage sensor (and any other listener) to refresh. Sent
# after the save so listeners always read post-mutation state.
async_dispatcher_send(self.hass, SIGNAL_DOCUMENTS_UPDATED)
# ------------------------------------------------------------------
# Accessors
# ------------------------------------------------------------------
@property
def documents(self) -> dict[str, dict[str, Any]]:
"""The raw ``{doc_id: metadata}`` map."""
docs: dict[str, dict[str, Any]] = self._data["documents"]
return docs
@property
def blobs(self) -> dict[str, dict[str, Any]]:
"""The raw ``{hash: {size, mime, refcount}}`` registry."""
blobs: dict[str, dict[str, Any]] = self._data["blobs"]
return blobs
def get(self, doc_id: str) -> dict[str, Any] | None:
"""Return a document's metadata (with its ``id``), or None."""
doc = self.documents.get(doc_id)
return {"id": doc_id, **doc} if doc is not None else None
def for_object(self, object_id: str) -> list[dict[str, Any]]:
"""All documents attached to an object, newest first."""
docs = [{"id": did, **d} for did, d in self.documents.items() if d.get("object_id") == object_id]
docs.sort(key=lambda d: d.get("added_at", ""), reverse=True)
return docs
# ------------------------------------------------------------------
# Add
# ------------------------------------------------------------------
async def async_add_file(
self,
object_id: str,
*,
content: bytes,
filename: str,
mime: str,
title: str | None = None,
tags: list[str] | None = None,
) -> dict[str, Any]:
"""Store an uploaded file (content-addressed + deduped) for an object.
Returns the new document dict plus ``deduped`` (the blob already existed)
and ``duplicate_in_object`` (the id of an existing doc on the *same*
object with identical content, if any — the caller surfaces a hint).
"""
if len(content) > MAX_DOC_BYTES:
raise ValueError("file_too_large")
digest, wrote_new = await self.hass.async_add_executor_job(self._store_blob_sync, content)
# Register / adopt the blob and bump its refcount.
blob = self.blobs.get(digest)
deduped = blob is not None
if blob is None:
blob = {"size": len(content), "mime": mime, "refcount": 0}
self.blobs[digest] = blob
blob["refcount"] += 1
duplicate_in_object = next(
(did for did, d in self.documents.items() if d.get("object_id") == object_id and d.get("hash") == digest),
None,
)
doc_id = uuid4().hex
doc = {
"object_id": object_id,
"kind": KIND_FILE,
"hash": digest,
"title": title or filename,
"filename": filename,
"mime": mime,
"size": len(content),
"tags": list(tags or []),
"task_ids": [],
"added_at": dt_util.utcnow().isoformat(),
}
self.documents[doc_id] = doc
await self._async_save()
if not wrote_new and not deduped:
_LOGGER.debug("Adopted pre-existing blob %s into registry", digest[:12])
return {
"id": doc_id,
"deduped": deduped,
"duplicate_in_object": duplicate_in_object,
**doc,
}
def _store_blob_sync(self, content: bytes) -> tuple[str, bool]:
"""Hash content and write the blob if absent. Returns (digest, wrote_new)."""
digest = hashlib.sha256(content).hexdigest()
path = self.blob_path(digest)
if path.exists():
return digest, False
self._blobs_dir.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
tmp.write_bytes(content)
os.replace(tmp, path) # atomic
return digest, True
async def async_add_weblink(
self,
object_id: str,
*,
url: str,
title: str | None = None,
tags: list[str] | None = None,
) -> dict[str, Any]:
"""Attach an external web-link (0 storage, NOT in backups)."""
doc_id = uuid4().hex
doc = {
"object_id": object_id,
"kind": KIND_WEBLINK,
"url": url,
"title": title or url,
"tags": list(tags or []),
"task_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:
"""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.
"""
created = 0
for meta in docs:
if not isinstance(meta, dict):
continue
tags = [x for x in (meta.get("tags") or []) if isinstance(x, str)]
title = meta.get("title")
if meta.get("kind") == KIND_WEBLINK:
url = meta.get("url")
# Only http(s) links — the add-link WS path enforces the same, so
# a crafted export can't smuggle a javascript:/data: URL that the
# frontend would later window.open (matches ws_documents_add_link).
if not isinstance(url, str) or not url.lower().startswith(("http://", "https://")):
continue
self.documents[uuid4().hex] = {
"object_id": object_id,
"kind": KIND_WEBLINK,
"url": url,
"title": title or url,
"tags": tags,
"task_ids": [],
"added_at": dt_util.utcnow().isoformat(),
}
created += 1
elif meta.get("kind") == KIND_FILE:
digest = meta.get("hash")
if not isinstance(digest, str) or len(digest) != 64 or not all(c in "0123456789abcdef" for c in digest):
continue
size = int(meta.get("size") or 0)
mime = meta.get("mime") or "application/octet-stream"
blob = self.blobs.get(digest)
if blob is None:
blob = {"size": size, "mime": mime, "refcount": 0}
self.blobs[digest] = blob
blob["refcount"] += 1
self.documents[uuid4().hex] = {
"object_id": object_id,
"kind": KIND_FILE,
"hash": digest,
"title": title or meta.get("filename") or "document",
"filename": meta.get("filename") or "document",
"mime": mime,
"size": size,
"tags": tags,
"task_ids": [],
"added_at": dt_util.utcnow().isoformat(),
}
created += 1
if created:
await self._async_save()
return created
# ------------------------------------------------------------------
# Update
# ------------------------------------------------------------------
async def async_update(
self,
doc_id: str,
*,
title: str | None = None,
tags: list[str] | None = None,
task_ids: list[str] | None = None,
task_pages: dict[str, int] | None = None,
) -> bool:
"""Update editable metadata (title / tags / task 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.
"""
doc = self.documents.get(doc_id)
if doc is None:
return False
if title is not None:
doc["title"] = title
if tags is not None:
doc["tags"] = list(tags)
if task_ids is not None:
doc["task_ids"] = list(task_ids)
if task_pages is not None:
merged = dict(doc.get("task_pages") or {})
for tid, page in task_pages.items():
if isinstance(page, int) and page >= 1:
merged[tid] = page
else:
merged.pop(tid, None)
doc["task_pages"] = merged
pages = doc.get("task_pages")
if pages is not None:
linked = set(doc.get("task_ids") or [])
pruned = {t: p for t, p in pages.items() if t in linked}
if pruned:
doc["task_pages"] = pruned
else:
doc.pop("task_pages", None)
await self._async_save()
return True
# ------------------------------------------------------------------
# Remove
# ------------------------------------------------------------------
async def async_remove(self, doc_id: str) -> int:
"""Remove a document. Returns bytes freed (0 if shared or a web-link).
For a shared file (refcount > 1) the blob stays — only the last
reference frees the bytes.
"""
doc = self.documents.pop(doc_id, None)
if doc is None:
return 0
freed = await self._deref_blob(doc)
await self._async_save()
return freed
async def async_remove_object(self, object_id: str) -> int:
"""Remove every document of an object (on object delete). Bytes freed."""
doc_ids = [did for did, d in self.documents.items() if d.get("object_id") == object_id]
if not doc_ids:
return 0
freed = 0
for did in doc_ids:
doc = self.documents.pop(did, None)
if doc is not None:
freed += await self._deref_blob(doc)
await self._async_save()
return freed
async def _deref_blob(self, doc: dict[str, Any]) -> int:
"""Decrement a file doc's blob refcount; delete the blob at 0."""
if doc.get("kind") != KIND_FILE:
return 0
digest = doc.get("hash")
if not isinstance(digest, str):
return 0
blob = self.blobs.get(digest)
if blob is None:
return 0
blob["refcount"] = blob.get("refcount", 1) - 1
if blob["refcount"] <= 0:
size = int(blob.get("size", 0))
self.blobs.pop(digest, None)
await self.hass.async_add_executor_job(self._delete_blob_sync, digest)
return size
return 0
def _delete_blob_sync(self, digest: str) -> None:
try:
self.blob_path(digest).unlink(missing_ok=True)
except OSError:
_LOGGER.warning("Could not delete blob %s", digest[:12])
# ------------------------------------------------------------------
# Storage summary (backs the sensor + the panel overview)
# ------------------------------------------------------------------
def storage_summary(self) -> dict[str, Any]:
"""Aggregate storage usage.
``total_bytes`` is the **physical** footprint (unique blobs) — the real
backup cost. ``logical_bytes`` counts each file doc's size (shared blobs
multiple times); the difference is the dedup saving.
"""
total_bytes = sum(int(b.get("size", 0)) for b in self.blobs.values())
logical_bytes = 0
file_count = 0
link_count = 0
by_object: dict[str, dict[str, int]] = {}
by_category: dict[str, int] = {}
for doc in self.documents.values():
obj = doc.get("object_id", "")
slot = by_object.setdefault(obj, {"bytes": 0, "files": 0, "links": 0})
if doc.get("kind") == KIND_FILE:
size = int(doc.get("size", 0))
logical_bytes += size
file_count += 1
slot["bytes"] += size
slot["files"] += 1
for tag in doc.get("tags") or ["other"]:
by_category[tag] = by_category.get(tag, 0) + size
else:
link_count += 1
slot["links"] += 1
return {
"total_bytes": total_bytes,
"logical_bytes": logical_bytes,
"dedup_savings_bytes": max(0, logical_bytes - total_bytes),
"blob_count": len(self.blobs),
"file_count": file_count,
"link_count": link_count,
"document_count": len(self.documents),
"by_object": by_object,
"by_category": by_category,
}
# ------------------------------------------------------------------
# Hygiene (backs the repair-issue scan)
# ------------------------------------------------------------------
async def async_find_issues(self) -> dict[str, list[str]]:
"""Detect storage anomalies for the repair-issue / cleanup flow.
- ``orphan_blobs``: blob files on disk not referenced by the registry.
- ``zero_refcount``: registry blobs whose refcount fell to 0 (a crash
between deref and delete).
- ``dangling_docs``: file documents whose blob is missing (external
delete / partial restore).
"""
on_disk: set[str] = await self.hass.async_add_executor_job(self._list_blob_files)
registered = set(self.blobs)
orphan_blobs = sorted(on_disk - registered)
zero_refcount = sorted(h for h, b in self.blobs.items() if int(b.get("refcount", 0)) <= 0)
dangling_docs = sorted(
did
for did, d in self.documents.items()
if d.get("kind") == KIND_FILE and (d.get("hash") not in self.blobs or d.get("hash") not in on_disk)
)
return {
"orphan_blobs": orphan_blobs,
"zero_refcount": zero_refcount,
"dangling_docs": dangling_docs,
}
def _list_blob_files(self) -> set[str]:
# Only our own blobs (a 64-char sha256 hex name) count — a foreign file
# dropped into the dir is none of our business and must never be
# reported as an orphan (the cleanup would otherwise try to delete it).
d = self._blobs_dir
try:
entries = list(d.iterdir())
except FileNotFoundError:
# Dir absent, or removed between the check and the scan (a
# concurrent delete / shared test config dir) — nothing to list.
return set()
return {p.name for p in entries if p.is_file() and len(p.name) == 64 and all(c in "0123456789abcdef" for c in p.name)}
# ------------------------------------------------------------------
# Cleanup (backs the repair-issue fix flow)
# ------------------------------------------------------------------
async def async_cleanup_issues(self) -> dict[str, int]:
"""Reclaim the anomalies found by :meth:`async_find_issues`.
Deletes orphaned blob files and stale (zero-refcount) blobs to reclaim
disk/backup space, and prunes dangling document records whose blob is
already gone (nothing left to serve). Files still referenced by a live
document are never touched. Returns per-category counts + bytes freed.
"""
issues = await self.async_find_issues()
freed = 0
for digest in issues["orphan_blobs"]:
freed += await self.hass.async_add_executor_job(self._reclaim_blob_sync, digest)
for digest in issues["zero_refcount"]:
blob = self.blobs.pop(digest, None)
freed += int(blob.get("size", 0)) if blob else 0
await self.hass.async_add_executor_job(self._delete_blob_sync, digest)
for did in issues["dangling_docs"]:
doc = self.documents.pop(did, None)
if doc is not None:
# Reconcile the now over-counted blob refcount so no phantom
# registry entry lingers; the file is already gone (0 real bytes).
await self._deref_blob(doc)
if any(issues.values()):
await self._async_save()
return {
"orphans_deleted": len(issues["orphan_blobs"]),
"zero_refcount_cleared": len(issues["zero_refcount"]),
"dangling_removed": len(issues["dangling_docs"]),
"bytes_freed": freed,
}
def _reclaim_blob_sync(self, digest: str) -> int:
"""Delete an orphan blob file, returning its size (0 if already gone)."""
try:
size = self.blob_path(digest).stat().st_size
except OSError:
size = 0
self._delete_blob_sync(digest)
return size
@@ -0,0 +1,213 @@
"""Entity analyzer for discovering numeric attributes and fetching recorder statistics."""
from __future__ import annotations
import logging
import statistics as py_stats
from dataclasses import dataclass, field
from datetime import timedelta
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
_STATISTICS_LOOKBACK_DAYS = 90
@dataclass
class AttributeInfo:
"""Information about a numeric attribute."""
name: str
current_value: float
unit: str | None = None
@dataclass
class StatisticsInfo:
"""Historical statistics for an entity from the HA recorder."""
has_data: bool = False
period_days: int = 0
mean: float | None = None
minimum: float | None = None
maximum: float | None = None
std_dev: float | None = None
percentile_10: float | None = None
percentile_90: float | None = None
recent_trend: str | None = None # "rising" | "falling" | "stable"
@dataclass
class EntityAnalysis:
"""Result of analyzing an entity for trigger suitability."""
entity_id: str
domain: str = ""
device_class: str | None = None
unit_of_measurement: str | None = None
is_numeric_state: bool = False
numeric_attributes: dict[str, AttributeInfo] = field(default_factory=dict)
current_state: str = ""
statistics: StatisticsInfo | None = None
class EntityAnalyzer:
"""Analyzes HA entities for maintenance trigger configuration."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the analyzer."""
self.hass = hass
async def async_analyze_entity(self, entity_id: str) -> EntityAnalysis | None:
"""Analyze an entity and return its properties including recorder statistics."""
state = self.hass.states.get(entity_id)
if state is None:
return None
domain = entity_id.split(".")[0]
device_class = state.attributes.get("device_class")
unit = state.attributes.get("unit_of_measurement")
# Check if state is numeric
is_numeric = False
try:
float(state.state)
is_numeric = True
except (ValueError, TypeError):
pass
# Find numeric attributes
numeric_attrs: dict[str, AttributeInfo] = {}
for attr_name, attr_value in state.attributes.items():
if attr_name.startswith("_"):
continue
try:
val = float(attr_value)
numeric_attrs[attr_name] = AttributeInfo(
name=attr_name,
current_value=val,
unit=None,
)
except (ValueError, TypeError):
continue
# Fetch recorder statistics
stats_info = await self._async_fetch_statistics(entity_id)
return EntityAnalysis(
entity_id=entity_id,
domain=domain,
device_class=device_class,
unit_of_measurement=unit,
is_numeric_state=is_numeric,
numeric_attributes=numeric_attrs,
current_state=state.state,
statistics=stats_info,
)
async def _async_fetch_statistics(self, entity_id: str) -> StatisticsInfo | None:
"""Fetch long-term statistics from the HA recorder."""
try:
from homeassistant.components.recorder import ( # type: ignore[attr-defined]
get_instance,
)
from homeassistant.components.recorder.statistics import (
statistics_during_period,
)
except ImportError:
_LOGGER.debug("Recorder statistics module not available")
return None
start_time = dt_util.utcnow() - timedelta(days=_STATISTICS_LOOKBACK_DAYS)
try:
result = await get_instance(self.hass).async_add_executor_job(
lambda: statistics_during_period(
self.hass,
start_time,
None, # end_time = now
{entity_id},
"day",
None, # units
{"mean", "min", "max"},
)
)
except (HomeAssistantError, ValueError, TypeError):
_LOGGER.debug("Failed to fetch statistics for %s", entity_id, exc_info=True)
return None
rows = result.get(entity_id, [])
if not rows:
return StatisticsInfo(has_data=False)
# Extract daily values
means: list[float] = []
mins: list[float] = []
maxs: list[float] = []
for row in rows:
m = row.get("mean")
if m is not None:
means.append(m)
mn = row.get("min")
if mn is not None:
mins.append(mn)
mx = row.get("max")
if mx is not None:
maxs.append(mx)
if not means and not mins and not maxs:
return StatisticsInfo(has_data=False)
# Use whichever series has data (means preferred, fall back to mins)
values = means if means else mins if mins else maxs
info = StatisticsInfo(
has_data=True,
period_days=len(rows),
)
if means:
info.mean = round(py_stats.mean(means), 3)
if mins:
info.minimum = round(min(mins), 3)
if maxs:
info.maximum = round(max(maxs), 3)
# Standard deviation
if len(values) >= 2:
info.std_dev = round(py_stats.stdev(values), 3)
# Percentiles
if len(values) >= 5:
sorted_vals = sorted(values)
n = len(sorted_vals)
idx_10 = (n - 1) * 0.1
lo, hi = int(idx_10), min(int(idx_10) + 1, n - 1)
frac = idx_10 - lo
info.percentile_10 = round(sorted_vals[lo] + frac * (sorted_vals[hi] - sorted_vals[lo]), 3)
idx_90 = (n - 1) * 0.9
lo, hi = int(idx_90), min(int(idx_90) + 1, n - 1)
frac = idx_90 - lo
info.percentile_90 = round(sorted_vals[lo] + frac * (sorted_vals[hi] - sorted_vals[lo]), 3)
# Recent trend (last 7 days vs previous 7 days)
if len(values) >= 14:
recent = values[-7:]
previous = values[-14:-7]
recent_avg = py_stats.mean(recent)
prev_avg = py_stats.mean(previous)
if prev_avg != 0:
change_pct = (recent_avg - prev_avg) / abs(prev_avg) * 100
if change_pct > 5:
info.recent_trend = "rising"
elif change_pct < -5:
info.recent_trend = "falling"
else:
info.recent_trend = "stable"
return info
@@ -0,0 +1,239 @@
"""Domain-specific attribute mapping for entity introspection.
Provides a mapping of HA entity domains to their commonly relevant
attributes for maintenance trigger configuration. This helps the frontend
show a dropdown of suitable attributes instead of a free text field.
"""
from __future__ import annotations
from typing import Any
from homeassistant.core import HomeAssistant
# Mapping of HA domains to their commonly useful attributes for triggers.
# Each entry is a dict with:
# - "attributes": list of attribute names relevant for maintenance triggers
# - "description": short description of what the domain represents
#
# The frontend can use this to show a dropdown of attributes when the user
# selects an entity, while still allowing manual input for unlisted attrs.
DOMAIN_ATTRIBUTE_MAP: dict[str, dict[str, Any]] = {
"climate": {
"description": "Climate / HVAC",
"attributes": [
"current_temperature",
"temperature",
"target_temp_high",
"target_temp_low",
"current_humidity",
"humidity",
"hvac_action",
"fan_mode",
"swing_mode",
"preset_mode",
],
},
"vacuum": {
"description": "Robot Vacuum",
"attributes": [
"battery_level",
"fan_speed",
"status",
"cleaning_time", # Xiaomi / Roborock
"cleaning_area",
"cleaning_count",
"total_cleaning_time", # Xiaomi / Roborock
"total_cleaning_area",
"total_cleaning_count",
"filter_left",
"side_brush_left",
"main_brush_left",
"sensor_dirty_left",
],
},
"cover": {
"description": "Cover / Blind / Shutter",
"attributes": [
"current_position",
"current_tilt_position",
],
},
"fan": {
"description": "Fan",
"attributes": [
"percentage",
"preset_mode",
"oscillating",
"direction",
],
},
"light": {
"description": "Light",
"attributes": [
"brightness",
"color_temp",
"color_temp_kelvin",
"hs_color",
"rgb_color",
],
},
"sensor": {
"description": "Sensor",
"attributes": [], # Sensors typically use state directly
},
"binary_sensor": {
"description": "Binary Sensor",
"attributes": [], # Binary sensors typically use state directly
},
"water_heater": {
"description": "Water Heater",
"attributes": [
"current_temperature",
"temperature",
"min_temp",
"max_temp",
"operation_mode",
],
},
"humidifier": {
"description": "Humidifier / Dehumidifier",
"attributes": [
"humidity",
"current_humidity",
"min_humidity",
"max_humidity",
"mode",
],
},
"media_player": {
"description": "Media Player",
"attributes": [
"volume_level",
"is_volume_muted",
"media_duration",
"media_position",
"source",
],
},
"weather": {
"description": "Weather",
"attributes": [
"temperature",
"humidity",
"pressure",
"wind_speed",
"wind_bearing",
"ozone",
"visibility",
],
},
"air_quality": {
"description": "Air Quality",
"attributes": [
"particulate_matter_2_5",
"particulate_matter_10",
"air_quality_index",
"carbon_dioxide",
"carbon_monoxide",
"nitrogen_dioxide",
"ozone",
"sulphur_dioxide",
"volatile_organic_compounds",
],
},
"switch": {
"description": "Switch",
"attributes": [
"current_power_w",
"today_energy_kwh",
],
},
"lock": {
"description": "Lock",
"attributes": [], # Locks typically use state directly
},
"valve": {
"description": "Valve",
"attributes": [
"current_position",
],
},
"lawn_mower": {
"description": "Lawn Mower",
"attributes": [
"battery_level",
],
},
}
def get_entity_attributes(hass: HomeAssistant, entity_id: str) -> dict[str, Any]:
"""Get relevant attributes for an entity, combining domain mapping with live state.
Returns a dict with:
- domain: the entity domain
- domain_description: human-readable domain description
- suggested_attributes: list of attribute names from the domain mapping
- available_attributes: list of dicts with name/value/numeric for all
current attributes of the entity
"""
domain = entity_id.split(".")[0] if "." in entity_id else ""
domain_info = DOMAIN_ATTRIBUTE_MAP.get(domain, {})
state = hass.states.get(entity_id)
if state is None:
return {
"entity_id": entity_id,
"domain": domain,
"domain_description": domain_info.get("description"),
"suggested_attributes": domain_info.get("attributes", []),
"available_attributes": [],
}
# Build available attributes from live state
available: list[dict[str, Any]] = []
for attr_name, attr_value in state.attributes.items():
# Skip internal/HA-framework attributes
if attr_name.startswith("_") or attr_name in (
"friendly_name",
"icon",
"entity_picture",
"supported_features",
"attribution",
"device_class",
"state_class",
"unit_of_measurement",
"options", # select/enum options list
):
continue
is_numeric = False
try:
float(attr_value)
is_numeric = True
except (ValueError, TypeError):
pass
available.append(
{
"name": attr_name,
"value": attr_value if isinstance(attr_value, (str, int, float, bool, type(None))) else str(attr_value),
"numeric": is_numeric,
}
)
# Suggested attributes: from domain mapping, filtered to those actually present
suggested = domain_info.get("attributes", [])
present_attr_names = {a["name"] for a in available}
# Keep suggested order but only include present ones, plus append
# any present numeric attrs not in the suggested list
filtered_suggested = [a for a in suggested if a in present_attr_names]
return {
"entity_id": entity_id,
"domain": domain,
"domain_description": domain_info.get("description"),
"suggested_attributes": filtered_suggested,
"available_attributes": available,
}
@@ -0,0 +1,175 @@
"""Helpers for rewriting entity_id references when HA renames an entity.
Used by the global ``EVENT_ENTITY_REGISTRY_UPDATED`` listener in ``__init__.py``
to keep ``trigger_config["entity_id"|"entity_ids"]`` and
``adaptive_config["environmental_entity"]`` in sync with the user's renames.
Without this, ``async_track_state_change_event`` (which subscribes by literal
entity_id) silently misses events on the new id — same dual-storage class of
bug as #48, but with feature breakage instead of a UI inconsistency.
"""
from __future__ import annotations
from typing import Any
def rewrite_trigger_config(config: dict[str, Any], old_id: str, new_id: str) -> tuple[dict[str, Any], bool]:
"""Return (new_config, changed) with all entity_id references rewritten.
Handles:
- flat ``entity_id`` (legacy single-entity)
- ``entity_ids`` list (multi-entity)
- ``_trigger_state`` keyed by entity_id (per-entity persisted state)
- compound triggers — recursive into ``conditions[].trigger_config``
"""
if not isinstance(config, dict):
return config, False
new_config = dict(config)
changed = False
if new_config.get("entity_id") == old_id:
new_config["entity_id"] = new_id
changed = True
eids = new_config.get("entity_ids")
if isinstance(eids, list) and old_id in eids:
new_config["entity_ids"] = [new_id if e == old_id else e for e in eids]
changed = True
state = new_config.get("_trigger_state")
if isinstance(state, dict) and old_id in state:
new_state = dict(state)
new_state[new_id] = new_state.pop(old_id)
new_config["_trigger_state"] = new_state
changed = True
if new_config.get("type") == "compound":
new_conditions: list[dict[str, Any]] = []
for cond in new_config.get("conditions", []) or []:
new_cond, cond_changed = rewrite_trigger_config(cond, old_id, new_id)
inner = new_cond.get("trigger_config") if isinstance(new_cond, dict) else None
if isinstance(inner, dict):
rewritten_inner, inner_changed = rewrite_trigger_config(inner, old_id, new_id)
if inner_changed:
new_cond = {**new_cond, "trigger_config": rewritten_inner}
cond_changed = True
if cond_changed:
changed = True
new_conditions.append(new_cond)
if changed:
new_config["conditions"] = new_conditions
return new_config, changed
def rewrite_task(task_data: dict[str, Any], old_id: str, new_id: str) -> tuple[dict[str, Any], bool]:
"""Rewrite trigger_config + adaptive_config.environmental_entity in a task.
Note: ``adaptive_config`` lives in the Store after the v1.x migration —
this helper still rewrites the inline copy for the legacy / pre-migration
path. The Store path is handled by ``rewrite_store``.
"""
new_task = dict(task_data)
changed = False
tc = new_task.get("trigger_config")
if isinstance(tc, dict):
new_tc, tc_changed = rewrite_trigger_config(tc, old_id, new_id)
if tc_changed:
new_task["trigger_config"] = new_tc
changed = True
ac = new_task.get("adaptive_config")
if isinstance(ac, dict) and ac.get("environmental_entity") == old_id:
new_task["adaptive_config"] = {**ac, "environmental_entity": new_id}
changed = True
return new_task, changed
def rewrite_tasks(tasks: dict[str, dict[str, Any]], old_id: str, new_id: str) -> tuple[dict[str, dict[str, Any]], bool]:
"""Rewrite all tasks in an entry; return (new_tasks, any_changed)."""
new_tasks: dict[str, dict[str, Any]] = {}
any_changed = False
for tid, td in tasks.items():
new_td, changed = rewrite_task(td, old_id, new_id)
new_tasks[tid] = new_td
if changed:
any_changed = True
return new_tasks, any_changed
def rewrite_store(store: Any, old_id: str, new_id: str) -> bool:
"""Rewrite ``adaptive_config.environmental_entity`` and ``trigger_runtime``
keys in a ``MaintenanceStore``.
These fields live in Store (not entry.data) after the v1.x migration —
`_DYNAMIC_TASK_FIELDS` includes ``adaptive_config``, and per-entity
trigger runtime is keyed by entity_id. Both must follow renames.
Returns True iff anything was rewritten.
"""
changed = False
tasks_state: dict[str, Any] = store._data.get("tasks", {})
for state in tasks_state.values():
ac = state.get("adaptive_config")
if isinstance(ac, dict) and ac.get("environmental_entity") == old_id:
state["adaptive_config"] = {**ac, "environmental_entity": new_id}
changed = True
runtime = state.get("trigger_runtime")
if isinstance(runtime, dict) and old_id in runtime:
runtime[new_id] = runtime.pop(old_id)
changed = True
return changed
def migrate_object_unique_ids(hass: Any, entry: Any, old_name: str | None, new_name: str | None) -> int:
"""Rewrite per-task entity unique_ids after an object RENAME.
Same dual-storage bug class as the trigger rewrites above, but for our
own entities: every per-task unique_id embeds the object's NAME SLUG
(``maintenance_supporter_{slug}_{task_id}[_suffix]``). Without this
migration a rename orphans all registry entries on the next reload —
the entities come back under NEW unique_ids (and new entity_ids), while
dashboards and automations keep pointing at the now-unavailable old ones.
Rewrites in place, preserving entity_ids, history, and user
customisations. Returns the number of migrated entries. A slug collision
(another object already using the new unique_id) is logged and skipped
rather than raised — the reload then falls back to fresh entities for
just that entry, which matches the pre-migration behaviour.
"""
from homeassistant.helpers import entity_registry as er
from ..const import slugify_object_name
old_slug = slugify_object_name(old_name or "")
new_slug = slugify_object_name(new_name or "")
if not old_slug or not new_slug or old_slug == new_slug:
return 0
old_prefix = f"maintenance_supporter_{old_slug}_"
new_prefix = f"maintenance_supporter_{new_slug}_"
ent_reg = er.async_get(hass)
migrated = 0
for reg_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id):
uid = reg_entry.unique_id or ""
if not uid.startswith(old_prefix):
continue
new_uid = new_prefix + uid[len(old_prefix) :]
try:
ent_reg.async_update_entity(reg_entry.entity_id, new_unique_id=new_uid)
migrated += 1
except ValueError:
import logging
logging.getLogger(__name__).warning(
"unique_id %s already taken while renaming %r -> %r; leaving %s",
new_uid,
old_name,
new_name,
reg_entry.entity_id,
)
return migrated
@@ -0,0 +1,69 @@
"""Cross-entry lookups for the global config entry's options.
The integration uses one global config entry (`unique_id == GLOBAL_UNIQUE_ID`)
that holds settings shared across every per-object entry — most notably the
`default_warning_days` value the user picks in the panel's General Settings.
Per-object task-create flows (panel, options-flow, WS) need to honour that
default instead of falling back to the hard-coded `DEFAULT_WARNING_DAYS`
constant. These helpers centralise the cross-entry lookup.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from homeassistant.core import HomeAssistant
from ..const import (
CONF_DEFAULT_WARNING_DAYS,
CONF_PANEL_TITLE,
DEFAULT_WARNING_DAYS,
DOMAIN,
GLOBAL_UNIQUE_ID,
MAX_PANEL_TITLE_LENGTH,
PANEL_TITLE,
)
def get_global_options(hass: HomeAssistant) -> Mapping[str, Any]:
"""Return the options dict from the global config entry, or empty mapping."""
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
return entry.options or entry.data
return {}
def get_default_warning_days(hass: HomeAssistant) -> int:
"""Resolve the integration-wide default warning_days for new tasks.
Reads the `default_warning_days` option from the global config entry; falls
back to the constant `DEFAULT_WARNING_DAYS` when the global entry does not
exist yet (during initial setup) or when no user value has been stored.
"""
raw = get_global_options(hass).get(CONF_DEFAULT_WARNING_DAYS, DEFAULT_WARNING_DAYS)
try:
value = int(raw)
except (TypeError, ValueError):
return DEFAULT_WARNING_DAYS
if value < 0 or value > 365:
return DEFAULT_WARNING_DAYS
return value
def get_panel_title(hass: HomeAssistant) -> str:
"""Resolve the sidebar panel title for the custom panel.
Reads the user-set `panel_title` option from the global config entry so a
user can rename the sidebar entry (e.g. to avoid clashing with HA's built-in
"Maintenance" dashboard, 2026.5+). Falls back to the default `PANEL_TITLE`
when unset, blank, or not a string. Trimmed and length-capped.
"""
raw = get_global_options(hass).get(CONF_PANEL_TITLE)
if not isinstance(raw, str):
return PANEL_TITLE
title = raw.strip()
if not title:
return PANEL_TITLE
return title[:MAX_PANEL_TITLE_LENGTH]
@@ -0,0 +1,19 @@
"""Internationalization helpers."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
def normalize_language(hass: HomeAssistant) -> str:
"""Return the HA UI language as a lowercase 2-letter table key.
HA emits regional language codes (e.g. ``zh-Hans``, ``zh-Hant``,
``pt-BR``), but the integration's localization tables — the
calendar/notification/config-flow Python string dicts, the
``name_<lang>`` template fields, and the ``styles.ts`` panel strings —
are keyed by the bare 2-letter prefix. Centralizing the normalization
keeps every consumer identical, so a regional-code user never silently
falls back to English. Defaults to ``en`` when the language is unset.
"""
return (getattr(hass.config, "language", None) or "en")[:2].lower()
@@ -0,0 +1,687 @@
"""Adaptive interval analyzer for smart maintenance scheduling.
Implements Exponential Weighted Average (EWA) and Weibull distribution
analysis to recommend optimal maintenance intervals based on completion
history and user feedback. Includes seasonal awareness (Phase 2) that
learns monthly maintenance patterns from history and adjusts recommendations.
Pure Python — only stdlib `math` and `statistics` modules used.
No HA dependencies — fully testable in isolation.
"""
from __future__ import annotations
import math
import statistics
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from homeassistant.util import dt as dt_util
from ..const import (
DEFAULT_ADAPTIVE_EWA_ALPHA,
DEFAULT_ADAPTIVE_MAX_INTERVAL,
DEFAULT_ADAPTIVE_MIN_COMPLETIONS,
DEFAULT_ADAPTIVE_MIN_INTERVAL,
DEFAULT_ADAPTIVE_RELIABILITY_TARGET,
DEFAULT_ADAPTIVE_WEIBULL_MIN,
DEFAULT_INTERVAL_DAYS,
DEFAULT_SEASONAL_FACTOR_MAX,
DEFAULT_SEASONAL_FACTOR_MIN,
DEFAULT_SEASONAL_MIN_DATA,
NORTHERN_SEASONS,
SOUTHERN_SEASONS,
MaintenanceFeedback,
)
from .schedule import read_legacy_fields
# Feedback multipliers for EWA: adjusts effective interval based on need
FEEDBACK_MULTIPLIERS: dict[str, float] = {
MaintenanceFeedback.NEEDED: 1.0, # interval was right or too long
MaintenanceFeedback.NOT_NEEDED: 1.3, # can extend interval
MaintenanceFeedback.NOT_SURE: 1.1, # slight extension
}
@dataclass
class SeasonalAnalysis:
"""Result of seasonal factor analysis."""
monthly_factors: list[float] # 12 floats, index 0=Jan, 11=Dec
current_month_factor: float # factor for the current month
data_months: int # how many distinct months have data
total_data_points: int # total intervals used
hemisphere: str # "north" | "south"
has_sufficient_data: bool # True if >= MIN_SEASONAL_DATA_POINTS
@dataclass
class IntervalAnalysis:
"""Result of analyzing a task's interval history."""
current_interval: int
average_actual_interval: float | None
interval_std_dev: float | None
ewa_prediction: float | None
weibull_prediction: int | None
weibull_beta: float | None
weibull_eta: float | None
recommended_interval: int | None
confidence: str # "low" | "medium" | "high"
feedback_count: int
data_points: int
recommendation_reason: str | None
# Seasonal awareness (Phase 2)
seasonal_factor: float | None = None
seasonal_factors: list[float] | None = None
seasonal_adjustment_reason: str | None = None # "learned" | "manual" | None
# Weibull advanced statistics (Phase 4)
weibull_r_squared: float | None = None
confidence_interval_low: int | None = None # R=0.95 (conservative)
confidence_interval_high: int | None = None # R=0.80 (aggressive)
# Sensor predictions (Phase 3)
degradation_rate: float | None = None # units/day
degradation_trend: str | None = None # "rising"|"falling"|"stable"
days_until_threshold: float | None = None
threshold_prediction_confidence: str | None = None # "low"|"medium"|"high"
environmental_factor: float | None = None # adjustment multiplier
environmental_entity: str | None = None
sensor_prediction_reason: str | None = None # "degradation"|"environmental"|"both"
class IntervalAnalyzer:
"""Analyzes maintenance completion patterns to recommend optimal intervals.
Two algorithms:
- EWA (Exponential Weighted Average): Primary, lightweight, always active.
Converges to optimal interval in ~8-10 observations.
- Weibull distribution: Secondary, activates after 5+ completions.
Reliability-based recommendation at configurable target (default 90%).
Both are blended using confidence-weighted averaging.
"""
def analyze(self, task_data: dict[str, Any], adaptive_config: dict[str, Any]) -> IntervalAnalysis:
"""Analyze a task's history and return interval recommendations.
Called from coordinator._async_update_data() on each refresh.
Stateless — reads history timestamps from task_data.
Args:
task_data: The full task dict (with history, interval_days, etc.)
adaptive_config: The task's adaptive_config dict.
Returns:
IntervalAnalysis with all computed metrics and recommendation.
"""
current_interval = read_legacy_fields(task_data)["interval_days"] or DEFAULT_INTERVAL_DAYS
history = task_data.get("history", [])
intervals = self._compute_intervals_from_history(history)
data_points = len(intervals)
alpha = adaptive_config.get("ewa_alpha", DEFAULT_ADAPTIVE_EWA_ALPHA)
min_interval = adaptive_config.get("min_interval_days", DEFAULT_ADAPTIVE_MIN_INTERVAL)
max_interval = adaptive_config.get("max_interval_days", DEFAULT_ADAPTIVE_MAX_INTERVAL)
feedback_count = adaptive_config.get("feedback_count", 0)
confidence = self._compute_confidence(feedback_count)
# Statistics
avg_interval: float | None = None
std_dev: float | None = None
if intervals:
avg_interval = statistics.mean(intervals)
if len(intervals) >= 2:
std_dev = statistics.stdev(intervals)
# EWA — use stored smoothed value if available, otherwise compute
ewa_prediction: float | None = adaptive_config.get("smoothed_interval")
if ewa_prediction is None and intervals:
ewa_prediction = self._exponential_weighted_average([float(i) for i in intervals], alpha)
# Weibull
weibull_beta: float | None = adaptive_config.get("weibull_beta")
weibull_eta: float | None = adaptive_config.get("weibull_eta")
weibull_prediction: int | None = None
weibull_r_squared: float | None = None
confidence_low: int | None = None
confidence_high: int | None = None
if data_points >= DEFAULT_ADAPTIVE_WEIBULL_MIN:
fit = self._weibull_fit([float(i) for i in intervals])
if fit is not None:
weibull_beta, weibull_eta, weibull_r_squared = fit
reliability = adaptive_config.get("reliability_target", DEFAULT_ADAPTIVE_RELIABILITY_TARGET)
weibull_prediction = self._weibull_recommended_interval(weibull_beta, weibull_eta, reliability)
# Confidence interval bounds (Phase 4)
confidence_low = self._weibull_recommended_interval(weibull_beta, weibull_eta, 0.95)
confidence_high = self._weibull_recommended_interval(weibull_beta, weibull_eta, 0.80)
# Blend recommendations
recommended: int | None = None
reason: str | None = None
if feedback_count >= DEFAULT_ADAPTIVE_MIN_COMPLETIONS:
recommended, reason = self._blend_recommendations(
base=current_interval,
ewa=ewa_prediction,
weibull=weibull_prediction,
confidence=confidence,
)
if recommended is not None:
recommended = max(min_interval, min(max_interval, recommended))
# Seasonal adjustment (Phase 2)
seasonal_factor: float | None = None
seasonal_factors_list: list[float] | None = None
seasonal_reason: str | None = None
seasonal_enabled = adaptive_config.get("seasonal_enabled", True)
if recommended is not None and seasonal_enabled:
intervals_with_months = self._compute_intervals_with_months(history)
hemisphere = adaptive_config.get("hemisphere", "north")
manual_overrides = adaptive_config.get("seasonal_overrides")
current_month = adaptive_config.get("_current_month") or dt_util.now().month
seasonal = self._compute_monthly_factors(intervals_with_months, hemisphere, manual_overrides, current_month)
seasonal_factors_list = [round(f, 2) for f in seasonal.monthly_factors]
if seasonal.has_sufficient_data or manual_overrides:
factor = seasonal.monthly_factors[current_month - 1]
seasonal_factor = round(factor, 2)
if manual_overrides and current_month in manual_overrides:
seasonal_reason = "manual"
elif seasonal.has_sufficient_data:
seasonal_reason = "learned"
recommended = self._apply_seasonal_adjustment(recommended, factor, min_interval, max_interval)
# Apply seasonal adjustment to confidence bounds too
if confidence_low is not None:
confidence_low = self._apply_seasonal_adjustment(confidence_low, factor, min_interval, max_interval)
if confidence_high is not None:
confidence_high = self._apply_seasonal_adjustment(confidence_high, factor, min_interval, max_interval)
if reason:
reason = f"{reason}_seasonal"
return IntervalAnalysis(
current_interval=current_interval,
average_actual_interval=round(avg_interval, 1) if avg_interval else None,
interval_std_dev=round(std_dev, 1) if std_dev else None,
ewa_prediction=round(ewa_prediction, 1) if ewa_prediction else None,
weibull_prediction=weibull_prediction,
weibull_beta=round(weibull_beta, 2) if weibull_beta else None,
weibull_eta=round(weibull_eta, 2) if weibull_eta else None,
recommended_interval=recommended,
confidence=confidence,
feedback_count=feedback_count,
data_points=data_points,
recommendation_reason=reason,
seasonal_factor=seasonal_factor,
seasonal_factors=seasonal_factors_list,
seasonal_adjustment_reason=seasonal_reason,
weibull_r_squared=(round(weibull_r_squared, 4) if weibull_r_squared is not None else None),
confidence_interval_low=confidence_low if confidence_low else None,
confidence_interval_high=confidence_high if confidence_high else None,
)
def update_on_completion(
self,
adaptive_config: dict[str, Any],
actual_interval: int,
feedback: str | None,
) -> dict[str, Any]:
"""Update adaptive config after a task completion.
This is the learning step — called from coordinator.complete_maintenance().
Args:
adaptive_config: Current adaptive_config dict (will be copied).
actual_interval: Days since last completion.
feedback: User feedback ("needed", "not_needed", "not_sure") or None.
Returns:
Updated adaptive_config dict (new object, original unchanged).
"""
config = dict(adaptive_config)
alpha = config.get("ewa_alpha", DEFAULT_ADAPTIVE_EWA_ALPHA)
min_interval = config.get("min_interval_days", DEFAULT_ADAPTIVE_MIN_INTERVAL)
max_interval = config.get("max_interval_days", DEFAULT_ADAPTIVE_MAX_INTERVAL)
# Apply feedback multiplier to get effective interval
multiplier = FEEDBACK_MULTIPLIERS.get(feedback or MaintenanceFeedback.NEEDED, 1.0)
effective_interval = actual_interval * multiplier
# Update EWA smoothed interval
prev_smoothed = config.get("smoothed_interval")
if prev_smoothed is not None:
smoothed = alpha * effective_interval + (1 - alpha) * prev_smoothed
else:
smoothed = effective_interval
config["smoothed_interval"] = round(smoothed, 2)
# Update feedback count
if feedback is not None:
config["feedback_count"] = config.get("feedback_count", 0) + 1
# Update confidence
config["confidence"] = self._compute_confidence(config.get("feedback_count", 0))
# Update recommendation
recommended, reason = self._blend_recommendations(
base=config.get("base_interval", actual_interval),
ewa=smoothed,
weibull=config.get("weibull_eta"), # Re-use last Weibull if available
confidence=config["confidence"],
)
if recommended is not None:
recommended = max(min_interval, min(max_interval, recommended))
# Apply seasonal adjustment if factors are stored from last analyze()
seasonal_enabled = config.get("seasonal_enabled", True)
stored_factors = config.get("_seasonal_factors")
if seasonal_enabled and stored_factors and len(stored_factors) == 12:
current_month = config.get("_current_month") or dt_util.now().month
factor = stored_factors[current_month - 1]
recommended = self._apply_seasonal_adjustment(recommended, factor, min_interval, max_interval)
if reason:
reason = f"{reason}_seasonal"
config["current_recommendation"] = recommended
config["recommendation_reason"] = reason
config["last_analysis_date"] = config.get("_current_date") or dt_util.now().date().isoformat()
return config
@staticmethod
def _completed_dates(history: list[dict[str, Any]]) -> list[datetime]:
"""Parse COMPLETED history entries into a chronologically sorted list.
Single source for the timestamp parsing + naive-TZ normalisation +
sort step shared by the interval extractors. Naive timestamps from
legacy entries are treated as HA local TZ so month boundaries (used
for seasonal analysis) stay consistent with the rest of the codebase.
Args:
history: List of history entry dicts with 'timestamp' and 'type'.
Returns:
Sorted list of completion datetimes (TZ-aware).
"""
completed_dates: list[datetime] = []
for entry in history:
if entry.get("type") != "completed":
continue
ts = entry.get("timestamp")
if not ts:
continue
try:
dt = datetime.fromisoformat(ts)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)
completed_dates.append(dt)
except (ValueError, TypeError):
continue
completed_dates.sort()
return completed_dates
@staticmethod
def _compute_intervals_from_history(
history: list[dict[str, Any]],
) -> list[int]:
"""Extract days between consecutive COMPLETED entries from history.
Args:
history: List of history entry dicts with 'timestamp' and 'type'.
Returns:
List of intervals in days between consecutive completions.
"""
completed_dates = IntervalAnalyzer._completed_dates(history)
# Compute intervals between consecutive completions
intervals: list[int] = []
for i in range(1, len(completed_dates)):
delta = (completed_dates[i] - completed_dates[i - 1]).days
if delta > 0: # Skip same-day completions
intervals.append(delta)
return intervals
@staticmethod
def _exponential_weighted_average(intervals: list[float], alpha: float) -> float:
"""Compute EWA over a list of intervals.
More recent values have higher weight.
Args:
intervals: List of interval values (days).
alpha: Smoothing factor (0 < alpha <= 1). Higher = more responsive.
Returns:
The EWA value.
"""
if not intervals:
return 0.0
if len(intervals) == 1:
return intervals[0]
ewa = intervals[0]
for val in intervals[1:]:
ewa = alpha * val + (1 - alpha) * ewa
return ewa
@staticmethod
def _weibull_fit(
intervals: list[float],
) -> tuple[float, float, float] | None:
"""Fit a Weibull distribution using median rank regression.
Pure Python implementation using only `math.log`.
Uses Bernard's approximation for median ranks and least-squares
regression in Weibull probability space.
Args:
intervals: List of interval values (days). Must have >= 5 items.
Returns:
(beta, eta, r_squared) tuple, or None if fit fails.
beta = shape parameter (>1 = wear-out pattern)
eta = scale parameter (characteristic life)
r_squared = goodness-of-fit (0.01.0)
"""
n = len(intervals)
if n < DEFAULT_ADAPTIVE_WEIBULL_MIN:
return None
# Sort intervals for rank ordering
sorted_intervals = sorted(intervals)
# Filter out zero/negative values
valid = [(i, v) for i, v in enumerate(sorted_intervals) if v > 0]
if len(valid) < DEFAULT_ADAPTIVE_WEIBULL_MIN:
return None
# Median rank (Bernard's approximation): F(i) = (i - 0.3) / (n + 0.4)
# Then transform to Weibull space:
# x = ln(t), y = ln(-ln(1 - F))
x_vals: list[float] = []
y_vals: list[float] = []
for rank, (_, t) in enumerate(valid, start=1):
f = (rank - 0.3) / (len(valid) + 0.4)
if f <= 0 or f >= 1:
continue
try:
x = math.log(t)
y = math.log(-math.log(1 - f))
x_vals.append(x)
y_vals.append(y)
except (ValueError, ZeroDivisionError):
continue
if len(x_vals) < 3:
return None
# Least-squares regression: y = beta * x - beta * ln(eta)
# => y = m * x + b, where m = beta, b = -beta * ln(eta)
n_pts = len(x_vals)
sum_x = sum(x_vals)
sum_y = sum(y_vals)
sum_xy = sum(x * y for x, y in zip(x_vals, y_vals, strict=True))
sum_x2 = sum(x * x for x in x_vals)
denom = n_pts * sum_x2 - sum_x * sum_x
if abs(denom) < 1e-10:
return None
beta = (n_pts * sum_xy - sum_x * sum_y) / denom
b = (sum_y - beta * sum_x) / n_pts
if beta <= 0:
return None
# eta = exp(-b / beta)
try:
eta = math.exp(-b / beta)
except (
OverflowError,
ZeroDivisionError,
): # pragma: no cover (defensive: beta>0 rules out div-zero; overflow needs pathological data)
return None
if eta <= 0: # pragma: no cover (math.exp is always > 0)
return None
# Compute R-squared for goodness-of-fit
mean_y = sum_y / n_pts
ss_tot = sum((y - mean_y) ** 2 for y in y_vals)
ss_res = sum((y - (beta * x + b)) ** 2 for x, y in zip(x_vals, y_vals, strict=True))
r_squared = 1.0 - (ss_res / ss_tot) if ss_tot > 0 else 0.0
return (beta, eta, r_squared)
@staticmethod
def _weibull_recommended_interval(beta: float, eta: float, reliability: float) -> int:
"""Calculate recommended interval from Weibull parameters.
t = eta * (-ln(R))^(1/beta)
Args:
beta: Weibull shape parameter.
eta: Weibull scale parameter.
reliability: Target reliability (e.g. 0.9 for 90%).
Returns:
Recommended interval in days (rounded).
"""
if beta <= 0 or eta <= 0 or reliability <= 0 or reliability >= 1:
return 0
try:
t = eta * ((-math.log(reliability)) ** (1 / beta))
return int(max(1, round(t)))
except (ValueError, ZeroDivisionError, OverflowError):
return 0
@staticmethod
def _compute_confidence(feedback_count: int) -> str:
"""Determine confidence level based on feedback count.
Args:
feedback_count: Number of feedback responses collected.
Returns:
"low", "medium", or "high"
"""
if feedback_count < DEFAULT_ADAPTIVE_MIN_COMPLETIONS:
return "low"
if feedback_count < 8:
return "medium"
return "high"
@staticmethod
def _compute_intervals_with_months(
history: list[dict[str, Any]],
) -> list[tuple[int, int]]:
"""Extract intervals with end-month from history.
Like _compute_intervals_from_history but returns tuples of
(interval_days, end_month) where end_month is the month (1-12)
of the second completion in each pair.
Args:
history: List of history entry dicts with 'timestamp' and 'type'.
Returns:
List of (interval_days, month) tuples.
"""
completed_dates = IntervalAnalyzer._completed_dates(history)
result: list[tuple[int, int]] = []
for i in range(1, len(completed_dates)):
delta = (completed_dates[i] - completed_dates[i - 1]).days
if delta > 0:
result.append((delta, completed_dates[i].month))
return result
@staticmethod
def _compute_monthly_factors(
intervals_with_months: list[tuple[int, int]],
hemisphere: str = "north",
manual_overrides: dict[int, float] | None = None,
current_month: int = 1,
) -> SeasonalAnalysis:
"""Compute seasonal factors from monthly interval data.
Groups intervals by end-month, computes per-month averages,
normalizes by the annual mean to get factors. Missing months
use a quarterly fallback based on hemisphere-aware season mapping.
Args:
intervals_with_months: List of (interval_days, month) tuples.
hemisphere: "north" or "south" for season mapping.
manual_overrides: Optional dict {month_num: factor} (1-12) for manual factors.
current_month: Current month (1-12) for the result.
Returns:
SeasonalAnalysis with 12 monthly factors.
"""
total_points = len(intervals_with_months)
# Default: all 1.0 (no seasonal adjustment)
factors = [1.0] * 12
has_sufficient_data = total_points >= DEFAULT_SEASONAL_MIN_DATA
if has_sufficient_data:
# Group intervals by month
monthly_intervals: dict[int, list[int]] = {}
for interval, month in intervals_with_months:
monthly_intervals.setdefault(month, []).append(interval)
# Compute per-month averages
monthly_means: dict[int, float] = {}
for month, ivals in monthly_intervals.items():
monthly_means[month] = statistics.mean(ivals)
# Annual mean across all intervals
all_intervals = [iv for iv, _ in intervals_with_months]
annual_mean = statistics.mean(all_intervals)
if annual_mean > 0:
# Compute raw factors for months with data
for month, mean_val in monthly_means.items():
factors[month - 1] = mean_val / annual_mean
# Quarterly fallback for months without data
seasons = NORTHERN_SEASONS if hemisphere == "north" else SOUTHERN_SEASONS
for _season_name, season_months in seasons.items():
# Compute average factor for months in this quarter that have data
season_factors = [factors[m - 1] for m in season_months if m in monthly_means]
if season_factors:
quarter_avg = statistics.mean(season_factors)
# Fill missing months in this quarter
for m in season_months:
if m not in monthly_means:
factors[m - 1] = quarter_avg
# Apply manual overrides (replace learned values)
if manual_overrides:
for month_num, factor_val in manual_overrides.items():
if 1 <= month_num <= 12:
factors[month_num - 1] = factor_val
# Clamp all factors
factors = [max(DEFAULT_SEASONAL_FACTOR_MIN, min(DEFAULT_SEASONAL_FACTOR_MAX, f)) for f in factors]
data_months = len({m for _, m in intervals_with_months}) if intervals_with_months else 0
return SeasonalAnalysis(
monthly_factors=factors,
current_month_factor=factors[current_month - 1],
data_months=data_months,
total_data_points=total_points,
hemisphere=hemisphere,
has_sufficient_data=has_sufficient_data,
)
@staticmethod
def _apply_seasonal_adjustment(
recommended: int,
seasonal_factor: float,
min_interval: int,
max_interval: int,
) -> int:
"""Apply seasonal factor to a recommended interval.
Multiplies the recommendation by the factor and clamps to bounds.
Args:
recommended: Base recommended interval in days.
seasonal_factor: Multiplier (< 1.0 shortens, > 1.0 lengthens).
min_interval: Minimum allowed interval.
max_interval: Maximum allowed interval.
Returns:
Adjusted interval in days.
"""
adjusted = round(recommended * seasonal_factor)
return max(min_interval, min(max_interval, adjusted))
@staticmethod
def _blend_recommendations(
base: int,
ewa: float | None,
weibull: int | float | None,
confidence: str,
) -> tuple[int | None, str | None]:
"""Blend base interval with statistical predictions.
Weights depend on confidence level:
- low: 100% base (no recommendation)
- medium: 50% base + 50% statistical
- high: 20% base + 80% statistical
Statistical = EWA if no Weibull, or avg(EWA, Weibull) if both available.
Args:
base: Current configured interval in days.
ewa: EWA prediction (days) or None.
weibull: Weibull prediction (days) or None.
confidence: "low", "medium", or "high".
Returns:
(recommended_interval, reason) tuple. None if insufficient data.
"""
if confidence == "low":
return (None, None)
# Determine statistical prediction
stat_predictions: list[float] = []
reason_parts: list[str] = []
if ewa is not None:
stat_predictions.append(ewa)
reason_parts.append("ewa")
if weibull is not None and weibull > 0:
stat_predictions.append(float(weibull))
reason_parts.append("weibull")
if not stat_predictions:
return (None, None)
statistical = statistics.mean(stat_predictions)
reason = "_and_".join(reason_parts)
# Blend based on confidence
if confidence == "medium":
blended = 0.5 * base + 0.5 * statistical
else: # high
blended = 0.2 * base + 0.8 * statistical
return (round(blended), reason)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,39 @@
"""Canonical notify-target discovery.
Single source of truth for the "which notify targets can the user pick"
list, shared by BOTH config surfaces: the global options flow
(`config_flow_options_global.py`) and the custom panel Settings view
(served through `_build_full_settings` in `websocket/dashboard.py`).
Before this helper the same merge lived twice — once in Python, once in
TypeScript — and the two had drifted (the panel excluded the
``notify.send_message`` *entity* and never injected the saved value; the
options flow injected the saved value but only excluded the ``send_message``
*service*). Routing both through here keeps them in lockstep.
"""
from __future__ import annotations
from homeassistant.core import HomeAssistant
# The generic notify action, not a real target — never offered as a choice,
# in either its service (``notify.send_message``) or entity form.
_SEND_MESSAGE = "send_message"
_SEND_MESSAGE_ENTITY = f"notify.{_SEND_MESSAGE}"
def build_notify_targets(hass: HomeAssistant, *, current: str | None = None) -> list[str]:
"""Return the sorted set of pickable notify targets.
Merges legacy notify *services* (mobile_app devices, notify groups) from
the service registry with notify *entities* (the newer model) from the
state machine — many single devices appear only as an entity. The generic
``send_message`` action is excluded in both forms. When ``current`` is a
non-empty saved value it is always included, so an already-configured but
currently-unavailable target still shows up as selected.
"""
targets: set[str] = {f"notify.{name}" for name in hass.services.async_services().get("notify", {}) if name != _SEND_MESSAGE}
targets.update(entity_id for entity_id in hass.states.async_entity_ids("notify") if entity_id != _SEND_MESSAGE_ENTITY)
if current:
targets.add(current)
return sorted(targets)
@@ -0,0 +1,79 @@
"""Object pause / seasonal mode (journey N3).
Seasonal equipment (pool, lawn mower, AC) is out of service for months at a
time. Vacation mode is global and archive retires the object entirely —
neither fits "paused until spring". A paused object keeps its tasks visible
(status ``paused``) but freezes schedules and fires nothing; resuming
re-anchors recurring tasks to a fresh cycle, exactly like an object
unarchive.
State lives on the object dict: ``paused_at`` (ISO timestamp marker; set =
paused) and ``paused_until`` (optional ISO date; the coordinator auto-resumes
on the first refresh on/after that day). The resume core is shared between
the ``object/resume`` WS command and the coordinator's auto-resume so the two
paths cannot drift.
"""
from __future__ import annotations
from datetime import date
from typing import TYPE_CHECKING, Any
from ..const import CONF_OBJECT, CONF_TASKS
if TYPE_CHECKING:
from ..storage import MaintenanceStore
def is_object_paused(obj: dict[str, Any]) -> bool:
"""True when the object dict carries the pause marker."""
return obj.get("paused_at") is not None
def pause_due_for_auto_resume(obj: dict[str, Any], today: date) -> bool:
"""True when a paused object's ``paused_until`` day has been reached."""
until = obj.get("paused_until")
if not is_object_paused(obj) or not until:
return False
try:
return today >= date.fromisoformat(str(until))
except (ValueError, TypeError):
# An unparseable date must not pause the object forever — resume.
return True
def build_resumed_entry_data(
entry_data: dict[str, Any],
store: MaintenanceStore | None,
today_iso: str,
) -> dict[str, Any]:
"""Return new entry data with the pause cleared and schedules re-anchored.
Mirrors the unarchive semantics: every ACTIVE recurring task gets a fresh
cycle from today (the pool pump doesn't come back 5 months overdue) —
one-time and manual tasks keep their dates. Mutates the Store's dynamic
state when one is provided (the caller saves); falls back to the static
dict otherwise (legacy shape). Archived tasks are untouched.
"""
from .schedule import is_recurring
new_data = dict(entry_data)
obj = dict(new_data.get(CONF_OBJECT, {}))
obj.pop("paused_at", None)
obj.pop("paused_until", None)
new_data[CONF_OBJECT] = obj
new_tasks: dict[str, Any] = {}
for tid, td in dict(new_data.get(CONF_TASKS, {})).items():
td = dict(td)
if td.get("archived_at") is None and is_recurring(td):
if store is not None:
store.set_last_performed(tid, today_iso)
state = store._ensure_task(tid)
state.pop("last_planned_due", None)
else:
td["last_performed"] = today_iso
td.pop("last_planned_due", None)
new_tasks[tid] = td
new_data[CONF_TASKS] = new_tasks
return new_data
@@ -0,0 +1,94 @@
"""Authorization helpers for write-capable WebSocket commands.
Home Assistant admins may always write. A non-admin user may write only when
operator write delegation is switched on (``operator_write_enabled`` global
option, default OFF) AND their user id is on the operator allowlist
(``admin_panel_user_ids``), which an admin manages under Settings → Panel
Access. With delegation off — the shipped default — content create / edit /
delete is admin-only and the allowlist grants read-only operator access.
IMPORTANT — escalation boundary: ``require_write`` must be used ONLY on
content-CRUD commands (object / task / group create-update-delete, user
assignment, per-task analysis writes). Global-config, bulk-import and vacation
commands keep ``@websocket_api.require_admin``, so an operator can never edit
the allowlist nor flip the delegation switch (both live in the global options,
gated by ``global/update``) — write access cannot be self-granted.
"""
from __future__ import annotations
from functools import wraps
from typing import TYPE_CHECKING, Any
from homeassistant.components.websocket_api.connection import ActiveConnection
from homeassistant.components.websocket_api.const import WebSocketCommandHandler
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import Unauthorized
from ..const import CONF_ADMIN_PANEL_USER_IDS, CONF_OPERATOR_WRITE_ENABLED
from .global_options import get_global_options
if TYPE_CHECKING:
from homeassistant.auth.models import User
def operator_user_ids(hass: HomeAssistant) -> list[str]:
"""Return the operator allowlist (``admin_panel_user_ids``) as string ids."""
raw = get_global_options(hass).get(CONF_ADMIN_PANEL_USER_IDS, []) or []
return [uid for uid in raw if isinstance(uid, str)]
def operator_write_enabled(hass: HomeAssistant) -> bool:
"""Whether operator write delegation is switched on (default False).
Reads the ``operator_write_enabled`` global option. While False — the
shipped default — only HA admins may write and the panel-access allowlist
is read-only; an admin must explicitly enable this for allowlisted
non-admins to gain content CRUD.
"""
return get_global_options(hass).get(CONF_OPERATOR_WRITE_ENABLED, False) is True
def user_can_write(hass: HomeAssistant, user: User | None) -> bool:
"""Whether a specific user may perform content writes.
True for HA admins. For non-admin users, true only when operator write
delegation is enabled AND their id is on the operator allowlist. False for
anonymous / missing users. This is the user-object variant used by the HTTP
document views (which resolve ``request["hass_user"]``); the WS layer calls
:func:`user_may_write`, which reads the user off the connection.
"""
if user is None:
return False
if user.is_admin:
return True
return operator_write_enabled(hass) and user.id in operator_user_ids(hass)
def user_may_write(hass: HomeAssistant, connection: ActiveConnection) -> bool:
"""Whether the connection's user may perform content writes.
True for HA admins. For non-admin users, true only when operator write
delegation is enabled AND their id is on the operator allowlist. False for
anonymous connections.
"""
return user_can_write(hass, connection.user)
def require_write(func: WebSocketCommandHandler) -> WebSocketCommandHandler:
"""Drop-in for ``@websocket_api.require_admin`` that also allows operators.
Mirrors HA's ``require_admin`` exactly (same decorator position: between
``@websocket_command`` and ``@async_response``), but authorises any user for
whom :func:`user_may_write` is true. Use ONLY on content-CRUD commands.
"""
@wraps(func)
def with_write(hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]) -> None:
"""Check write permission and call the wrapped handler."""
if not user_may_write(hass, connection):
raise Unauthorized
func(hass, connection, msg)
return with_write
@@ -0,0 +1,171 @@
"""QR code generation helpers for Maintenance Supporter."""
from __future__ import annotations
import logging
import urllib.parse
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from .qrcodegen import QrCode
_LOGGER = logging.getLogger(__name__)
# Map QR action to embedded icon type
_ACTION_ICON_MAP: dict[str, str] = {
"view": "info",
"complete": "check",
# v1.3.0: lightning-bolt for the one-tap quick-complete QR.
"quick_complete": "lightning",
}
def build_qr_url(
hass: HomeAssistant,
entry_id: str,
task_id: str | None = None,
action: str = "view",
base_url_override: str | None = None,
url_mode: str = "server",
) -> str:
"""Build the URL to encode in a QR code.
url_mode controls the URL scheme:
- "companion": homeassistant://navigate/… (Companion App deep link, most persistent)
- "local": http://homeassistant.local:8123/… (mDNS, survives IP changes)
- "server": auto-detected server URL (current HA URL)
For "server" mode, resolution order: base_url_override > get_url() > external > internal.
Raises ValueError only in "server" mode when no URL can be determined.
"""
if url_mode == "companion":
base = "homeassistant://navigate"
elif url_mode == "local":
base = "http://homeassistant.local:8123"
elif base_url_override:
base = base_url_override.rstrip("/")
else:
# Try HA's get_url() which considers external/internal/cloud URLs
try:
from homeassistant.helpers.network import get_url
base = get_url(hass).rstrip("/")
except Exception: # noqa: BLE001 - get_url() can raise NoURLAvailableError + various network helpers
if hass.config.external_url:
base = hass.config.external_url.rstrip("/")
elif hass.config.internal_url:
base = hass.config.internal_url.rstrip("/")
else:
raise ValueError(
"No Home Assistant URL configured. Set an external or internal URL in Settings → System → Network."
) from None
params: dict[str, str] = {"entry_id": entry_id}
if task_id:
params["task_id"] = task_id
if action and action != "view":
params["action"] = action
query = urllib.parse.urlencode(params)
return f"{base}/maintenance-supporter?{query}"
def _icon_elements(icon: str, cx: float, cy: float, r: float, fill: str) -> str:
"""Return SVG elements for an icon centered at (cx, cy) fitting within radius r."""
if icon == "info":
# Letter "i": dot on top + vertical stem
dot_cy = cy - r * 0.38
dot_r = r * 0.13
stem_x = cx - r * 0.10
stem_y = cy - r * 0.12
stem_w = r * 0.20
stem_h = r * 0.58
stem_rx = r * 0.06
return (
f'<circle cx="{cx:.2f}" cy="{dot_cy:.2f}" r="{dot_r:.2f}" fill="{fill}"/>'
f'<rect x="{stem_x:.2f}" y="{stem_y:.2f}" width="{stem_w:.2f}" '
f'height="{stem_h:.2f}" rx="{stem_rx:.2f}" fill="{fill}"/>'
)
if icon == "check":
# Checkmark polyline
sw = r * 0.22
x1, y1 = cx - r * 0.38, cy + r * 0.02
x2, y2 = cx - r * 0.08, cy + r * 0.35
x3, y3 = cx + r * 0.42, cy - r * 0.30
return (
f'<polyline points="{x1:.2f},{y1:.2f} {x2:.2f},{y2:.2f} {x3:.2f},{y3:.2f}" '
f'fill="none" stroke="{fill}" stroke-width="{sw:.2f}" '
f'stroke-linecap="round" stroke-linejoin="round"/>'
)
if icon == "lightning":
# Lightning-bolt polygon. Classic ⚡ silhouette: top-right wide,
# narrows to bottom-left, with a slight inward kink at the middle
# for the recognisable bolt shape. Coordinates in units of r so
# the bolt scales with the surrounding logo circle.
points = [
(cx + r * 0.18, cy - r * 0.55), # top-right peak
(cx - r * 0.32, cy + r * 0.10), # mid-left waist
(cx - r * 0.02, cy + r * 0.10), # mid-right waist
(cx - r * 0.18, cy + r * 0.55), # bottom-left tip
(cx + r * 0.32, cy - r * 0.10), # mid-right outward
(cx + r * 0.02, cy - r * 0.10), # mid-left outward
]
pts_str = " ".join(f"{x:.2f},{y:.2f}" for x, y in points)
return f'<polygon points="{pts_str}" fill="{fill}"/>'
return ""
def generate_qr_svg(
url: str,
border: int = 2,
dark: str = "#000000",
light: str = "#FFFFFF",
icon: str | None = None,
) -> str:
"""Generate a QR code as an SVG string.
Returns raw SVG markup (no data URI wrapping).
When icon is set ("info" or "check"), uses HIGH error correction and
embeds a circular logo with the icon in the center of the QR code.
"""
ecc = QrCode.Ecc.HIGH if icon else QrCode.Ecc.MEDIUM
qr = QrCode.encode_text(url, ecc)
svg = qr.to_svg_str(border)
# Replace default colors if custom ones are requested
if dark != "#000000":
svg = svg.replace('fill="#000000"', f'fill="{dark}"')
if light != "#FFFFFF":
svg = svg.replace('fill="#FFFFFF"', f'fill="{light}"')
if icon:
size = qr.get_size()
total = size + border * 2
cx = total / 2
cy = total / 2
# Logo covers ~18% of QR width — safe with HIGH ECC (30% tolerance)
logo_r = size * 0.09
pad = logo_r * 0.18 # white padding ring around circle
logo_svg = (
f'<circle cx="{cx:.2f}" cy="{cy:.2f}" r="{logo_r + pad:.2f}" fill="{light}"/>'
f'<circle cx="{cx:.2f}" cy="{cy:.2f}" r="{logo_r:.2f}" fill="{dark}"/>'
+ _icon_elements(icon, cx, cy, logo_r * 0.70, light)
)
svg = svg.replace("</svg>", f"{logo_svg}\n</svg>")
return svg
def generate_qr_svg_data_uri(
url: str,
border: int = 2,
dark: str = "#000000",
light: str = "#FFFFFF",
icon: str | None = None,
) -> str:
"""Generate a QR code as an SVG data URI (for use in <img src>)."""
svg = generate_qr_svg(url, border=border, dark=dark, light=light, icon=icon)
encoded = urllib.parse.quote(svg, safe="")
return f"data:image/svg+xml,{encoded}"
@@ -0,0 +1,700 @@
#
# QR Code generator library (Python)
#
# Copyright (c) Project Nayuki. (MIT License)
# https://www.nayuki.io/page/qr-code-generator-library
#
# Permission is hereby granted, free of charge, to any person obtaining a copy of
# this software and associated documentation files (the "Software"), to deal in
# the Software without restriction, including without limitation the rights to
# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
# the Software, and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
# - The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# - The Software is provided "as is", without warranty of any kind, express or
# implied, including but not limited to the warranties of merchantability,
# fitness for a particular purpose and noninfringement. In no event shall the
# authors or copyright holders be liable for any claim, damages or other
# liability, whether in an action of contract, tort or otherwise, arising from,
# out of or in connection with the Software or the use or other dealings in the
# Software.
#
# Vendored from: https://github.com/nayuki/QR-Code-generator/blob/master/python/qrcodegen.py
# Used by maintenance_supporter for QR code generation (helpers/qr_generator.py).
from __future__ import annotations
import collections, itertools, re
from collections.abc import Sequence
from typing import Optional, Union
# ---- QR Code symbol class ----
class QrCode:
"""A QR Code symbol, which is a type of two-dimension barcode.
Invented by Denso Wave and described in the ISO/IEC 18004 standard.
Instances of this class represent an immutable square grid of dark and light cells.
The class provides static factory functions to create a QR Code from text or binary data.
The class covers the QR Code Model 2 specification, supporting all versions (sizes)
from 1 to 40, all 4 error correction levels, and 4 character encoding modes.
Ways to create a QR Code object:
- High level: Take the payload data and call QrCode.encode_text() or QrCode.encode_binary().
- Mid level: Custom-make the list of segments and call QrCode.encode_segments().
- Low level: Custom-make the array of data codeword bytes (including
segment headers and final padding, excluding error correction codewords),
supply the appropriate version number, and call the QrCode() constructor.
(Note that all ways require supplying the desired error correction level.)"""
# ---- Static factory functions (high level) ----
@staticmethod
def encode_text(text: str, ecl: QrCode.Ecc) -> QrCode:
"""Returns a QR Code representing the given Unicode text string at the given error correction level.
As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer
Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible
QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the
ecl argument if it can be done without increasing the version."""
segs: list[QrSegment] = QrSegment.make_segments(text)
return QrCode.encode_segments(segs, ecl)
@staticmethod
def encode_binary(data: Union[bytes,Sequence[int]], ecl: QrCode.Ecc) -> QrCode:
"""Returns a QR Code representing the given binary data at the given error correction level.
This function always encodes using the binary segment mode, not any text mode. The maximum number of
bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output.
The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version."""
return QrCode.encode_segments([QrSegment.make_bytes(data)], ecl)
# ---- Static factory functions (mid level) ----
@staticmethod
def encode_segments(segs: Sequence[QrSegment], ecl: QrCode.Ecc, minversion: int = 1, maxversion: int = 40, mask: int = -1, boostecl: bool = True) -> QrCode:
"""Returns a QR Code representing the given segments with the given encoding parameters.
The smallest possible QR Code version within the given range is automatically
chosen for the output. Iff boostecl is true, then the ECC level of the result
may be higher than the ecl argument if it can be done without increasing the
version. The mask number is either between 0 to 7 (inclusive) to force that
mask, or -1 to automatically choose an appropriate mask (which may be slow).
This function allows the user to create a custom sequence of segments that switches
between modes (such as alphanumeric and byte) to encode text in less space.
This is a mid-level API; the high-level API is encode_text() and encode_binary()."""
if not (QrCode.MIN_VERSION <= minversion <= maxversion <= QrCode.MAX_VERSION) or not (-1 <= mask <= 7):
raise ValueError("Invalid value")
# Find the minimal version number to use
for version in range(minversion, maxversion + 1):
datacapacitybits: int = QrCode._get_num_data_codewords(version, ecl) * 8 # Number of data bits available
datausedbits: Optional[int] = QrSegment.get_total_bits(segs, version)
if (datausedbits is not None) and (datausedbits <= datacapacitybits):
break # This version number is found to be suitable
if version >= maxversion: # All versions in the range could not fit the given data
msg: str = "Segment too long"
if datausedbits is not None:
msg = f"Data length = {datausedbits} bits, Max capacity = {datacapacitybits} bits"
raise DataTooLongError(msg)
assert datausedbits is not None
# Increase the error correction level while the data still fits in the current version number
for newecl in (QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH): # From low to high
if boostecl and (datausedbits <= QrCode._get_num_data_codewords(version, newecl) * 8):
ecl = newecl
# Concatenate all segments to create the data bit string
bb = _BitBuffer()
for seg in segs:
bb.append_bits(seg.get_mode().get_mode_bits(), 4)
bb.append_bits(seg.get_num_chars(), seg.get_mode().num_char_count_bits(version))
bb.extend(seg._bitdata)
assert len(bb) == datausedbits
# Add terminator and pad up to a byte if applicable
datacapacitybits = QrCode._get_num_data_codewords(version, ecl) * 8
assert len(bb) <= datacapacitybits
bb.append_bits(0, min(4, datacapacitybits - len(bb)))
bb.append_bits(0, -len(bb) % 8) # Note: Python's modulo on negative numbers behaves better than C family languages
assert len(bb) % 8 == 0
# Pad with alternating bytes until data capacity is reached
for padbyte in itertools.cycle((0xEC, 0x11)):
if len(bb) >= datacapacitybits:
break
bb.append_bits(padbyte, 8)
# Pack bits into bytes in big endian
datacodewords = bytearray([0] * (len(bb) // 8))
for (i, bit) in enumerate(bb):
datacodewords[i >> 3] |= bit << (7 - (i & 7))
# Create the QR Code object
return QrCode(version, ecl, datacodewords, mask)
# ---- Private fields ----
_version: int
_size: int
_errcorlvl: QrCode.Ecc
_mask: int
_modules: list[list[bool]]
_isfunction: list[list[bool]]
# ---- Constructor (low level) ----
def __init__(self, version: int, errcorlvl: QrCode.Ecc, datacodewords: Union[bytes,Sequence[int]], msk: int) -> None:
"""Creates a new QR Code with the given version number,
error correction level, data codeword bytes, and mask number.
This is a low-level API that most users should not use directly.
A mid-level API is the encode_segments() function."""
if not (QrCode.MIN_VERSION <= version <= QrCode.MAX_VERSION):
raise ValueError("Version value out of range")
if not (-1 <= msk <= 7):
raise ValueError("Mask value out of range")
self._version = version
self._size = version * 4 + 17
self._errcorlvl = errcorlvl
self._modules = [[False] * self._size for _ in range(self._size)]
self._isfunction = [[False] * self._size for _ in range(self._size)]
self._draw_function_patterns()
allcodewords: bytes = self._add_ecc_and_interleave(bytearray(datacodewords))
self._draw_codewords(allcodewords)
if msk == -1:
minpenalty: int = 1 << 32
for i in range(8):
self._apply_mask(i)
self._draw_format_bits(i)
penalty = self._get_penalty_score()
if penalty < minpenalty:
msk = i
minpenalty = penalty
self._apply_mask(i)
assert 0 <= msk <= 7
self._mask = msk
self._apply_mask(msk)
self._draw_format_bits(msk)
del self._isfunction
# ---- Accessor methods ----
def get_version(self) -> int:
return self._version
def get_size(self) -> int:
return self._size
def get_error_correction_level(self) -> QrCode.Ecc:
return self._errcorlvl
def get_mask(self) -> int:
return self._mask
def get_module(self, x: int, y: int) -> bool:
"""Returns the color of the module (pixel) at the given coordinates, which is False
for light or True for dark. The top left corner has the coordinates (x=0, y=0).
If the given coordinates are out of bounds, then False (light) is returned."""
return (0 <= x < self._size) and (0 <= y < self._size) and self._modules[y][x]
def to_svg_str(self, border: int) -> str:
"""Returns a string of SVG code for an image depicting this QR Code, with the given number
of border modules. The string always uses Unix newlines (\\n), regardless of the platform."""
if border < 0:
raise ValueError("Border must be non-negative")
parts: list[str] = []
for y in range(self._size):
for x in range(self._size):
if self.get_module(x, y):
parts.append(f"M{x+border},{y+border}h1v1h-1z")
return f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 {self._size+border*2} {self._size+border*2}" stroke="none">
<rect width="100%" height="100%" fill="#FFFFFF"/>
<path d="{" ".join(parts)}" fill="#000000"/>
</svg>
"""
# ---- Private helper methods for constructor: Drawing function modules ----
def _draw_function_patterns(self) -> None:
for i in range(self._size):
self._set_function_module(6, i, i % 2 == 0)
self._set_function_module(i, 6, i % 2 == 0)
self._draw_finder_pattern(3, 3)
self._draw_finder_pattern(self._size - 4, 3)
self._draw_finder_pattern(3, self._size - 4)
alignpatpos: list[int] = self._get_alignment_pattern_positions()
numalign: int = len(alignpatpos)
skips: Sequence[tuple[int,int]] = ((0, 0), (0, numalign - 1), (numalign - 1, 0))
for i in range(numalign):
for j in range(numalign):
if (i, j) not in skips:
self._draw_alignment_pattern(alignpatpos[i], alignpatpos[j])
self._draw_format_bits(0)
self._draw_version()
def _draw_format_bits(self, mask: int) -> None:
data: int = self._errcorlvl.formatbits << 3 | mask
rem: int = data
for _ in range(10):
rem = (rem << 1) ^ ((rem >> 9) * 0x537)
bits: int = (data << 10 | rem) ^ 0x5412
assert bits >> 15 == 0
for i in range(0, 6):
self._set_function_module(8, i, _get_bit(bits, i))
self._set_function_module(8, 7, _get_bit(bits, 6))
self._set_function_module(8, 8, _get_bit(bits, 7))
self._set_function_module(7, 8, _get_bit(bits, 8))
for i in range(9, 15):
self._set_function_module(14 - i, 8, _get_bit(bits, i))
for i in range(0, 8):
self._set_function_module(self._size - 1 - i, 8, _get_bit(bits, i))
for i in range(8, 15):
self._set_function_module(8, self._size - 15 + i, _get_bit(bits, i))
self._set_function_module(8, self._size - 8, True)
def _draw_version(self) -> None:
if self._version < 7:
return
rem: int = self._version
for _ in range(12):
rem = (rem << 1) ^ ((rem >> 11) * 0x1F25)
bits: int = self._version << 12 | rem
assert bits >> 18 == 0
for i in range(18):
bit: bool = _get_bit(bits, i)
a: int = self._size - 11 + i % 3
b: int = i // 3
self._set_function_module(a, b, bit)
self._set_function_module(b, a, bit)
def _draw_finder_pattern(self, x: int, y: int) -> None:
for dy in range(-4, 5):
for dx in range(-4, 5):
xx, yy = x + dx, y + dy
if (0 <= xx < self._size) and (0 <= yy < self._size):
self._set_function_module(xx, yy, max(abs(dx), abs(dy)) not in (2, 4))
def _draw_alignment_pattern(self, x: int, y: int) -> None:
for dy in range(-2, 3):
for dx in range(-2, 3):
self._set_function_module(x + dx, y + dy, max(abs(dx), abs(dy)) != 1)
def _set_function_module(self, x: int, y: int, isdark: bool) -> None:
assert type(isdark) is bool
self._modules[y][x] = isdark
self._isfunction[y][x] = True
# ---- Private helper methods for constructor: Codewords and masking ----
def _add_ecc_and_interleave(self, data: bytearray) -> bytes:
version: int = self._version
assert len(data) == QrCode._get_num_data_codewords(version, self._errcorlvl)
numblocks: int = QrCode._NUM_ERROR_CORRECTION_BLOCKS[self._errcorlvl.ordinal][version]
blockecclen: int = QrCode._ECC_CODEWORDS_PER_BLOCK [self._errcorlvl.ordinal][version]
rawcodewords: int = QrCode._get_num_raw_data_modules(version) // 8
numshortblocks: int = numblocks - rawcodewords % numblocks
shortblocklen: int = rawcodewords // numblocks
blocks: list[bytes] = []
rsdiv: bytes = QrCode._reed_solomon_compute_divisor(blockecclen)
k: int = 0
for i in range(numblocks):
dat: bytearray = data[k : k + shortblocklen - blockecclen + (0 if i < numshortblocks else 1)]
k += len(dat)
ecc: bytes = QrCode._reed_solomon_compute_remainder(dat, rsdiv)
if i < numshortblocks:
dat.append(0)
blocks.append(dat + ecc)
assert k == len(data)
result = bytearray()
for i in range(len(blocks[0])):
for (j, blk) in enumerate(blocks):
if (i != shortblocklen - blockecclen) or (j >= numshortblocks):
result.append(blk[i])
assert len(result) == rawcodewords
return result
def _draw_codewords(self, data: bytes) -> None:
assert len(data) == QrCode._get_num_raw_data_modules(self._version) // 8
i: int = 0
for right in range(self._size - 1, 0, -2):
if right <= 6:
right -= 1
for vert in range(self._size):
for j in range(2):
x: int = right - j
upward: bool = (right + 1) & 2 == 0
y: int = (self._size - 1 - vert) if upward else vert
if (not self._isfunction[y][x]) and (i < len(data) * 8):
self._modules[y][x] = _get_bit(data[i >> 3], 7 - (i & 7))
i += 1
assert i == len(data) * 8
def _apply_mask(self, mask: int) -> None:
if not (0 <= mask <= 7):
raise ValueError("Mask value out of range")
masker: collections.abc.Callable[[int,int],int] = QrCode._MASK_PATTERNS[mask]
for y in range(self._size):
for x in range(self._size):
self._modules[y][x] ^= (masker(x, y) == 0) and (not self._isfunction[y][x])
def _get_penalty_score(self) -> int:
result: int = 0
size: int = self._size
modules: list[list[bool]] = self._modules
for y in range(size):
runcolor: bool = False
runx: int = 0
runhistory = collections.deque([0] * 7, 7)
for x in range(size):
if modules[y][x] == runcolor:
runx += 1
if runx == 5:
result += QrCode._PENALTY_N1
elif runx > 5:
result += 1
else:
self._finder_penalty_add_history(runx, runhistory)
if not runcolor:
result += self._finder_penalty_count_patterns(runhistory) * QrCode._PENALTY_N3
runcolor = modules[y][x]
runx = 1
result += self._finder_penalty_terminate_and_count(runcolor, runx, runhistory) * QrCode._PENALTY_N3
for x in range(size):
runcolor = False
runy: int = 0
runhistory = collections.deque([0] * 7, 7)
for y in range(size):
if modules[y][x] == runcolor:
runy += 1
if runy == 5:
result += QrCode._PENALTY_N1
elif runy > 5:
result += 1
else:
self._finder_penalty_add_history(runy, runhistory)
if not runcolor:
result += self._finder_penalty_count_patterns(runhistory) * QrCode._PENALTY_N3
runcolor = modules[y][x]
runy = 1
result += self._finder_penalty_terminate_and_count(runcolor, runy, runhistory) * QrCode._PENALTY_N3
for y in range(size - 1):
for x in range(size - 1):
if modules[y][x] == modules[y][x + 1] == modules[y + 1][x] == modules[y + 1][x + 1]:
result += QrCode._PENALTY_N2
dark: int = sum((1 if cell else 0) for row in modules for cell in row)
total: int = size**2
k: int = (abs(dark * 20 - total * 10) + total - 1) // total - 1
assert 0 <= k <= 9
result += k * QrCode._PENALTY_N4
assert 0 <= result <= 2568888
return result
# ---- Private helper functions ----
def _get_alignment_pattern_positions(self) -> list[int]:
if self._version == 1:
return []
else:
numalign: int = self._version // 7 + 2
step: int = (self._version * 8 + numalign * 3 + 5) // (numalign * 4 - 4) * 2
result: list[int] = [(self._size - 7 - i * step) for i in range(numalign - 1)] + [6]
return list(reversed(result))
@staticmethod
def _get_num_raw_data_modules(ver: int) -> int:
if not (QrCode.MIN_VERSION <= ver <= QrCode.MAX_VERSION):
raise ValueError("Version number out of range")
result: int = (16 * ver + 128) * ver + 64
if ver >= 2:
numalign: int = ver // 7 + 2
result -= (25 * numalign - 10) * numalign - 55
if ver >= 7:
result -= 36
assert 208 <= result <= 29648
return result
@staticmethod
def _get_num_data_codewords(ver: int, ecl: QrCode.Ecc) -> int:
return QrCode._get_num_raw_data_modules(ver) // 8 \
- QrCode._ECC_CODEWORDS_PER_BLOCK [ecl.ordinal][ver] \
* QrCode._NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]
@staticmethod
def _reed_solomon_compute_divisor(degree: int) -> bytes:
if not (1 <= degree <= 255):
raise ValueError("Degree out of range")
result = bytearray([0] * (degree - 1) + [1])
root: int = 1
for _ in range(degree):
for j in range(degree):
result[j] = QrCode._reed_solomon_multiply(result[j], root)
if j + 1 < degree:
result[j] ^= result[j + 1]
root = QrCode._reed_solomon_multiply(root, 0x02)
return result
@staticmethod
def _reed_solomon_compute_remainder(data: bytes, divisor: bytes) -> bytes:
result = bytearray([0] * len(divisor))
for b in data:
factor: int = b ^ result.pop(0)
result.append(0)
for (i, coef) in enumerate(divisor):
result[i] ^= QrCode._reed_solomon_multiply(coef, factor)
return result
@staticmethod
def _reed_solomon_multiply(x: int, y: int) -> int:
if (x >> 8 != 0) or (y >> 8 != 0):
raise ValueError("Byte out of range")
z: int = 0
for i in reversed(range(8)):
z = (z << 1) ^ ((z >> 7) * 0x11D)
z ^= ((y >> i) & 1) * x
assert z >> 8 == 0
return z
def _finder_penalty_count_patterns(self, runhistory: collections.deque[int]) -> int:
n: int = runhistory[1]
assert n <= self._size * 3
core: bool = n > 0 and (runhistory[2] == runhistory[4] == runhistory[5] == n) and runhistory[3] == n * 3
return (1 if (core and runhistory[0] >= n * 4 and runhistory[6] >= n) else 0) \
+ (1 if (core and runhistory[6] >= n * 4 and runhistory[0] >= n) else 0)
def _finder_penalty_terminate_and_count(self, currentruncolor: bool, currentrunlength: int, runhistory: collections.deque[int]) -> int:
if currentruncolor:
self._finder_penalty_add_history(currentrunlength, runhistory)
currentrunlength = 0
currentrunlength += self._size
self._finder_penalty_add_history(currentrunlength, runhistory)
return self._finder_penalty_count_patterns(runhistory)
def _finder_penalty_add_history(self, currentrunlength: int, runhistory: collections.deque[int]) -> None:
if runhistory[0] == 0:
currentrunlength += self._size
runhistory.appendleft(currentrunlength)
# ---- Constants and tables ----
MIN_VERSION: int = 1
MAX_VERSION: int = 40
_PENALTY_N1: int = 3
_PENALTY_N2: int = 3
_PENALTY_N3: int = 40
_PENALTY_N4: int = 10
_ECC_CODEWORDS_PER_BLOCK: Sequence[Sequence[int]] = (
(-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30), # Low
(-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28), # Medium
(-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30), # Quartile
(-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30)) # High
_NUM_ERROR_CORRECTION_BLOCKS: Sequence[Sequence[int]] = (
(-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25), # Low
(-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49), # Medium
(-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68), # Quartile
(-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81)) # High
_MASK_PATTERNS: Sequence[collections.abc.Callable[[int,int],int]] = (
(lambda x, y: (x + y) % 2 ),
(lambda x, y: y % 2 ),
(lambda x, y: x % 3 ),
(lambda x, y: (x + y) % 3 ),
(lambda x, y: (x // 3 + y // 2) % 2 ),
(lambda x, y: x * y % 2 + x * y % 3 ),
(lambda x, y: (x * y % 2 + x * y % 3) % 2 ),
(lambda x, y: ((x + y) % 2 + x * y % 3) % 2),
)
# ---- Public helper enumeration ----
class Ecc:
ordinal: int
formatbits: int
def __init__(self, i: int, fb: int) -> None:
self.ordinal = i
self.formatbits = fb
LOW : QrCode.Ecc
MEDIUM : QrCode.Ecc
QUARTILE: QrCode.Ecc
HIGH : QrCode.Ecc
Ecc.LOW = Ecc(0, 1)
Ecc.MEDIUM = Ecc(1, 0)
Ecc.QUARTILE = Ecc(2, 3)
Ecc.HIGH = Ecc(3, 2)
# ---- Data segment class ----
class QrSegment:
@staticmethod
def make_bytes(data: Union[bytes,Sequence[int]]) -> QrSegment:
bb = _BitBuffer()
for b in data:
bb.append_bits(b, 8)
return QrSegment(QrSegment.Mode.BYTE, len(data), bb)
@staticmethod
def make_numeric(digits: str) -> QrSegment:
if not QrSegment.is_numeric(digits):
raise ValueError("String contains non-numeric characters")
bb = _BitBuffer()
i: int = 0
while i < len(digits):
n: int = min(len(digits) - i, 3)
bb.append_bits(int(digits[i : i + n]), n * 3 + 1)
i += n
return QrSegment(QrSegment.Mode.NUMERIC, len(digits), bb)
@staticmethod
def make_alphanumeric(text: str) -> QrSegment:
if not QrSegment.is_alphanumeric(text):
raise ValueError("String contains unencodable characters in alphanumeric mode")
bb = _BitBuffer()
for i in range(0, len(text) - 1, 2):
temp: int = QrSegment._ALPHANUMERIC_ENCODING_TABLE[text[i]] * 45
temp += QrSegment._ALPHANUMERIC_ENCODING_TABLE[text[i + 1]]
bb.append_bits(temp, 11)
if len(text) % 2 > 0:
bb.append_bits(QrSegment._ALPHANUMERIC_ENCODING_TABLE[text[-1]], 6)
return QrSegment(QrSegment.Mode.ALPHANUMERIC, len(text), bb)
@staticmethod
def make_segments(text: str) -> list[QrSegment]:
if text == "":
return []
elif QrSegment.is_numeric(text):
return [QrSegment.make_numeric(text)]
elif QrSegment.is_alphanumeric(text):
return [QrSegment.make_alphanumeric(text)]
else:
return [QrSegment.make_bytes(text.encode("UTF-8"))]
@staticmethod
def make_eci(assignval: int) -> QrSegment:
bb = _BitBuffer()
if assignval < 0:
raise ValueError("ECI assignment value out of range")
elif assignval < (1 << 7):
bb.append_bits(assignval, 8)
elif assignval < (1 << 14):
bb.append_bits(0b10, 2)
bb.append_bits(assignval, 14)
elif assignval < 1000000:
bb.append_bits(0b110, 3)
bb.append_bits(assignval, 21)
else:
raise ValueError("ECI assignment value out of range")
return QrSegment(QrSegment.Mode.ECI, 0, bb)
@staticmethod
def is_numeric(text: str) -> bool:
return QrSegment._NUMERIC_REGEX.fullmatch(text) is not None
@staticmethod
def is_alphanumeric(text: str) -> bool:
return QrSegment._ALPHANUMERIC_REGEX.fullmatch(text) is not None
_mode: QrSegment.Mode
_numchars: int
_bitdata: list[int]
def __init__(self, mode: QrSegment.Mode, numch: int, bitdata: Sequence[int]) -> None:
if numch < 0:
raise ValueError()
self._mode = mode
self._numchars = numch
self._bitdata = list(bitdata)
def get_mode(self) -> QrSegment.Mode:
return self._mode
def get_num_chars(self) -> int:
return self._numchars
def get_data(self) -> list[int]:
return list(self._bitdata)
@staticmethod
def get_total_bits(segs: Sequence[QrSegment], version: int) -> Optional[int]:
result = 0
for seg in segs:
ccbits: int = seg.get_mode().num_char_count_bits(version)
if seg.get_num_chars() >= (1 << ccbits):
return None
result += 4 + ccbits + len(seg._bitdata)
return result
_NUMERIC_REGEX: re.Pattern[str] = re.compile(r"[0-9]*")
_ALPHANUMERIC_REGEX: re.Pattern[str] = re.compile(r"[A-Z0-9 $%*+./:-]*")
_ALPHANUMERIC_ENCODING_TABLE: dict[str,int] = {ch: i for (i, ch) in enumerate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:")}
class Mode:
_modebits: int
_charcounts: tuple[int,int,int]
def __init__(self, modebits: int, charcounts: tuple[int,int,int]):
self._modebits = modebits
self._charcounts = charcounts
def get_mode_bits(self) -> int:
return self._modebits
def num_char_count_bits(self, ver: int) -> int:
return self._charcounts[(ver + 7) // 17]
NUMERIC : QrSegment.Mode
ALPHANUMERIC: QrSegment.Mode
BYTE : QrSegment.Mode
KANJI : QrSegment.Mode
ECI : QrSegment.Mode
Mode.NUMERIC = Mode(0x1, (10, 12, 14))
Mode.ALPHANUMERIC = Mode(0x2, ( 9, 11, 13))
Mode.BYTE = Mode(0x4, ( 8, 16, 16))
Mode.KANJI = Mode(0x8, ( 8, 10, 12))
Mode.ECI = Mode(0x7, ( 0, 0, 0))
# ---- Private helper class ----
class _BitBuffer(list[int]):
def append_bits(self, val: int, n: int) -> None:
if (n < 0) or (val >> n != 0):
raise ValueError("Value out of range")
self.extend(((val >> i) & 1) for i in reversed(range(n)))
def _get_bit(x: int, i: int) -> bool:
return (x >> i) & 1 != 0
class DataTooLongError(ValueError):
pass
@@ -0,0 +1,206 @@
"""Archive & auto-delete retention policy (v2.10.0).
Two parts:
* **Pure decisions** — :func:`should_auto_archive` / :func:`should_auto_delete`
reason over a single (merged) task dict + the global day thresholds + an
injected ``today``. No Home Assistant imports, so every branch is unit-testable
with plain dicts.
* **The sweep** — :func:`async_run_retention_sweep` walks every object entry once
(wired to a daily timer in ``__init__.async_setup``), applies the archives in
one ConfigEntry write per entry, deletes the eligible tasks via the shared
``websocket.tasks.async_delete_task`` helper, and reloads each touched entry so
its entities reflect the new state.
Policy (locked design):
* Auto-archive applies to **completed one-off tasks only** — recurring / sensor
tasks never reach a terminal "done" state, so they are archived manually only.
* Auto-delete applies to **auto-archived** one-offs only (``archived_reason ==
"auto"``). A manually archived item is never auto-deleted — deleting a manual
archive stays an explicit user action.
* Both thresholds are "0 = disabled" (archive) / "0 = never" (delete).
"""
from __future__ import annotations
import logging
from datetime import date
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from ..const import ARCHIVE_REASON_AUTO
_LOGGER = logging.getLogger(__name__)
def _to_date(value: Any) -> date | None:
"""Coerce a stored date / ISO timestamp string to a ``date`` (or None).
Accepts both ``"2026-06-24"`` (last_performed) and a full ISO timestamp
``"2026-06-24T12:00:00+00:00"`` (archived_at) — the leading ``YYYY-MM-DD``
is all the day-granular policy needs.
"""
if not isinstance(value, str) or len(value) < 10:
return None
try:
return date.fromisoformat(value[:10])
except ValueError:
return None
def is_completed_oneoff(task: dict[str, Any]) -> bool:
"""True iff ``task`` is a one-off (``one_time`` recurrence) that's been done."""
# Local import keeps this module HA-free for the pure-function tests.
from .schedule import KIND_ONE_TIME, Schedule
return Schedule.parse(task).kind == KIND_ONE_TIME and bool(task.get("last_performed"))
def should_auto_archive(task: dict[str, Any], *, archive_days: int, today: date) -> bool:
"""Decide whether a (merged) task should be auto-archived now.
True only for an active (not-yet-archived) completed one-off whose completion
is at least ``archive_days`` days in the past. ``archive_days <= 0`` disables
auto-archive entirely.
"""
if archive_days <= 0:
return False
if task.get("archived_at") is not None:
return False
if not is_completed_oneoff(task):
return False
last_performed = _to_date(task.get("last_performed"))
if last_performed is None:
return False
return (today - last_performed).days >= archive_days
def should_auto_delete(task: dict[str, Any], *, delete_days: int, today: date) -> bool:
"""Decide whether an auto-archived task should be auto-deleted now.
True only for a task archived **automatically** (``archived_reason == "auto"``
— which, by construction, is always a completed one-off) at least
``delete_days`` days ago. ``delete_days <= 0`` means "never delete". Manual /
object-cascade archives are deliberately excluded.
"""
if delete_days <= 0:
return False
if task.get("archived_at") is None:
return False
if task.get("archived_reason") != ARCHIVE_REASON_AUTO:
return False
archived_on = _to_date(task.get("archived_at"))
if archived_on is None:
return False
return (today - archived_on).days >= delete_days
def _coerce_int(value: Any, default: int) -> int:
"""Best-effort int coercion (settings come through the WS as int already)."""
try:
return int(value)
except (TypeError, ValueError):
return default
def _global_options(hass: HomeAssistant) -> dict[str, Any]:
"""Return the global entry's options (or data), or {} when absent."""
from ..const import DOMAIN, GLOBAL_UNIQUE_ID
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
opts: dict[str, Any] = dict(entry.options or entry.data)
return opts
return {}
def _merged_tasks(entry: Any) -> dict[str, Any]:
"""Static (ConfigEntry) + dynamic (Store) task data for an object entry."""
from ..const import CONF_TASKS
tasks = entry.data.get(CONF_TASKS, {})
rd = getattr(entry, "runtime_data", None)
store = getattr(rd, "store", None) if rd else None
merged: dict[str, Any] = store.merge_all_tasks(tasks) if store is not None else tasks
return merged
async def async_run_retention_sweep(hass: HomeAssistant) -> None:
"""Auto-archive overdue-done one-offs and auto-delete aged auto-archives.
Idempotent and cheap: only writes / reloads an entry that actually has work
this pass. Safe to call from a daily timer or directly from a test.
"""
from ..const import (
ARCHIVE_REASON_AUTO as _REASON_AUTO,
)
from ..const import (
CONF_ARCHIVE_ONEOFF_DAYS,
CONF_DELETE_ARCHIVED_ONEOFF_DAYS,
CONF_TASKS,
DEFAULT_ARCHIVE_ONEOFF_DAYS,
DEFAULT_DELETE_ARCHIVED_ONEOFF_DAYS,
)
from .aggregate import get_object_entries
opts = _global_options(hass)
archive_days = _coerce_int(
opts.get(CONF_ARCHIVE_ONEOFF_DAYS, DEFAULT_ARCHIVE_ONEOFF_DAYS),
DEFAULT_ARCHIVE_ONEOFF_DAYS,
)
delete_days = _coerce_int(
opts.get(CONF_DELETE_ARCHIVED_ONEOFF_DAYS, DEFAULT_DELETE_ARCHIVED_ONEOFF_DAYS),
DEFAULT_DELETE_ARCHIVED_ONEOFF_DAYS,
)
if archive_days <= 0 and delete_days <= 0:
return
today = dt_util.now().date()
now_iso = dt_util.now().isoformat()
for entry in get_object_entries(hass):
merged = _merged_tasks(entry)
# The two sets are disjoint by construction: archive needs archived_at
# None; delete needs archived_at set — a task can't be both this pass.
to_archive = [tid for tid, td in merged.items() if should_auto_archive(td, archive_days=archive_days, today=today)]
to_delete = [tid for tid, td in merged.items() if should_auto_delete(td, delete_days=delete_days, today=today)]
if not to_archive and not to_delete:
continue
if to_archive:
new_tasks = dict(entry.data.get(CONF_TASKS, {}))
for tid in to_archive:
if tid not in new_tasks:
continue
td = dict(new_tasks[tid])
td["archived_at"] = now_iso
td["archived_reason"] = _REASON_AUTO
new_tasks[tid] = td
new_data = dict(entry.data)
new_data[CONF_TASKS] = new_tasks
hass.config_entries.async_update_entry(entry, data=new_data)
_LOGGER.info(
"Auto-archived %d completed one-off task(s) in %s",
len(to_archive),
entry.title,
)
if to_delete:
from ..websocket.tasks import async_delete_task
deleted = 0
for tid in to_delete:
if await async_delete_task(hass, entry, tid):
deleted += 1
if deleted:
_LOGGER.info(
"Auto-deleted %d archived one-off task(s) in %s",
deleted,
entry.title,
)
# Reload once so entities reflect the archive (inert) / delete (gone).
await hass.config_entries.async_reload(entry.entry_id)
@@ -0,0 +1,320 @@
"""Defensive sanitization for config-flow input.
The WebSocket schemas enforce length and range caps on every str/int field at
the boundary. Config-flow forms accept arbitrary lengths because HA's selectors
don't enforce them. To keep both paths at parity (and prevent a malicious or
buggy programmatic config-flow caller from bloating ConfigEntry.data), every
config-flow save handler runs the relevant cap helper below right before
persisting.
"""
from __future__ import annotations
from typing import Any
from ..const import (
MAX_DATE_LENGTH,
MAX_ENTITY_SLUG_LENGTH,
MAX_ICON_LENGTH,
MAX_ID_LENGTH,
MAX_INTERVAL_DAYS,
MAX_META_LENGTH,
MAX_NAME_LENGTH,
MAX_SCHEDULE_TIME_LENGTH,
MAX_TEXT_LENGTH,
MAX_TYPE_LENGTH,
MAX_URL_LENGTH,
)
from .task_fields import EARLIEST_COMPLETION_RANGE
# Per-field cap for task dicts. Values mirror the voluptuous schemas in
# websocket/tasks.py so an admin who reaches the same field through the UI
# can't smuggle past a longer string than they could over the WS API.
_TASK_STR_LIMITS: dict[str, int] = {
"name": MAX_NAME_LENGTH,
"type": MAX_TYPE_LENGTH,
"schedule_type": MAX_TYPE_LENGTH,
"interval_anchor": MAX_TYPE_LENGTH,
"last_performed": MAX_DATE_LENGTH,
"notes": MAX_TEXT_LENGTH,
"documentation_url": MAX_URL_LENGTH,
"custom_icon": MAX_ICON_LENGTH,
"nfc_tag_id": 256,
"responsible_user_id": MAX_META_LENGTH,
"entity_slug": MAX_ENTITY_SLUG_LENGTH,
"created_at": MAX_DATE_LENGTH,
"schedule_time": MAX_SCHEDULE_TIME_LENGTH,
"priority": MAX_TYPE_LENGTH,
"reading_unit": 32,
}
_OBJECT_STR_LIMITS: dict[str, int] = {
"name": MAX_NAME_LENGTH,
"manufacturer": MAX_META_LENGTH,
"model": MAX_META_LENGTH,
"serial_number": MAX_META_LENGTH,
"area_id": MAX_META_LENGTH,
"installation_date": MAX_DATE_LENGTH,
"warranty_expiry": MAX_DATE_LENGTH, # (#67)
"documentation_url": MAX_URL_LENGTH, # v1.4.0 #43
"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)
}
_GROUP_STR_LIMITS: dict[str, int] = {
"name": MAX_NAME_LENGTH,
"description": MAX_TEXT_LENGTH,
}
def _cap_strings(d: dict[str, Any], limits: dict[str, int]) -> None:
"""Truncate string fields in-place to their per-field max length."""
for field, max_len in limits.items():
v = d.get(field)
if isinstance(v, str) and len(v) > max_len:
d[field] = v[:max_len]
def cap_task_fields(task_data: dict[str, Any]) -> dict[str, Any]:
"""Truncate user-controllable strings + numerics on a task dict in-place.
Returns the same dict for fluent use. Mirrors the WS schema caps:
- String fields → individual length caps from `_TASK_STR_LIMITS`
- `interval_days` → 1..MAX_INTERVAL_DAYS (negative/zero coerced to 1)
- `warning_days` → 0..365
- `checklist` → list of strings, each ≤ 500 chars, list ≤ 100 items
"""
_cap_strings(task_data, _TASK_STR_LIMITS)
iv = task_data.get("interval_days")
if isinstance(iv, int):
if iv < 1:
task_data["interval_days"] = 1
elif iv > MAX_INTERVAL_DAYS:
task_data["interval_days"] = MAX_INTERVAL_DAYS
wd = task_data.get("warning_days")
if isinstance(wd, int):
if wd < 0:
task_data["warning_days"] = 0
elif wd > 365:
task_data["warning_days"] = 365
ecd = task_data.get("earliest_completion_days")
if ecd is not None:
if not isinstance(ecd, int) or isinstance(ecd, bool):
task_data.pop("earliest_completion_days", None)
else:
lo, hi = EARLIEST_COMPLETION_RANGE
task_data["earliest_completion_days"] = max(lo, min(ecd, hi))
cl = task_data.get("checklist")
if cl is not None:
if not isinstance(cl, list):
task_data.pop("checklist", None)
else:
from ..const import MAX_CHECKLIST_ITEM_LENGTH, MAX_CHECKLIST_ITEMS
cleaned = [item.strip()[:MAX_CHECKLIST_ITEM_LENGTH] for item in cl if isinstance(item, str)]
cleaned = [c for c in cleaned if c]
task_data["checklist"] = cleaned[:MAX_CHECKLIST_ITEMS]
lb = task_data.get("labels")
if lb is not None:
task_data["labels"] = sanitize_labels(lb)
if task_data.get("assignee_pool") is not None:
task_data["assignee_pool"] = sanitize_assignee_pool(task_data["assignee_pool"])
rs = task_data.get("rotation_strategy")
if rs is not None:
from ..const import ROTATION_STRATEGIES
if rs not in ROTATION_STRATEGIES:
task_data.pop("rotation_strategy", None)
# v1.3.0: per-task on_complete_action — embedded HA service-call config.
# Strict shape: {service: "domain.name", target?: dict, data?: dict}.
# Drops the field entirely on any structural problem; the action layer
# treats absence as "no action configured" (not an error).
cap_action_field(task_data)
# v1.3.0: per-task quick_complete_defaults — pre-fill values used when
# the user scans the "quick complete" QR code. Schema mirrors the
# complete_maintenance kwargs.
cap_quick_complete_defaults_field(task_data)
return task_data
def parse_labels_text(raw: str) -> list[str]:
"""Split a comma/newline-separated labels string into a trimmed list.
The config flow enters labels as free text; the panel sends a real list.
This only splits + trims — dedup and per-label capping happen in
:func:`sanitize_labels` (via :func:`cap_task_fields`).
"""
parts = str(raw).replace("\n", ",").split(",")
return [p.strip() for p in parts if p.strip()]
def sanitize_labels(value: object) -> list[str]:
"""Clean a labels list: str items, trimmed, capped, deduped, ≤ MAX_LABELS."""
if not isinstance(value, list):
return []
from ..const import MAX_LABEL_LENGTH, MAX_LABELS
seen: set[str] = set()
out: list[str] = []
for item in value:
if not isinstance(item, str):
continue
v = item.strip()[:MAX_LABEL_LENGTH]
if v and v not in seen:
seen.add(v)
out.append(v)
return out[:MAX_LABELS]
def sanitize_assignee_pool(value: object) -> list[str]:
"""Clean an assignee pool: str user-ids, trimmed, deduped, ≤ MAX_ASSIGNEE_POOL."""
if not isinstance(value, list):
return []
from ..const import MAX_ASSIGNEE_POOL
seen: set[str] = set()
out: list[str] = []
for item in value:
if not isinstance(item, str):
continue
v = item.strip()[:MAX_META_LENGTH]
if v and v not in seen:
seen.add(v)
out.append(v)
return out[:MAX_ASSIGNEE_POOL]
# ─── v1.3.0: completion-action helpers ──────────────────────────────────
# Hard caps. service names rarely exceed 64 chars; data dicts intended
# for built-in HA services rarely exceed 1 KB serialised.
_MAX_SERVICE_NAME_LENGTH = 100
_MAX_ACTION_DATA_BYTES = 1024
_MAX_TARGET_FIELD_LENGTH = 200
# Privileged service domains an on-complete action may NOT call. A completion
# action is meant to nudge devices/notify — not run shell/scripts, reboot the
# host, purge the recorder, or stop HA. Blocking these closes an operator->admin
# escalation (an allowlisted operator setting an action that runs with system
# rights when the task is completed). Domain-specific services stay available
# (e.g. light.turn_on instead of the generic homeassistant.turn_on).
_FORBIDDEN_ACTION_DOMAINS = frozenset({"shell_command", "python_script", "hassio", "homeassistant", "recorder", "backup"})
def cap_action_field(task_data: dict[str, Any]) -> None:
"""Validate + truncate task_data['on_complete_action'] in-place.
Drops the entire field on any structural problem. Passes silently when
not present (it's optional).
"""
import re
action = task_data.get("on_complete_action")
if action is None:
return
if not isinstance(action, dict):
task_data.pop("on_complete_action", None)
return
service = action.get("service")
if (
not isinstance(service, str)
or len(service) > _MAX_SERVICE_NAME_LENGTH
or not re.fullmatch(r"[a-z][a-z0-9_]*\.[a-z0-9_]+", service)
):
task_data.pop("on_complete_action", None)
return
# Reject privileged service domains (arbitrary code / host control).
if service.split(".", 1)[0] in _FORBIDDEN_ACTION_DOMAINS:
task_data.pop("on_complete_action", None)
return
cleaned: dict[str, Any] = {"service": service}
target = action.get("target")
if isinstance(target, dict):
cleaned_target: dict[str, Any] = {}
for key in ("entity_id", "device_id", "area_id", "label_id", "floor_id"):
v = target.get(key)
if isinstance(v, str) and 0 < len(v) <= _MAX_TARGET_FIELD_LENGTH:
cleaned_target[key] = v
elif isinstance(v, list):
cleaned_list = [s for s in v if isinstance(s, str) and 0 < len(s) <= _MAX_TARGET_FIELD_LENGTH]
if cleaned_list:
cleaned_target[key] = cleaned_list[:50] # cap target list length
if cleaned_target:
cleaned["target"] = cleaned_target
data = action.get("data")
if isinstance(data, dict):
# Cheap size guard via JSON serialisation
import json
try:
serialised = json.dumps(data)
except (TypeError, ValueError):
serialised = None
if serialised is not None and len(serialised) <= _MAX_ACTION_DATA_BYTES:
cleaned["data"] = data
task_data["on_complete_action"] = cleaned
def cap_quick_complete_defaults_field(task_data: dict[str, Any]) -> None:
"""Validate + truncate task_data['quick_complete_defaults'] in-place.
Drops malformed entries silently (per-field), preserves the rest.
"""
defaults = task_data.get("quick_complete_defaults")
if defaults is None:
return
if not isinstance(defaults, dict):
task_data.pop("quick_complete_defaults", None)
return
cleaned: dict[str, Any] = {}
notes = defaults.get("notes")
if isinstance(notes, str) and notes:
cleaned["notes"] = notes[:MAX_TEXT_LENGTH]
cost = defaults.get("cost")
if isinstance(cost, (int, float)) and 0 <= cost <= 1_000_000:
cleaned["cost"] = float(cost)
duration = defaults.get("duration")
if isinstance(duration, int) and 0 <= duration <= 525_600:
cleaned["duration"] = duration
feedback = defaults.get("feedback")
if feedback in ("needed", "not_needed"):
cleaned["feedback"] = feedback
if cleaned:
task_data["quick_complete_defaults"] = cleaned
else:
task_data.pop("quick_complete_defaults", None)
def cap_object_fields(obj_data: dict[str, Any]) -> dict[str, Any]:
"""Truncate user-controllable strings on an object dict in-place."""
_cap_strings(obj_data, _OBJECT_STR_LIMITS)
return obj_data
def cap_group_fields(group_data: dict[str, Any]) -> dict[str, Any]:
"""Truncate user-controllable strings on a group dict in-place."""
_cap_strings(group_data, _GROUP_STR_LIMITS)
return group_data
@@ -0,0 +1,444 @@
"""The task recurrence as a single value object (Schedule).
Phase 2 of docs/design/schedule-model-v2.md: the recurrence math is centralized
here behind one interface (``next_due`` / ``span_days``) and adapted from the
existing flat task fields via :meth:`Schedule.from_legacy` — **no storage change
and no behaviour change** (the logic is a faithful move of the old
``MaintenanceTask.next_due``).
The point is the boundary: callers ask the Schedule for a computed date/span,
never for raw ``every`` / ``unit``. Adding calendar patterns later
(``weekdays`` / ``nth_weekday`` / ``day_of_month``) is then a new ``kind`` +
branch here, not another field threaded through every consumer.
Dates in/out are ``datetime.date`` objects; string parsing stays at the model
boundary so this module is pure and trivially unit-testable.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Any
from .dates import (
add_interval,
interval_span_days,
next_day_of_month,
next_nth_weekday,
next_weekday_in_set,
parse_iso_date,
roll_back_to_business_day,
)
# Recurrence kinds. Phase 2 covers the v2.6.x set; the calendar kinds
# (weekdays / nth_weekday / day_of_month) arrive with the roadmap feature.
KIND_INTERVAL = "interval"
KIND_ONE_TIME = "one_time"
KIND_MANUAL = "manual"
KIND_WEEKDAYS = "weekdays" # e.g. every Mon & Thu
KIND_NTH_WEEKDAY = "nth_weekday" # e.g. 1st Saturday of the month
KIND_DAY_OF_MONTH = "day_of_month" # e.g. the 15th
# The calendar kinds are fixed schedules (occurrences are absolute dates), so the
# completion/planned anchor distinction doesn't apply to them.
_CALENDAR_KINDS = (KIND_WEEKDAYS, KIND_NTH_WEEKDAY, KIND_DAY_OF_MONTH)
# Planned-anchor month/year stepping is bounded to avoid an unbounded loop on
# absurd data (a task untouched for >2000 cycles falls back to the last step).
_MAX_PLANNED_STEPS = 2000
# (#83) offset bound: ±15 days covers every sensible "N days before/after the
# pattern date" case without letting a bogus payload shift schedules by years.
_MAX_OFFSET_DAYS = 15
def _sanitize_offset(raw: object) -> int:
if isinstance(raw, bool) or not isinstance(raw, int):
return 0
return max(-_MAX_OFFSET_DAYS, min(raw, _MAX_OFFSET_DAYS))
def _sanitize_day(raw: object) -> int | None:
"""day 1..31, or -1 = last day of the month; anything else -> None."""
if isinstance(raw, bool) or not isinstance(raw, int):
return None
if raw == -1 or 1 <= raw <= 31:
return raw
return None
@dataclass(frozen=True)
class Schedule:
"""A task's time recurrence. Triggers (sensors) are orthogonal and handled
by the coordinator's status precedence, not here."""
kind: str = KIND_MANUAL
every: int | None = None # interval count (legacy: interval_days)
unit: str = "days" # days | weeks | months | years
anchor: str = "completion" # completion | planned
due_date: date | None = None # one_time
weekdays: tuple[int, ...] = () # weekdays kind: 0=Mon … 6=Sun
nth: int | None = None # nth_weekday kind: 1..5, or -1 = last
weekday: int | None = None # nth_weekday kind: 0=Mon … 6=Sun
day: int | None = None # day_of_month kind: 1..31 (clamped), -1 = last day
months: tuple[int, ...] = () # nth_weekday/day_of_month: restrict months (1..12)
# (#83) end-of-month scheduling extras — calendar kinds only:
business: bool = False # day_of_month: roll a weekend date back to Friday
offset_days: int = 0 # shift the computed occurrence by ±N days
@classmethod
def from_legacy(
cls,
*,
schedule_type: str | None,
interval_days: int | None,
interval_unit: str | None,
interval_anchor: str | None,
due_date: str | None,
) -> Schedule:
"""Adapt the flat v2.6.x task fields to a Schedule (no storage change).
Mirrors the old ``next_due`` dispatch exactly: one-time → ``one_time``;
any positive interval → ``interval`` (incl. a sensor task's safety
interval, since next-due was always schedule_type-agnostic except
one-time); otherwise ``manual`` (no schedule).
"""
if schedule_type == KIND_ONE_TIME:
return cls(kind=KIND_ONE_TIME, due_date=parse_iso_date(due_date))
if not interval_days or interval_days <= 0:
return cls(kind=KIND_MANUAL)
return cls(
kind=KIND_INTERVAL,
every=interval_days,
unit=interval_unit or "days",
anchor=interval_anchor or "completion",
)
def next_due(
self,
*,
last_performed: date | None,
created_at: date | None,
last_planned_due: date | None,
today: date,
) -> date | None:
"""The next due date, or None for manual / archived one-time tasks."""
if self.kind == KIND_ONE_TIME:
# Due on the fixed date; archived (no re-arm) once completed.
if last_performed is not None or self.due_date is None:
return None
return self.due_date
if self.kind in _CALENDAR_KINDS:
# Fixed calendar schedule. First-time anchors on created_at/today so
# a never-done task stays visibly overdue once its date passes (the
# #30 lesson); after completion it's the next occurrence strictly
# after last_performed. The completion/planned anchor doesn't apply.
if last_performed is not None:
return self._calendar_occurrence(last_performed, inclusive=False)
return self._calendar_occurrence(created_at or today, inclusive=True)
if self.kind != KIND_INTERVAL:
return None
every = self.every or 0
if every <= 0:
return None
if last_performed is None:
# First-time anchor: created_at if known, else today (issue #30).
return add_interval(created_at or today, every, self.unit)
if self.anchor == "planned":
# Anchor from the previously planned due date so a late completion
# doesn't drift the schedule.
anchor = last_planned_due or last_performed
if self.unit in (None, "days", "weeks"):
step = every * (7 if self.unit == "weeks" else 1)
days_gap = (last_performed - anchor).days
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.
candidate = anchor
for _ in range(_MAX_PLANNED_STEPS):
candidate = add_interval(candidate, every, self.unit)
if candidate > last_performed:
return candidate
return candidate
return add_interval(last_performed, every, self.unit)
def _calendar_occurrence(self, ref: date, *, inclusive: bool) -> date | None:
"""Next EFFECTIVE occurrence of a calendar kind on/after ``ref``.
(#83) The effective date is the base pattern date, optionally rolled
back to a business day (``business``, day_of_month only), then shifted
by ``offset_days``. Bases are searched from ``ref - offset`` so the
shifted result still lands on/after ``ref``; when the business
rollback pushes a candidate before ``ref``, the next base is tried
(bounded — a rollback moves at most a few days, ≤14 even with a
Workday-provided holiday calendar in play).
"""
offset = timedelta(days=self.offset_days)
search = ref - offset
search_inclusive = inclusive
for _ in range(6):
base = self._base_occurrence(search, inclusive=search_inclusive)
if base is None:
return None
effective = base
if self.business and self.kind == KIND_DAY_OF_MONTH:
effective = roll_back_to_business_day(effective)
effective = effective + offset
if (effective >= ref) if inclusive else (effective > ref):
return effective
search = base
search_inclusive = False # strictly after the base just tried
return None
def _base_occurrence(self, ref: date, *, inclusive: bool) -> date | None:
"""Next base pattern date of a calendar kind on/after ``ref``."""
months = self.months or None
if self.kind == KIND_WEEKDAYS:
return next_weekday_in_set(ref, self.weekdays, inclusive=inclusive)
if self.kind == KIND_NTH_WEEKDAY:
if self.nth is None or self.weekday is None:
return None
return next_nth_weekday(ref, self.nth, self.weekday, months, inclusive=inclusive)
if self.kind == KIND_DAY_OF_MONTH:
if self.day is None:
return None
return next_day_of_month(ref, self.day, months, inclusive=inclusive)
return None
def span_days(self) -> int:
"""Approximate length of one cycle in days (0 when there is no recurrence).
For progress bars and the due-soon warning cap — unit-aware, so a
6-month task is ~183 days, not 6. The calendar kinds use a nominal cycle
(weekly → 7, monthly patterns → 30).
"""
if self.kind == KIND_INTERVAL:
return interval_span_days(self.every, self.unit)
if self.kind == KIND_WEEKDAYS:
return 7
if self.kind in (KIND_NTH_WEEKDAY, KIND_DAY_OF_MONTH):
return 30
return 0
# --- serialization (Phase 3: nested `schedule` storage) ----------------
def to_dict(self) -> dict[str, Any]:
"""Canonical nested form for storage. Defaults are omitted to keep the
stored dict minimal (``unit`` defaults to days, ``anchor`` to completion)."""
d: dict[str, Any] = {"kind": self.kind}
if self.kind == KIND_INTERVAL:
d["every"] = self.every
if self.unit and self.unit != "days":
d["unit"] = self.unit
if self.anchor and self.anchor != "completion":
d["anchor"] = self.anchor
elif self.kind == KIND_ONE_TIME and self.due_date is not None:
d["due_date"] = self.due_date.isoformat()
elif self.kind == KIND_WEEKDAYS:
d["weekdays"] = list(self.weekdays)
elif self.kind == KIND_NTH_WEEKDAY:
d["nth"] = self.nth
d["weekday"] = self.weekday
if self.months:
d["months"] = list(self.months)
elif self.kind == KIND_DAY_OF_MONTH:
d["day"] = self.day
if self.months:
d["months"] = list(self.months)
if self.business:
d["business"] = True
if self.kind in _CALENDAR_KINDS and self.offset_days:
d["offset"] = self.offset_days
return d
@classmethod
def from_dict(cls, d: Mapping[str, Any]) -> Schedule:
"""Read the nested form produced by :meth:`to_dict`."""
kind = d.get("kind", KIND_MANUAL)
if kind == KIND_ONE_TIME:
return cls(kind=KIND_ONE_TIME, due_date=parse_iso_date(d.get("due_date")))
if kind == KIND_INTERVAL:
return cls(
kind=KIND_INTERVAL,
every=d.get("every"),
unit=d.get("unit") or "days",
anchor=d.get("anchor") or "completion",
)
if kind == KIND_WEEKDAYS:
return cls(
kind=KIND_WEEKDAYS,
weekdays=tuple(d.get("weekdays") or ()),
offset_days=_sanitize_offset(d.get("offset")),
)
if kind == KIND_NTH_WEEKDAY:
return cls(
kind=KIND_NTH_WEEKDAY,
nth=d.get("nth"),
weekday=d.get("weekday"),
months=tuple(d.get("months") or ()),
offset_days=_sanitize_offset(d.get("offset")),
)
if kind == KIND_DAY_OF_MONTH:
return cls(
kind=KIND_DAY_OF_MONTH,
day=_sanitize_day(d.get("day")),
months=tuple(d.get("months") or ()),
business=d.get("business") is True,
offset_days=_sanitize_offset(d.get("offset")),
)
return cls(kind=KIND_MANUAL)
@classmethod
def parse(cls, task: Mapping[str, Any]) -> Schedule:
"""Build from a task dict — nested ``schedule`` if present, else the flat
v2.6.x fields. The single read path during/after migration (and for old
exports), so both formats are accepted forever."""
nested = task.get("schedule")
if isinstance(nested, Mapping):
return cls.from_dict(nested)
return cls.from_legacy(
schedule_type=task.get("schedule_type"),
interval_days=task.get("interval_days"),
interval_unit=task.get("interval_unit"),
interval_anchor=task.get("interval_anchor"),
due_date=task.get("due_date"),
)
def is_recurring(task: Mapping[str, Any]) -> bool:
"""True iff the task dict has a cycling schedule (interval or calendar kind).
One-off and manual tasks don't re-arm; a recurring task gets a fresh cycle
when its object is unarchived (D2) or resumed from a seasonal pause (N3).
Single source for both — the websocket layer delegates here.
"""
return Schedule.parse(task).kind in (
KIND_INTERVAL,
KIND_WEEKDAYS,
KIND_NTH_WEEKDAY,
KIND_DAY_OF_MONTH,
)
# --- flat <-> nested adapters (Phase 3) ------------------------------------
#
# Readers that still speak the flat v2.6.x shape (the WS payload, export, CSV,
# the edit-form prefill) go through these so there is exactly one translation
# point. ``schedule_type`` is *derived*: sensors (a trigger) are orthogonal to
# the recurrence kind, so a triggered task reports "sensor_based" regardless of
# whether it also carries a safety interval.
FLAT_RECURRENCE_KEYS = (
"schedule_type",
"interval_days",
"interval_unit",
"interval_anchor",
"due_date",
)
def normalize_task_storage(task: Mapping[str, Any]) -> dict[str, Any]:
"""Return a copy of ``task`` with its recurrence stored as nested ``schedule``.
The single flat→nested writer used by the migration and by every persist
path, so new and existing tasks converge on one storage shape. The flat
recurrence keys are dropped; every other field is preserved exactly.
Sensor-ness stays in ``trigger_config`` (the derived ``schedule_type`` is
reconstructed on read by :func:`read_legacy_fields`).
Overlays are resolved so an edit takes effect: when a caller copies a stored
(nested) task and overlays flat fields — the options edit flow does exactly
this — the present flat keys win, but any absent ones fall back to the
existing nested schedule. So changing only ``interval_days`` keeps the unit
(the issue #58 class) instead of silently resetting it to days.
Idempotent: a pure-nested task (no flat keys) is returned unchanged.
"""
out = dict(task)
nested = out.get("schedule")
if isinstance(nested, Mapping) and nested.get("kind") in _CALENDAR_KINDS:
# Calendar kinds can't be expressed via the flat fields, so the nested
# schedule is authoritative — keep it and drop any stray flat keys.
for key in FLAT_RECURRENCE_KEYS:
out.pop(key, None)
return out
has_flat = any(key in out for key in FLAT_RECURRENCE_KEYS)
has_nested = isinstance(nested, Mapping)
if has_nested and not has_flat:
return out
if not has_nested and not has_flat:
out["schedule"] = Schedule(kind=KIND_MANUAL).to_dict()
return out
# Effective flat view: nested-derived base, overridden by present flat keys.
merged = read_legacy_fields(out) # nested-derived (hybrid) or flat (pure)
for key in FLAT_RECURRENCE_KEYS:
if key in out:
merged[key] = out[key]
schedule = Schedule.from_legacy(
schedule_type=merged["schedule_type"],
interval_days=merged["interval_days"],
interval_unit=merged["interval_unit"],
interval_anchor=merged["interval_anchor"],
due_date=merged["due_date"],
).to_dict()
for key in FLAT_RECURRENCE_KEYS:
out.pop(key, None)
out["schedule"] = schedule
return out
def legacy_schedule_type(schedule: Schedule, *, has_trigger: bool) -> str:
"""The v2.6.x ``schedule_type`` string for a Schedule + trigger presence.
The calendar kinds have no v2.6.x equivalent, so they surface their own kind
(``nth_weekday`` etc.); consumers that understand them read the nested
``schedule`` directly, the rest get a coarse but honest label.
"""
if has_trigger:
return "sensor_based"
if schedule.kind == KIND_ONE_TIME:
return "one_time"
if schedule.kind == KIND_INTERVAL:
return "time_based"
if schedule.kind == KIND_MANUAL:
return "manual"
return schedule.kind
def read_legacy_fields(task: Mapping[str, Any]) -> dict[str, Any]:
"""The flat recurrence view of a task dict, accepting either storage shape.
A flat (v2.6.x) task is returned field-for-field as stored — so existing
readers are behaviour-identical until the data is actually migrated. A
task with a nested ``schedule`` is translated back to the flat view its
consumers expect (``interval_days`` carries the count, ``due_date`` the ISO
string, etc.). Missing values use the long-standing flat defaults.
"""
nested = task.get("schedule")
if not isinstance(nested, Mapping):
return {
"schedule_type": task.get("schedule_type", "time_based"),
"interval_days": task.get("interval_days"),
"interval_unit": task.get("interval_unit", "days"),
"interval_anchor": task.get("interval_anchor", "completion"),
"due_date": task.get("due_date"),
}
sched = Schedule.from_dict(nested)
return {
"schedule_type": legacy_schedule_type(sched, has_trigger=bool(task.get("trigger_config"))),
"interval_days": sched.every,
"interval_unit": sched.unit,
"interval_anchor": sched.anchor,
"due_date": sched.due_date.isoformat() if sched.due_date else None,
}
@@ -0,0 +1,637 @@
"""Sensor-driven prediction engine for maintenance tasks (Phase 3).
Computes degradation rates from recorder statistics, predicts when sensor
values will reach trigger thresholds, and correlates environmental sensors
with maintenance intervals.
Only applicable to SENSOR_BASED tasks with threshold or counter triggers.
"""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
from homeassistant.util import dt as dt_util
from ..const import (
DEFAULT_DEGRADATION_LOOKBACK_DAYS,
DEFAULT_DEGRADATION_MIN_POINTS,
DEFAULT_DEGRADATION_SIGNIFICANCE,
DEFAULT_ENVIRONMENTAL_CORRELATION_MIN,
DEFAULT_ENVIRONMENTAL_FACTOR_MAX,
DEFAULT_ENVIRONMENTAL_FACTOR_MIN,
DEFAULT_ENVIRONMENTAL_LOOKBACK_DAYS,
DEFAULT_ENVIRONMENTAL_MIN_COMPLETIONS,
)
from .schedule import read_legacy_fields
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
_LOGGER = logging.getLogger(__name__)
_SECONDS_PER_DAY = 86400.0
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class DegradationAnalysis:
"""Result of degradation rate monitoring for a sensor-based task."""
entity_id: str
slope_per_day: float | None # units per day (positive = rising)
trend: str # "rising" | "falling" | "stable" | "insufficient_data"
r_squared: float | None # goodness of fit (0.0-1.0)
current_value: float | None
data_points: int
lookback_days: int
@dataclass
class ThresholdPrediction:
"""Prediction of when sensor will reach trigger threshold."""
days_until_threshold: float | None # None if can't predict
predicted_date: str | None # ISO date string
threshold_value: float
threshold_direction: str # "above" | "below"
current_value: float
rate_per_day: float
confidence: str # "low" | "medium" | "high" based on r_squared
@dataclass
class EnvironmentalAnalysis:
"""Analysis of environmental entity correlation with maintenance intervals."""
entity_id: str
current_value: float | None
average_value: float | None
correlation: float | None # Pearson coefficient (-1 to 1)
adjustment_factor: float # multiplier to apply on interval (default 1.0)
has_sufficient_data: bool
data_points: int
@dataclass
class SensorPredictionResult:
"""Combined result of all sensor-driven predictions for a task."""
degradation: DegradationAnalysis | None
threshold_prediction: ThresholdPrediction | None
environmental: EnvironmentalAnalysis | None
# ---------------------------------------------------------------------------
# Main predictor class
# ---------------------------------------------------------------------------
class SensorPredictor:
"""Computes sensor-driven predictions for maintenance tasks.
Async — requires HA recorder access for statistics_during_period.
Only applicable to sensor_based tasks with threshold/counter triggers.
"""
def __init__(self, hass: HomeAssistant) -> None:
self.hass = hass
# ------------------------------------------------------------------
# Public entry point
# ------------------------------------------------------------------
async def async_analyze(
self,
task_data: dict[str, Any],
adaptive_config: dict[str, Any],
) -> SensorPredictionResult | None:
"""Run full sensor prediction analysis for a task.
Returns None if task is not sensor_based or has no trigger entity.
"""
# Guard: only for sensor_based tasks with trigger config (schedule_type
# is derived from the trigger when storage is nested — read_legacy_fields)
if read_legacy_fields(task_data)["schedule_type"] != "sensor_based":
return None
trigger_config = task_data.get("trigger_config") or {}
entity_id = trigger_config.get("entity_id")
if not entity_id:
return None
trigger_type = trigger_config.get("type", "threshold")
if trigger_type not in ("threshold", "counter"):
return None
attribute = trigger_config.get("attribute")
# 1. Degradation rate
degradation = await self._async_compute_degradation(
entity_id,
attribute,
DEFAULT_DEGRADATION_LOOKBACK_DAYS,
)
# 2. Threshold prediction (synchronous math)
threshold_prediction = None
if degradation and degradation.slope_per_day is not None:
threshold_prediction = self._compute_threshold_prediction(degradation, trigger_config)
# 3. Environmental analysis (optional)
environmental = None
env_entity = adaptive_config.get("environmental_entity")
if env_entity:
env_attribute = adaptive_config.get("environmental_attribute")
environmental = await self._async_analyze_environmental(env_entity, env_attribute, task_data)
return SensorPredictionResult(
degradation=degradation,
threshold_prediction=threshold_prediction,
environmental=environmental,
)
# ------------------------------------------------------------------
# Degradation rate
# ------------------------------------------------------------------
async def _async_compute_degradation(
self,
entity_id: str,
attribute: str | None,
lookback_days: int,
) -> DegradationAnalysis:
"""Compute degradation rate using linear regression on recorder data."""
points = await self._async_fetch_statistics_points(entity_id, lookback_days)
if len(points) < DEFAULT_DEGRADATION_MIN_POINTS:
return DegradationAnalysis(
entity_id=entity_id,
slope_per_day=None,
trend="insufficient_data",
r_squared=None,
current_value=points[-1][1] if points else None,
data_points=len(points),
lookback_days=lookback_days,
)
result = self._linear_regression(points)
if result is None:
return DegradationAnalysis(
entity_id=entity_id,
slope_per_day=None,
trend="insufficient_data",
r_squared=None,
current_value=points[-1][1],
data_points=len(points),
lookback_days=lookback_days,
)
slope_per_second, _intercept, r_squared = result
slope_per_day = slope_per_second * _SECONDS_PER_DAY
# Classify trend
values = [v for _, v in points]
mean_val = sum(values) / len(values) if values else 1.0
if mean_val == 0:
mean_val = 1.0 # avoid division by zero
ratio = abs(slope_per_day) / abs(mean_val)
if ratio < DEFAULT_DEGRADATION_SIGNIFICANCE:
trend = "stable"
elif slope_per_day > 0:
trend = "rising"
else:
trend = "falling"
return DegradationAnalysis(
entity_id=entity_id,
slope_per_day=round(slope_per_day, 6),
trend=trend,
r_squared=round(r_squared, 4) if r_squared is not None else None,
current_value=points[-1][1],
data_points=len(points),
lookback_days=lookback_days,
)
# ------------------------------------------------------------------
# Threshold prediction
# ------------------------------------------------------------------
@staticmethod
def _compute_threshold_prediction(
degradation: DegradationAnalysis,
trigger_config: dict[str, Any],
) -> ThresholdPrediction | None:
"""Calculate days until sensor reaches trigger threshold.
For trigger_above: only predicts if slope > 0 (value rising toward threshold).
For trigger_below: only predicts if slope < 0 (value falling toward threshold).
Counter triggers: predicts based on counter increment rate.
"""
slope = degradation.slope_per_day
current = degradation.current_value
if slope is None or current is None or slope == 0:
return None
trigger_type = trigger_config.get("type", "threshold")
# Determine threshold and direction
threshold_value: float | None = None
direction: str = ""
if trigger_type == "counter":
# Counter: predict when delta reaches target
target = trigger_config.get("trigger_target_value")
delta_mode = trigger_config.get("trigger_delta_mode", False)
baseline = trigger_config.get("trigger_baseline_value", 0)
if target is None:
return None
if delta_mode:
# Current delta = current - baseline
current_delta = current - (baseline or 0)
threshold_value = float(target)
current = current_delta
else:
threshold_value = float(target)
direction = "above"
if slope <= 0:
return None # Counter not increasing; prediction impossible
else:
# Threshold trigger
above = trigger_config.get("trigger_above")
below = trigger_config.get("trigger_below")
if above is not None and slope > 0:
threshold_value = float(above)
direction = "above"
elif below is not None and slope < 0:
threshold_value = float(below)
direction = "below"
else:
return None
if threshold_value is None:
return None
# Calculate days until threshold
delta = threshold_value - current
if direction == "above" and delta <= 0:
days_until = 0.0 # already exceeded
elif direction == "below" and delta >= 0:
days_until = 0.0 # already exceeded
else:
days_until = min(abs(delta / slope), 3650) # Cap at 10 years
# Confidence from r_squared
r2 = degradation.r_squared or 0.0
if r2 >= 0.7:
confidence = "high"
elif r2 >= 0.3:
confidence = "medium"
else:
confidence = "low"
# Predicted date
predicted_date = None
if days_until > 0:
try:
pred_dt = dt_util.now() + timedelta(days=days_until)
predicted_date = pred_dt.strftime("%Y-%m-%d")
except OverflowError:
# Near-zero slope → astronomically large days_until
predicted_date = None
return ThresholdPrediction(
days_until_threshold=round(days_until, 1),
predicted_date=predicted_date,
threshold_value=threshold_value,
threshold_direction=direction,
current_value=current,
rate_per_day=slope,
confidence=confidence,
)
# ------------------------------------------------------------------
# Environmental correlation
# ------------------------------------------------------------------
async def _async_analyze_environmental(
self,
env_entity_id: str,
env_attribute: str | None,
task_data: dict[str, Any],
) -> EnvironmentalAnalysis:
"""Analyze correlation between environmental sensor and maintenance intervals.
Algorithm:
1. Fetch env entity recorder data (90 days hourly)
2. For each completion in task history, find closest env value
3. Compute Pearson correlation between env_value and actual_interval
4. If |correlation| > threshold, compute adjustment factor
"""
# Fetch environmental stats
env_points = await self._async_fetch_statistics_points(env_entity_id, DEFAULT_ENVIRONMENTAL_LOOKBACK_DAYS)
# Get current environmental value
current_env: float | None = None
state = self.hass.states.get(env_entity_id)
if state:
try:
if env_attribute:
raw = state.attributes.get(env_attribute)
else:
raw = state.state
if raw is None:
raise TypeError("No value available")
current_env = float(raw)
except (TypeError, ValueError):
current_env = None
if not env_points or len(env_points) < 10:
return EnvironmentalAnalysis(
entity_id=env_entity_id,
current_value=current_env,
average_value=None,
correlation=None,
adjustment_factor=1.0,
has_sufficient_data=False,
data_points=0,
)
# Extract completion intervals with env values at completion time
history = task_data.get("history") or []
completed = [h for h in history if h.get("type") == "completed" and h.get("timestamp")]
completed.sort(key=lambda h: h["timestamp"])
if len(completed) < 2:
return EnvironmentalAnalysis(
entity_id=env_entity_id,
current_value=current_env,
average_value=None,
correlation=None,
adjustment_factor=1.0,
has_sufficient_data=False,
data_points=0,
)
# Compute intervals and find env value at each completion
intervals: list[float] = []
env_at_completion: list[float] = []
for i in range(1, len(completed)):
try:
ts_prev = datetime.fromisoformat(completed[i - 1]["timestamp"])
ts_curr = datetime.fromisoformat(completed[i]["timestamp"])
except (ValueError, TypeError):
continue
# Defensive TZ handling: legacy entries may be naive — assume HA
# local TZ. Mixing naive/aware datetimes raises TypeError on
# subtraction below.
if ts_prev.tzinfo is None:
ts_prev = ts_prev.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)
if ts_curr.tzinfo is None:
ts_curr = ts_curr.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE)
interval_days = (ts_curr - ts_prev).total_seconds() / _SECONDS_PER_DAY
if interval_days <= 0:
continue
# Find closest env point to completion timestamp
completion_ts = ts_curr.timestamp()
env_val = self._find_closest_value(env_points, completion_ts)
if env_val is not None:
intervals.append(interval_days)
env_at_completion.append(env_val)
if len(intervals) < DEFAULT_ENVIRONMENTAL_MIN_COMPLETIONS:
return EnvironmentalAnalysis(
entity_id=env_entity_id,
current_value=current_env,
average_value=None,
correlation=None,
adjustment_factor=1.0,
has_sufficient_data=False,
data_points=len(intervals),
)
# Compute Pearson correlation
correlation = self._pearson_correlation(env_at_completion, intervals)
# Average environmental value
all_env_values = [v for _, v in env_points]
avg_env = sum(all_env_values) / len(all_env_values)
# Compute adjustment factor
adjustment_factor = 1.0
if (
correlation is not None
and abs(correlation) >= DEFAULT_ENVIRONMENTAL_CORRELATION_MIN
and current_env is not None
and avg_env != 0
):
# Positive correlation: higher env → longer intervals needed
# Negative correlation: higher env → shorter intervals needed
deviation = (current_env - avg_env) / abs(avg_env)
# Scale: correlation strength * deviation * sensitivity
adjustment_factor = 1.0 - correlation * deviation * 0.5
adjustment_factor = max(
DEFAULT_ENVIRONMENTAL_FACTOR_MIN,
min(DEFAULT_ENVIRONMENTAL_FACTOR_MAX, adjustment_factor),
)
adjustment_factor = round(adjustment_factor, 3)
return EnvironmentalAnalysis(
entity_id=env_entity_id,
current_value=current_env,
average_value=round(avg_env, 2),
correlation=round(correlation, 4) if correlation is not None else None,
adjustment_factor=adjustment_factor,
has_sufficient_data=True,
data_points=len(intervals),
)
# ------------------------------------------------------------------
# Recorder statistics
# ------------------------------------------------------------------
async def _async_fetch_statistics_points(
self,
entity_id: str,
days: int,
) -> list[tuple[float, float]]:
"""Fetch recorder statistics as (timestamp_epoch_seconds, value) pairs.
Uses statistics_during_period with "hour" period for degradation analysis.
Returns sorted list of (timestamp_seconds, value) tuples.
"""
try:
from homeassistant.components.recorder import ( # type: ignore[attr-defined]
get_instance,
)
from homeassistant.components.recorder.statistics import (
statistics_during_period,
)
except ImportError:
_LOGGER.debug("Recorder statistics module not available")
return []
start_time = dt_util.now() - timedelta(days=days)
try:
result = await get_instance(self.hass).async_add_executor_job(
lambda: statistics_during_period(
self.hass,
start_time,
None, # end_time = now
{entity_id},
"hour",
None, # units
{"mean", "state"},
)
)
except Exception: # noqa: BLE001 - recorder fetch can fail many ways (DB lock, timeout, schema mismatch); empty list is a safe fallback
_LOGGER.debug("Failed to fetch statistics for %s", entity_id, exc_info=True)
return []
rows = result.get(entity_id, [])
if not rows:
return []
points: list[tuple[float, float]] = []
for row in rows:
# HA Python API (statistics_during_period) returns start as
# epoch seconds (float), not milliseconds or datetime objects.
start = row.get("start")
if start is None:
continue
if isinstance(start, (int, float)):
ts = float(start) # already epoch seconds
elif isinstance(start, datetime):
ts = start.timestamp()
else:
continue
# Prefer "mean" for gauge sensors, "state" for counters
val = row.get("mean")
if val is None:
val = row.get("state")
if val is None:
continue
try:
points.append((ts, float(val)))
except (TypeError, ValueError):
continue
points.sort(key=lambda p: p[0])
return points
# ------------------------------------------------------------------
# Statistical helpers (pure Python, no numpy)
# ------------------------------------------------------------------
@staticmethod
def _linear_regression(
points: list[tuple[float, float]],
) -> tuple[float, float, float] | None:
"""Compute simple linear regression: y = slope*x + intercept.
Returns (slope, intercept, r_squared) or None if insufficient data.
Uses least squares method.
"""
n = len(points)
if n < 2:
return None
# Normalize X-values to avoid catastrophic cancellation.
# Raw Unix timestamps (~1.7e9) squared exceed Float64 precision,
# causing the denominator (n*Σx²−(Σx)²) to lose significant digits.
# Translation does not change the slope; intercept is adjusted below.
x0 = points[0][0]
sum_x = sum(p[0] - x0 for p in points)
sum_y = sum(p[1] for p in points)
sum_xy = sum((p[0] - x0) * p[1] for p in points)
sum_x2 = sum((p[0] - x0) ** 2 for p in points)
sum_y2 = sum(p[1] ** 2 for p in points)
denom = n * sum_x2 - sum_x**2
if abs(denom) < 1e-15:
return None
slope = (n * sum_xy - sum_x * sum_y) / denom
intercept = (sum_y - slope * sum_x) / n - slope * x0
# R-squared (coefficient of determination)
ss_tot = sum_y2 - (sum_y**2) / n
if abs(ss_tot) < 1e-15:
r_squared = 1.0 if abs(slope) < 1e-15 else 0.0
else:
ss_res = sum((p[1] - (slope * p[0] + intercept)) ** 2 for p in points)
r_squared = max(0.0, 1.0 - ss_res / ss_tot)
return slope, intercept, r_squared
@staticmethod
def _pearson_correlation(
x_vals: list[float],
y_vals: list[float],
) -> float | None:
"""Compute Pearson correlation coefficient between two lists.
Returns float in [-1, 1] or None if insufficient data or zero variance.
"""
n = len(x_vals)
if n < 3 or n != len(y_vals):
return None
mean_x = sum(x_vals) / n
mean_y = sum(y_vals) / n
cov = sum((x - mean_x) * (y - mean_y) for x, y in zip(x_vals, y_vals, strict=True))
var_x = sum((x - mean_x) ** 2 for x in x_vals)
var_y = sum((y - mean_y) ** 2 for y in y_vals)
denom = math.sqrt(var_x * var_y)
if denom < 1e-15:
return None
return cov / denom
@staticmethod
def _find_closest_value(
points: list[tuple[float, float]],
target_ts: float,
) -> float | None:
"""Find the value in points closest to target_ts (binary search)."""
if not points:
return None
lo, hi = 0, len(points) - 1
while lo < hi:
mid = (lo + hi) // 2
if points[mid][0] < target_ts:
lo = mid + 1
else:
hi = mid
# Check neighbors
best_idx = lo
if lo > 0:
if abs(points[lo - 1][0] - target_ts) < abs(points[lo][0] - target_ts):
best_idx = lo - 1
# Only return if within 24 hours
if abs(points[best_idx][0] - target_ts) > _SECONDS_PER_DAY:
return None
return points[best_idx][1]
@@ -0,0 +1,176 @@
"""Single source of truth for global-setting validation.
Each writable global setting is declared ONCE here as a ``SettingSpec`` (key +
type + optional numeric range / string cap). The WS write handler
(``websocket/dashboard.py``) derives its allow-list and range/cap tables from
this registry, and the options flow (``config_flow_options_global.py``) pulls
its NumberSelector min/max from the same specs — so the ranges can't drift
between the two surfaces (they previously lived as three hand-kept copies).
Bespoke normalisation that isn't a plain range/cap (panel-title trim,
title-style enum, quiet-hours regex, list sanitisers, notify-service
validation) stays in the WS handler; the registry only covers the mechanical
type + range + length checks.
"""
from __future__ import annotations
from dataclasses import dataclass
import voluptuous as vol
from ..const import (
CONF_ACTION_COMPLETE_ENABLED,
CONF_ACTION_SKIP_ENABLED,
CONF_ACTION_SNOOZE_ENABLED,
CONF_ADMIN_PANEL_USER_IDS,
CONF_ADVANCED_ADAPTIVE,
CONF_ADVANCED_BUDGET,
CONF_ADVANCED_CHECKLISTS,
CONF_ADVANCED_COMPLETION_ACTIONS,
CONF_ADVANCED_ENVIRONMENTAL,
CONF_ADVANCED_GROUPS,
CONF_ADVANCED_PREDICTIONS,
CONF_ADVANCED_SCHEDULE_TIME,
CONF_ADVANCED_SEASONAL,
CONF_ARCHIVE_ONEOFF_DAYS,
CONF_BUDGET_ALERT_THRESHOLD,
CONF_BUDGET_ALERTS_ENABLED,
CONF_BUDGET_CURRENCY,
CONF_BUDGET_MONTHLY,
CONF_BUDGET_YEARLY,
CONF_DEFAULT_WARNING_DAYS,
CONF_DELETE_ARCHIVED_ONEOFF_DAYS,
CONF_DISABLED_TEMPLATE_IDS,
CONF_MAX_NOTIFICATIONS_PER_DAY,
CONF_NOTIFICATION_BUNDLE_THRESHOLD,
CONF_NOTIFICATION_BUNDLING_ENABLED,
CONF_NOTIFICATION_TITLE_STYLE,
CONF_NOTIFICATIONS_ENABLED,
CONF_NOTIFY_DUE_SOON_ENABLED,
CONF_NOTIFY_DUE_SOON_INTERVAL,
CONF_NOTIFY_OVERDUE_ENABLED,
CONF_NOTIFY_OVERDUE_INTERVAL,
CONF_NOTIFY_SERVICE,
CONF_NOTIFY_TRIGGERED_ENABLED,
CONF_NOTIFY_TRIGGERED_INTERVAL,
CONF_OBJECTS_TABLE_COLUMNS,
CONF_OPERATOR_WRITE_ENABLED,
CONF_PANEL_ENABLED,
CONF_PANEL_TITLE,
CONF_QUIET_HOURS_ENABLED,
CONF_QUIET_HOURS_END,
CONF_QUIET_HOURS_START,
CONF_REMINDER_LEAD_DAYS,
CONF_SNOOZE_DURATION_HOURS,
CONF_WARRANTY_REMINDER_DAYS,
CONF_WARRANTY_REMINDER_ENABLED,
CONF_WEEKLY_DIGEST_ENABLED,
)
@dataclass(frozen=True)
class SettingSpec:
"""Validation spec for one writable global setting."""
key: str
py_type: type # int | float | bool | str | list — used for isinstance()
int_range: tuple[int, int] | None = None
float_range: tuple[float, float] | None = None
max_len: int | None = None # string length cap
# The complete set of keys accepted by global/update. Order groups related
# settings; it has no functional meaning.
SETTING_SPECS: tuple[SettingSpec, ...] = (
# General
SettingSpec(CONF_DEFAULT_WARNING_DAYS, int, int_range=(1, 365)),
SettingSpec(CONF_NOTIFICATIONS_ENABLED, bool),
SettingSpec(CONF_NOTIFY_SERVICE, str, max_len=200),
SettingSpec(CONF_PANEL_ENABLED, bool),
# panel_title is trimmed+capped to MAX_PANEL_TITLE_LENGTH by a bespoke rule,
# not a plain drop-if-too-long — so no max_len here.
SettingSpec(CONF_PANEL_TITLE, str),
# Advanced-feature toggles
SettingSpec(CONF_ADVANCED_ADAPTIVE, bool),
SettingSpec(CONF_ADVANCED_PREDICTIONS, bool),
SettingSpec(CONF_ADVANCED_SEASONAL, bool),
SettingSpec(CONF_ADVANCED_ENVIRONMENTAL, bool),
SettingSpec(CONF_ADVANCED_BUDGET, bool),
SettingSpec(CONF_ADVANCED_GROUPS, bool),
SettingSpec(CONF_ADVANCED_CHECKLISTS, bool),
SettingSpec(CONF_ADVANCED_SCHEDULE_TIME, bool),
SettingSpec(CONF_ADVANCED_COMPLETION_ACTIONS, bool),
# Governance (list elements sanitised by a bespoke rule in the handler)
SettingSpec(CONF_ADMIN_PANEL_USER_IDS, list),
SettingSpec(CONF_OPERATOR_WRITE_ENABLED, bool),
SettingSpec(CONF_OBJECTS_TABLE_COLUMNS, list),
# v2.21: hidden template ids (bespoke known-id sanitiser in the handler)
SettingSpec(CONF_DISABLED_TEMPLATE_IDS, list),
# Archive automation
SettingSpec(CONF_ARCHIVE_ONEOFF_DAYS, int, int_range=(0, 3650)),
SettingSpec(CONF_DELETE_ARCHIVED_ONEOFF_DAYS, int, int_range=(0, 3650)),
# Notification per-status
SettingSpec(CONF_NOTIFY_DUE_SOON_ENABLED, bool),
SettingSpec(CONF_NOTIFY_DUE_SOON_INTERVAL, int, int_range=(0, 720)),
SettingSpec(CONF_NOTIFY_OVERDUE_ENABLED, bool),
SettingSpec(CONF_NOTIFY_OVERDUE_INTERVAL, int, int_range=(0, 720)),
SettingSpec(CONF_NOTIFY_TRIGGERED_ENABLED, bool),
SettingSpec(CONF_NOTIFY_TRIGGERED_INTERVAL, int, int_range=(0, 720)),
# Quiet hours (HH:MM[:SS] validated by a bespoke regex in the handler)
SettingSpec(CONF_QUIET_HOURS_ENABLED, bool),
SettingSpec(CONF_QUIET_HOURS_START, str, max_len=5),
SettingSpec(CONF_QUIET_HOURS_END, str, max_len=5),
# Limits + bundling
SettingSpec(CONF_MAX_NOTIFICATIONS_PER_DAY, int, int_range=(0, 1000)),
SettingSpec(CONF_NOTIFICATION_BUNDLING_ENABLED, bool),
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),
# Actions
SettingSpec(CONF_ACTION_COMPLETE_ENABLED, bool),
SettingSpec(CONF_ACTION_SKIP_ENABLED, bool),
SettingSpec(CONF_ACTION_SNOOZE_ENABLED, bool),
SettingSpec(CONF_SNOOZE_DURATION_HOURS, int, int_range=(1, 168)),
SettingSpec(CONF_WEEKLY_DIGEST_ENABLED, bool),
SettingSpec(CONF_WARRANTY_REMINDER_ENABLED, bool),
SettingSpec(CONF_WARRANTY_REMINDER_DAYS, int, int_range=(1, 365)),
# List of days-before-due (bespoke int-list sanitiser in the WS handler).
SettingSpec(CONF_REMINDER_LEAD_DAYS, list),
# Budget
SettingSpec(CONF_BUDGET_MONTHLY, float, float_range=(0.0, 10_000_000.0)),
SettingSpec(CONF_BUDGET_YEARLY, float, float_range=(0.0, 100_000_000.0)),
SettingSpec(CONF_BUDGET_ALERTS_ENABLED, bool),
SettingSpec(CONF_BUDGET_ALERT_THRESHOLD, int, int_range=(10, 100)),
SettingSpec(CONF_BUDGET_CURRENCY, str, max_len=5),
)
_SPEC_BY_KEY: dict[str, SettingSpec] = {s.key: s for s in SETTING_SPECS}
# ─── Derived views (single source → the tables the handler used to hand-keep) ─
ALLOWED_SETTING_KEYS: dict[str, type | vol.Any] = {s.key: s.py_type for s in SETTING_SPECS}
INT_RANGES: dict[str, tuple[int, int]] = {s.key: s.int_range for s in SETTING_SPECS if s.int_range is not None}
FLOAT_RANGES: dict[str, tuple[float, float]] = {s.key: s.float_range for s in SETTING_SPECS if s.float_range is not None}
STR_MAX_LENGTHS: dict[str, int] = {s.key: s.max_len for s in SETTING_SPECS if s.max_len is not None}
def int_range(key: str) -> tuple[int, int]:
"""Return the (min, max) for an int setting — for the options-flow selector.
Raises KeyError if the key isn't a registered int setting, so a typo fails
loudly at import/first-use rather than silently using a wrong bound.
"""
spec = _SPEC_BY_KEY[key]
if spec.int_range is None:
raise KeyError(f"{key} is not an int-ranged setting")
return spec.int_range
def float_range(key: str) -> tuple[float, float]:
"""Return the (min, max) for a float setting — for the options-flow selector."""
spec = _SPEC_BY_KEY[key]
if spec.float_range is None:
raise KeyError(f"{key} is not a float-ranged setting")
return spec.float_range
@@ -0,0 +1,50 @@
"""Shared task-status derivation from a coordinator data dict.
The per-task ``sensor`` and ``binary_sensor`` entities both need to recompute a
task's status from the coordinator's plain data dict (e.g. right after a live
trigger update) without rebuilding a full :class:`MaintenanceTask`. They used to
carry byte-identical ``_compute_live_status`` copies; this is the single source.
This is the *dict* status ladder — a lightweight mirror of
:pyattr:`MaintenanceTask.status`. It intentionally omits the model's sub-day
``schedule_time`` refinement and the span-capped warning window (issue #58),
which require the live ``Schedule`` object; those are applied by the next
coordinator refresh. Keep the trigger / overdue / due-soon precedence here in
sync with the model property.
"""
from __future__ import annotations
from typing import Any
from ..const import DEFAULT_WARNING_DAYS, MaintenanceStatus
def compute_status_from_task_dict(task: dict[str, Any]) -> str:
"""Compute task status from a coordinator data dict.
Mirrors :pyattr:`MaintenanceTask.status` for the archived / trigger /
overdue / due-soon / ok ladder. ``_trigger_active`` and ``_days_until_due``
are the coordinator-computed live fields.
"""
# Archived takes precedence over everything (v2.10.0) — see the model twin.
if task.get("archived_at") is not None:
return MaintenanceStatus.ARCHIVED
# Seasonal pause (v2.20, N3): object-wide, injected by the coordinator.
# Keeps a live trigger event from flipping a frozen task to TRIGGERED
# between refreshes.
if task.get("_paused"):
return MaintenanceStatus.PAUSED
if task.get("_trigger_active", False):
return MaintenanceStatus.TRIGGERED
days = task.get("_days_until_due")
if days is None:
return MaintenanceStatus.OK
warning_days = task.get("warning_days", DEFAULT_WARNING_DAYS)
if days < 0:
return MaintenanceStatus.OVERDUE
if days <= warning_days:
return MaintenanceStatus.DUE_SOON
return MaintenanceStatus.OK
@@ -0,0 +1,44 @@
"""Single source of truth for task-field enums + numeric ranges.
The task/trigger forms exist in TWO hand-written UIs (the panel's
``task-dialog.ts`` and the HA options config-flow) plus the WS create/update
schemas and the sanitizer. This module pins the *values* those surfaces must
agree on — enum option sets and numeric bounds — so they are consumed from one
place on the Python side (WS schemas, sanitizer, config-flow selectors) and
enforced against the TypeScript dialog by the parity tripwires in
``tests/test_frontend_const_parity.py`` / ``tests/test_parity_task_fields.py``.
This is the field-level companion of ``helpers/settings_registry`` (which does
the same for the global settings). Full form *generation* from these specs is
the remaining long-term step; until then, a new enum value or bound changed
here propagates to every Python surface by construction and fails the build if
the TS dialog wasn't updated.
"""
from __future__ import annotations
from ..const import (
MAX_INTERVAL_DAYS,
ROTATION_STRATEGIES,
TaskPriority,
)
# ─── Enum option sets ────────────────────────────────────────────────────────
# Priority choices, in display order. Derived from the TaskPriority enum so a
# new priority level lands in the WS schemas + config-flow dropdowns
# automatically (the TS dialog is pinned by test_ts_priority_keys_match_enum).
TASK_PRIORITIES: tuple[str, ...] = tuple(p.value for p in TaskPriority)
# next_due anchoring: from the completion date (drifts) or the planned date.
INTERVAL_ANCHORS: tuple[str, ...] = ("completion", "planned")
# Shared-task rotation strategies (defined in const; re-exported here so all
# task-field consumers import from one module).
ROTATION_STRATEGY_VALUES: tuple[str, ...] = ROTATION_STRATEGIES
# ─── Numeric bounds (inclusive) ─────────────────────────────────────────────
WARNING_DAYS_RANGE: tuple[int, int] = (0, 365)
EARLIEST_COMPLETION_RANGE: tuple[int, int] = (0, 3650)
INTERVAL_DAYS_RANGE: tuple[int, int] = (1, MAX_INTERVAL_DAYS)
@@ -0,0 +1,131 @@
"""Threshold calculator for suggesting trigger values based on recorder statistics."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from homeassistant.core import HomeAssistant
from .entity_analyzer import EntityAnalysis, EntityAnalyzer, StatisticsInfo
_LOGGER = logging.getLogger(__name__)
@dataclass
class ThresholdSuggestions:
"""Suggested threshold values for a trigger."""
current_value: float | None = None
unit: str = ""
average: float | None = None
minimum: float | None = None
maximum: float | None = None
suggested_above: float | None = None
suggested_below: float | None = None
data_period_days: int = 0
percentile_10: float | None = None
percentile_90: float | None = None
trend: str | None = None
class ThresholdCalculator:
"""Calculates intelligent threshold suggestions based on entity statistics."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the calculator."""
self.hass = hass
async def async_calculate_suggestions(
self,
entity_id: str,
attribute: str | None = None,
analysis: EntityAnalysis | None = None,
) -> ThresholdSuggestions:
"""Generate threshold suggestions based on recorder statistics."""
state = self.hass.states.get(entity_id)
if state is None:
return ThresholdSuggestions()
unit = state.attributes.get("unit_of_measurement", "")
try:
if attribute:
current = float(state.attributes.get(attribute, 0))
else:
current = float(state.state)
except (ValueError, TypeError):
return ThresholdSuggestions(unit=unit)
# Fetch analysis if not provided
if analysis is None:
analyzer = EntityAnalyzer(self.hass)
analysis = await analyzer.async_analyze_entity(entity_id)
# Try statistics-based suggestions
if analysis and analysis.statistics and analysis.statistics.has_data:
stats = analysis.statistics
return self._suggestions_from_statistics(current, unit, stats)
# Fallback: naive calculation
return self._naive_suggestions(current, unit)
def _suggestions_from_statistics(
self,
current: float,
unit: str,
stats: StatisticsInfo,
) -> ThresholdSuggestions:
"""Calculate suggestions from recorder statistics."""
suggested_above = None
suggested_below = None
if stats.percentile_90 is not None:
# Above: 20% above P90 (catches unusual highs)
suggested_above = round(stats.percentile_90 * 1.2, 2)
if stats.percentile_10 is not None:
# Below: 20% below P10 (catches unusual lows)
suggested_below = round(stats.percentile_10 * 0.8, 2)
# Ensure suggestions don't cross each other or current value nonsensically
if suggested_above is not None and suggested_below is not None:
if suggested_above <= suggested_below:
# Range too narrow, use wider margins
if stats.mean is not None and stats.std_dev is not None:
suggested_above = round(stats.mean + 2 * stats.std_dev, 2)
suggested_below = round(stats.mean - 2 * stats.std_dev, 2)
return ThresholdSuggestions(
current_value=round(current, 2),
unit=unit,
average=stats.mean,
minimum=stats.minimum,
maximum=stats.maximum,
suggested_above=suggested_above,
suggested_below=suggested_below,
data_period_days=stats.period_days,
percentile_10=stats.percentile_10,
percentile_90=stats.percentile_90,
trend=stats.recent_trend,
)
def _naive_suggestions(self, current: float, unit: str) -> ThresholdSuggestions:
"""Fallback suggestions when no statistics are available."""
if current > 0:
suggested_above = round(current * 1.5, 2)
suggested_below = round(current * 0.5, 2)
else:
suggested_above = round(current + 10, 2)
suggested_below = round(current - 10, 2)
return ThresholdSuggestions(
current_value=round(current, 2),
unit=unit,
average=None,
minimum=None,
maximum=None,
suggested_above=suggested_above,
suggested_below=suggested_below,
data_period_days=0,
)
@@ -0,0 +1,201 @@
"""Per-type trigger fallback evaluators for the coordinator refresh.
The event-driven triggers (entity/triggers/) are the primary evaluation with
their timers and persistence; this module is the *refresh-time* fallback that
keeps `_trigger_current_value` / `_trigger_active` correct even when an event
was missed — and, for the accumulator types, makes the progress visible at all
(run counts / runtime hours live in persisted trigger state, not in an entity).
Each evaluator is a pure function of the trigger config (plus a state lookup
where the type reads live entities), returning a :class:`FallbackResult` the
coordinator applies. Extracted from five near-identical inline branches so
every rule is individually testable.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from homeassistant.util import dt as dt_util
if TYPE_CHECKING:
from homeassistant.core import State
# A state lookup — hass.states.get, injected so the evaluators stay pure.
StateGetter = Callable[[str], "State | None"]
@dataclass(slots=True)
class FallbackResult:
"""What a fallback evaluation learned.
``current_value`` is None when nothing could be read (leave the previous
value); ``active`` is None when the fallback must not touch the
event-driven trigger state (e.g. a pending for_minutes timer).
"""
current_value: float | None = None
active: bool | None = None
def _aggregate(per_entity: list[bool], entity_logic: str) -> bool:
return all(per_entity) if entity_logic == "all" else any(per_entity)
def _numeric_entity_value(get_state: StateGetter, entity_id: str, attribute: str | None) -> float | None:
"""Read a numeric value from an entity state/attribute (None when unusable)."""
state = get_state(entity_id)
if state is None or state.state in ("unavailable", "unknown"):
return None
try:
raw = state.attributes.get(attribute) if attribute else state.state
if raw is None:
return None
return float(raw)
except (ValueError, TypeError):
return None
def evaluate_threshold(
get_state: StateGetter,
trigger_config: dict[str, Any],
entity_ids: list[str],
) -> FallbackResult:
"""Threshold: value above/below a limit; for_minutes only ever deactivates."""
attribute = trigger_config.get("attribute")
entity_logic = trigger_config.get("entity_logic", "any")
for_minutes = trigger_config.get("trigger_for_minutes", 0)
above = trigger_config.get("trigger_above")
below = trigger_config.get("trigger_below")
per_entity: list[bool] = []
last_value: float | None = None
for eid in entity_ids:
value = _numeric_entity_value(get_state, eid, attribute)
if value is None:
per_entity.append(False)
continue
last_value = value
exceeds = (above is not None and value > above) or (below is not None and value < below)
per_entity.append(exceeds)
aggregated = _aggregate(per_entity, entity_logic) if per_entity else False
active: bool | None
if for_minutes == 0:
active = aggregated
elif not aggregated and last_value is not None:
# Back in the normal range — safe to deactivate even with for_minutes.
active = False
else:
# for_minutes pending — leave the event-driven timer in charge.
active = None
return FallbackResult(current_value=last_value, active=active)
def evaluate_counter(
get_state: StateGetter,
trigger_config: dict[str, Any],
entity_ids: list[str],
) -> FallbackResult:
"""Counter: value (or delta from a per-entity baseline) reaches a target."""
attribute = trigger_config.get("attribute")
entity_logic = trigger_config.get("entity_logic", "any")
target = trigger_config.get("trigger_target_value", 0)
delta_mode = trigger_config.get("trigger_delta_mode", False)
trigger_state = trigger_config.get("_trigger_state", {})
per_entity: list[bool] = []
last_value: float | None = None
for eid in entity_ids:
value = _numeric_entity_value(get_state, eid, attribute)
if value is None:
per_entity.append(False)
continue
last_value = value
if delta_mode:
baseline = trigger_state.get(eid, {}).get("baseline_value")
if baseline is None:
baseline = trigger_config.get("trigger_baseline_value")
per_entity.append(baseline is not None and (value - baseline) >= target)
else:
per_entity.append(value >= target)
active = _aggregate(per_entity, entity_logic) if per_entity else None
return FallbackResult(current_value=last_value, active=active)
def evaluate_state_change(
trigger_config: dict[str, Any],
entity_ids: list[str],
) -> FallbackResult:
"""State change: surface the persisted transition count (forum #16).
Counting stays event-driven; the fallback only reads what the trigger
persisted so the run count (and the progress header) is visible before
the target fires.
"""
trigger_state = trigger_config.get("_trigger_state", {})
target_changes = trigger_config.get("trigger_target_changes")
entity_logic = trigger_config.get("entity_logic", "any")
per_entity: list[bool] = []
best_count: float | None = None
for eid in entity_ids:
cc = trigger_state.get(eid, {}).get("change_count")
if cc is None:
# Legacy flat storage
cc = trigger_config.get("trigger_change_count")
if cc is None:
continue
count = float(cc)
best_count = count if best_count is None else max(best_count, count)
if target_changes:
per_entity.append(count >= target_changes)
active = _aggregate(per_entity, entity_logic) if per_entity else None
return FallbackResult(current_value=best_count, active=active)
def evaluate_runtime(
trigger_config: dict[str, Any],
entity_ids: list[str],
) -> FallbackResult:
"""Runtime: reconstruct accumulated hours (+ live on-time while running)."""
trigger_state = trigger_config.get("_trigger_state", {})
target_hours = trigger_config.get("trigger_runtime_hours")
entity_logic = trigger_config.get("entity_logic", "any")
per_entity: list[bool] = []
best_hours: float | None = None
for eid in entity_ids:
es = trigger_state.get(eid, {})
seconds = es.get("accumulated_seconds")
if seconds is None:
continue
total = float(seconds)
on_since = es.get("on_since")
if on_since:
on_dt = dt_util.parse_datetime(on_since)
if on_dt is not None:
# Older payloads may be naive — assume UTC (live writes are
# TZ-aware) so the subtraction below can't raise. Mirrors
# threshold.py's exceeded_since handling.
if on_dt.tzinfo is None:
from datetime import UTC
on_dt = on_dt.replace(tzinfo=UTC)
total += max(0.0, (dt_util.utcnow() - on_dt).total_seconds())
hours = total / 3600.0
best_hours = hours if best_hours is None else max(best_hours, hours)
if target_hours:
per_entity.append(hours >= target_hours)
active = _aggregate(per_entity, entity_logic) if per_entity else None
return FallbackResult(
current_value=round(best_hours, 2) if best_hours is not None else None,
active=active,
)
@@ -0,0 +1,325 @@
"""Vacation mode (v1.2.0).
Suppresses notifications for non-exempt tasks during a configured date range
plus a buffer (so a task that comes due the day of return doesn't fire
immediately). Sensor-triggered notifications are suppressed too unless the
task is on the exempt list.
The exempt list lives at the global config-entry level — it is *persistent*
across vacations, not per-vacation. Use case: pool chemistry that the
neighbour checks regardless of whether you're away.
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
from typing import TYPE_CHECKING, Any
from homeassistant.util import dt as dt_util
from ..const import (
CONF_VACATION_BUFFER_DAYS,
CONF_VACATION_ENABLED,
CONF_VACATION_END,
CONF_VACATION_EXEMPT_TASK_IDS,
CONF_VACATION_START,
DEFAULT_VACATION_BUFFER_DAYS,
DEFAULT_WARNING_DAYS,
DOMAIN,
GLOBAL_UNIQUE_ID,
)
from .dates import add_interval
from .schedule import (
KIND_DAY_OF_MONTH,
KIND_NTH_WEEKDAY,
KIND_WEEKDAYS,
Schedule,
)
_CALENDAR_KINDS = (KIND_WEEKDAYS, KIND_NTH_WEEKDAY, KIND_DAY_OF_MONTH)
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
@dataclass(frozen=True)
class VacationState:
"""Frozen snapshot of vacation config.
Frozen so callers can pass it around without worrying about mutation
during a notification-decision window.
"""
enabled: bool
start: date | None
end: date | None
buffer_days: int
exempt_task_ids: frozenset[str] = field(default_factory=frozenset)
@property
def window_end(self) -> date | None:
"""Last day on which suppression still applies (inclusive)."""
if self.end is None:
return None
return self.end + timedelta(days=max(0, self.buffer_days))
def is_active(self, at: datetime | None = None) -> bool:
"""True if today falls within [start, end + buffer] and the toggle is on."""
if not self.enabled or self.start is None or self.end is None:
return False
today = (at or dt_util.now()).date()
return self.start <= today <= (self.window_end or self.end)
def is_silent_for(self, task_id: str, at: datetime | None = None) -> bool:
"""Return True if a notification for *task_id* must be suppressed.
Suppressed iff vacation is currently active AND the task is not in
the exempt list. Exempt tasks fire normally even during vacation.
"""
if not self.is_active(at):
return False
return task_id not in self.exempt_task_ids
def as_wire_dict(self) -> dict[str, Any]:
"""Serialise for the WS wire. Single source for /vacation/state and the
/settings vacation embed, so the two can never drift."""
return {
"enabled": self.enabled,
"start": self.start.isoformat() if self.start else None,
"end": self.end.isoformat() if self.end else None,
"buffer_days": self.buffer_days,
"exempt_task_ids": sorted(self.exempt_task_ids),
"is_active": self.is_active(),
"window_end": self.window_end.isoformat() if self.window_end else None,
}
@classmethod
def from_options(cls, options: Mapping[str, Any]) -> VacationState:
"""Build a VacationState from a global-entry options mapping.
Same coercion rules as :func:`get_vacation_state` but from an already
resolved options dict (used by the /settings embed, which may be passed
an empty mapping when no global entry exists).
"""
raw_exempt = options.get(CONF_VACATION_EXEMPT_TASK_IDS) or []
exempt: list[str] = []
if isinstance(raw_exempt, list):
for x in raw_exempt:
if isinstance(x, str):
stripped = x.strip()
if stripped and len(stripped) <= 64:
exempt.append(stripped)
return cls(
enabled=bool(options.get(CONF_VACATION_ENABLED, False)),
start=_coerce_date(options.get(CONF_VACATION_START)),
end=_coerce_date(options.get(CONF_VACATION_END)),
buffer_days=_coerce_buffer(options.get(CONF_VACATION_BUFFER_DAYS, DEFAULT_VACATION_BUFFER_DAYS)),
exempt_task_ids=frozenset(exempt),
)
def _coerce_date(value: Any) -> date | None:
"""Parse an ISO YYYY-MM-DD string from config; tolerant of None / junk."""
if not value or not isinstance(value, str):
return None
try:
return date.fromisoformat(value)
except (TypeError, ValueError):
return None
def _coerce_buffer(value: Any) -> int:
try:
i = int(value)
except (TypeError, ValueError):
return DEFAULT_VACATION_BUFFER_DAYS
if i < 0 or i > 14:
return DEFAULT_VACATION_BUFFER_DAYS
return i
def _task_warning_days(task: Mapping[str, Any]) -> int:
"""warning_days for a stored task, defaulting when missing/blank/invalid.
Missing (or blank/non-numeric from legacy/imported storage) → the default;
a real value (including 0) is kept. Never raises on a non-int value.
"""
value = task.get("warning_days")
if value is None or value == "":
return DEFAULT_WARNING_DAYS
try:
return int(value)
except (TypeError, ValueError):
return DEFAULT_WARNING_DAYS
def _global_options(hass: HomeAssistant) -> Mapping[str, Any]:
"""Return options dict from the global config entry."""
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
return entry.options or entry.data
return {}
def get_vacation_state(hass: HomeAssistant) -> VacationState:
"""Read the current vacation state from the global config entry."""
return VacationState.from_options(_global_options(hass))
# ---------------------------------------------------------------------------
# Preview
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class PreviewEvent:
"""A single projected status transition during the vacation window."""
date: date
status: str # "due_soon" | "overdue" | "triggered_est"
def _events_from_next_due(
next_due: date,
warning_days: int,
today: date,
window_start: date,
window_end: date,
) -> list[PreviewEvent]:
"""DUE_SOON / OVERDUE preview events for a single next-due date. Shared by
the interval and calendar projections (DRY)."""
due_soon_from = next_due - timedelta(days=max(0, warning_days))
events: list[PreviewEvent] = []
if window_start <= due_soon_from <= window_end and due_soon_from > today:
events.append(PreviewEvent(date=due_soon_from, status="due_soon"))
if window_start <= next_due <= window_end and next_due > today:
events.append(PreviewEvent(date=next_due, status="overdue"))
# Edge: task is already DUE_SOON or OVERDUE today and stays in that state
# — surface as a "today" event so the user can act on it before leaving.
if not events:
if today >= next_due and today <= window_end:
events.append(PreviewEvent(date=today, status="overdue"))
elif today >= due_soon_from and today <= window_end and due_soon_from <= today < next_due:
events.append(PreviewEvent(date=today, status="due_soon"))
return events
def _project_time_based(
last_performed: date | None,
created_at: date | None,
interval_days: int | None,
warning_days: int,
today: date,
window_start: date,
window_end: date,
interval_unit: str | None = None,
) -> list[PreviewEvent]:
"""Project DUE_SOON / OVERDUE dates for a time-based task."""
if not interval_days or interval_days <= 0:
return []
anchor = last_performed or created_at or today
# Unit-aware (weeks/months/years), not raw days — else a 6-month task would
# preview as due in 6 days during vacation planning.
next_due = add_interval(anchor, interval_days, interval_unit or "days")
return _events_from_next_due(next_due, warning_days, today, window_start, window_end)
def compute_preview(
state: VacationState,
tasks: Iterable[Mapping[str, Any]],
today: date | None = None,
) -> list[dict[str, Any]]:
"""Project status changes for each task during [start, end+buffer].
Each input *task* dict must carry: ``task_id``, ``entry_id``,
``object_name``, ``task_name``, ``schedule_type``, plus the dynamic
fields ``last_performed``, ``created_at``, ``interval_days``,
``warning_days``, ``enabled`` (all optional).
Returns a list of preview rows; tasks with no projected events in the
window are omitted (caller may still want to display the task list
elsewhere).
"""
if state.start is None or state.end is None:
return []
today = today or dt_util.now().date()
window_start = max(state.start, today)
window_end = state.window_end or state.end
if window_end < window_start:
return []
rows: list[dict[str, Any]] = []
for t in tasks:
if not t.get("enabled", True):
continue
task_id = str(t.get("task_id") or "")
if not task_id:
continue
schedule_type = t.get("schedule_type") or "time_based"
events: list[PreviewEvent] = []
kind: str
confidence: str
if schedule_type == "time_based":
events = _project_time_based(
last_performed=_coerce_date(t.get("last_performed")),
created_at=_coerce_date(t.get("created_at")),
interval_days=t.get("interval_days"),
warning_days=_task_warning_days(t),
today=today,
window_start=window_start,
window_end=window_end,
interval_unit=t.get("interval_unit"),
)
kind = "time_based"
confidence = "deterministic"
elif schedule_type == "sensor_based":
# Sensor triggers are non-deterministic. Surface every sensor task
# in the window with a single "may fire anytime" event so the user
# can decide per-task whether to exempt or pre-complete.
events = [PreviewEvent(date=window_start, status="triggered_est")]
kind = "sensor_based"
confidence = "unpredictable"
elif schedule_type in _CALENDAR_KINDS:
# Calendar kinds (weekdays / nth_weekday / day_of_month): project the
# next occurrence via the Schedule (the flat fields can't express it).
raw = t.get("schedule")
sched = Schedule.from_dict(raw) if isinstance(raw, dict) else None
nd = (
sched.next_due(
last_performed=_coerce_date(t.get("last_performed")),
created_at=_coerce_date(t.get("created_at")),
last_planned_due=None,
today=today,
)
if sched
else None
)
events = _events_from_next_due(nd, _task_warning_days(t), today, window_start, window_end) if nd else []
kind = schedule_type
confidence = "deterministic"
else:
# Manual / one-time tasks have no auto-due — never appear here.
continue
if not events:
continue
rows.append(
{
"task_id": task_id,
"entry_id": t.get("entry_id"),
"object_name": t.get("object_name") or "",
"task_name": t.get("task_name") or "",
"kind": kind,
"confidence": confidence,
"events": [{"date": e.date.isoformat(), "status": e.status} for e in events],
"will_suppress": task_id not in state.exempt_task_ids,
}
)
# Stable ordering: object name then task name, matches #40 sort
rows.sort(key=lambda r: ((r["object_name"] or "").lower(), (r["task_name"] or "").lower()))
return rows
@@ -0,0 +1,166 @@
"""Business-day provider bridging HA's Workday integration (#83 follow-up).
The day-of-month schedule's ``business`` flag rolls weekend dates back to the
previous business day. Out of the box that means a plain Mon-Fri rule — but
when the user has Home Assistant's **Workday** integration configured, "last
business day" should honour that configuration: country/region public
holidays, custom working weekdays (e.g. Mon-Sat), and the add/remove-holiday
overrides.
Layering: ``helpers/dates.py`` and ``helpers/schedule.py`` stay hass-free and
purely testable. This module keeps a process-global provider (the same
pattern Home Assistant itself uses for ``dt_util``'s default timezone):
``async_setup_business_days`` installs a predicate built from the first
Workday config entry during integration setup; ``roll_back_to_business_day``
consults it via :func:`is_business_day` and falls back to ``weekday < 5``
when none is installed (no Workday integration, unit tests).
Note: edits to the Workday configuration are picked up the next time this
integration is (re)loaded — holiday calendars change rarely enough that a
live listener isn't worth the coupling.
"""
from __future__ import annotations
import logging
from collections.abc import Callable, Mapping
from datetime import date
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
_LOGGER = logging.getLogger(__name__)
BusinessDayFn = Callable[[date], bool]
WORKDAY_DOMAIN = "workday"
_WEEKDAY_KEYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
_DEFAULT_WORKDAYS = ("mon", "tue", "wed", "thu", "fri")
_provider: BusinessDayFn | None = None
def set_business_day_provider(fn: BusinessDayFn | None) -> None:
"""Install (or clear, with ``None``) the global business-day predicate."""
global _provider # deliberate module-global, see docstring
_provider = fn
def is_business_day(d: date) -> bool:
"""True when *d* is a business day.
Uses the installed Workday-backed provider when present; otherwise the
plain Mon-Fri rule. A crashing provider must never break due-date
computation, so it degrades to the weekday rule.
"""
if _provider is not None:
try:
return _provider(d)
except Exception: # noqa: BLE001 - degrade, never break scheduling
_LOGGER.warning(
"Business-day provider raised; falling back to Mon-Fri rule",
exc_info=True,
)
return d.weekday() < 5
return d.weekday() < 5
def build_provider_from_workday_options(
options: Mapping[str, Any],
) -> BusinessDayFn | None:
"""Build a business-day predicate from a Workday config entry's options.
Mirrors the Workday integration's semantics: a date is a business day when
its weekday is in ``workdays`` and — if ``"holiday"`` is excluded — the
date is not a public holiday (per ``country``/``province``, adjusted by
``add_holidays``/``remove_holidays``). Returns ``None`` when the
``holidays`` package is unavailable (it ships as a Workday requirement, so
this only happens when Workday isn't actually installed).
"""
try:
import holidays as holidays_pkg # Workday's own dependency
except ImportError:
return None
raw_workdays = options.get("workdays")
if raw_workdays is None:
raw_workdays = _DEFAULT_WORKDAYS
workday_names = set(raw_workdays)
workdays = {i for i, key in enumerate(_WEEKDAY_KEYS) if key in workday_names}
if not workdays:
return None # a config with no working days can't drive roll-back
excludes = set(options.get("excludes") or ("sat", "sun", "holiday"))
holidays_excluded = "holiday" in excludes
calendar: Any = None
country = options.get("country")
if holidays_excluded and country:
try:
calendar = holidays_pkg.country_holidays(country, subdiv=options.get("province") or None)
except Exception: # noqa: BLE001 - unknown country/subdiv in options
_LOGGER.warning(
"Could not build a holiday calendar for Workday config %r",
country,
exc_info=True,
)
calendar = None
# add_holidays / remove_holidays: ISO dates are honoured exactly; the
# Workday integration additionally allows *name fragments* in
# remove_holidays — matched case-insensitively against the holiday name.
added: set[date] = set()
for raw in options.get("add_holidays") or ():
try:
added.add(date.fromisoformat(str(raw)))
except (ValueError, TypeError):
continue
removed: set[date] = set()
removed_names: list[str] = []
for raw in options.get("remove_holidays") or ():
try:
removed.add(date.fromisoformat(str(raw)))
except (ValueError, TypeError):
removed_names.append(str(raw).casefold())
def provider(d: date) -> bool:
if d.weekday() not in workdays:
return False
if not holidays_excluded:
return True
if d in added:
return False
# `d in calendar` lazily populates the year on demand.
if calendar is not None and d in calendar:
if d in removed:
return True
if removed_names:
name = str(calendar.get(d) or "").casefold()
if any(fragment in name for fragment in removed_names):
return True
return False
return True
return provider
def async_setup_business_days(hass: HomeAssistant) -> None:
"""Install the business-day provider from the first Workday config entry.
Called during integration setup. Without a (loadable) Workday entry the
provider is cleared and business-day rolling uses the plain Mon-Fri rule.
"""
for entry in hass.config_entries.async_entries(WORKDAY_DOMAIN):
options: dict[str, Any] = {**entry.data, **entry.options}
fn = build_provider_from_workday_options(options)
if fn is not None:
set_business_day_provider(fn)
_LOGGER.debug(
"Business days follow Workday config %r (country=%s)",
entry.title,
options.get("country"),
)
return
set_business_day_provider(None)