Part of #670
Summary
Convert refresh behavior to manager-owned dirty-lock refreshes. The manager owns one
60-second loop, refreshes locks sequentially with one provider poll in flight at a time, and
notifications target dirty per-lock coordinators. After task-7 proves no entities remain bound
to the manager, this task removes the bridge's global-notify leg.
Scope / changes
- Refactor
_async_update_data() at coordinator.py:1950-1979 into explicit manager methods
such as async_refresh_lock(entry_id) -> set[str] and async_refresh_all_locks() -> set[str].
- Preserve data updates performed by
_update_lock_data() and parent sync currently invoked at
coordinator.py:1962-1967, save at coordinator.py:1974, and quick-refresh scheduling at
coordinator.py:1977.
- Make the manager's single 60-second loop refresh locks sequentially: iterate entry IDs and
await each provider poll before starting the next. Do not use gather() or per-lock
update_interval polling; per-lock coordinators were created with update_interval=None in
task-6.
- Convert the bridge into per-lock-only deferred notification once no entities remain on the
manager: pending entry-id set, call_soon flush, skip IDs missing from self.kmlocks, and
call only lock_coordinator.async_set_updated_data(...).
- Keep
self.data = dict(self.kmlocks) current after every refresh/mutation for
config_flow.py:240-245 and __init__.py:313-319.
- Replace Phase A global-notification fallback with per-lock notification scheduling where dirty
IDs are known, using conservative “all entry IDs” fallback only for paths not yet precisely
scoped and only through per-lock targets.
- Update refresh request paths from
coordinator.py:1382, coordinator.py:1439,
coordinator.py:1466, coordinator.py:1730, coordinator.py:1778, and
providers/zha.py:481-487 to use the manager pipeline.
Plan reference
§5.3 Phase B, §5.4 refresh paths, §5.5, §7.2,
§8 PR 4; decision 3; 2026-07-07 refresh-loop, dual-target, and guard-regression decisions.
Dependencies
Depends on: #681
Acceptance criteria
Risk / notes
The main risk is breaking setup/add/update/delete/reset refresh semantics.
Keep any fallback dirty set conservative until task-9 and task-10 narrow specific flows, but
never reintroduce manager/global listener fan-out after this task removes that bridge leg.
Size
L
Persistence note (2026-07-07, per @raman325)
Do not build custom disk-write tracking. _async_save_data() (coordinator.py:260-289) already uses HA's Store (coordinator.py:133) and already dedupes via config == self._prev_kmlocks_dict (coordinator.py:284-286). It currently calls self._store.async_save() immediately on each changed refresh (coordinator.py:288); optionally switch to Store.async_delay_save (HA's built-in debounced save) to coalesce disk writes under bursts — a small optional refinement, not a requirement. The "dirty lock" sets here and in #684 are notification scoping (which per-lock coordinator to notify), not a reimplementation of HA state dedup (#677) or of the existing save dedup.
Part of #670
Summary
Convert refresh behavior to manager-owned dirty-lock refreshes. The manager owns one
60-second loop, refreshes locks sequentially with one provider poll in flight at a time, and
notifications target dirty per-lock coordinators. After
task-7proves no entities remain boundto the manager, this task removes the bridge's global-notify leg.
Scope / changes
_async_update_data()atcoordinator.py:1950-1979into explicit manager methodssuch as
async_refresh_lock(entry_id) -> set[str]andasync_refresh_all_locks() -> set[str]._update_lock_data()and parent sync currently invoked atcoordinator.py:1962-1967, save atcoordinator.py:1974, and quick-refresh scheduling atcoordinator.py:1977.await each provider poll before starting the next. Do not use
gather()or per-lockupdate_intervalpolling; per-lock coordinators were created withupdate_interval=Noneintask-6.manager: pending entry-id set,
call_soonflush, skip IDs missing fromself.kmlocks, andcall only
lock_coordinator.async_set_updated_data(...).self.data = dict(self.kmlocks)current after every refresh/mutation forconfig_flow.py:240-245and__init__.py:313-319.IDs are known, using conservative “all entry IDs” fallback only for paths not yet precisely
scoped and only through per-lock targets.
coordinator.py:1382,coordinator.py:1439,coordinator.py:1466,coordinator.py:1730,coordinator.py:1778, andproviders/zha.py:481-487to use the manager pipeline.Plan reference
§5.3 Phase B, §5.4 refresh paths, §5.5, §7.2,
§8 PR 4; decision 3; 2026-07-07 refresh-loop, dual-target, and guard-regression decisions.
Dependencies
Depends on: #681
Acceptance criteria
poll in flight at a time; per-lock coordinators do not independently poll.
entities remain manager-bound, so no entity loses notifications during the transition.
fan-out is both deferred and scoped to the dirty entry set, not global.
pytest tests/test_coordinator_lifecycle.py tests/test_coordinator.py tests/test_entity.pyplus per-lock fan-out and refreshed guard tests added for this task.ruff check custom_components/ tests/,mypy custom_components/keymaster/.Risk / notes
The main risk is breaking setup/add/update/delete/reset refresh semantics.
Keep any fallback dirty set conservative until
task-9andtask-10narrow specific flows, butnever reintroduce manager/global listener fan-out after this task removes that bridge leg.
Size
L
Persistence note (2026-07-07, per @raman325)
Do not build custom disk-write tracking.
_async_save_data()(coordinator.py:260-289) already uses HA'sStore(coordinator.py:133) and already dedupes viaconfig == self._prev_kmlocks_dict(coordinator.py:284-286). It currently callsself._store.async_save()immediately on each changed refresh (coordinator.py:288); optionally switch toStore.async_delay_save(HA's built-in debounced save) to coalesce disk writes under bursts — a small optional refinement, not a requirement. The "dirty lock" sets here and in #684 are notification scoping (which per-lock coordinator to notify), not a reimplementation of HA state dedup (#677) or of the existing save dedup.