New apps Added
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""Helper modules for the Maintenance Supporter integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.0–1.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)
|
||||
Reference in New Issue
Block a user