429 files

This commit is contained in:
Home Assistant Version Control committed 2026-08-30 02:43:31 +00:00
1 parent 472c19bceb
commit 963cce81a7
566 files changed
+50311 -47616

No files matched your search

@@ -8,6 +8,7 @@ from datetime import timedelta
from typing import Any
import voluptuous as vol
from homeassistant.auth import EVENT_USER_REMOVED
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import (
@@ -51,6 +52,7 @@ from .const import (
CONF_INSTALL_ASSIST_SENTENCES,
CONF_OBJECT,
CONF_PANEL_ENABLED,
CONF_SHOPPING_LIST_ENTITY,
CONF_TASKS,
CONF_WEEKLY_DIGEST_ENABLED,
DEFAULT_PANEL_ENABLED,
@@ -141,6 +143,7 @@ SERVICE_COMPLETE_SCHEMA = vol.Schema(
# #133: when the maintenance was actually performed (backfill a past
# completion). Future values are rejected by the coordinator.
vol.Optional("completed_at"): cv.datetime,
vol.Optional("via_tag_scan"): cv.boolean,
}
)
@@ -422,6 +425,7 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
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
@@ -478,6 +482,9 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
reading_value=call.data.get("reading_value"),
completed_by=completed_by,
completed_at=call.data.get("completed_at"),
# Proof of presence: an automation that reacted to the physical
# scan itself passes via_tag_scan to satisfy require_tag_scan.
tag_verified=bool(call.data.get("via_tag_scan")),
)
async def _handle_reset(call: ServiceCall) -> None:
@@ -765,7 +772,14 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
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)
await runtime_data.coordinator.complete_maintenance(
task_id=task_id,
unattended=True,
# mobile_app fires the action with the registration's
# user in the context - credit them (#128 attribution,
# feeds least_completed rotation); bug audit 2026-08-29.
completed_by=event.context.user_id if event.context else None,
)
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")
@@ -835,6 +849,7 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
completed_by=user_id,
notes="Completed via NFC tag",
unattended=True,
tag_verified=True,
)
except ServiceValidationError as err:
# A tag tap cannot capture a cost or a photo. Log it
@@ -862,7 +877,10 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
# (DeviceInfo.suggested_area only fires once, on first device creation).
@callback
def _object_entries_for_device(
hass: HomeAssistant, device: dr.DeviceEntry
# object: HA 2026.9 registry lookups return DeviceEntry |
# ChildDeviceEntry (a union 2026.7 cannot import); the body reads
# attributes via getattr already.
hass: HomeAssistant, device: object
) -> list[ConfigEntry]:
"""Our object entries that live on this device.
@@ -894,7 +912,7 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
for ce in hass.config_entries.async_entries(DOMAIN):
if ce.entry_id in seen or ce.unique_id == GLOBAL_UNIQUE_ID:
continue
if (ce.data.get(CONF_OBJECT) or {}).get("ha_device_id") == device.id:
if (ce.data.get(CONF_OBJECT) or {}).get("ha_device_id") == getattr(device, "id", None):
found.append(ce)
return found
@@ -940,6 +958,21 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
from .helpers.entity_rename import 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,
)
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}
)
from .shopping_sync import SHOPPING_SYNC_KEY
sync = hass.data.get(DOMAIN, {}).get(SHOPPING_SYNC_KEY)
if sync is not None:
await sync.async_handle_rename(old_eid, new_eid)
for ce in hass.config_entries.async_entries(DOMAIN):
if ce.unique_id == GLOBAL_UNIQUE_ID:
continue
@@ -984,6 +1017,20 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
unsub_entity = hass.bus.async_listen(er.EVENT_ENTITY_REGISTRY_UPDATED, _on_entity_registry_update)
# A deleted HA user used to be reconciled only at the next boot: the
# least_completed rotation kept assigning the ghost meanwhile and the
# 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,
)
if gentry is not None:
await _check_admin_panel_user_orphans(hass, gentry)
unsub_user_removed = hass.bus.async_listen(EVENT_USER_REMOVED, _on_user_removed)
# 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
@@ -1003,6 +1050,16 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
await async_maybe_send_lead_reminders(hass)
unsub_digest = async_track_time_change(hass, _weekly_digest_tick, hour=8, minute=0, second=0)
# The 08:00 tick drops lead reminders inside quiet hours; a quiet window
# ending after 08:00 therefore silenced them for good (bug audit
# 2026-08-29). A second, reminder-only pass at noon catches those - the
# per-day dedup inside makes it a no-op when 08:00 already delivered.
async def _lead_reminder_retry(now: Any) -> None:
await async_maybe_send_lead_reminders(hass)
unsub_lead_retry = async_track_time_change(hass, _lead_reminder_retry, hour=12, minute=0, second=0)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, lambda _e: unsub_lead_retry())
# 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).
@@ -1010,6 +1067,7 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
# Store unsub callbacks so they can be cleaned up when domain is unloaded
hass.data[DOMAIN][EVENT_UNSUBS_KEY] = [
unsub_user_removed,
unsub_notification,
unsub_tag,
unsub_action,
@@ -1017,6 +1075,7 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
unsub_entity,
unsub_retention,
unsub_digest,
unsub_lead_retry,
]
# Once HA has started (all entries loaded), flag object entries that were
@@ -1195,6 +1254,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf
entry.async_on_unload(summary_coordinator.async_teardown_listeners)
await summary_coordinator.async_refresh()
# v2.67: mirror auto "buy" tasks into the user's to-do list (no-op
# until the shopping_list_entity option is set). Owned by the GLOBAL
# entry's lifecycle - created here, torn down in async_unload_entry -
# so a reload of the hub entry gets a fresh instance (the shared-
# runtime setup only runs once per boot; bug audit 2026-08-29).
from .shopping_sync import SHOPPING_SYNC_KEY, ShoppingListSync
if SHOPPING_SYNC_KEY not in hass.data[DOMAIN]:
shopping_sync = ShoppingListSync(hass)
await shopping_sync.async_setup()
hass.data[DOMAIN][SHOPPING_SYNC_KEY] = shopping_sync
# 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:
@@ -1563,6 +1634,13 @@ async def _async_global_options_updated(hass: HomeAssistant, entry: ConfigEntry)
await async_sync_assist_sentences(
hass, entry.options.get(CONF_INSTALL_ASSIST_SENTENCES, False)
)
# 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
# pulls its rows out of a list that is no longer the target.
from .shopping_sync import schedule_resync
schedule_resync(hass)
_ORPHAN_ISSUE_PREFIX = "orphan_admin_panel_user_"
@@ -1693,6 +1771,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: MaintenanceSupporterCon
if entry.unique_id == GLOBAL_UNIQUE_ID:
# Unregister panel when global entry is unloaded
await async_unregister_panel(hass)
from .shopping_sync import SHOPPING_SYNC_KEY
shopping_sync = hass.data.get(DOMAIN, {}).pop(SHOPPING_SYNC_KEY, None)
if shopping_sync is not None:
shopping_sync.async_teardown()
# Flush a pending debounced store save BEFORE tearing down — belt and
# suspenders next to the store cache: disk is current the moment the entry
@@ -1773,6 +1856,10 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
from homeassistant.helpers.dispatcher import async_dispatcher_send
async_dispatcher_send(hass, SIGNAL_OBJECT_ENTRY_REMOVED, entry.entry_id)
# Its buy-task rows in the shopping list are orphans now (bug audit 2026-08-29).
from .shopping_sync import schedule_resync as _schedule_shopping_resync
_schedule_shopping_resync(hass)
async def async_remove_config_entry_device(