1633 lines
69 KiB
Python
1633 lines
69 KiB
Python
"""The Maintenance Supporter integration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from datetime import timedelta
|
|
from typing import Any
|
|
|
|
import voluptuous as vol
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STOP
|
|
from homeassistant.core import (
|
|
Event,
|
|
HomeAssistant,
|
|
ServiceCall,
|
|
SupportsResponse,
|
|
callback,
|
|
)
|
|
from homeassistant.exceptions import ServiceValidationError
|
|
from homeassistant.helpers import (
|
|
config_validation as cv,
|
|
)
|
|
from homeassistant.helpers import (
|
|
device_registry as dr,
|
|
)
|
|
from homeassistant.helpers import (
|
|
entity_registry as er,
|
|
)
|
|
from homeassistant.helpers.event import (
|
|
async_track_time_change,
|
|
async_track_time_interval,
|
|
)
|
|
from homeassistant.helpers.start import async_at_started
|
|
from homeassistant.helpers.typing import ConfigType
|
|
|
|
from .const import (
|
|
CONF_ADMIN_PANEL_USER_IDS,
|
|
CONF_ADVANCED_ADAPTIVE,
|
|
CONF_ADVANCED_BUDGET,
|
|
CONF_ADVANCED_CHECKLISTS,
|
|
CONF_ADVANCED_ENVIRONMENTAL,
|
|
CONF_ADVANCED_GROUPS,
|
|
CONF_ADVANCED_PREDICTIONS,
|
|
CONF_ADVANCED_SCHEDULE_TIME,
|
|
CONF_ADVANCED_SEASONAL,
|
|
CONF_BUDGET_MONTHLY,
|
|
CONF_BUDGET_YEARLY,
|
|
CONF_GROUPS,
|
|
CONF_INSTALL_ASSIST_SENTENCES,
|
|
CONF_OBJECT,
|
|
CONF_PANEL_ENABLED,
|
|
CONF_TASKS,
|
|
CONF_WEEKLY_DIGEST_ENABLED,
|
|
DEFAULT_PANEL_ENABLED,
|
|
DEFAULT_WARNING_DAYS,
|
|
DOMAIN,
|
|
EVENT_UNSUBS_KEY,
|
|
GLOBAL_UNIQUE_ID,
|
|
MAX_COST,
|
|
MAX_DURATION_MINUTES,
|
|
MAX_LABEL_LENGTH,
|
|
MAX_LABELS,
|
|
MAX_NAME_LENGTH,
|
|
MAX_TEXT_LENGTH,
|
|
MAX_TYPE_LENGTH,
|
|
PLATFORMS,
|
|
SERVICE_ADD_OBJECT,
|
|
SERVICE_ADD_TASK,
|
|
SERVICE_COMPLETE,
|
|
SERVICE_DELETE_TASK,
|
|
SERVICE_EXPORT,
|
|
SERVICE_LIST_TASKS,
|
|
SERVICE_RESET,
|
|
SERVICE_SKIP,
|
|
SERVICE_UPDATE_TASK,
|
|
SIGNAL_NEW_OBJECT_ENTRY,
|
|
SIGNAL_OBJECT_ENTRY_REMOVED,
|
|
STORES_CACHE_KEY,
|
|
)
|
|
from .const import (
|
|
DOCUMENT_STORE_KEY as _DS_KEY,
|
|
)
|
|
from .const import (
|
|
NOTIFICATION_MANAGER_KEY as _NM_KEY,
|
|
)
|
|
from .coordinator import MaintenanceCoordinator
|
|
from .entity.summary_coordinator import MaintenanceSummaryCoordinator
|
|
from .frontend import async_register_card
|
|
from .helpers.assist_sentences import async_sync as async_sync_assist_sentences
|
|
from .helpers.dates import INTERVAL_UNITS
|
|
from .helpers.documents import DocumentStore
|
|
from .helpers.notification_manager import NotificationManager
|
|
from .helpers.schedule import normalize_task_storage
|
|
from .helpers.task_fields import (
|
|
INTERVAL_DAYS_RANGE,
|
|
TASK_PRIORITIES,
|
|
WARNING_DAYS_RANGE,
|
|
)
|
|
from .panel import async_register_panel, async_unregister_panel
|
|
from .storage import MaintenanceStore, async_migrate_to_store
|
|
from .websocket import async_register_commands
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
|
# Re-exported from const for the existing deferred `from . import ...` users.
|
|
NOTIFICATION_MANAGER_KEY = _NM_KEY
|
|
DOCUMENT_STORE_KEY = _DS_KEY
|
|
|
|
|
|
@dataclass
|
|
class MaintenanceSupporterData:
|
|
"""Runtime data for a Maintenance Supporter config entry."""
|
|
|
|
coordinator: MaintenanceCoordinator | None = None
|
|
store: MaintenanceStore | None = None
|
|
# Only set on the global entry: aggregates status counts for the summary
|
|
# sensors. None on per-object entries.
|
|
summary_coordinator: MaintenanceSummaryCoordinator | None = None
|
|
|
|
|
|
type MaintenanceSupporterConfigEntry = ConfigEntry[MaintenanceSupporterData]
|
|
|
|
|
|
# --- Service Schemas ---
|
|
SERVICE_COMPLETE_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
|
|
vol.Optional("notes"): vol.All(cv.string, vol.Length(max=2000)),
|
|
vol.Optional("cost"): vol.All(vol.Coerce(float), vol.Range(min=0, max=MAX_COST)),
|
|
vol.Optional("duration"): vol.All(vol.Coerce(int), vol.Range(min=0, max=MAX_DURATION_MINUTES)),
|
|
# Meter readings (v2.20, #83): recorded value for `reading` tasks.
|
|
vol.Optional("reading_value"): vol.All(vol.Coerce(float), vol.Range(min=-1e12, max=1e12)),
|
|
}
|
|
)
|
|
|
|
SERVICE_RESET_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
|
|
vol.Optional("date"): cv.date,
|
|
}
|
|
)
|
|
|
|
SERVICE_SKIP_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
|
|
vol.Optional("reason"): vol.All(cv.string, vol.Length(max=2000)),
|
|
}
|
|
)
|
|
|
|
SERVICE_EXPORT_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Optional("format", default="json"): vol.In(["json", "yaml"]),
|
|
vol.Optional("include_history", default=True): cv.boolean,
|
|
}
|
|
)
|
|
|
|
SERVICE_ADD_OBJECT_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required("name"): vol.All(cv.string, vol.Length(min=1, max=255)),
|
|
vol.Optional("area_id"): cv.string,
|
|
vol.Optional("manufacturer"): cv.string,
|
|
vol.Optional("model"): cv.string,
|
|
vol.Optional("serial_number"): cv.string,
|
|
vol.Optional("installation_date"): cv.string,
|
|
vol.Optional("warranty_expiry"): cv.string,
|
|
vol.Optional("documentation_url"): cv.string,
|
|
vol.Optional("notes"): cv.string,
|
|
}
|
|
)
|
|
|
|
# The service task schemas mirror the WS `task/create` / `task/update` schemas
|
|
# field-for-field, consuming the SAME constants + ranges (const.py /
|
|
# helpers.task_fields) rather than restating literals — the service path is
|
|
# just a third UI onto the same storage, so a value the WS API rejects must not
|
|
# be reachable from an automation. `vol.Coerce` stays (unlike the WS schemas)
|
|
# because YAML/templated service data arrives as strings.
|
|
SERVICE_ADD_TASK_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required("entry_id"): cv.string,
|
|
vol.Required("name"): vol.All(cv.string, vol.Length(min=1, max=MAX_NAME_LENGTH)),
|
|
vol.Optional("task_type"): vol.All(cv.string, vol.Length(max=MAX_TYPE_LENGTH)),
|
|
vol.Optional("schedule_type"): vol.All(cv.string, vol.Length(max=MAX_TYPE_LENGTH)),
|
|
vol.Optional("interval_days"): vol.All(
|
|
vol.Coerce(int), vol.Range(min=INTERVAL_DAYS_RANGE[0], max=INTERVAL_DAYS_RANGE[1])
|
|
),
|
|
vol.Optional("interval_unit"): vol.In(INTERVAL_UNITS),
|
|
vol.Optional("due_date"): cv.string,
|
|
# Nested recurrence for the calendar kinds (weekdays / nth_weekday /
|
|
# day_of_month); takes precedence over the flat fields above.
|
|
vol.Optional("schedule"): dict,
|
|
vol.Optional("warning_days"): vol.All(vol.Coerce(int), vol.Range(min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1])),
|
|
vol.Optional("enabled"): cv.boolean,
|
|
vol.Optional("notes"): vol.All(cv.string, vol.Length(max=MAX_TEXT_LENGTH)),
|
|
}
|
|
)
|
|
|
|
SERVICE_UPDATE_TASK_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required("entry_id"): cv.string,
|
|
vol.Required("task_id"): cv.string,
|
|
vol.Optional("name"): vol.All(cv.string, vol.Length(min=1, max=MAX_NAME_LENGTH)),
|
|
vol.Optional("task_type"): vol.All(cv.string, vol.Length(max=MAX_TYPE_LENGTH)),
|
|
vol.Optional("schedule_type"): vol.All(cv.string, vol.Length(max=MAX_TYPE_LENGTH)),
|
|
vol.Optional("interval_days"): vol.All(
|
|
vol.Coerce(int), vol.Range(min=INTERVAL_DAYS_RANGE[0], max=INTERVAL_DAYS_RANGE[1])
|
|
),
|
|
vol.Optional("interval_unit"): vol.In(INTERVAL_UNITS),
|
|
vol.Optional("due_date"): cv.string,
|
|
vol.Optional("schedule"): dict,
|
|
vol.Optional("warning_days"): vol.All(vol.Coerce(int), vol.Range(min=WARNING_DAYS_RANGE[0], max=WARNING_DAYS_RANGE[1])),
|
|
vol.Optional("enabled"): cv.boolean,
|
|
vol.Optional("notes"): vol.All(cv.string, vol.Length(max=MAX_TEXT_LENGTH)),
|
|
vol.Optional("priority"): vol.In(TASK_PRIORITIES),
|
|
vol.Optional("labels"): vol.All([vol.All(cv.string, vol.Length(max=MAX_LABEL_LENGTH))], vol.Length(max=MAX_LABELS)),
|
|
}
|
|
)
|
|
|
|
SERVICE_DELETE_TASK_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Required("entry_id"): cv.string,
|
|
vol.Required("task_id"): cv.string,
|
|
}
|
|
)
|
|
|
|
SERVICE_LIST_TASKS_SCHEMA = vol.Schema(
|
|
{
|
|
vol.Optional("entry_id"): cv.string,
|
|
vol.Optional("status"): vol.In(["ok", "due_soon", "overdue", "triggered"]),
|
|
}
|
|
)
|
|
|
|
|
|
async def async_maybe_send_weekly_digest(hass: HomeAssistant, *, force: bool = False) -> None:
|
|
"""Send the opt-in weekly digest when it's due.
|
|
|
|
Gated on: it being Monday (skipped when ``force``), the global
|
|
``weekly_digest_enabled`` setting, and there being something to report.
|
|
Extracted from the daily 08:00 time-change tick so it's unit-testable and
|
|
can be triggered directly.
|
|
"""
|
|
from homeassistant.util import dt as dt_util
|
|
|
|
if not force and dt_util.now().weekday() != 0: # Monday only
|
|
return
|
|
global_entry = next(
|
|
(e for e in hass.config_entries.async_entries(DOMAIN) if e.unique_id == GLOBAL_UNIQUE_ID),
|
|
None,
|
|
)
|
|
if global_entry is None:
|
|
return
|
|
options = global_entry.options or global_entry.data
|
|
if not options.get(CONF_WEEKLY_DIGEST_ENABLED, False):
|
|
return
|
|
from .helpers.aggregate import compute_status_counts
|
|
|
|
counts = compute_status_counts(hass)
|
|
overdue = int(counts.get("overdue", 0))
|
|
due_soon = int(counts.get("due_soon", 0)) + int(counts.get("triggered", 0))
|
|
if overdue == 0 and due_soon == 0:
|
|
return # nothing to report — stay silent
|
|
nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
|
|
if nm is not None:
|
|
await nm.async_send_weekly_digest(overdue, due_soon)
|
|
|
|
|
|
async def async_maybe_send_warranty_reminders(hass: HomeAssistant, *, force: bool = False) -> None:
|
|
"""Remind about maintenance-object warranties nearing expiry.
|
|
|
|
Opt-in (``warranty_reminder_enabled``). Fires the day an object's warranty is
|
|
exactly ``warranty_reminder_days`` days out — once, no per-object persistence
|
|
needed. ``force`` widens it to the whole 0..N window (for the test hook).
|
|
"""
|
|
from datetime import date
|
|
|
|
from homeassistant.util import dt as dt_util
|
|
|
|
from .const import (
|
|
CONF_WARRANTY_REMINDER_DAYS,
|
|
CONF_WARRANTY_REMINDER_ENABLED,
|
|
DEFAULT_WARRANTY_REMINDER_DAYS,
|
|
)
|
|
|
|
global_entry = next(
|
|
(e for e in hass.config_entries.async_entries(DOMAIN) if e.unique_id == GLOBAL_UNIQUE_ID),
|
|
None,
|
|
)
|
|
if global_entry is None:
|
|
return
|
|
options = global_entry.options or global_entry.data
|
|
if not options.get(CONF_WARRANTY_REMINDER_ENABLED, False):
|
|
return
|
|
days = int(options.get(CONF_WARRANTY_REMINDER_DAYS, DEFAULT_WARRANTY_REMINDER_DAYS))
|
|
today = dt_util.now().date()
|
|
names: list[str] = []
|
|
for entry in hass.config_entries.async_entries(DOMAIN):
|
|
if entry.unique_id == GLOBAL_UNIQUE_ID:
|
|
continue
|
|
obj = entry.data.get(CONF_OBJECT) or {}
|
|
expiry = obj.get("warranty_expiry")
|
|
if not expiry:
|
|
continue
|
|
try:
|
|
delta = (date.fromisoformat(expiry) - today).days
|
|
except (ValueError, TypeError):
|
|
continue
|
|
if delta == days or (force and 0 <= delta <= days):
|
|
names.append(obj.get("name") or entry.title)
|
|
if not names:
|
|
return
|
|
nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
|
|
if nm is not None:
|
|
await nm.async_send_warranty_reminder(names, days)
|
|
|
|
|
|
async def async_maybe_send_lead_reminders(hass: HomeAssistant) -> None:
|
|
"""Send extra lead-time reminders for tasks due in exactly N days.
|
|
|
|
Opt-in via the ``reminder_lead_days`` list (e.g. [14, 3, 0]) — for every
|
|
active task whose days-until-due matches one of the configured leads, one
|
|
reminder fires through the notification manager (per-user routing, quiet
|
|
hours, vacation, snooze, daily limit all apply). Runs from the daily
|
|
08:00 tick, so each lead fires at most once per day per task, and once
|
|
per cycle overall (the day-match only occurs on one day per lead).
|
|
Complements — does not replace — the warning_days status-change path.
|
|
"""
|
|
from .const import CONF_REMINDER_LEAD_DAYS
|
|
from .models.maintenance_task import MaintenanceTask
|
|
|
|
global_entry = next(
|
|
(e for e in hass.config_entries.async_entries(DOMAIN) if e.unique_id == GLOBAL_UNIQUE_ID),
|
|
None,
|
|
)
|
|
if global_entry is None:
|
|
return
|
|
options = global_entry.options or global_entry.data
|
|
leads_raw = options.get(CONF_REMINDER_LEAD_DAYS) or []
|
|
leads = {v for v in leads_raw if isinstance(v, int) and not isinstance(v, bool)}
|
|
if not leads:
|
|
return
|
|
|
|
nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
|
|
if nm is None:
|
|
return
|
|
|
|
for entry in hass.config_entries.async_entries(DOMAIN):
|
|
if entry.unique_id == GLOBAL_UNIQUE_ID:
|
|
continue
|
|
obj = entry.data.get(CONF_OBJECT) or {}
|
|
if obj.get("archived_at") is not None:
|
|
continue
|
|
# v2.20 (N3): a seasonally paused object's schedules are frozen — no
|
|
# lead reminders until it resumes.
|
|
if obj.get("paused_at") is not None:
|
|
continue
|
|
rd = getattr(entry, "runtime_data", None)
|
|
coordinator = getattr(rd, "coordinator", None)
|
|
if coordinator is None:
|
|
continue
|
|
# Merged data so last_performed reflects the Store, not stale entry data.
|
|
merged = coordinator._get_merged_tasks_data()
|
|
obj_name = obj.get("name") or entry.title
|
|
for task_id, task_data in merged.items():
|
|
if not task_data.get("enabled", True):
|
|
continue
|
|
if task_data.get("archived_at") is not None:
|
|
continue
|
|
task = MaintenanceTask.from_dict(task_data)
|
|
days = task.days_until_due
|
|
# days == 0 is a valid lead ("on the due date"); negatives are the
|
|
# overdue path's job (notify_overdue_interval_hours).
|
|
if days is None or days < 0 or days not in leads:
|
|
continue
|
|
await nm.async_send_lead_reminder(
|
|
entry_id=entry.entry_id,
|
|
task_id=task_id,
|
|
task_name=task.name,
|
|
object_name=obj_name,
|
|
days=days,
|
|
next_due=task.next_due.isoformat() if task.next_due else None,
|
|
responsible_user_id=task.responsible_user_id,
|
|
)
|
|
|
|
|
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|
"""Set up the Maintenance Supporter integration."""
|
|
return await _async_setup_shared(hass)
|
|
|
|
|
|
async def _async_setup_shared(hass: HomeAssistant) -> bool:
|
|
"""Build the entry-independent runtime (services, WS API, listeners,
|
|
notification manager, document store).
|
|
|
|
HA calls ``async_setup`` exactly once per boot, but unloading the LAST
|
|
entry pops ``hass.data[DOMAIN]`` — so an uninstall → reinstall within the
|
|
same HA run must rebuild all of this (journey O1). ``async_setup_entry``
|
|
re-invokes this when it finds the shared runtime missing. Re-running is
|
|
safe: service/WS registrations overwrite in place, and the HTTP-level
|
|
registrations (card static paths, document views) carry their own guards
|
|
outside the popped dict.
|
|
"""
|
|
hass.data.setdefault(DOMAIN, {})
|
|
|
|
# "Business day" scheduling (the day-of-month `business` flag) follows the
|
|
# user's Workday integration when one is configured — public holidays and
|
|
# custom working weekdays instead of the plain Mon-Fri rule (#83 follow-up).
|
|
from .helpers.workday import async_setup_business_days
|
|
|
|
await async_setup_business_days(hass)
|
|
|
|
# Create the notification manager (shared across all entries)
|
|
hass.data[DOMAIN][NOTIFICATION_MANAGER_KEY] = NotificationManager(hass)
|
|
|
|
# Create + load the global document store (per-object doc metadata + the
|
|
# content-addressed blob registry; binaries live on disk under /config).
|
|
doc_store = DocumentStore(hass)
|
|
await doc_store.async_load()
|
|
hass.data[DOMAIN][DOCUMENT_STORE_KEY] = doc_store
|
|
|
|
# Authenticated upload + serve endpoints for document blobs (the blobs live
|
|
# under /config, so they must never be exposed via an unauthenticated path).
|
|
from .views import async_register_document_views
|
|
|
|
async_register_document_views(hass)
|
|
|
|
async def _handle_complete(call: ServiceCall) -> None:
|
|
"""Handle the complete service call."""
|
|
entity_id = call.data[ATTR_ENTITY_ID]
|
|
coordinator = _get_coordinator_for_entity(hass, entity_id)
|
|
if coordinator is None:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="no_coordinator_for_entity",
|
|
translation_placeholders={"entity_id": entity_id},
|
|
)
|
|
task_id = _get_task_id_for_entity(hass, entity_id)
|
|
if task_id is None:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="no_task_for_entity",
|
|
translation_placeholders={"entity_id": entity_id},
|
|
)
|
|
await coordinator.complete_maintenance(
|
|
task_id=task_id,
|
|
notes=call.data.get("notes"),
|
|
cost=call.data.get("cost"),
|
|
duration=call.data.get("duration"),
|
|
reading_value=call.data.get("reading_value"),
|
|
)
|
|
|
|
async def _handle_reset(call: ServiceCall) -> None:
|
|
"""Handle the reset service call."""
|
|
entity_id = call.data[ATTR_ENTITY_ID]
|
|
coordinator = _get_coordinator_for_entity(hass, entity_id)
|
|
if coordinator is None:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="no_coordinator_for_entity",
|
|
translation_placeholders={"entity_id": entity_id},
|
|
)
|
|
task_id = _get_task_id_for_entity(hass, entity_id)
|
|
if task_id is None:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="no_task_for_entity",
|
|
translation_placeholders={"entity_id": entity_id},
|
|
)
|
|
await coordinator.reset_maintenance(
|
|
task_id=task_id,
|
|
date=call.data.get("date"),
|
|
)
|
|
|
|
async def _handle_skip(call: ServiceCall) -> None:
|
|
"""Handle the skip service call."""
|
|
entity_id = call.data[ATTR_ENTITY_ID]
|
|
coordinator = _get_coordinator_for_entity(hass, entity_id)
|
|
if coordinator is None:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="no_coordinator_for_entity",
|
|
translation_placeholders={"entity_id": entity_id},
|
|
)
|
|
task_id = _get_task_id_for_entity(hass, entity_id)
|
|
if task_id is None:
|
|
raise ServiceValidationError(
|
|
translation_domain=DOMAIN,
|
|
translation_key="no_task_for_entity",
|
|
translation_placeholders={"entity_id": entity_id},
|
|
)
|
|
await coordinator.skip_maintenance(
|
|
task_id=task_id,
|
|
reason=call.data.get("reason"),
|
|
)
|
|
|
|
async def _handle_export(call: ServiceCall) -> None:
|
|
"""Handle the export_data service call."""
|
|
from .export import build_export_data, serialize_export_to_file
|
|
|
|
fmt = call.data.get("format", "json")
|
|
include_history = call.data.get("include_history", True)
|
|
|
|
# Phase 1: gather data on the event loop (accesses HA APIs)
|
|
data = build_export_data(hass, include_history=include_history)
|
|
|
|
# Phase 2: serialize in executor (CPU-bound, no HA API calls)
|
|
file_path = hass.config.path(f"maintenance_export.{fmt}")
|
|
await hass.async_add_executor_job(serialize_export_to_file, data, fmt, file_path)
|
|
|
|
# Fire lightweight event (file path only, not the full payload)
|
|
hass.bus.async_fire(
|
|
f"{DOMAIN}_export_completed",
|
|
{"format": fmt, "file_path": file_path},
|
|
)
|
|
|
|
async def _handle_add_object(call: ServiceCall) -> dict[str, Any] | None:
|
|
"""Handle the add_object service — create a maintenance object."""
|
|
from .websocket.objects import async_create_object
|
|
from .websocket.tasks import _is_safe_url
|
|
|
|
url = call.data.get("documentation_url")
|
|
if url and not _is_safe_url(url):
|
|
raise ServiceValidationError("Only http/https URLs are allowed")
|
|
try:
|
|
entry_id = await async_create_object(
|
|
hass,
|
|
name=call.data["name"],
|
|
area_id=call.data.get("area_id"),
|
|
manufacturer=call.data.get("manufacturer"),
|
|
model=call.data.get("model"),
|
|
serial_number=call.data.get("serial_number"),
|
|
installation_date=call.data.get("installation_date"),
|
|
warranty_expiry=call.data.get("warranty_expiry"),
|
|
documentation_url=url,
|
|
notes=call.data.get("notes"),
|
|
)
|
|
except ValueError as err:
|
|
raise ServiceValidationError(str(err)) from err
|
|
return {"entry_id": entry_id} if call.return_response else None
|
|
|
|
async def _handle_add_task(call: ServiceCall) -> dict[str, Any] | None:
|
|
"""Handle the add_task service — add a task to an existing object."""
|
|
from .websocket.tasks import async_create_task_simple
|
|
|
|
try:
|
|
task_id = await async_create_task_simple(
|
|
hass,
|
|
entry_id=call.data["entry_id"],
|
|
name=call.data["name"],
|
|
task_type=call.data.get("task_type", "custom"),
|
|
schedule_type=call.data.get("schedule_type", "time_based"),
|
|
interval_days=call.data.get("interval_days"),
|
|
interval_unit=call.data.get("interval_unit", "days"),
|
|
due_date=call.data.get("due_date"),
|
|
warning_days=call.data.get("warning_days", DEFAULT_WARNING_DAYS),
|
|
enabled=call.data.get("enabled", True),
|
|
notes=call.data.get("notes"),
|
|
schedule=call.data.get("schedule"),
|
|
)
|
|
except ValueError as err:
|
|
raise ServiceValidationError(str(err)) from err
|
|
return {"task_id": task_id} if call.return_response else None
|
|
|
|
hass.services.async_register(DOMAIN, SERVICE_COMPLETE, _handle_complete, schema=SERVICE_COMPLETE_SCHEMA)
|
|
hass.services.async_register(DOMAIN, SERVICE_RESET, _handle_reset, schema=SERVICE_RESET_SCHEMA)
|
|
hass.services.async_register(DOMAIN, SERVICE_SKIP, _handle_skip, schema=SERVICE_SKIP_SCHEMA)
|
|
hass.services.async_register(DOMAIN, SERVICE_EXPORT, _handle_export, schema=SERVICE_EXPORT_SCHEMA)
|
|
hass.services.async_register(
|
|
DOMAIN,
|
|
SERVICE_ADD_OBJECT,
|
|
_handle_add_object,
|
|
schema=SERVICE_ADD_OBJECT_SCHEMA,
|
|
supports_response=SupportsResponse.OPTIONAL,
|
|
)
|
|
hass.services.async_register(
|
|
DOMAIN,
|
|
SERVICE_ADD_TASK,
|
|
_handle_add_task,
|
|
schema=SERVICE_ADD_TASK_SCHEMA,
|
|
supports_response=SupportsResponse.OPTIONAL,
|
|
)
|
|
|
|
async def _handle_update_task(call: ServiceCall) -> None:
|
|
"""Handle update_task — patch the common fields of an existing task."""
|
|
from .websocket.tasks_persist import async_update_task_simple
|
|
|
|
updates = {
|
|
"name": call.data.get("name"),
|
|
"type": call.data.get("task_type"),
|
|
"schedule_type": call.data.get("schedule_type"),
|
|
"interval_days": call.data.get("interval_days"),
|
|
"interval_unit": call.data.get("interval_unit"),
|
|
"due_date": call.data.get("due_date"),
|
|
"schedule": call.data.get("schedule"),
|
|
"warning_days": call.data.get("warning_days"),
|
|
"enabled": call.data.get("enabled"),
|
|
"notes": call.data.get("notes"),
|
|
"priority": call.data.get("priority"),
|
|
"labels": call.data.get("labels"),
|
|
}
|
|
try:
|
|
await async_update_task_simple(
|
|
hass,
|
|
entry_id=call.data["entry_id"],
|
|
task_id=call.data["task_id"],
|
|
updates=updates,
|
|
)
|
|
except ValueError as err:
|
|
raise ServiceValidationError(str(err)) from err
|
|
|
|
async def _handle_delete_task(call: ServiceCall) -> None:
|
|
"""Handle delete_task — remove a task and its side-state."""
|
|
from .websocket.tasks_crud import async_delete_task
|
|
|
|
entry_id = call.data["entry_id"]
|
|
entry = hass.config_entries.async_get_entry(entry_id)
|
|
if entry is None or entry.domain != DOMAIN or entry.unique_id == GLOBAL_UNIQUE_ID:
|
|
raise ServiceValidationError(f"No maintenance object found for entry_id {entry_id!r}")
|
|
if not await async_delete_task(hass, entry, call.data["task_id"]):
|
|
raise ServiceValidationError(f"No task {call.data['task_id']!r} in {entry.title!r}")
|
|
await hass.config_entries.async_reload(entry_id)
|
|
|
|
async def _handle_list_tasks(call: ServiceCall) -> dict[str, Any]:
|
|
"""Handle list_tasks — a queryable snapshot for automations/voice."""
|
|
wanted_entry = call.data.get("entry_id")
|
|
wanted_status = call.data.get("status")
|
|
tasks: list[dict[str, Any]] = []
|
|
for ce in hass.config_entries.async_entries(DOMAIN):
|
|
if ce.unique_id == GLOBAL_UNIQUE_ID:
|
|
continue
|
|
if wanted_entry and ce.entry_id != wanted_entry:
|
|
continue
|
|
rd = getattr(ce, "runtime_data", None)
|
|
coordinator = getattr(rd, "coordinator", None) if rd else None
|
|
if coordinator is None or not coordinator.data:
|
|
continue
|
|
object_name = ce.data.get(CONF_OBJECT, {}).get("name", ce.title)
|
|
for task_id, task in coordinator.data.get(CONF_TASKS, {}).items():
|
|
status = str(task.get("_status", ""))
|
|
if status == "archived":
|
|
continue
|
|
if wanted_status and status != wanted_status:
|
|
continue
|
|
tasks.append(
|
|
{
|
|
"entry_id": ce.entry_id,
|
|
"task_id": task_id,
|
|
"object_name": object_name,
|
|
"name": task.get("name"),
|
|
"status": status,
|
|
"next_due": task.get("_next_due"),
|
|
"days_until_due": task.get("_days_until_due"),
|
|
"type": task.get("type"),
|
|
"priority": task.get("priority", "normal"),
|
|
}
|
|
)
|
|
return {"tasks": tasks, "count": len(tasks)}
|
|
|
|
hass.services.async_register(
|
|
DOMAIN,
|
|
SERVICE_UPDATE_TASK,
|
|
_handle_update_task,
|
|
schema=SERVICE_UPDATE_TASK_SCHEMA,
|
|
)
|
|
hass.services.async_register(
|
|
DOMAIN,
|
|
SERVICE_DELETE_TASK,
|
|
_handle_delete_task,
|
|
schema=SERVICE_DELETE_TASK_SCHEMA,
|
|
)
|
|
hass.services.async_register(
|
|
DOMAIN,
|
|
SERVICE_LIST_TASKS,
|
|
_handle_list_tasks,
|
|
schema=SERVICE_LIST_TASKS_SCHEMA,
|
|
supports_response=SupportsResponse.ONLY,
|
|
)
|
|
|
|
# Register WebSocket commands
|
|
async_register_commands(hass)
|
|
|
|
# Register Lovelace card (always available)
|
|
await async_register_card(hass)
|
|
|
|
# Register listener for mobile app notification action buttons
|
|
async def _handle_notification_action(event: Event) -> None:
|
|
"""Handle mobile_app_notification_action events from Companion App."""
|
|
action = event.data.get("action", "")
|
|
if not action.startswith("MS_"):
|
|
return
|
|
|
|
# Parse action: MS_COMPLETE_{entry_id}_{task_id}
|
|
# MS_SKIP_{entry_id}_{task_id}
|
|
# MS_SNOOZE_{entry_id}_{task_id}
|
|
# entry_id is a 32-char hex UUID, task_id is a 32-char hex UUID
|
|
if action.startswith("MS_COMPLETE_"):
|
|
action_type = "complete"
|
|
remainder = action[len("MS_COMPLETE_") :]
|
|
elif action.startswith("MS_SKIP_"):
|
|
action_type = "skip"
|
|
remainder = action[len("MS_SKIP_") :]
|
|
elif action.startswith("MS_SNOOZE_"):
|
|
action_type = "snooze"
|
|
remainder = action[len("MS_SNOOZE_") :]
|
|
else:
|
|
return
|
|
|
|
# Split on underscore — entry_id and task_id contain no underscores
|
|
parts = remainder.split("_", 1)
|
|
if len(parts) != 2 or not parts[0] or not parts[1]:
|
|
_LOGGER.warning("Invalid notification action format: %s", action)
|
|
return
|
|
|
|
entry_id, task_id = parts
|
|
|
|
config_entry = hass.config_entries.async_get_entry(entry_id)
|
|
runtime_data = getattr(config_entry, "runtime_data", None) if config_entry else None
|
|
if not runtime_data or not hasattr(runtime_data, "coordinator") or not runtime_data.coordinator:
|
|
_LOGGER.warning("No coordinator found for notification action (entry_id=%s)", entry_id)
|
|
return
|
|
|
|
nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
|
|
|
|
try:
|
|
if action_type == "complete":
|
|
_LOGGER.info("Completing task %s via notification action", task_id)
|
|
await runtime_data.coordinator.complete_maintenance(task_id=task_id, unattended=True)
|
|
elif action_type == "skip":
|
|
_LOGGER.info("Skipping task %s via notification action", task_id)
|
|
await runtime_data.coordinator.skip_maintenance(task_id=task_id, reason="Skipped from notification")
|
|
elif action_type == "snooze":
|
|
_LOGGER.info("Snoozing task %s via notification action", task_id)
|
|
if nm is not None:
|
|
nm.snooze_task(entry_id, task_id)
|
|
return # Snooze: keep notification visible for later reminder
|
|
except ServiceValidationError as err:
|
|
# The task demands details a notification button cannot capture.
|
|
# Not an error in our code — log it plainly and deliberately do
|
|
# NOT dismiss the notification, so the reminder survives until the
|
|
# user completes the task properly in the panel.
|
|
_LOGGER.warning(
|
|
"Notification completion of task %s rejected: %s",
|
|
task_id,
|
|
err,
|
|
)
|
|
return
|
|
except Exception:
|
|
_LOGGER.exception(
|
|
"Failed to handle notification action %s for task %s",
|
|
action_type,
|
|
task_id,
|
|
)
|
|
return
|
|
|
|
# Action succeeded — dismiss notification and clear NM rate-limit state
|
|
if nm is not None:
|
|
await nm.async_dismiss_task_notification(task_id)
|
|
nm.clear_task_state(entry_id, task_id)
|
|
|
|
unsub_notification = hass.bus.async_listen("mobile_app_notification_action", _handle_notification_action)
|
|
|
|
# Register listener for NFC tag scans → complete linked tasks
|
|
async def _handle_tag_scanned(event: Event) -> None:
|
|
"""Handle tag_scanned events — complete task linked to NFC tag."""
|
|
tag_id = event.data.get("tag_id", "")
|
|
if not tag_id:
|
|
return
|
|
|
|
# Search all entries for a task with matching nfc_tag_id
|
|
for ce in hass.config_entries.async_entries(DOMAIN):
|
|
if ce.unique_id == GLOBAL_UNIQUE_ID:
|
|
continue
|
|
tasks = ce.data.get(CONF_TASKS, {})
|
|
for task_id, task_data in tasks.items():
|
|
if task_data.get("nfc_tag_id") != tag_id:
|
|
continue
|
|
# Found matching task
|
|
runtime_data = getattr(ce, "runtime_data", None)
|
|
if not runtime_data or not getattr(runtime_data, "coordinator", None):
|
|
_LOGGER.warning(
|
|
"No coordinator for NFC tag match (entry=%s)",
|
|
ce.entry_id,
|
|
)
|
|
continue
|
|
_LOGGER.debug(
|
|
"Completing task %s via NFC tag scan (%s)",
|
|
task_id,
|
|
tag_id,
|
|
)
|
|
user_id = event.context.user_id if event.context else None
|
|
try:
|
|
await runtime_data.coordinator.complete_maintenance(
|
|
task_id=task_id,
|
|
completed_by=user_id,
|
|
notes="Completed via NFC tag",
|
|
unattended=True,
|
|
)
|
|
except ServiceValidationError as err:
|
|
# A tag tap cannot capture a cost or a photo. Log it
|
|
# plainly and leave the task open so the user finishes it
|
|
# in the panel — the honest outcome, not a silent no-op.
|
|
_LOGGER.warning("NFC completion of task %s rejected: %s", task_id, err)
|
|
return
|
|
|
|
# No match found — not our tag, ignore silently
|
|
|
|
unsub_tag = hass.bus.async_listen("tag_scanned", _handle_tag_scanned)
|
|
|
|
# v1.3.0: per-task on_complete_action listener (Layer B). Subscribes to
|
|
# EVENT_TASK_COMPLETED — same event power users can listen for in their
|
|
# own automations. Single listener handles all entries' tasks.
|
|
from .helpers.action_listener import register_action_listener
|
|
|
|
unsub_action = register_action_listener(hass)
|
|
|
|
# v1.5.3 (#48): reverse-sync HA device.area_id → obj.area_id when the user
|
|
# changes the area in the HA UI / device settings. The forward sync
|
|
# (obj.area_id → device.area_id) happens via the per-entry update listener
|
|
# registered in async_setup_entry. Together these two listeners keep the
|
|
# dashboard area and the HA device area in sync after the initial creation
|
|
# (DeviceInfo.suggested_area only fires once, on first device creation).
|
|
@callback
|
|
def _on_device_registry_update(
|
|
event: Event[dr.EventDeviceRegistryUpdatedData],
|
|
) -> None:
|
|
if event.data["action"] != "update":
|
|
return
|
|
changes = event.data.get("changes") or {}
|
|
if "area_id" not in changes:
|
|
return
|
|
device_id = event.data["device_id"]
|
|
device = dr.async_get(hass).async_get(device_id)
|
|
if device is None:
|
|
return
|
|
for ce_id in device.config_entries:
|
|
ce = hass.config_entries.async_get_entry(ce_id)
|
|
if ce is None or ce.domain != DOMAIN or ce.unique_id == GLOBAL_UNIQUE_ID:
|
|
continue
|
|
obj = dict(ce.data.get(CONF_OBJECT, {}))
|
|
if obj.get("area_id") == device.area_id:
|
|
return # already in sync — break the loop with the forward listener
|
|
obj["area_id"] = device.area_id
|
|
new_data = {**ce.data, CONF_OBJECT: obj}
|
|
hass.config_entries.async_update_entry(ce, data=new_data)
|
|
return
|
|
|
|
unsub_device = hass.bus.async_listen(dr.EVENT_DEVICE_REGISTRY_UPDATED, _on_device_registry_update)
|
|
|
|
# v1.5.4: rewrite stored entity_id references when HA renames an entity.
|
|
# Without this, ``trigger_config["entity_id"]`` /
|
|
# ``adaptive_config["environmental_entity"]`` go stale and the underlying
|
|
# ``async_track_state_change_event`` listeners silently miss events on the
|
|
# new id. Same dual-storage bug class as #48 — different field, same fix.
|
|
async def _on_entity_registry_update(
|
|
event: Event[er.EventEntityRegistryUpdatedData],
|
|
) -> None:
|
|
if event.data["action"] != "update":
|
|
return
|
|
changes = event.data.get("changes") or {}
|
|
if "entity_id" not in changes:
|
|
return # not a rename
|
|
new_eid = event.data["entity_id"]
|
|
old_eid = changes["entity_id"]
|
|
if not isinstance(old_eid, str) or old_eid == new_eid:
|
|
return
|
|
|
|
from .helpers.entity_rename import rewrite_store, rewrite_tasks
|
|
|
|
for ce in hass.config_entries.async_entries(DOMAIN):
|
|
if ce.unique_id == GLOBAL_UNIQUE_ID:
|
|
continue
|
|
|
|
# 1. trigger_config refs live in entry.data
|
|
entry_changed = False
|
|
tasks = ce.data.get(CONF_TASKS, {})
|
|
if tasks:
|
|
new_tasks, entry_changed = rewrite_tasks(tasks, old_eid, new_eid)
|
|
if entry_changed:
|
|
new_data = {**ce.data, CONF_TASKS: new_tasks}
|
|
hass.config_entries.async_update_entry(ce, data=new_data)
|
|
|
|
# 2. adaptive_config.environmental_entity + trigger_runtime keys
|
|
# live in Store (post-migration; see _DYNAMIC_TASK_FIELDS).
|
|
rd = getattr(ce, "runtime_data", None)
|
|
store = getattr(rd, "store", None) if rd else None
|
|
store_changed = False
|
|
if store is not None:
|
|
store_changed = rewrite_store(store, old_eid, new_eid)
|
|
if store_changed:
|
|
# Force-save before the reload — the reload re-instantiates
|
|
# the store from disk, so a debounced save would race the
|
|
# reload and lose the rewrite.
|
|
await store.async_save()
|
|
|
|
if not (entry_changed or store_changed):
|
|
continue
|
|
|
|
# The trigger listeners on `BaseTrigger` subscribed to the OLD
|
|
# entity_id and won't follow the rename — schedule a reload so
|
|
# they re-instantiate against the new id.
|
|
hass.config_entries.async_schedule_reload(ce.entry_id)
|
|
_LOGGER.info(
|
|
"Rewrote entity references %s → %s for entry %s (entry_data=%s, store=%s) and scheduled reload",
|
|
old_eid,
|
|
new_eid,
|
|
ce.entry_id,
|
|
entry_changed,
|
|
store_changed,
|
|
)
|
|
|
|
unsub_entity = hass.bus.async_listen(er.EVENT_ENTITY_REGISTRY_UPDATED, _on_entity_registry_update)
|
|
|
|
# v2.10.0: daily archive / auto-delete retention sweep (completed one-offs).
|
|
# The pure decision logic + the sweep live in helpers/retention; this just
|
|
# paces it once a day. First fire is +24h, which is plenty for a day-granular
|
|
# policy (a freshly due archive simply waits for the next tick).
|
|
from .helpers.retention import async_run_retention_sweep
|
|
|
|
async def _retention_tick(_now: Any) -> None:
|
|
await async_run_retention_sweep(hass)
|
|
|
|
unsub_retention = async_track_time_interval(hass, _retention_tick, timedelta(hours=24), cancel_on_shutdown=True)
|
|
|
|
# v2.15.0: opt-in weekly digest — one summary notification on Monday morning.
|
|
# Fires daily at 08:00 local; the gating lives in async_maybe_send_weekly_digest.
|
|
async def _weekly_digest_tick(now: Any) -> None:
|
|
await async_maybe_send_weekly_digest(hass)
|
|
await async_maybe_send_warranty_reminders(hass)
|
|
await async_maybe_send_lead_reminders(hass)
|
|
|
|
unsub_digest = async_track_time_change(hass, _weekly_digest_tick, hour=8, minute=0, second=0)
|
|
# async_track_time_change has no cancel_on_shutdown, so cancel it explicitly
|
|
# on HA stop too — otherwise the daily timer lingers past shutdown when the
|
|
# entries aren't formally unloaded (e.g. test teardown).
|
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, lambda _e: unsub_digest())
|
|
|
|
# Store unsub callbacks so they can be cleaned up when domain is unloaded
|
|
hass.data[DOMAIN][EVENT_UNSUBS_KEY] = [
|
|
unsub_notification,
|
|
unsub_tag,
|
|
unsub_action,
|
|
unsub_device,
|
|
unsub_entity,
|
|
unsub_retention,
|
|
unsub_digest,
|
|
]
|
|
|
|
# Once HA has started (all entries loaded), flag object entries that were
|
|
# orphaned by a deleted global entry as a fixable repair issue (#86).
|
|
async_at_started(hass, _sync_missing_global_entry_issue)
|
|
|
|
return True
|
|
|
|
|
|
def _detect_advanced_feature_usage(hass: HomeAssistant, global_options: dict[str, Any]) -> dict[str, bool]:
|
|
"""Scan existing entries to detect which advanced features are in use."""
|
|
adaptive = False
|
|
predictions = False
|
|
seasonal = False
|
|
environmental = False
|
|
checklists = False
|
|
schedule_time = False
|
|
|
|
for entry in hass.config_entries.async_entries(DOMAIN):
|
|
if entry.unique_id == GLOBAL_UNIQUE_ID:
|
|
continue
|
|
tasks = entry.data.get(CONF_TASKS, {})
|
|
for task_data in tasks.values():
|
|
ac = task_data.get("adaptive_config") or {}
|
|
if ac.get("enabled"):
|
|
adaptive = True
|
|
if ac.get("sensor_prediction_enabled"):
|
|
predictions = True
|
|
if ac.get("seasonal_enabled"):
|
|
seasonal = True
|
|
if ac.get("environmental_entity"):
|
|
environmental = True
|
|
if task_data.get("checklist"):
|
|
checklists = True
|
|
if task_data.get("schedule_time"):
|
|
schedule_time = True
|
|
|
|
budget = global_options.get(CONF_BUDGET_MONTHLY, 0) > 0 or global_options.get(CONF_BUDGET_YEARLY, 0) > 0
|
|
groups = bool(global_options.get(CONF_GROUPS, {}))
|
|
|
|
return {
|
|
CONF_ADVANCED_ADAPTIVE: adaptive,
|
|
CONF_ADVANCED_PREDICTIONS: predictions,
|
|
CONF_ADVANCED_SEASONAL: seasonal,
|
|
CONF_ADVANCED_ENVIRONMENTAL: environmental,
|
|
CONF_ADVANCED_BUDGET: budget,
|
|
CONF_ADVANCED_GROUPS: groups,
|
|
CONF_ADVANCED_CHECKLISTS: checklists,
|
|
CONF_ADVANCED_SCHEDULE_TIME: schedule_time,
|
|
}
|
|
|
|
|
|
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
"""Migrate a config entry forward in version (HA pattern).
|
|
|
|
minor_version 1 → 2 (issue #30): backfill ``created_at`` for tasks that
|
|
were created before the field existed. Uses the earliest history entry
|
|
timestamp when available, otherwise today. This locks the next_due
|
|
fallback anchor so the schedule advances normally instead of always
|
|
pointing at "today".
|
|
|
|
minor_version 2 → 3 (schedule-model v2): move the flat recurrence fields
|
|
(interval_days / interval_unit / interval_anchor / schedule_type / due_date)
|
|
into a single nested ``schedule`` object. Behaviour is identical — readers
|
|
accept both shapes — this just makes storage canonical and unblocks the
|
|
calendar recurrence kinds (nth-weekday etc.).
|
|
|
|
minor_version 3 → 4 (discussion #49): seed ``responsible_user_id`` on
|
|
rotation tasks that were configured without an initial assignee — those
|
|
were invisible to every user filter until their first completion.
|
|
"""
|
|
if entry.version > 1 or entry.minor_version >= 4:
|
|
return True
|
|
|
|
is_object_entry = entry.unique_id != GLOBAL_UNIQUE_ID
|
|
data = dict(entry.data)
|
|
minor = entry.minor_version
|
|
|
|
# 1 → 2: backfill created_at (issue #30)
|
|
if minor < 2:
|
|
if is_object_entry:
|
|
from homeassistant.util import dt as dt_util
|
|
|
|
tasks_data = data.get(CONF_TASKS, {})
|
|
if tasks_data:
|
|
today_iso = dt_util.now().date().isoformat()
|
|
new_tasks: dict[str, dict[str, Any]] = {}
|
|
for task_id, td in tasks_data.items():
|
|
if "created_at" in td or td.get("last_performed"):
|
|
new_tasks[task_id] = td
|
|
continue
|
|
# Try to recover creation date from earliest history entry
|
|
anchor: str = today_iso
|
|
history = td.get("history") or []
|
|
if isinstance(history, list) and history:
|
|
timestamps: list[str] = [
|
|
ts for h in history if isinstance(h, dict) and isinstance((ts := h.get("timestamp")), str) and ts
|
|
]
|
|
if timestamps:
|
|
# ISO timestamps sort lexicographically; take YYYY-MM-DD
|
|
anchor = min(timestamps)[:10]
|
|
new_td = dict(td)
|
|
new_td["created_at"] = anchor
|
|
new_tasks[task_id] = new_td
|
|
data[CONF_TASKS] = new_tasks
|
|
minor = 2
|
|
|
|
# 2 → 3: flat recurrence fields → nested `schedule` (schedule-model v2)
|
|
if minor < 3:
|
|
if is_object_entry:
|
|
tasks_data = data.get(CONF_TASKS, {})
|
|
if tasks_data:
|
|
data[CONF_TASKS] = {task_id: normalize_task_storage(td) for task_id, td in tasks_data.items()}
|
|
minor = 3
|
|
|
|
# 3 → 4: rotation tasks without an initial assignee get one (discussion #49)
|
|
if minor < 4:
|
|
if is_object_entry:
|
|
from .helpers.sanitize import seed_rotation_assignee
|
|
|
|
tasks_data = data.get(CONF_TASKS, {})
|
|
if tasks_data:
|
|
new_tasks = {}
|
|
for task_id, td in tasks_data.items():
|
|
new_td = dict(td)
|
|
seed_rotation_assignee(new_td)
|
|
new_tasks[task_id] = new_td
|
|
data[CONF_TASKS] = new_tasks
|
|
minor = 4
|
|
|
|
hass.config_entries.async_update_entry(entry, data=data, minor_version=minor)
|
|
_LOGGER.info("Migrated entry %s to minor_version %s", entry.entry_id, minor)
|
|
return True
|
|
|
|
|
|
async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConfigEntry) -> bool:
|
|
"""Set up Maintenance Supporter from a config entry."""
|
|
# Unloading the LAST entry pops hass.data[DOMAIN] (notification manager,
|
|
# document store, event listeners) and HA runs async_setup only once per
|
|
# boot — a reinstall in the same run must rebuild the shared runtime
|
|
# (journey O1: sensor setup crashed on the missing document store).
|
|
if NOTIFICATION_MANAGER_KEY not in hass.data.setdefault(DOMAIN, {}):
|
|
await _async_setup_shared(hass)
|
|
|
|
is_global = entry.unique_id == GLOBAL_UNIQUE_ID
|
|
|
|
if is_global:
|
|
# Global entry: no per-object coordinator, but a summary coordinator
|
|
# aggregates task status counts across all objects to back the global
|
|
# summary sensors (sensor.maintenance_supporter_overdue, etc.).
|
|
summary_coordinator = MaintenanceSummaryCoordinator(hass)
|
|
entry.runtime_data = MaintenanceSupporterData(summary_coordinator=summary_coordinator)
|
|
summary_coordinator.async_setup_listeners()
|
|
entry.async_on_unload(summary_coordinator.async_teardown_listeners)
|
|
await summary_coordinator.async_refresh()
|
|
|
|
# One-time migration: auto-enable advanced feature flags for existing users
|
|
options = dict(entry.options or entry.data)
|
|
if CONF_ADVANCED_ADAPTIVE not in options:
|
|
flags = _detect_advanced_feature_usage(hass, options)
|
|
options.update(flags)
|
|
hass.config_entries.async_update_entry(entry, options=options)
|
|
_LOGGER.info("Migrated advanced feature flags: %s", flags)
|
|
|
|
# Register panel if enabled in options (on by default — see const).
|
|
if entry.options.get(CONF_PANEL_ENABLED, DEFAULT_PANEL_ENABLED):
|
|
await async_register_panel(hass)
|
|
|
|
# Listen for options changes (panel toggle)
|
|
entry.async_on_unload(entry.add_update_listener(_async_global_options_updated))
|
|
|
|
# Keep <config>/custom_sentences/ in step with the opt-in setting. Runs
|
|
# on every setup, not just on change, so an upgrade that ships new
|
|
# sentences reaches an install that already opted in.
|
|
await async_sync_assist_sentences(
|
|
hass, entry.options.get(CONF_INSTALL_ASSIST_SENTENCES, False)
|
|
)
|
|
|
|
# Initial orphan check for admin_panel_user_ids (HA users deleted
|
|
# while the integration was offline land here as repair issues).
|
|
await _check_admin_panel_user_orphans(hass, entry)
|
|
|
|
# v1.5.4: also clear stale ``task.responsible_user_id`` so the
|
|
# dashboard stops showing the ghost of deleted users.
|
|
await _check_task_responsible_user_orphans(hass)
|
|
|
|
# Surface a configured-but-missing notify service as a repair issue.
|
|
# Deferred to HA-started so lazily-registered notify services (mobile_app,
|
|
# notify groups) are present before we judge the service missing.
|
|
entry.async_on_unload(async_at_started(hass, _verify_notify_service))
|
|
|
|
# Surface document-storage anomalies (orphan/dangling blobs after a
|
|
# crash or partial restore) as a fixable repair issue. Deferred to
|
|
# HA-started so the store has finished loading.
|
|
entry.async_on_unload(async_at_started(hass, _check_document_storage_issues))
|
|
|
|
# A global entry now exists — clear the orphan repair issue immediately
|
|
# (e.g. right after the repair flow recreated it, when HA is already
|
|
# started and the async_at_started check above won't fire again).
|
|
_sync_missing_global_entry_issue(hass)
|
|
|
|
_LOGGER.debug("Global config entry set up: %s", entry.entry_id)
|
|
else:
|
|
# Maintenance object entry: fetch-or-create the Store. Reused across
|
|
# entry RELOADS (cache in a top-level hass.data key): a second Store
|
|
# instance for the same file would race the first one's pending
|
|
# debounced save — see STORES_CACHE_KEY in const.py.
|
|
stores: dict[str, MaintenanceStore] = hass.data.setdefault(STORES_CACHE_KEY, {})
|
|
cached_store = stores.get(entry.entry_id)
|
|
store = cached_store if cached_store is not None else MaintenanceStore(hass, entry.entry_id)
|
|
stores[entry.entry_id] = store
|
|
|
|
if cached_store is None:
|
|
# First setup this run: load from disk + one-time migration.
|
|
# Compare against the CAPTURED snapshot, not the live entry.data:
|
|
# the migration awaits store I/O, and a concurrent WS write during
|
|
# that window replaces entry.data — an identity check against the
|
|
# live attribute then fails spuriously and this write clobbers the
|
|
# concurrent update with the pre-await snapshot (lost update, seen
|
|
# live when rapid part creates raced a reload's setup).
|
|
data_before_migration = entry.data
|
|
cleaned_data = await async_migrate_to_store(hass, entry.entry_id, data_before_migration, store)
|
|
if cleaned_data is not data_before_migration:
|
|
hass.config_entries.async_update_entry(entry, data=dict(cleaned_data))
|
|
# A cached store's memory is authoritative — re-loading from disk here
|
|
# would drop any change still sitting in its debounce window.
|
|
|
|
# Reconcile the entry.data <-> Store split (journey I1): drop store
|
|
# state orphaned by a crash between the two writes of a deletion.
|
|
# Same reconciliation for spare-part stock state (journey S6): a crash
|
|
# between the ConfigEntry write and the Store save on a part deletion
|
|
# leaves its stock orphaned forever otherwise.
|
|
pruned_parts = store.prune_part_orphans(set(entry.data.get("parts") or {}))
|
|
if pruned_parts:
|
|
_LOGGER.info("Pruned %d orphaned part stock state(s) for %s", pruned_parts, entry.title)
|
|
await store.async_save()
|
|
|
|
pruned = store.prune_orphans(set(entry.data.get(CONF_TASKS, {})))
|
|
if pruned:
|
|
_LOGGER.info(
|
|
"Pruned %d orphaned task state(s) from store for %s",
|
|
pruned,
|
|
entry.title,
|
|
)
|
|
await store.async_save()
|
|
|
|
coordinator = MaintenanceCoordinator(hass, entry, store)
|
|
entry.runtime_data = MaintenanceSupporterData(coordinator=coordinator, store=store)
|
|
await coordinator.async_config_entry_first_refresh()
|
|
|
|
# v1.5.3 (#48): forward-sync obj fields → device_registry whenever the
|
|
# entry data changes (WS update OR config-flow re-edit). DeviceInfo
|
|
# only seeds these on first device creation; after that, dashboard
|
|
# edits never reach the device unless we push them. Combined with the
|
|
# global EVENT_DEVICE_REGISTRY_UPDATED listener registered in
|
|
# async_setup, this gives the area a true bidirectional sync.
|
|
entry.async_on_unload(entry.add_update_listener(_async_sync_obj_to_device))
|
|
|
|
# Notify WS subscribers that a new object entry is available
|
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
|
|
|
async_dispatcher_send(hass, SIGNAL_NEW_OBJECT_ENTRY, entry.entry_id)
|
|
|
|
# Buy-task catch-up (spare parts): converge the shopping reminders with
|
|
# the current stock/pause/archive state — covers import/restore, a
|
|
# resume/unarchive, and any change made while this entry was unloaded.
|
|
# Declarative + idempotent, reloads only when something changed.
|
|
if entry.data.get("parts"):
|
|
from .parts_runtime import schedule_buy_task_reconcile
|
|
|
|
schedule_buy_task_reconcile(hass, entry)
|
|
|
|
_LOGGER.debug(
|
|
"Maintenance object entry set up: %s (%s)",
|
|
entry.title,
|
|
entry.entry_id,
|
|
)
|
|
|
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
|
return True
|
|
|
|
|
|
async def _async_sync_obj_to_device(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
"""Push obj.* (area_id, name, manufacturer, model, serial_number) → device.
|
|
|
|
Fires whenever an object entry's data is updated. Without this, the
|
|
dashboard's WS update of obj.area_id never reaches HA's device_registry,
|
|
so the device stays stuck on whatever DeviceInfo.suggested_area set at
|
|
first creation (issue #48).
|
|
"""
|
|
obj = entry.data.get(CONF_OBJECT, {}) or {}
|
|
if obj.get("ha_device_id"):
|
|
# Linked to an EXISTING device owned by another integration (2.19) —
|
|
# pushing our object metadata would overwrite that device's
|
|
# name/manufacturer/area. The owning integration stays authoritative.
|
|
return
|
|
dev_reg = dr.async_get(hass)
|
|
devices = dr.async_entries_for_config_entry(dev_reg, entry.entry_id)
|
|
obj_name = obj.get("name")
|
|
obj_area = obj.get("area_id")
|
|
obj_manu = obj.get("manufacturer")
|
|
obj_model = obj.get("model")
|
|
obj_serial = obj.get("serial_number")
|
|
for device in devices:
|
|
kwargs: dict[str, Any] = {}
|
|
if device.area_id != obj_area:
|
|
kwargs["area_id"] = obj_area
|
|
# Only push name when obj has one (HA requires non-empty device.name).
|
|
if obj_name and device.name != obj_name:
|
|
kwargs["name"] = obj_name
|
|
if device.manufacturer != obj_manu:
|
|
kwargs["manufacturer"] = obj_manu
|
|
if device.model != obj_model:
|
|
kwargs["model"] = obj_model
|
|
if device.serial_number != obj_serial:
|
|
kwargs["serial_number"] = obj_serial
|
|
if kwargs:
|
|
dev_reg.async_update_device(device.id, **kwargs)
|
|
|
|
|
|
@callback
|
|
def _verify_notify_service(hass: HomeAssistant) -> None:
|
|
"""Sync the "configured notify service missing" repair issue with reality.
|
|
|
|
Runs once HA has started (lazily-registered services such as mobile_app are
|
|
available by then, avoiding a false alarm during boot) and again whenever the
|
|
global options change (the service may have just been (re)configured).
|
|
"""
|
|
nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
|
|
if isinstance(nm, NotificationManager):
|
|
nm.async_verify_configured_service()
|
|
|
|
|
|
_DOC_STORAGE_ISSUE_ID = "document_storage_issues"
|
|
|
|
|
|
async def _check_document_storage_issues(hass: HomeAssistant) -> None:
|
|
"""Sync the document-storage repair issue with the current on-disk reality.
|
|
|
|
The store is consistent under normal operation; anomalies (orphaned blobs,
|
|
dangling records, stale registry entries) only arise from a crash, an
|
|
external file delete, or a partial restore — so a boot-time scan is the
|
|
right trigger. The fixable issue's flow reclaims the space; once a later
|
|
scan comes back clean the issue is removed.
|
|
"""
|
|
from homeassistant.helpers import issue_registry as ir
|
|
|
|
store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
|
|
if not isinstance(store, DocumentStore):
|
|
return
|
|
issues = await store.async_find_issues()
|
|
if any(issues.values()):
|
|
ir.async_create_issue(
|
|
hass,
|
|
DOMAIN,
|
|
_DOC_STORAGE_ISSUE_ID,
|
|
is_fixable=True,
|
|
severity=ir.IssueSeverity.WARNING,
|
|
translation_key=_DOC_STORAGE_ISSUE_ID,
|
|
)
|
|
else:
|
|
ir.async_delete_issue(hass, DOMAIN, _DOC_STORAGE_ISSUE_ID)
|
|
|
|
|
|
_MISSING_GLOBAL_ISSUE_ID = "missing_global_entry"
|
|
|
|
|
|
@callback
|
|
def _sync_missing_global_entry_issue(hass: HomeAssistant) -> None:
|
|
"""Surface "object entries exist but the global entry is gone" as a repair.
|
|
|
|
The config flow always creates the global "Maintenance Supporter" entry
|
|
before the first object, but a user can later delete just that entry from
|
|
Settings → Devices & Services, leaving orphan object entries behind. That
|
|
strips the summary sensors, the sidebar panel and the digests — and the
|
|
dashboard KPI chips read "unknown" (#86, 2nd report). The fixable issue's
|
|
flow recreates the global entry with default settings; it clears itself once
|
|
a global entry is present again.
|
|
"""
|
|
from homeassistant.helpers import issue_registry as ir
|
|
|
|
entries = hass.config_entries.async_entries(DOMAIN)
|
|
has_global = any(e.unique_id == GLOBAL_UNIQUE_ID for e in entries)
|
|
has_objects = any(e.unique_id != GLOBAL_UNIQUE_ID for e in entries)
|
|
if has_objects and not has_global:
|
|
ir.async_create_issue(
|
|
hass,
|
|
DOMAIN,
|
|
_MISSING_GLOBAL_ISSUE_ID,
|
|
is_fixable=True,
|
|
severity=ir.IssueSeverity.ERROR,
|
|
translation_key=_MISSING_GLOBAL_ISSUE_ID,
|
|
)
|
|
else:
|
|
ir.async_delete_issue(hass, DOMAIN, _MISSING_GLOBAL_ISSUE_ID)
|
|
|
|
|
|
async def _async_global_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
"""React to global options changes (panel toggle / sidebar title)."""
|
|
panel_enabled = entry.options.get(CONF_PANEL_ENABLED, DEFAULT_PANEL_ENABLED)
|
|
if panel_enabled:
|
|
# force=True so a changed sidebar title (CONF_PANEL_TITLE) re-registers
|
|
# the panel; it's a no-op refresh when the title is unchanged.
|
|
await async_register_panel(hass, force=True)
|
|
else:
|
|
await async_unregister_panel(hass)
|
|
await _check_admin_panel_user_orphans(hass, entry)
|
|
# The notify service may have just been (re)configured — re-check it.
|
|
_verify_notify_service(hass)
|
|
# Install or remove the Assist sentence files to match the setting.
|
|
await async_sync_assist_sentences(
|
|
hass, entry.options.get(CONF_INSTALL_ASSIST_SENTENCES, False)
|
|
)
|
|
|
|
|
|
_ORPHAN_ISSUE_PREFIX = "orphan_admin_panel_user_"
|
|
|
|
|
|
async def _check_task_responsible_user_orphans(
|
|
hass: HomeAssistant,
|
|
) -> None:
|
|
"""Clear task assignments pointing at deleted HA users.
|
|
|
|
Same class of bug as ``admin_panel_user_ids`` orphans (#48 audit) but for
|
|
tasks: a user can be deleted in HA while we hold their UUID in entry data.
|
|
Two references need pruning:
|
|
|
|
* ``responsible_user_id`` — dashboards would render "Unknown user"
|
|
forever; notifications already fall back to the global service.
|
|
* ``assignee_pool`` (2.17 rotation) — a ghost member is WORSE than a
|
|
ghost pointer: ``advance_rotation`` keeps assigning the deleted user
|
|
(``least_completed`` picks them forever at 0 completions), so pruning
|
|
only the pointer would resurrect it on the next completion.
|
|
|
|
When the pool shrinks below 2 members the rotation is dissolved
|
|
(mirroring the task-dialog save rule); when the orphaned pointer leaves a
|
|
healthy pool behind, responsibility moves to the first remaining member
|
|
so the shared task never goes unassigned. Silent removal with an info
|
|
log — no repair issue, no user prompt.
|
|
"""
|
|
valid_ids = {u.id for u in await hass.auth.async_get_users()}
|
|
if not valid_ids:
|
|
# Defensive: production HA always has at least the owner user. An
|
|
# empty list means we're in a test harness without an auth fixture
|
|
# or auth is mid-load — either way, don't prune references we
|
|
# can't actually verify as orphaned.
|
|
return
|
|
for entry in hass.config_entries.async_entries(DOMAIN):
|
|
if entry.unique_id == GLOBAL_UNIQUE_ID:
|
|
continue
|
|
tasks = entry.data.get(CONF_TASKS, {})
|
|
new_tasks: dict[str, dict[str, Any]] = {}
|
|
changed = False
|
|
for tid, td in tasks.items():
|
|
ruid = td.get("responsible_user_id")
|
|
pool = [u for u in (td.get("assignee_pool") or []) if isinstance(u, str)]
|
|
pruned_pool = [u for u in pool if u in valid_ids]
|
|
resp_orphaned = bool(ruid) and ruid not in valid_ids
|
|
if not resp_orphaned and pruned_pool == pool:
|
|
new_tasks[tid] = td
|
|
continue
|
|
|
|
new_td = dict(td)
|
|
changed = True
|
|
if pruned_pool != pool:
|
|
_LOGGER.info(
|
|
"Removing %d deleted user(s) from assignee_pool on task %s (%s)",
|
|
len(pool) - len(pruned_pool),
|
|
tid,
|
|
entry.title,
|
|
)
|
|
if len(pruned_pool) >= 2:
|
|
new_td["assignee_pool"] = pruned_pool
|
|
else:
|
|
# A rotation needs at least two members.
|
|
new_td.pop("assignee_pool", None)
|
|
new_td.pop("rotation_strategy", None)
|
|
if resp_orphaned:
|
|
_LOGGER.info(
|
|
"Clearing orphaned responsible_user_id %s on task %s (%s)",
|
|
ruid,
|
|
tid,
|
|
entry.title,
|
|
)
|
|
if len(pruned_pool) >= 2:
|
|
# Keep the shared task assigned: hand over to the pool.
|
|
new_td["responsible_user_id"] = pruned_pool[0]
|
|
else:
|
|
new_td.pop("responsible_user_id", None)
|
|
new_tasks[tid] = new_td
|
|
if changed:
|
|
new_data = {**entry.data, CONF_TASKS: new_tasks}
|
|
hass.config_entries.async_update_entry(entry, data=new_data)
|
|
|
|
|
|
async def _check_admin_panel_user_orphans(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
"""Surface admin_panel_user_ids entries pointing at deleted HA users.
|
|
|
|
Each orphaned id becomes a fixable repair issue. The repair flow lets
|
|
the admin remove the entry from the list with one click. Issues for
|
|
ids that have been validated, removed from the list, or restored are
|
|
cleared automatically.
|
|
"""
|
|
from homeassistant.helpers import issue_registry as ir
|
|
|
|
user_ids_raw = entry.options.get(CONF_ADMIN_PANEL_USER_IDS, []) or []
|
|
if not isinstance(user_ids_raw, list):
|
|
user_ids_raw = []
|
|
user_ids: set[str] = {u for u in user_ids_raw if isinstance(u, str)}
|
|
valid_ids = {u.id for u in await hass.auth.async_get_users()}
|
|
|
|
# 1) Drop any pre-existing orphan issue whose target id is no longer
|
|
# in the list (admin removed it) OR is now valid (user re-created).
|
|
# Scope the iteration to OUR domain via the (domain, issue_id) tuple
|
|
# keys instead of scanning every integration's issues.
|
|
issue_reg = ir.async_get(hass)
|
|
stale_issue_ids = [iid for (dom, iid) in issue_reg.issues if dom == DOMAIN and iid.startswith(_ORPHAN_ISSUE_PREFIX)]
|
|
for issue_id in stale_issue_ids:
|
|
target_uid = issue_id[len(_ORPHAN_ISSUE_PREFIX) :]
|
|
if target_uid not in user_ids or target_uid in valid_ids:
|
|
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
|
|
|
# 2) Create issues for ids in the list that no longer match a HA user.
|
|
for uid in user_ids:
|
|
if uid in valid_ids:
|
|
continue
|
|
ir.async_create_issue(
|
|
hass,
|
|
DOMAIN,
|
|
f"{_ORPHAN_ISSUE_PREFIX}{uid}",
|
|
is_fixable=True,
|
|
severity=ir.IssueSeverity.WARNING,
|
|
translation_key="orphan_admin_panel_user",
|
|
translation_placeholders={"user_id": uid[:8]},
|
|
data={"user_id": uid, "entry_id": entry.entry_id},
|
|
)
|
|
|
|
|
|
async def async_unload_entry(hass: HomeAssistant, entry: MaintenanceSupporterConfigEntry) -> bool:
|
|
"""Unload a config entry."""
|
|
if entry.unique_id == GLOBAL_UNIQUE_ID:
|
|
# Unregister panel when global entry is unloaded
|
|
await async_unregister_panel(hass)
|
|
|
|
# Flush a pending debounced store save BEFORE tearing down — belt and
|
|
# suspenders next to the store cache: disk is current the moment the entry
|
|
# goes away, whether or not it ever comes back this run.
|
|
store = hass.data.get(STORES_CACHE_KEY, {}).get(entry.entry_id)
|
|
if store is not None:
|
|
await store.async_save()
|
|
|
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
|
|
|
# Clean up domain data if no entries left
|
|
remaining = [e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id != entry.entry_id]
|
|
if not remaining:
|
|
nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
|
|
if nm is not None:
|
|
await nm.async_unload()
|
|
for unsub in hass.data.get(DOMAIN, {}).get(EVENT_UNSUBS_KEY, []):
|
|
unsub()
|
|
hass.data.pop(DOMAIN, None)
|
|
|
|
return unload_ok
|
|
|
|
|
|
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
"""Clean up store file when a config entry is permanently deleted."""
|
|
if entry.unique_id == GLOBAL_UNIQUE_ID:
|
|
# Deleting the global entry while object entries remain is a broken
|
|
# state (no summary sensors / panel / digests) — surface it right away
|
|
# as a fixable repair issue so the user can restore it (#86).
|
|
_sync_missing_global_entry_issue(hass)
|
|
return
|
|
# #111: hand any shared spare-part pool to a borrower BEFORE the Store goes
|
|
# — the stock numbers exist nowhere else. Must happen here rather than in
|
|
# the panel's delete: HA's own Configure-UI removal never reaches that path.
|
|
from .helpers.shared_parts import async_transfer_pools_on_removal
|
|
|
|
try:
|
|
await async_transfer_pools_on_removal(hass, entry)
|
|
except Exception:
|
|
_LOGGER.exception("Could not transfer shared spare parts from %s", entry.title)
|
|
|
|
store = hass.data.get(STORES_CACHE_KEY, {}).pop(entry.entry_id, None)
|
|
if store is None:
|
|
store = MaintenanceStore(hass, entry.entry_id)
|
|
await store.async_remove()
|
|
# Drop the per-entry reconcile lock — the module dict would otherwise grow
|
|
# by one lock per object ever created in this HA run.
|
|
from .parts_runtime import discard_reconcile_lock
|
|
|
|
discard_reconcile_lock(entry.entry_id)
|
|
# v1.5.4: also called from ws_delete_object — but if the user removes the
|
|
# config entry from HA's "Configure" UI, that path doesn't run, leaving
|
|
# phantom task_refs in groups. Belt-and-suspenders.
|
|
from .websocket import cleanup_group_refs, object_id_for_entry
|
|
|
|
cleanup_group_refs(hass, entry_id=entry.entry_id)
|
|
|
|
# Remove this object's documents (refcount-aware — a blob shared with another
|
|
# object survives; only bytes freed for good are reclaimed). Archiving keeps
|
|
# docs; permanent deletion is the trigger, and this hook fires for the WS,
|
|
# the Configure-UI, and the service delete paths alike — the single place it
|
|
# belongs.
|
|
doc_store = hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
|
|
if isinstance(doc_store, DocumentStore):
|
|
await doc_store.async_remove_object(object_id_for_entry(entry))
|
|
|
|
_LOGGER.debug("Removed store for entry %s", entry.entry_id)
|
|
# The global summary coordinator has no listener for entry removal, so tell
|
|
# it to drop this object from its counts immediately (otherwise the summary
|
|
# sensors stay stale until the next event or a restart).
|
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
|
|
|
async_dispatcher_send(hass, SIGNAL_OBJECT_ENTRY_REMOVED, entry.entry_id)
|
|
|
|
|
|
async def async_remove_config_entry_device(
|
|
hass: HomeAssistant,
|
|
config_entry: ConfigEntry,
|
|
device_entry: dr.DeviceEntry,
|
|
) -> bool:
|
|
"""Allow device removal when it has no entities."""
|
|
entity_registry = er.async_get(hass)
|
|
return not er.async_entries_for_device(entity_registry, device_entry.id)
|
|
|
|
|
|
def _get_coordinator_for_entity(hass: HomeAssistant, entity_id: str) -> MaintenanceCoordinator | None:
|
|
"""Find the coordinator that manages the given entity."""
|
|
entity_registry = er.async_get(hass)
|
|
entry = entity_registry.async_get(entity_id)
|
|
if entry is None:
|
|
return None
|
|
|
|
config_entry_id = entry.config_entry_id
|
|
if config_entry_id is None:
|
|
return None
|
|
|
|
config_entry = hass.config_entries.async_get_entry(config_entry_id)
|
|
if config_entry is None:
|
|
return None
|
|
|
|
runtime_data: MaintenanceSupporterData | None = getattr(config_entry, "runtime_data", None)
|
|
if runtime_data is None:
|
|
return None
|
|
|
|
return runtime_data.coordinator
|
|
|
|
|
|
def _get_task_id_for_entity(hass: HomeAssistant, entity_id: str) -> str | None:
|
|
"""Extract the task ID from an entity's unique_id."""
|
|
entity_registry = er.async_get(hass)
|
|
entry = entity_registry.async_get(entity_id)
|
|
if entry is None or entry.unique_id is None:
|
|
return None
|
|
|
|
# unique_id format: maintenance_supporter_{object_slug}_{task_id}
|
|
# task_id is a 32-char hex UUID without dashes at the end
|
|
unique_id = entry.unique_id
|
|
prefix = "maintenance_supporter_"
|
|
if not unique_id.startswith(prefix):
|
|
return None
|
|
|
|
# Find the task_id: last part after the object slug
|
|
# Object slug could have underscores, so we find the task_id
|
|
# by looking at what config entry this entity belongs to
|
|
config_entry_id = entry.config_entry_id
|
|
if config_entry_id is None:
|
|
return None
|
|
|
|
config_entry = hass.config_entries.async_get_entry(config_entry_id)
|
|
if config_entry is None:
|
|
return None
|
|
|
|
# Look up which task matches this unique_id
|
|
# Sensor unique_id: maintenance_supporter_{slug}_{task_id}
|
|
# Binary sensor: maintenance_supporter_{slug}_{task_id}_overdue
|
|
from .const import CONF_TASKS
|
|
|
|
tasks: dict[str, Any] = config_entry.data.get(CONF_TASKS, {})
|
|
clean_id = unique_id.removesuffix("_overdue")
|
|
for task_id in tasks:
|
|
if clean_id.endswith(f"_{task_id}"):
|
|
return str(task_id)
|
|
|
|
return None
|