429 files
This commit is contained in:
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(
|
||||
|
||||
Reference in new issue
Block a user