393 files

This commit is contained in:
Home Assistant Version Control
2026-09-27 17:16:54 +00:00
parent b05b7a897e
commit 39d97ac2db
404 changed files with 54006 additions and 11034 deletions
@@ -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