182 files

This commit is contained in:
Home Assistant Version Control
2026-08-17 12:11:35 +00:00
parent 7dddf6bb13
commit ece15a1c1b
183 changed files with 11457 additions and 3092 deletions
@@ -12,7 +12,7 @@ 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
from ..const import CONF_OBJECT, CONF_TASKS, DOMAIN, GLOBAL_UNIQUE_ID, MaintenanceStatus
if TYPE_CHECKING:
from .. import MaintenanceSupporterData
@@ -27,9 +27,46 @@ _COUNTED_STATUSES = (
)
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 object_name(entry: ConfigEntry) -> str:
"""The object's display name, falling back to the entry title.
One rule for the ~13 hand-written fallbacks that existed in four spellings
— half used ``.get("name", title)``, which kept an EMPTY name instead of
falling through to the title."""
return str((entry.data.get(CONF_OBJECT) or {}).get("name") or entry.title)
def merged_tasks(entry: ConfigEntry) -> dict[str, Any]:
"""Merged task data (static ConfigEntry + dynamic Store) for an entry.
THE read path for task dicts outside the coordinator — it was re-implemented
eight times, and only one copy overlaid the in-cycle checklist ticks. The
ticks are overlaid HERE rather than via the Store merge whitelist: merged
dicts feed MaintenanceTask.from_dict all over the coordinator, and this
field is presentation state the model never needs. Degrades to the static
data when the Store is unavailable.
"""
tasks_data: dict[str, Any] = entry.data.get(CONF_TASKS, {})
rd = getattr(entry, "runtime_data", None)
store = getattr(rd, "store", None) if rd else None
if store is None:
return tasks_data
merged: dict[str, Any] = store.merge_all_tasks(tasks_data)
for tid, td in merged.items():
progress = store.get_task_state(tid).get("checklist_progress")
if progress:
td["checklist_progress"] = progress
return merged
def get_object_entries(hass: HomeAssistant, entry_ids: set[str] | None = None) -> list[ConfigEntry]:
"""Return all non-global config entries for this domain, optionally
narrowed to a selection (``entry_ids=None`` means all objects)."""
return [
entry
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.unique_id != GLOBAL_UNIQUE_ID and (entry_ids is None or entry.entry_id in entry_ids)
]
def get_runtime_data(hass: HomeAssistant, entry_id: str) -> MaintenanceSupporterData | None:
@@ -18,6 +18,7 @@ from ..const import (
MAX_CHECKLIST_ITEM_LENGTH,
MAX_CHECKLIST_ITEMS,
)
from .aggregate import merged_tasks
from .dates import INTERVAL_UNITS
from .global_options import get_default_warning_days
from .schedule import read_legacy_fields
@@ -33,6 +34,11 @@ _COLUMNS = [
"object_area_id",
"object_installation_date",
"object_warranty_expiry",
# The 2026-08 round-trip audit: the IMPORT read these two from day one,
# but the export never wrote the columns — a CSV migration silently
# dropped object notes + documentation URL.
"object_documentation_url",
"object_notes",
"task_name",
"task_type",
"enabled",
@@ -83,10 +89,7 @@ def export_objects_csv(hass: HomeAssistant, entry_ids: set[str] | None = None) -
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
tasks_data = merged_tasks(entry)
rd = getattr(entry, "runtime_data", None)
coord_data = rd.coordinator.data if rd and rd.coordinator else None
@@ -104,6 +107,8 @@ def export_objects_csv(hass: HomeAssistant, entry_ids: set[str] | None = None) -
"object_area_id": _csv_safe(obj_data.get("area_id", "")),
"object_installation_date": _csv_safe(obj_data.get("installation_date", "")),
"object_warranty_expiry": _csv_safe(obj_data.get("warranty_expiry", "")),
"object_documentation_url": _csv_safe(obj_data.get("documentation_url") or ""),
"object_notes": _csv_safe(obj_data.get("notes") or ""),
"task_name": _csv_safe(tdata.get("name", "")),
"task_type": tdata.get("type", "custom"),
"enabled": tdata.get("enabled", True),
@@ -169,10 +174,7 @@ def export_object_records_csv(hass: HomeAssistant, entry_ids: set[str] | None =
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
tasks_data = merged_tasks(entry)
writer.writerow(
{
"object_name": _csv_safe(obj_data.get("name", "")),
@@ -18,6 +18,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from ..const import (
CONF_ADVANCED_SCHEDULE_TIME,
CONF_DEFAULT_WARNING_DAYS,
CONF_PANEL_TITLE,
DEFAULT_WARNING_DAYS,
@@ -46,6 +47,16 @@ def get_global_options(hass: HomeAssistant) -> Mapping[str, Any]:
return (entry.options or entry.data) if entry is not None else {}
def is_schedule_time_enabled(hass: HomeAssistant) -> bool:
"""The global advanced flag for time-of-day scheduling.
Reads the global entry's options on every call (cheap), so a toggle in
Settings takes effect on the next refresh without a restart. Was copied
inline by the coordinator, calendar, and sensor before living here.
"""
return bool(get_global_options(hass).get(CONF_ADVANCED_SCHEDULE_TIME, False))
def get_default_warning_days(hass: HomeAssistant) -> int:
"""Resolve the integration-wide default warning_days for new tasks.
@@ -33,9 +33,9 @@ from ..const import (
DEFAULT_MAX_NOTIFICATIONS_PER_DAY,
DEFAULT_SNOOZE_DURATION_HOURS,
DOMAIN,
GLOBAL_UNIQUE_ID,
MaintenanceStatus,
)
from .global_options import get_global_options
from .i18n import normalize_language
_LOGGER = logging.getLogger(__name__)
@@ -572,6 +572,17 @@ _NOTIFICATION_STRINGS: dict[str, dict[str, str]] = {
}
def _service_payload(title: str, message: str, *, tag: str, url: str = "/maintenance-supporter") -> dict[str, Any]:
"""Notify payload with the deep link doubled into ``url`` (iOS) and
``clickAction`` (Android) — five hand-built copies each repeated the
link string twice."""
return {
"title": title,
"message": message,
"data": {"tag": tag, "url": url, "clickAction": url},
}
def _notif_t(key: str, lang: str, **kwargs: str) -> str:
"""Get notification translation string."""
strings = _NOTIFICATION_STRINGS.get(lang, _NOTIFICATION_STRINGS["en"])
@@ -705,10 +716,66 @@ class NotificationManager:
@property
def _global_options(self) -> Mapping[str, Any]:
"""Get global options from the global config entry."""
for entry in self.hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
return entry.options or entry.data
return {}
return get_global_options(self.hass)
def _rate_limited(self, key: str, min_seconds: float) -> bool:
"""True when ``key`` fired less than ``min_seconds`` ago.
The _SENT_ONCE sentinel never blocks here — only the status-change
path stores it, with its own interval-hours semantics. Extracted from
the bundle/budget copies of the elapsed check."""
last = self._last_notified.get(key)
if last is None or last == _SENT_ONCE:
return False
return (dt_util.now() - last).total_seconds() < min_seconds
async def _resolve_and_send(
self,
responsible_user_id: str | None,
*,
title: str,
message: str,
entry_id: str,
task_id: str,
) -> bool:
"""Resolve targets (per-user services, else the global service) and
fan the notification out; True when at least one send succeeded.
The status-change and lead-reminder paths carried drifting copies of
this block — one logged the per-user fallback, the other was silent.
"""
target_services: list[str] = []
if responsible_user_id:
user_services = await get_user_notify_services(self.hass, responsible_user_id)
if user_services:
target_services = user_services
_LOGGER.debug(
"Sending notification to user %s services: %s",
responsible_user_id,
user_services,
)
else:
_LOGGER.debug(
"User %s has no notification services, falling back to global",
responsible_user_id,
)
if not target_services and self.notify_service:
target_services = [self.notify_service]
if not target_services:
_LOGGER.warning("No notification services available")
return False
success = False
for service in target_services:
if await self._async_send_notification_to_service(
service=service,
title=title,
message=message,
entry_id=entry_id,
task_id=task_id,
):
success = True
return success
@property
def _lang(self) -> str:
@@ -937,44 +1004,13 @@ class NotificationManager:
lang = self._lang
title, message = self._build_message(new_status, lang, task_name, object_name, days_until_due, next_due)
# Determine target services: user-specific or global
target_services = []
if responsible_user_id:
user_services = await get_user_notify_services(self.hass, responsible_user_id)
if user_services:
target_services = user_services
_LOGGER.debug(
"Sending notification to user %s services: %s",
responsible_user_id,
user_services,
)
else:
_LOGGER.debug(
"User %s has no notification services, falling back to global",
responsible_user_id,
)
# Fallback to global service
if not target_services and self.notify_service:
target_services = [self.notify_service]
if not target_services:
_LOGGER.warning("No notification services available")
return
# Send to all target services
success = False
for service in target_services:
if await self._async_send_notification_to_service(
service=service,
title=title,
message=message,
entry_id=entry_id,
task_id=task_id,
):
success = True
if not success:
if not await self._resolve_and_send(
responsible_user_id,
title=title,
message=message,
entry_id=entry_id,
task_id=task_id,
):
return
# Record send time (only on success)
@@ -1085,15 +1121,14 @@ class NotificationManager:
}
)
service_data: dict[str, Any] = {"title": title, "message": message}
data: dict[str, Any] = {
"tag": f"maintenance_{task_id}",
"url": f"/maintenance-supporter?entry_id={entry_id}&task_id={task_id}",
"clickAction": f"/maintenance-supporter?entry_id={entry_id}&task_id={task_id}",
}
service_data = _service_payload(
title,
message,
tag=f"maintenance_{task_id}",
url=f"/maintenance-supporter?entry_id={entry_id}&task_id={task_id}",
)
if actions:
data["actions"] = actions[:3] # Android supports max 3
service_data["data"] = data
service_data["data"]["actions"] = actions[:3] # Android supports max 3
try:
return await async_dispatch_notify(self.hass, service, service_data)
@@ -1117,12 +1152,8 @@ class NotificationManager:
# Rate-limit bundled notifications (once per hour)
bundle_key = f"{entry_id}_bundled"
if bundle_key in self._last_notified:
last = self._last_notified[bundle_key]
if last != _SENT_ONCE:
elapsed = (dt_util.now() - last).total_seconds()
if elapsed < 3600:
return
if self._rate_limited(bundle_key, 3600):
return
if not self._check_daily_limit():
return
@@ -1148,15 +1179,12 @@ class NotificationManager:
if self.title_style == "object_name" and object_name:
title = object_name
service_data: dict[str, Any] = {
"title": title,
"message": message,
"data": {
"tag": f"maintenance_bundled_{entry_id}",
"url": f"/maintenance-supporter?entry_id={entry_id}",
"clickAction": f"/maintenance-supporter?entry_id={entry_id}",
},
}
service_data = _service_payload(
title,
message,
tag=f"maintenance_bundled_{entry_id}",
url=f"/maintenance-supporter?entry_id={entry_id}",
)
try:
if await async_dispatch_notify(self.hass, self.notify_service, service_data):
@@ -1178,15 +1206,11 @@ class NotificationManager:
if not self.enabled or not self.notify_service:
return
lang = self._lang
service_data: dict[str, Any] = {
"title": _notif_t("digest_title", lang),
"message": _notif_t("digest_message", lang, overdue=str(overdue), due_soon=str(due_soon)),
"data": {
"tag": "maintenance_weekly_digest",
"url": "/maintenance-supporter",
"clickAction": "/maintenance-supporter",
},
}
service_data = _service_payload(
_notif_t("digest_title", lang),
_notif_t("digest_message", lang, overdue=str(overdue), due_soon=str(due_soon)),
tag="maintenance_weekly_digest",
)
try:
await async_dispatch_notify(self.hass, self.notify_service, service_data)
_LOGGER.debug("Weekly digest sent: %s overdue, %s due soon", overdue, due_soon)
@@ -1202,21 +1226,17 @@ class NotificationManager:
if not self.enabled or not self.notify_service or not names:
return
lang = self._lang
service_data: dict[str, Any] = {
"title": _notif_t("warranty_title", lang),
"message": _notif_t(
service_data = _service_payload(
_notif_t("warranty_title", lang),
_notif_t(
"warranty_message",
lang,
count=str(len(names)),
days=str(days),
names=", ".join(names),
),
"data": {
"tag": "maintenance_warranty_reminder",
"url": "/maintenance-supporter",
"clickAction": "/maintenance-supporter",
},
}
tag="maintenance_warranty_reminder",
)
try:
await async_dispatch_notify(self.hass, self.notify_service, service_data)
_LOGGER.debug("Warranty reminder sent: %s object(s)", len(names))
@@ -1269,27 +1289,13 @@ class NotificationManager:
due=next_due if next_due is not None else "?",
)
target_services: list[str] = []
if responsible_user_id:
user_services = await get_user_notify_services(self.hass, responsible_user_id)
if user_services:
target_services = user_services
if not target_services and self.notify_service:
target_services = [self.notify_service]
if not target_services:
return
success = False
for service in target_services:
if await self._async_send_notification_to_service(
service=service,
title=title,
message=message,
entry_id=entry_id,
task_id=task_id,
):
success = True
if success:
if await self._resolve_and_send(
responsible_user_id,
title=title,
message=message,
entry_id=entry_id,
task_id=task_id,
):
self._daily_count += 1
_LOGGER.debug("Lead reminder sent: %s due in %s day(s)", task_name, days)
@@ -1309,12 +1315,8 @@ class NotificationManager:
# Rate-limit budget alerts (once per 24 hours per period)
budget_key = f"_budget_{period}"
if budget_key in self._last_notified:
last = self._last_notified[budget_key]
if last != _SENT_ONCE:
elapsed = (dt_util.now() - last).total_seconds()
if elapsed < 86400: # 24 hours
return
if self._rate_limited(budget_key, 86400):
return
if not self._check_daily_limit():
return
@@ -1331,15 +1333,7 @@ class NotificationManager:
budget=f"{budget:.2f}{currency_symbol}",
)
service_data: dict[str, Any] = {
"title": title,
"message": message,
"data": {
"tag": f"maintenance_budget_{period}",
"url": "/maintenance-supporter",
"clickAction": "/maintenance-supporter",
},
}
service_data = _service_payload(title, message, tag=f"maintenance_budget_{period}")
try:
if await async_dispatch_notify(self.hass, self.notify_service, service_data):
@@ -30,6 +30,7 @@ from ..const import (
GLOBAL_UNIQUE_ID,
MAX_ADOPTED_NOTES,
)
from .aggregate import object_name
PROBLEM_DEVICE_CLASS = "problem"
# safety (NAS disk-health / lifespan thresholds) and tamper alarms behave like
@@ -95,7 +96,7 @@ def _object_by_device(hass: HomeAssistant) -> dict[str, dict[str, str]]:
obj = entry.data.get(CONF_OBJECT, {})
dev = obj.get("ha_device_id")
if dev:
out[dev] = {"entry_id": entry.entry_id, "name": obj.get("name", entry.title)}
out[dev] = {"entry_id": entry.entry_id, "name": object_name(entry)}
return out
@@ -108,24 +108,17 @@ def _coerce_int(value: Any, default: int) -> int:
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
from .global_options import get_global_options
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 {}
return dict(get_global_options(hass))
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
from .aggregate import merged_tasks
return merged_tasks(entry)
async def async_run_retention_sweep(hass: HomeAssistant) -> None:
@@ -171,17 +164,18 @@ async def async_run_retention_sweep(hass: HomeAssistant) -> None:
continue
if to_archive:
new_tasks = dict(entry.data.get(CONF_TASKS, {}))
from .entry_tasks import write_tasks
existing = entry.data.get(CONF_TASKS, {})
updates: dict[str, dict[str, Any]] = {}
for tid in to_archive:
if tid not in new_tasks:
if tid not in existing:
continue
td = dict(new_tasks[tid])
td = dict(existing[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)
updates[tid] = td
write_tasks(hass, entry, updates)
_LOGGER.info(
"Auto-archived %d completed one-off task(s) in %s",
len(to_archive),
@@ -98,6 +98,37 @@ def _cap_strings(d: dict[str, Any], limits: dict[str, int]) -> None:
d[field] = v[:max_len]
# Keys a FRESH task copy must never inherit: per-task-unique identity
# (entity_slug, nfc_tag_id) and dynamic lifecycle state (history, schedule
# anchors, a one-shot due_override defer, adaptive tuning, archive markers).
# One list for task-duplicate, object-duplicate, and object-replace — the
# three hand-copied lists had drifted (task-duplicate kept archive markers,
# and none of them dropped due_override).
_FRESH_COPY_STRIP_KEYS = (
"entity_slug",
"nfc_tag_id",
"history",
"last_performed",
"last_planned_due",
"due_override",
"adaptive_config",
"archived_at",
"archived_reason",
)
def strip_task_runtime_state(task: dict[str, Any]) -> dict[str, Any]:
"""Remove unique/dynamic state from a task dict copy, in place.
Returns the same dict for fluent use.
"""
for key in _FRESH_COPY_STRIP_KEYS:
task.pop(key, None)
if isinstance(task.get("trigger_config"), dict):
task["trigger_config"].pop("_trigger_state", None)
return task
def cap_task_fields(task_data: dict[str, Any]) -> dict[str, Any]:
"""Truncate user-controllable strings + numerics on a task dict in-place.
@@ -30,7 +30,7 @@ from ..const import (
DOMAIN,
GLOBAL_UNIQUE_ID,
)
from .dates import add_interval
from .dates import add_interval, parse_iso_date
from .schedule import (
KIND_DAY_OF_MONTH,
KIND_NTH_WEEKDAY,
@@ -122,12 +122,7 @@ class VacationState:
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
return parse_iso_date(value)
def _coerce_buffer(value: Any) -> int: