"""Spare-parts runtime — the hass-aware driver around helpers/parts.py. Owns the three mutations (consume on completion, restock, manual adjust) and applies the declarative buy-task reconcile to the entry. The pure rules live in :mod:`helpers.parts`; this module only wires them to the Store, the ConfigEntry and the event bus. """ from __future__ import annotations import logging from typing import Any from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util from .const import ( CONF_OBJECT, CONF_PART_SEARCH_URL_TEMPLATE, CONF_PARTS, CONF_TASK_CONSUMES_PARTS, CONF_TASKS, DOMAIN, EVENT_PART_RESTOCKED, EVENT_PART_STOCK_LOW, EVENT_PART_STOCK_OUT, ) from .helpers.global_options import get_global_options from .helpers.i18n import normalize_language from .helpers.parts import ( PART_REF_FIELD, reconcile_buy_tasks, stock_transition, ) _LOGGER = logging.getLogger(__name__) _TRANSITION_EVENTS = { "low": EVENT_PART_STOCK_LOW, "out": EVENT_PART_STOCK_OUT, "restocked": EVENT_PART_RESTOCKED, } # Dispatcher signal fired after any stock change — the parts sensors listen. SIGNAL_PARTS_UPDATED = f"{DOMAIN}_parts_updated" def _get_store(hass: HomeAssistant, entry: ConfigEntry) -> Any: rd = getattr(entry, "runtime_data", None) return getattr(rd, "store", None) if rd else None def _fire_transition( hass: HomeAssistant, entry: ConfigEntry, part: dict[str, Any], stock: float | None, transition: str | None, ) -> None: event = _TRANSITION_EVENTS.get(transition or "") if event is None: return obj = entry.data.get(CONF_OBJECT, {}) hass.bus.async_fire( event, { "entry_id": entry.entry_id, "object_id": obj.get("id", ""), "object_name": obj.get("name", entry.title), "part_id": part["id"], "part_name": part.get("name", ""), "stock": stock, "reorder_threshold": part.get("reorder_threshold"), }, ) def _signal_parts_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: from homeassistant.helpers.dispatcher import async_dispatcher_send async_dispatcher_send(hass, SIGNAL_PARTS_UPDATED, entry.entry_id) async def async_change_part_stock( hass: HomeAssistant, entry: ConfigEntry, part_id: str, *, delta: float | None = None, absolute: int | None = None, ) -> float | None: """Change one part's stock (clamped at 0), fire the edge event, save. Returns the new stock, or None when the part is unknown / untracked with a pure delta (a delta on an untracked part starts tracking from 0 so a first "restock" works naturally). Schedules the buy-task reconcile. """ part = (entry.data.get(CONF_PARTS) or {}).get(part_id) store = _get_store(hass, entry) if part is None or store is None: return None old = store.get_part_stock(part_id) if absolute is not None: new = max(0.0, float(absolute)) else: new = max(0.0, (old or 0) + float(delta or 0)) store.set_part_stock(part_id, new) # Immediate save: part CRUD and the buy-task reconcile may reload the entry # right after, which re-reads the store from disk — a debounced save would # silently lose the stock write across that reload. await store.async_save() _fire_transition(hass, entry, part, new, stock_transition(part, old, new)) _signal_parts_updated(hass, entry) schedule_buy_task_reconcile(hass, entry) return new async def async_handle_completion_parts( hass: HomeAssistant, entry: ConfigEntry, task_data: dict[str, Any], *, restock_quantity: float | None = None, used_parts: list[dict[str, Any]] | None = None, ) -> None: """Completion-side part effects: consume linked parts / restock a buy task. Called from the coordinator's complete path with the (pre-completion) task dict. Consumption only tracks parts that HAVE a tracked stock; a buy task (carrying ``part_ref``) restocks its part by ``restock_quantity`` (dialog override) or the part's configured default (min 1). Best-effort by design: a broken part link must never block the completion itself. """ parts = entry.data.get(CONF_PARTS) or {} store = _get_store(hass, entry) if store is None: return changed = False ref = task_data.get(PART_REF_FIELD) if isinstance(ref, dict) and ref.get("part_id") in parts: part = parts[ref["part_id"]] qty = restock_quantity if restock_quantity and restock_quantity > 0 else float(part.get("restock_quantity") or 1) old = store.get_part_stock(part["id"]) new = max(0, (old or 0) + qty) store.set_part_stock(part["id"], new) _fire_transition(hass, entry, part, new, stock_transition(part, old, new)) changed = True # #99: an explicit per-completion selection REPLACES the fixed links — # including the empty selection ("nothing used this time"). None keeps the # automatic consumes_parts behaviour. links = used_parts if used_parts is not None else (task_data.get(CONF_TASK_CONSUMES_PARTS) or []) # #111: a link may name another object's pool. Every touched entry has its # own parts dict and its own Store, so collect them per entry and save each. touched: dict[str, tuple[ConfigEntry, Any]] = {} if changed: touched[entry.entry_id] = (entry, store) broken: list[str] = [] for link in links: if not isinstance(link, dict): continue target_entry = entry target_parts = parts target_store = store foreign_id = str(link.get("entry_id") or "").strip() if foreign_id and foreign_id != entry.entry_id: resolved = hass.config_entries.async_get_entry(foreign_id) if resolved is None: broken.append(str(link.get("part_id") or "?")) continue target_entry = resolved target_parts = resolved.data.get(CONF_PARTS) or {} resolved_store = _get_store(hass, resolved) if resolved_store is None: broken.append(str(link.get("part_id") or "?")) continue target_store = resolved_store part = target_parts.get(link.get("part_id")) if part is None: # The pool is gone (owner deleted, or the part removed from it). # Silence here would record a completion that consumed nothing and # tell nobody — surface it instead. broken.append(str(link.get("part_id") or "?")) continue old = target_store.get_part_stock(part["id"]) if old is None: continue # catalog-only part — nothing to decrement qty = float(link.get("quantity", 1) or 1) new = max(0, old - qty) target_store.set_part_stock(part["id"], new) _fire_transition(hass, target_entry, part, new, stock_transition(part, old, new)) touched[target_entry.entry_id] = (target_entry, target_store) changed = True if broken: _raise_broken_link_issue(hass, entry, broken) for target_entry, target_store in touched.values(): await target_store.async_save() # reconcile may reload — see async_change_part_stock _signal_parts_updated(hass, target_entry) schedule_buy_task_reconcile(hass, target_entry) def schedule_buy_task_reconcile(hass: HomeAssistant, entry: ConfigEntry) -> None: """Run the buy-task reconcile as a background task. Deferred because applying a diff reloads the entry — which must never happen from inside the coordinator call (complete/restock) that triggered the stock change. The reconcile is declarative/idempotent, so overlapping schedules converge. """ entry_id = entry.entry_id async def _run() -> None: current = hass.config_entries.async_get_entry(entry_id) if current is not None: await async_reconcile_buy_tasks(hass, current) hass.async_create_task(_run(), name=f"{DOMAIN}_buy_task_reconcile_{entry_id}") _RECONCILE_LOCKS: dict[str, Any] = {} def discard_reconcile_lock(entry_id: str) -> None: """Forget a removed entry's reconcile lock (called from async_remove_entry).""" _RECONCILE_LOCKS.pop(entry_id, None) async def async_reconcile_buy_tasks(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Apply the declarative buy-task reconcile to *entry*. A buy task exists exactly while its part opts in AND is low (see helpers/parts.reconcile_buy_tasks for the episode semantics). Creates and removals are applied through the same primitives the WS CRUD uses (store init / full delete-cleanup), then the entry reloads ONCE so per-task entities appear/disappear. Returns True when anything changed. Concurrency: serialized per entry (reconciles overlap freely with rapid CRUD), and the ConfigEntry write applies only the computed DIFF onto a fresh read of entry.data — a whole-map write from the pre-await snapshot could clobber a part/task another handler persisted in between (a lost update seen live when three part creates raced the first reconcile). """ import asyncio lock = _RECONCILE_LOCKS.setdefault(entry.entry_id, asyncio.Lock()) async with lock: return await _reconcile_buy_tasks_locked(hass, entry) async def _reconcile_buy_tasks_locked(hass: HomeAssistant, entry: ConfigEntry) -> bool: parts = entry.data.get(CONF_PARTS) or {} # An archived or paused object must stay quiet (journey S4): no shopping # reminders while it's retired/out of season. Declaratively: an inert # object desires NO buy tasks — open reminders are removed, and the # resume/unarchive setup catch-up recreates them if the part is still low. obj = entry.data.get(CONF_OBJECT, {}) if obj.get("archived_at") is not None or obj.get("paused_at") is not None: parts = {} store = _get_store(hass, entry) if store is None: return False tasks: dict[str, Any] = entry.data.get(CONF_TASKS, {}) new_tasks, created, removed, changed = reconcile_buy_tasks( parts, store.all_part_stocks(), tasks, object_id=entry.data.get(CONF_OBJECT, {}).get("id", ""), lang=normalize_language(hass), search_template=get_global_options(hass).get(CONF_PART_SEARCH_URL_TEMPLATE), today=dt_util.now().date(), is_task_done=lambda td: store.get_last_performed(td["id"]) is not None, ) if not changed: return False # The diff relative to the snapshot: brand-new buy tasks, and existing # tasks whose part_ref marker was detached. Only these keys are written. detached = [ tid for tid in tasks if tid in new_tasks and tasks[tid].get(PART_REF_FIELD) != new_tasks[tid].get(PART_REF_FIELD) ] # Removals first, via the shared full-cleanup primitive (entity registry, # store state, group refs, notification state) — without per-task reloads. from .websocket.tasks_crud import async_delete_task for tid in removed: await async_delete_task(hass, entry, tid) # Apply the diff onto a FRESH read (never write back pre-await snapshots). current = hass.config_entries.async_get_entry(entry.entry_id) if current is None: return True new_data = dict(current.data) merged_tasks = dict(new_data.get(CONF_TASKS, {})) obj = dict(new_data.get(CONF_OBJECT, {})) task_ids = list(obj.get("task_ids", [])) for tid in created: merged_tasks[tid] = new_tasks[tid] if tid not in task_ids: task_ids.append(tid) for tid in detached: if tid in merged_tasks: td = dict(merged_tasks[tid]) td.pop(PART_REF_FIELD, None) merged_tasks[tid] = td obj["task_ids"] = [t for t in task_ids if t in merged_tasks] new_data[CONF_TASKS] = merged_tasks new_data[CONF_OBJECT] = obj hass.config_entries.async_update_entry(current, data=new_data) for tid in created: store.init_task(tid) await store.async_save() _LOGGER.debug( "Buy-task reconcile for %s: +%d / -%d", entry.title, len(created), len(removed), ) if created or removed: # Entities for created/removed tasks appear/vanish on reload. await hass.config_entries.async_reload(entry.entry_id) return True BROKEN_LINK_ISSUE_PREFIX = "broken_part_link_" def _raise_broken_link_issue(hass: HomeAssistant, entry: ConfigEntry, part_ids: list[str]) -> None: """Tell the user a completion could not decrement what it was linked to. Deliberately a repair issue rather than a refused completion: the work WAS done, and losing the history entry would be the bigger harm. What must not happen is the silent version — a tick that consumed nothing and said so nowhere. """ from homeassistant.helpers import issue_registry as ir object_name = str((entry.data.get(CONF_OBJECT) or {}).get("name") or entry.title) ir.async_create_issue( hass, DOMAIN, f"{BROKEN_LINK_ISSUE_PREFIX}{entry.entry_id}", is_fixable=False, severity=ir.IssueSeverity.WARNING, translation_key="broken_part_link", translation_placeholders={ "object_name": object_name, "count": str(len(part_ids)), }, ) _LOGGER.warning( "Completion on %s referenced %d spare part(s) that no longer exist: %s", object_name, len(part_ids), ", ".join(part_ids), )