393 files
This commit is contained in:
@@ -58,9 +58,8 @@ from .const import (
|
||||
CONF_PANEL_ENABLED,
|
||||
CONF_SHOPPING_LIST_ENTITY,
|
||||
CONF_TASKS,
|
||||
CONF_VACATION_EXEMPT_TASK_IDS,
|
||||
CONF_WEEKLY_DIGEST_ENABLED,
|
||||
DEFAULT_PANEL_ENABLED,
|
||||
DEFAULT_WARNING_DAYS,
|
||||
DOCUMENT_TEXT_INDEX_KEY,
|
||||
DOMAIN,
|
||||
EVENT_UNSUBS_KEY,
|
||||
@@ -86,8 +85,6 @@ from .const import (
|
||||
SIGNAL_OBJECT_ENTRY_REMOVED,
|
||||
STORES_CACHE_KEY,
|
||||
TriggerType,
|
||||
slugify_object_name,
|
||||
task_unique_id,
|
||||
)
|
||||
from .const import (
|
||||
DOCUMENT_STORE_KEY as _DS_KEY,
|
||||
@@ -98,14 +95,20 @@ from .const import (
|
||||
from .coordinator import MaintenanceCoordinator
|
||||
from .entity.summary_coordinator import MaintenanceSummaryCoordinator
|
||||
from .frontend import async_register_card
|
||||
from .helpers.aggregate import iter_live_tasks
|
||||
from .helpers.aggregate import object_name as aggregate_object_name
|
||||
from .helpers.aggregate import task_sensor_entity_id as aggregate_task_sensor_entity_id
|
||||
from .helpers.assist_sentences import async_sync as async_sync_assist_sentences
|
||||
from .helpers.dates import INTERVAL_UNITS, local_date_from_iso
|
||||
from .helpers.documents import DocumentStore
|
||||
from .helpers.global_options import get_global_entry
|
||||
from .helpers.global_options import entry_option, get_default_warning_days, get_global_entry
|
||||
from .helpers.notification_gates import task_may_notify
|
||||
from .helpers.notification_ids import parse_action_id
|
||||
from .helpers.notification_manager import NotificationManager
|
||||
from .helpers.notify_hooks import KIND_LEAD_TIME
|
||||
from .helpers.pause import is_task_inert
|
||||
from .helpers.permissions import service_tier
|
||||
from .helpers.phases import task_label
|
||||
from .helpers.schedule import normalize_task_storage
|
||||
from .helpers.task_fields import (
|
||||
INTERVAL_DAYS_RANGE,
|
||||
@@ -160,10 +163,20 @@ SERVICE_COMPLETE_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
def _not_in_future(value: date_cls) -> date_cls:
|
||||
"""A reset date is a day the work WAS done: a future one (9999-12-20 via
|
||||
the service, any household member) overflowed the schedule math inside
|
||||
every refresh and kept the object in setup-retry across restarts (bug
|
||||
audit 2026-09-27). Evaluated at call time, in HA's time zone."""
|
||||
if value > dt_util.now().date():
|
||||
raise vol.Invalid("date must not be in the future")
|
||||
return value
|
||||
|
||||
|
||||
SERVICE_RESET_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): vol.All(cv.entity_ids, vol.Length(min=1)),
|
||||
vol.Optional("date"): cv.date,
|
||||
vol.Optional("date"): vol.All(cv.date, _not_in_future),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -276,8 +289,7 @@ async def async_maybe_send_weekly_digest(hass: HomeAssistant, *, force: bool = F
|
||||
global_entry = get_global_entry(hass)
|
||||
if global_entry is None:
|
||||
return
|
||||
options = global_entry.options or global_entry.data
|
||||
if not options.get(CONF_WEEKLY_DIGEST_ENABLED, False):
|
||||
if not entry_option(global_entry, CONF_WEEKLY_DIGEST_ENABLED):
|
||||
return
|
||||
from .helpers.aggregate import compute_status_counts
|
||||
|
||||
@@ -302,25 +314,26 @@ async def async_maybe_send_warranty_reminders(hass: HomeAssistant, *, force: boo
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
CONF_WARRANTY_REMINDER_DAYS,
|
||||
CONF_WARRANTY_REMINDER_ENABLED,
|
||||
DEFAULT_WARRANTY_REMINDER_DAYS,
|
||||
)
|
||||
from .const import CONF_WARRANTY_REMINDER_DAYS, CONF_WARRANTY_REMINDER_ENABLED
|
||||
|
||||
global_entry = get_global_entry(hass)
|
||||
if global_entry is None:
|
||||
return
|
||||
options = global_entry.options or global_entry.data
|
||||
if not options.get(CONF_WARRANTY_REMINDER_ENABLED, False):
|
||||
if not entry_option(global_entry, CONF_WARRANTY_REMINDER_ENABLED):
|
||||
return
|
||||
days = int(options.get(CONF_WARRANTY_REMINDER_DAYS, DEFAULT_WARRANTY_REMINDER_DAYS))
|
||||
days = int(entry_option(global_entry, CONF_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 {}
|
||||
# A retired (archived) object's warranty is nobody's business any
|
||||
# more — the lead-reminder loop below already skipped them (bug audit
|
||||
# 2026-09-26). A paused object keeps its reminder: the warranty runs
|
||||
# out whether the pool is in its winter pause or not.
|
||||
if obj.get("archived_at") is not None:
|
||||
continue
|
||||
expiry = obj.get("warranty_expiry")
|
||||
if not expiry:
|
||||
continue
|
||||
@@ -354,8 +367,7 @@ async def async_maybe_send_lead_reminders(hass: HomeAssistant) -> None:
|
||||
global_entry = get_global_entry(hass)
|
||||
if global_entry is None:
|
||||
return
|
||||
options = global_entry.options or global_entry.data
|
||||
leads_raw = options.get(CONF_REMINDER_LEAD_DAYS) or []
|
||||
leads_raw = entry_option(global_entry, 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
|
||||
@@ -370,21 +382,18 @@ async def async_maybe_send_lead_reminders(hass: HomeAssistant) -> None:
|
||||
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()
|
||||
payload = (coordinator.data or {}).get(CONF_TASKS) or {}
|
||||
obj_name = aggregate_object_name(entry)
|
||||
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:
|
||||
# Disabled / archived task or a seasonally paused object (v2.20,
|
||||
# N3: schedules frozen) — the one inert predicate.
|
||||
if is_task_inert(task_data, obj):
|
||||
continue
|
||||
# The per-task gates the lead-time kind declares (#173 mute,
|
||||
# vacation, snooze) — before the model is even built.
|
||||
@@ -399,7 +408,10 @@ async def async_maybe_send_lead_reminders(hass: HomeAssistant) -> None:
|
||||
await nm.async_send_lead_reminder(
|
||||
entry_id=entry.entry_id,
|
||||
task_id=task_id,
|
||||
task_name=task.name,
|
||||
# The label every other reminder carries (due phase #139,
|
||||
# next calendar events #189) — this one sent the bare name
|
||||
# (bug audit 2026-09-26).
|
||||
task_name=task_label(task_data, (payload.get(task_id) or {}).get("_next_event_titles")),
|
||||
object_name=obj_name,
|
||||
days=days,
|
||||
next_due=task.next_due.isoformat() if task.next_due else None,
|
||||
@@ -420,9 +432,13 @@ def _trigger_target_and_unit(hass: HomeAssistant, tc: dict[str, Any]) -> tuple[f
|
||||
return tc.get("trigger_target_changes"), None
|
||||
if ttype == TriggerType.COMPOUND:
|
||||
return None, None
|
||||
from .entity.triggers import primary_entity_id
|
||||
|
||||
unit: str | None = None
|
||||
if tc.get("entity_id"):
|
||||
st = hass.states.get(tc["entity_id"])
|
||||
# The first watched entity — a trigger stored with only the plural
|
||||
# ``entity_ids`` had no unit here (DRY audit 2026-09-26 B).
|
||||
if source := primary_entity_id(tc):
|
||||
st = hass.states.get(source)
|
||||
if st:
|
||||
unit = st.attributes.get("unit_of_measurement")
|
||||
if ttype == TriggerType.COUNTER:
|
||||
@@ -519,12 +535,16 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
|
||||
state = hass.states.get(person_entity_id)
|
||||
if state is None or not person_entity_id.startswith("person."):
|
||||
raise ServiceValidationError(
|
||||
f"{person_entity_id!r} is not a known person entity"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="person_not_found",
|
||||
translation_placeholders={"entity_id": person_entity_id},
|
||||
)
|
||||
user_id = state.attributes.get("user_id")
|
||||
if not user_id:
|
||||
raise ServiceValidationError(
|
||||
f"Person {state.name!r} is not linked to a Home Assistant user account"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="person_without_user",
|
||||
translation_placeholders={"name": state.name},
|
||||
)
|
||||
return str(user_id)
|
||||
|
||||
@@ -573,7 +593,14 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
|
||||
errors.append(f"{eid}: {err}")
|
||||
if errors:
|
||||
raise ServiceValidationError(
|
||||
f"{label}: {done} of {len(ids)} succeeded; " + " | ".join(errors)
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="bulk_partial",
|
||||
translation_placeholders={
|
||||
"action": label,
|
||||
"done": str(done),
|
||||
"total": str(len(ids)),
|
||||
"errors": " | ".join(errors),
|
||||
},
|
||||
)
|
||||
|
||||
async def _handle_complete(call: ServiceCall) -> None:
|
||||
@@ -586,12 +613,18 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
|
||||
for field in ("reading_value", "reading_values", "cost", "duration"):
|
||||
if call.data.get(field) is not None:
|
||||
raise ServiceValidationError(
|
||||
f"'{field}' can only be used with a single entity_id"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="single_entity_only",
|
||||
translation_placeholders={"field": field},
|
||||
)
|
||||
# An explicit ``via_tag_scan: false`` claims nothing — only a
|
||||
# TRUE flag is the one-tag-one-task proof that must not fan out.
|
||||
if call.data.get("via_tag_scan"):
|
||||
raise ServiceValidationError("'via_tag_scan' can only be used with a single entity_id")
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="single_entity_only",
|
||||
translation_placeholders={"field": "via_tag_scan"},
|
||||
)
|
||||
# #128: explicit person beats the call context; the context covers the
|
||||
# common case for free (a dashboard tap propagates the tapping user).
|
||||
if call.data.get("completed_by"):
|
||||
@@ -615,7 +648,11 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
|
||||
or None
|
||||
)
|
||||
except ValueError as err:
|
||||
raise ServiceValidationError(str(err)) from err
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_input",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
await coordinator.complete_maintenance(
|
||||
task_id=task_id,
|
||||
source="service",
|
||||
@@ -682,7 +719,7 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
|
||||
|
||||
url = call.data.get("documentation_url")
|
||||
if url and not _is_safe_url(url):
|
||||
raise ServiceValidationError("Only http/https URLs are allowed")
|
||||
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="unsafe_url")
|
||||
try:
|
||||
entry_id = await async_create_object(
|
||||
hass,
|
||||
@@ -697,7 +734,11 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
|
||||
notes=call.data.get("notes"),
|
||||
)
|
||||
except ValueError as err:
|
||||
raise ServiceValidationError(str(err)) from err
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_input",
|
||||
translation_placeholders={"error": 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:
|
||||
@@ -714,30 +755,39 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
|
||||
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),
|
||||
# The integration-wide default like every other create path —
|
||||
# the bare constant 7 ignored the setting (bug audit 2026-09-26).
|
||||
warning_days=call.data["warning_days"] if "warning_days" in call.data else get_default_warning_days(hass),
|
||||
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
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_input",
|
||||
translation_placeholders={"error": 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)
|
||||
# Content services hold a calling user to the tier of the matching WS
|
||||
# command (helpers.permissions.service_tier); complete/reset/skip stay
|
||||
# open to everyone like their panel actions.
|
||||
hass.services.async_register(DOMAIN, SERVICE_EXPORT, service_tier(hass, _handle_export, admin=True), schema=SERVICE_EXPORT_SCHEMA)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_ADD_OBJECT,
|
||||
_handle_add_object,
|
||||
service_tier(hass, _handle_add_object),
|
||||
schema=SERVICE_ADD_OBJECT_SCHEMA,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_ADD_TASK,
|
||||
_handle_add_task,
|
||||
service_tier(hass, _handle_add_task),
|
||||
schema=SERVICE_ADD_TASK_SCHEMA,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
@@ -763,9 +813,7 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
|
||||
# #128: assignment via automations. Person entity -> HA user id; the
|
||||
# clear flag maps to "" (async_update_task_simple pops the key).
|
||||
if call.data.get("responsible_user") and call.data.get("clear_responsible_user"):
|
||||
raise ServiceValidationError(
|
||||
"Provide either responsible_user or clear_responsible_user, not both"
|
||||
)
|
||||
raise ServiceValidationError(translation_domain=DOMAIN, translation_key="responsible_user_conflict")
|
||||
if call.data.get("responsible_user"):
|
||||
updates["responsible_user_id"] = _resolve_person_user_id(call.data["responsible_user"])
|
||||
elif call.data.get("clear_responsible_user"):
|
||||
@@ -778,7 +826,11 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
|
||||
updates=updates,
|
||||
)
|
||||
except ValueError as err:
|
||||
raise ServiceValidationError(str(err)) from err
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_input",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
async def _handle_delete_task(call: ServiceCall) -> None:
|
||||
"""Handle delete_task — remove a task and its side-state."""
|
||||
@@ -787,9 +839,17 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
|
||||
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}")
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="object_not_found",
|
||||
translation_placeholders={"entry_id": entry_id},
|
||||
)
|
||||
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}")
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="task_not_found_in_object",
|
||||
translation_placeholders={"task_id": call.data["task_id"], "object_name": entry.title},
|
||||
)
|
||||
await hass.config_entries.async_reload(entry_id)
|
||||
|
||||
async def _handle_list_tasks(call: ServiceCall) -> dict[str, Any]:
|
||||
@@ -797,74 +857,61 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
|
||||
wanted_entry = call.data.get("entry_id")
|
||||
wanted_status = call.data.get("status")
|
||||
tasks: list[dict[str, Any]] = []
|
||||
ent_reg = er.async_get(hass)
|
||||
for ce in hass.config_entries.async_entries(DOMAIN):
|
||||
if ce.unique_id == GLOBAL_UNIQUE_ID:
|
||||
# The shared cross-object walk (loaded objects, archived tasks left
|
||||
# out) — the Assist snapshot uses the same one (DRY audit 2026-09-26 B).
|
||||
for ce, task_id, task in iter_live_tasks(hass, wanted_entry or None):
|
||||
status = str(task.get("_status", ""))
|
||||
if wanted_status and status != wanted_status:
|
||||
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 = aggregate_object_name(ce)
|
||||
# #151 follow-up: the task sensor's REGISTERED entity_id (users
|
||||
# rename them), so a row can be fed straight into complete/skip.
|
||||
object_slug = slugify_object_name((ce.data.get(CONF_OBJECT) or {}).get("name", "unknown"))
|
||||
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
|
||||
# #151: last completion + live trigger reading, so
|
||||
# notification automations don't need extra lookups.
|
||||
lp = task.get("last_performed")
|
||||
days_since: int | None = None
|
||||
if lp:
|
||||
try:
|
||||
days_since = (dt_util.now().date() - date_cls.fromisoformat(str(lp)[:10])).days
|
||||
except ValueError:
|
||||
days_since = None
|
||||
tc = task.get("trigger_config") or {}
|
||||
trigger_target, trigger_unit = _trigger_target_and_unit(hass, tc)
|
||||
tasks.append(
|
||||
{
|
||||
"entry_id": ce.entry_id,
|
||||
"task_id": task_id,
|
||||
"entity_id": ent_reg.async_get_entity_id(
|
||||
"sensor", DOMAIN, task_unique_id(object_slug, 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"),
|
||||
"last_performed": lp,
|
||||
"days_since_last_completed": days_since,
|
||||
"trigger_type": tc.get("type"),
|
||||
"trigger_current_value": task.get("_trigger_current_value"),
|
||||
# Delta-mode counters: progress since the last service
|
||||
# (what the panel bar shows); None for every other trigger.
|
||||
"trigger_current_delta": task.get("_trigger_current_delta"),
|
||||
"trigger_target": trigger_target,
|
||||
"trigger_unit": trigger_unit,
|
||||
}
|
||||
)
|
||||
# #151: last completion + live trigger reading, so
|
||||
# notification automations don't need extra lookups.
|
||||
lp = task.get("last_performed")
|
||||
days_since: int | None = None
|
||||
if lp:
|
||||
try:
|
||||
days_since = (dt_util.now().date() - date_cls.fromisoformat(str(lp)[:10])).days
|
||||
except ValueError:
|
||||
days_since = None
|
||||
tc = task.get("trigger_config") or {}
|
||||
trigger_target, trigger_unit = _trigger_target_and_unit(hass, tc)
|
||||
tasks.append(
|
||||
{
|
||||
"entry_id": ce.entry_id,
|
||||
"task_id": task_id,
|
||||
# #151 follow-up: the task sensor's REGISTERED entity_id
|
||||
# (users rename them), so a row can be fed straight into
|
||||
# complete/skip.
|
||||
"entity_id": aggregate_task_sensor_entity_id(hass, ce.data.get(CONF_OBJECT) or {}, task_id),
|
||||
"object_name": aggregate_object_name(ce),
|
||||
"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"),
|
||||
"last_performed": lp,
|
||||
"days_since_last_completed": days_since,
|
||||
"trigger_type": tc.get("type"),
|
||||
"trigger_current_value": task.get("_trigger_current_value"),
|
||||
# Delta-mode counters: progress since the last service
|
||||
# (what the panel bar shows); None for every other trigger.
|
||||
"trigger_current_delta": task.get("_trigger_current_delta"),
|
||||
"trigger_target": trigger_target,
|
||||
"trigger_unit": trigger_unit,
|
||||
}
|
||||
)
|
||||
return {"tasks": tasks, "count": len(tasks)}
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_UPDATE_TASK,
|
||||
_handle_update_task,
|
||||
service_tier(hass, _handle_update_task),
|
||||
schema=SERVICE_UPDATE_TASK_SCHEMA,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_DELETE_TASK,
|
||||
_handle_delete_task,
|
||||
service_tier(hass, _handle_delete_task),
|
||||
schema=SERVICE_DELETE_TASK_SCHEMA,
|
||||
)
|
||||
hass.services.async_register(
|
||||
@@ -882,32 +929,16 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
|
||||
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]:
|
||||
# MS_<COMPLETE|SKIP|SNOOZE>_<entry_id>_<task_id> — the one parser of
|
||||
# the ids build_action_buttons sends (helpers/notification_ids).
|
||||
try:
|
||||
parsed = parse_action_id(action)
|
||||
except ValueError:
|
||||
_LOGGER.warning("Invalid notification action format: %s", action)
|
||||
return
|
||||
|
||||
entry_id, task_id = parts
|
||||
if parsed is None:
|
||||
return # not a task button of ours (or a test-send button)
|
||||
action_type, entry_id, task_id = parsed
|
||||
|
||||
config_entry = hass.config_entries.async_get_entry(entry_id)
|
||||
runtime_data = getattr(config_entry, "runtime_data", None) if config_entry else None
|
||||
@@ -1109,13 +1140,10 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
|
||||
if not isinstance(old_eid, str) or old_eid == new_eid:
|
||||
return
|
||||
|
||||
from .helpers.entity_rename import rewrite_store, rewrite_tasks
|
||||
from .helpers.entity_rename import rewrite_object, rewrite_store, rewrite_tasks
|
||||
|
||||
# v2.67: the global shopping-list target is an entity reference too.
|
||||
gentry = next(
|
||||
(e for e in hass.config_entries.async_entries(DOMAIN) if e.unique_id == GLOBAL_UNIQUE_ID),
|
||||
None,
|
||||
)
|
||||
gentry = get_global_entry(hass)
|
||||
if gentry is not None and gentry.options.get(CONF_SHOPPING_LIST_ENTITY) == old_eid:
|
||||
hass.config_entries.async_update_entry(
|
||||
gentry, options={**gentry.options, CONF_SHOPPING_LIST_ENTITY: new_eid}
|
||||
@@ -1130,14 +1158,25 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
|
||||
if ce.unique_id == GLOBAL_UNIQUE_ID:
|
||||
continue
|
||||
|
||||
# 1. trigger_config refs live in entry.data
|
||||
# 1. trigger_config refs (+ calendar-kind schedules, to-do mirror
|
||||
# targets) live in entry.data, the battery fleet's manual
|
||||
# include / exclude lists on the object (bug audit 2026-09-27).
|
||||
entry_changed = False
|
||||
new_data = dict(ce.data)
|
||||
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)
|
||||
new_tasks, tasks_changed = rewrite_tasks(tasks, old_eid, new_eid)
|
||||
if tasks_changed:
|
||||
new_data[CONF_TASKS] = new_tasks
|
||||
entry_changed = True
|
||||
obj = ce.data.get(CONF_OBJECT)
|
||||
if isinstance(obj, dict):
|
||||
new_obj, obj_changed = rewrite_object(obj, old_eid, new_eid)
|
||||
if obj_changed:
|
||||
new_data[CONF_OBJECT] = new_obj
|
||||
entry_changed = True
|
||||
if entry_changed:
|
||||
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).
|
||||
@@ -1157,8 +1196,12 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
|
||||
|
||||
# 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.
|
||||
# they re-instantiate against the new id. The to-do mirror's list
|
||||
# listeners are shared (global entry): re-armed on the new ids.
|
||||
hass.config_entries.async_schedule_reload(ce.entry_id)
|
||||
from .helpers.todo_mirror import schedule_rearm as _rearm_todo_mirror
|
||||
|
||||
_rearm_todo_mirror(hass)
|
||||
_LOGGER.info(
|
||||
"Rewrote entity references %s → %s for entry %s (entry_data=%s, store=%s) and scheduled reload",
|
||||
old_eid,
|
||||
@@ -1175,10 +1218,7 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
|
||||
# admin-panel allowlist kept the stale id (bug audit 2026-08-29).
|
||||
async def _on_user_removed(_event: Event) -> None:
|
||||
await _check_task_responsible_user_orphans(hass)
|
||||
gentry = next(
|
||||
(e for e in hass.config_entries.async_entries(DOMAIN) if e.unique_id == GLOBAL_UNIQUE_ID),
|
||||
None,
|
||||
)
|
||||
gentry = get_global_entry(hass)
|
||||
if gentry is not None:
|
||||
await _check_admin_panel_user_orphans(hass, gentry)
|
||||
|
||||
@@ -1458,7 +1498,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf
|
||||
_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):
|
||||
if entry_option(entry, CONF_PANEL_ENABLED):
|
||||
await async_register_panel(hass)
|
||||
|
||||
# Listen for options changes (panel toggle)
|
||||
@@ -1467,9 +1507,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf
|
||||
# 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)
|
||||
)
|
||||
await async_sync_assist_sentences(hass, entry_option(entry, CONF_INSTALL_ASSIST_SENTENCES))
|
||||
|
||||
# Initial orphan check for admin_panel_user_ids (HA users deleted
|
||||
# while the integration was offline land here as repair issues).
|
||||
@@ -1524,6 +1562,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf
|
||||
# A cached store's memory is authoritative — re-loading from disk here
|
||||
# would drop any change still sitting in its debounce window.
|
||||
|
||||
# Catalog triggers written by a signature that could never fire
|
||||
# (gree/daikin hvac_action runtime, 2026-09-25) are repaired once.
|
||||
from .helpers.catalog_heal import heal_catalog_triggers
|
||||
|
||||
if (healed_data := heal_catalog_triggers(hass, entry.data)) is not None:
|
||||
_LOGGER.info("Repaired catalog trigger(s) of %s (AC filter runtime now counts on the HVAC mode)", entry.title)
|
||||
hass.config_entries.async_update_entry(entry, data=healed_data)
|
||||
|
||||
# Every write path validates spare parts, but ~27 readers assume a dict
|
||||
# with an "id" — a hand-edited / damaged record crashed the sensor
|
||||
# platform and the buy-task sync at setup (found 2026-09-26). Drop
|
||||
# non-dict records and fill a missing id from the key, once.
|
||||
stored_parts = entry.data.get("parts")
|
||||
raw_parts: dict[str, Any] = stored_parts if isinstance(stored_parts, dict) else {}
|
||||
damaged = [pid for pid, p in raw_parts.items() if not isinstance(p, dict) or p.get("id") != pid]
|
||||
if damaged:
|
||||
fixed_parts = {pid: {**p, "id": pid} for pid, p in raw_parts.items() if isinstance(p, dict)}
|
||||
_LOGGER.warning("Repaired damaged spare-part record(s) %s of %s", damaged, entry.title)
|
||||
hass.config_entries.async_update_entry(entry, data={**entry.data, "parts": fixed_parts})
|
||||
|
||||
# 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
|
||||
@@ -1583,8 +1641,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .helpers.device_link import is_self_link
|
||||
from .helpers.issues import device_link_lost_issue_id
|
||||
|
||||
issue_id = f"device_link_lost_{entry.entry_id}"
|
||||
issue_id = device_link_lost_issue_id(entry.entry_id)
|
||||
if resolved is None:
|
||||
# Two distinct stories share one issue id (so resolve/delete
|
||||
# stays single-path): the linked device is GONE, or the link
|
||||
@@ -1867,7 +1926,7 @@ def _sync_missing_global_entry_issue(hass: HomeAssistant) -> None:
|
||||
|
||||
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)
|
||||
panel_enabled = entry_option(entry, CONF_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.
|
||||
@@ -1878,9 +1937,7 @@ async def _async_global_options_updated(hass: HomeAssistant, entry: ConfigEntry)
|
||||
# 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)
|
||||
)
|
||||
await async_sync_assist_sentences(hass, entry_option(entry, CONF_INSTALL_ASSIST_SENTENCES))
|
||||
# v2.67: the shopping-list target may have changed (set / cleared / swapped).
|
||||
# This listener does NOT reload the entry, so the sync must be nudged
|
||||
# explicitly - it re-reads the option, re-arms its state listener and
|
||||
@@ -1980,7 +2037,7 @@ async def _check_admin_panel_user_orphans(hass: HomeAssistant, entry: ConfigEntr
|
||||
"""
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
user_ids_raw = entry.options.get(CONF_ADMIN_PANEL_USER_IDS, []) or []
|
||||
user_ids_raw = entry_option(entry, 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)}
|
||||
@@ -2028,9 +2085,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: MaintenanceSupporterCon
|
||||
todo_mirror = hass.data.get(DOMAIN, {}).pop(TODO_MIRROR_KEY, None)
|
||||
if todo_mirror is not None:
|
||||
todo_mirror.async_teardown()
|
||||
text_index = hass.data.get(DOMAIN, {}).get(DOCUMENT_TEXT_INDEX_KEY)
|
||||
if text_index is not None:
|
||||
text_index.cancel()
|
||||
# NOT the full-text index: it is shared runtime (built once per boot
|
||||
# by _async_setup_shared, which a hub reload does not re-run) — a
|
||||
# reload of the global entry (any settings save) closed it for the
|
||||
# rest of the run and new uploads were never indexed again (bug audit
|
||||
# 2026-09-27). The last entry to unload cancels it below.
|
||||
|
||||
# Flush a pending debounced store save BEFORE tearing down — belt and
|
||||
# suspenders next to the store cache: disk is current the moment the entry
|
||||
@@ -2063,6 +2122,38 @@ async def async_unload_entry(hass: HomeAssistant, entry: MaintenanceSupporterCon
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def _async_forget_removed_object_tasks(hass: HomeAssistant, entry: ConfigEntry, store: MaintenanceStore) -> None:
|
||||
"""What a task delete cleans up per task, for every task of a deleted
|
||||
object: its rows on the family's to-do lists (D#183 — the Store record
|
||||
naming them goes with the object), its ids on the persistent vacation
|
||||
exempt list, and the notification manager's bookkeeping. All three
|
||||
outlived the object (bug audit 2026-09-27)."""
|
||||
task_ids = [str(tid) for tid in (entry.data.get(CONF_TASKS) or {})]
|
||||
|
||||
from .helpers.todo_mirror import TODO_MIRROR_KEY
|
||||
|
||||
mirror = hass.data.get(DOMAIN, {}).get(TODO_MIRROR_KEY)
|
||||
if mirror is not None:
|
||||
for task_id in task_ids:
|
||||
try:
|
||||
await mirror.async_forget_task(store, task_id)
|
||||
except Exception: # noqa: BLE001 — a list provider error must not block the delete
|
||||
_LOGGER.warning("Could not take the to-do rows of task %s off their lists", task_id, exc_info=True)
|
||||
|
||||
ge = get_global_entry(hass)
|
||||
exempt = (ge.options.get(CONF_VACATION_EXEMPT_TASK_IDS) if ge is not None else None) or []
|
||||
if ge is not None and isinstance(exempt, list) and any(t in exempt for t in task_ids):
|
||||
gone = set(task_ids)
|
||||
hass.config_entries.async_update_entry(
|
||||
ge,
|
||||
options={**dict(ge.options), CONF_VACATION_EXEMPT_TASK_IDS: [t for t in exempt if t not in gone]},
|
||||
)
|
||||
|
||||
nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
|
||||
if isinstance(nm, NotificationManager):
|
||||
nm.purge_entry(entry.entry_id)
|
||||
|
||||
|
||||
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:
|
||||
@@ -2072,11 +2163,14 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
_sync_missing_global_entry_issue(hass)
|
||||
return
|
||||
|
||||
# A "lost its device link" notice belongs to the object; deleting the object
|
||||
# answers it, so it must not outlive it as an orphan in the Repairs list.
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
# Every repair issue about the object — its device link, a broken part
|
||||
# link, its tasks' missing trigger entities and stale action targets —
|
||||
# answers itself with the object gone. Only the device-link notice was
|
||||
# deleted; the others stayed in Repairs for good, with a fix flow that had
|
||||
# nothing left to fix (bug audit 2026-09-26).
|
||||
from .helpers.issues import async_purge_entry_issues
|
||||
|
||||
ir.async_delete_issue(hass, DOMAIN, f"device_link_lost_{entry.entry_id}")
|
||||
async_purge_entry_issues(hass, entry.entry_id)
|
||||
|
||||
# #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
|
||||
@@ -2091,6 +2185,8 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
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_load()
|
||||
await _async_forget_removed_object_tasks(hass, entry, store)
|
||||
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.
|
||||
@@ -2111,7 +2207,16 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
# 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))
|
||||
object_id = object_id_for_entry(entry)
|
||||
removed_doc_ids = {doc["id"] for doc in doc_store.for_object(object_id)}
|
||||
await doc_store.async_remove_object(object_id)
|
||||
# ...and the references OTHER objects hold to those documents: a
|
||||
# completion photo on a moved task's history, a spare part's manual.
|
||||
# A document delete already forgot them; an object delete left them
|
||||
# dangling (bug audit 2026-09-26, SEC-10).
|
||||
from .helpers.documents import async_forget_doc_ids
|
||||
|
||||
await async_forget_doc_ids(hass, removed_doc_ids)
|
||||
|
||||
_LOGGER.debug("Removed store for entry %s", entry.entry_id)
|
||||
# The global summary coordinator has no listener for entry removal, so tell
|
||||
@@ -2187,15 +2292,18 @@ def _get_task_id_for_entity(hass: HomeAssistant, entity_id: str) -> str | None:
|
||||
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
|
||||
# Look up which task matches this unique_id (const.task_unique_id):
|
||||
# maintenance_supporter_{slug}_{task_id}[_{suffix}] — the task sensor has
|
||||
# no suffix; the overdue binary sensor, the next-due / days-until-due
|
||||
# sensors and the complete/skip/reset buttons each add one. Only
|
||||
# "_overdue" used to be recognised, so picking a task's next-due sensor
|
||||
# as the service target failed with no_task_for_entity.
|
||||
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}"):
|
||||
marker = f"_{task_id}"
|
||||
if unique_id.endswith(marker) or f"{marker}_" in unique_id:
|
||||
return str(task_id)
|
||||
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user