updated apps
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user