updated apps

This commit is contained in:
2026-07-14 23:57:03 -04:00
parent 6cc7212cef
commit 010e828e9c
797 changed files with 45153 additions and 4246 deletions
@@ -54,7 +54,10 @@ from .const import (
DEFAULT_PANEL_ENABLED,
DEFAULT_WARNING_DAYS,
DOMAIN,
EVENT_UNSUBS_KEY,
GLOBAL_UNIQUE_ID,
MAX_COST,
MAX_DURATION_MINUTES,
PLATFORMS,
SERVICE_ADD_OBJECT,
SERVICE_ADD_TASK,
@@ -67,6 +70,13 @@ from .const import (
SERVICE_UPDATE_TASK,
SIGNAL_NEW_OBJECT_ENTRY,
SIGNAL_OBJECT_ENTRY_REMOVED,
STORES_CACHE_KEY,
)
from .const import (
DOCUMENT_STORE_KEY as _DS_KEY,
)
from .const import (
NOTIFICATION_MANAGER_KEY as _NM_KEY,
)
from .coordinator import MaintenanceCoordinator
from .entity.summary_coordinator import MaintenanceSummaryCoordinator
@@ -82,8 +92,9 @@ from .websocket import async_register_commands
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
NOTIFICATION_MANAGER_KEY = "_notification_manager"
DOCUMENT_STORE_KEY = "_document_store"
# Re-exported from const for the existing deferred `from . import ...` users.
NOTIFICATION_MANAGER_KEY = _NM_KEY
DOCUMENT_STORE_KEY = _DS_KEY
@dataclass
@@ -105,8 +116,8 @@ SERVICE_COMPLETE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
vol.Optional("notes"): vol.All(cv.string, vol.Length(max=2000)),
vol.Optional("cost"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1_000_000)),
vol.Optional("duration"): vol.All(vol.Coerce(int), vol.Range(min=0, max=525_600)),
vol.Optional("cost"): vol.All(vol.Coerce(float), vol.Range(min=0, max=MAX_COST)),
vol.Optional("duration"): vol.All(vol.Coerce(int), vol.Range(min=0, max=MAX_DURATION_MINUTES)),
# Meter readings (v2.20, #83): recorded value for `reading` tasks.
vol.Optional("reading_value"): vol.All(vol.Coerce(float), vol.Range(min=-1e12, max=1e12)),
}
@@ -884,7 +895,7 @@ async def _async_setup_shared(hass: HomeAssistant) -> bool:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, lambda _e: unsub_digest())
# Store unsub callbacks so they can be cleaned up when domain is unloaded
hass.data[DOMAIN]["_event_unsubs"] = [
hass.data[DOMAIN][EVENT_UNSUBS_KEY] = [
unsub_notification,
unsub_tag,
unsub_action,
@@ -1069,16 +1080,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf
_LOGGER.debug("Global config entry set up: %s", entry.entry_id)
else:
# Maintenance object entry: create Store + coordinator
store = MaintenanceStore(hass, entry.entry_id)
# Maintenance object entry: fetch-or-create the Store. Reused across
# entry RELOADS (cache in a top-level hass.data key): a second Store
# instance for the same file would race the first one's pending
# debounced save — see STORES_CACHE_KEY in const.py.
stores: dict[str, MaintenanceStore] = hass.data.setdefault(STORES_CACHE_KEY, {})
cached_store = stores.get(entry.entry_id)
store = cached_store if cached_store is not None else MaintenanceStore(hass, entry.entry_id)
stores[entry.entry_id] = store
# Migrate dynamic state from ConfigEntry.data → Store (one-time)
cleaned_data = await async_migrate_to_store(hass, entry.entry_id, entry.data, store)
if cleaned_data is not entry.data:
hass.config_entries.async_update_entry(entry, data=dict(cleaned_data))
if cached_store is None:
# First setup this run: load from disk + one-time migration.
# Compare against the CAPTURED snapshot, not the live entry.data:
# the migration awaits store I/O, and a concurrent WS write during
# that window replaces entry.data — an identity check against the
# live attribute then fails spuriously and this write clobbers the
# concurrent update with the pre-await snapshot (lost update, seen
# live when rapid part creates raced a reload's setup).
data_before_migration = entry.data
cleaned_data = await async_migrate_to_store(hass, entry.entry_id, data_before_migration, store)
if cleaned_data is not data_before_migration:
hass.config_entries.async_update_entry(entry, data=dict(cleaned_data))
# A cached store's memory is authoritative — re-loading from disk here
# would drop any change still sitting in its debounce window.
# Reconcile the entry.data <-> Store split (journey I1): drop store
# state orphaned by a crash between the two writes of a deletion.
# Same reconciliation for spare-part stock state (journey S6): a crash
# between the ConfigEntry write and the Store save on a part deletion
# leaves its stock orphaned forever otherwise.
pruned_parts = store.prune_part_orphans(set(entry.data.get("parts") or {}))
if pruned_parts:
_LOGGER.info("Pruned %d orphaned part stock state(s) for %s", pruned_parts, entry.title)
await store.async_save()
pruned = store.prune_orphans(set(entry.data.get(CONF_TASKS, {})))
if pruned:
_LOGGER.info(
@@ -1105,6 +1140,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: MaintenanceSupporterConf
async_dispatcher_send(hass, SIGNAL_NEW_OBJECT_ENTRY, entry.entry_id)
# Buy-task catch-up (spare parts): converge the shopping reminders with
# the current stock/pause/archive state — covers import/restore, a
# resume/unarchive, and any change made while this entry was unloaded.
# Declarative + idempotent, reloads only when something changed.
if entry.data.get("parts"):
from .parts_runtime import schedule_buy_task_reconcile
schedule_buy_task_reconcile(hass, entry)
_LOGGER.debug(
"Maintenance object entry set up: %s (%s)",
entry.title,
@@ -1373,6 +1417,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: MaintenanceSupporterCon
# Unregister panel when global entry is unloaded
await async_unregister_panel(hass)
# Flush a pending debounced store save BEFORE tearing down — belt and
# suspenders next to the store cache: disk is current the moment the entry
# goes away, whether or not it ever comes back this run.
store = hass.data.get(STORES_CACHE_KEY, {}).get(entry.entry_id)
if store is not None:
await store.async_save()
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
# Clean up domain data if no entries left
@@ -1381,7 +1432,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: MaintenanceSupporterCon
nm = hass.data.get(DOMAIN, {}).get(NOTIFICATION_MANAGER_KEY)
if nm is not None:
await nm.async_unload()
for unsub in hass.data.get(DOMAIN, {}).get("_event_unsubs", []):
for unsub in hass.data.get(DOMAIN, {}).get(EVENT_UNSUBS_KEY, []):
unsub()
hass.data.pop(DOMAIN, None)
@@ -1396,8 +1447,15 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
# as a fixable repair issue so the user can restore it (#86).
_sync_missing_global_entry_issue(hass)
return
store = MaintenanceStore(hass, entry.entry_id)
store = hass.data.get(STORES_CACHE_KEY, {}).pop(entry.entry_id, None)
if store is None:
store = MaintenanceStore(hass, entry.entry_id)
await store.async_remove()
# Drop the per-entry reconcile lock — the module dict would otherwise grow
# by one lock per object ever created in this HA run.
from .parts_runtime import discard_reconcile_lock
discard_reconcile_lock(entry.entry_id)
# v1.5.4: also called from ws_delete_object — but if the user removes the
# config entry from HA's "Configure" UI, that path doesn't run, leaving
# phantom task_refs in groups. Belt-and-suspenders.