155155)
156156
157157
158+ # Derived keys computed from an RMW-guarded raw word. When a poll's stale
159+ # snapshot is skipped for the raw word, its derived keys must be recomputed from
160+ # the authoritative post-merge cache rather than merged from the snapshot,
161+ # otherwise the poll's transient derived view could regress a completed RMW.
162+ _RMW_DERIVED_KEYS : dict [str , dict [str , Callable [[dict [str , Any ]], Any ]]] = {
163+ "MBF_PAR_FILTRATION_CONF" : {
164+ "filtration_speed_state" : compute_filtration_speed_state ,
165+ },
166+ }
167+
168+
158169def _collapse_u32_register_pairs (result : dict [str , Any ]) -> None :
159170 """Replace each known LOW/HIGH register pair with a single combined entry."""
160171 for combined , low_key , high_key in _U32_REGISTER_PAIRS :
@@ -196,6 +207,13 @@ def __init__(self, config: Mapping[str, Any]) -> None:
196207 # _client_lock (connection management) to avoid reentrant deadlock: the
197208 # RMW write path re-enters _client_lock via get_client().
198209 self ._cache_lock = asyncio .Lock ()
210+ # RMW freshness guard: the poll builds its device snapshot outside the
211+ # lock, so a completed RMW could still be overwritten by an in-flight
212+ # poll that read the old word before the RMW ran. Each RMW bumps a
213+ # monotonic generation and stamps the key it committed; the poll skips
214+ # any key committed after the generation it captured at its start.
215+ self ._rmw_generation : int = 0
216+ self ._rmw_key_generation : dict [str , int ] = {}
199217
200218 # Connection retry parameters
201219 self ._connection_attempts = 0
@@ -492,12 +510,20 @@ async def close(self) -> None:
492510 self ._connection_attempts = 0
493511 self ._consecutive_errors = 0
494512 self ._backoff_until = None
495- # Reset notification polling state so the next connect starts with a full read
496- self ._cached_result = {}
497513 self ._polls_since_full_read = _FULL_READ_INTERVAL
498514 self ._last_notification = 0
499515 self ._last_was_full_read = True
500516 self ._cached_timers = {}
517+ # Reset the cache and per-key RMW stamps under _cache_lock so the reset
518+ # is ordered against an in-flight poll's _merge_poll_result rather than
519+ # racing it. Keep _rmw_generation MONOTONIC across the close (bump, never
520+ # reset): a poll that captured a high generation before the close must
521+ # still compare as older than any post-reconnect RMW, so a stale snapshot
522+ # cannot overwrite a fresh write after a reconnect.
523+ async with self ._cache_lock :
524+ self ._cached_result = {}
525+ self ._rmw_key_generation = {}
526+ self ._rmw_generation += 1
501527
502528 async def async_read_register (
503529 self ,
@@ -665,6 +691,12 @@ async def _read_register_ranges(
665691
666692 async def _perform_read_all (self ) -> dict [str , Any ]:
667693 result : dict [str , Any ] = {}
694+ # Capture the RMW generation before any device I/O. Any key an RMW
695+ # commits after this point must not be clobbered by our stale snapshot.
696+ # Read outside the lock: a slightly older value only makes the poll more
697+ # conservative (skip a key it could have merged), never the reverse; the
698+ # authoritative comparison happens under _cache_lock at write time.
699+ poll_generation = self ._rmw_generation
668700
669701 @overload
670702 def get_safe (regs : list [int ], idx : int ) -> int | None : ...
@@ -1187,13 +1219,44 @@ def get_safe(
11871219 # Update cache after fixup and derived fields so partial reads
11881220 # start from consistent values including derived flags. Hold
11891221 # _cache_lock only around the write so a concurrent RMW cannot observe
1190- # a torn cache; the slow I/O above stays outside the lock.
1191- async with self . _cache_lock :
1192- self ._cached_result . update (result )
1222+ # a torn cache; the slow I/O above stays outside the lock. Skip any key
1223+ # an RMW committed after our snapshot so a stale word cannot undo it.
1224+ await self ._merge_poll_result (result , poll_generation )
11931225
11941226 # _LOGGER.debug("All Results: %s", result)
11951227 return result
11961228
1229+ async def _merge_poll_result (
1230+ self , result : dict [str , Any ], poll_generation : int
1231+ ) -> None :
1232+ """Merge a poll's device snapshot into the cache under _cache_lock.
1233+
1234+ Skips any key an RMW committed after *poll_generation* (the generation
1235+ captured before this poll's device I/O began), so a stale in-flight
1236+ snapshot cannot undo a completed RMW write.
1237+
1238+ Derived keys computed from a guarded raw word (e.g.
1239+ ``filtration_speed_state`` from ``MBF_PAR_FILTRATION_CONF``) are the
1240+ poll's own transient view of the stale word, so when a guarded raw key
1241+ is skipped its derived keys are recomputed from the authoritative
1242+ post-merge cache rather than merged from the snapshot.
1243+ """
1244+ async with self ._cache_lock :
1245+ skipped : set [str ] = set ()
1246+ for key , value in result .items ():
1247+ if self ._rmw_key_generation .get (key , 0 ) > poll_generation :
1248+ skipped .add (key )
1249+ continue
1250+ self ._cached_result [key ] = value
1251+ # Recompute derived keys whose guarded source was skipped, so the
1252+ # poll's stale derived view cannot regress a completed RMW.
1253+ for source , derived in _RMW_DERIVED_KEYS .items ():
1254+ if source in skipped :
1255+ for derived_key , recompute in derived .items ():
1256+ self ._cached_result [derived_key ] = recompute (
1257+ self ._cached_result
1258+ )
1259+
11971260 async def async_write_register (
11981261 self , address : int , value : int | list [int ], apply : bool = False
11991262 ) -> dict [str , Any ] | None :
@@ -1247,8 +1310,9 @@ async def async_set_filtration_speed(
12471310 change (before the next poll) starts from the updated word.
12481311
12491312 The read-compute-write-back is serialized against concurrent polls
1250- and other RMW writes, so a poll cannot restore a stale packed word
1251- between the cache read and the write-back.
1313+ and other RMW writes, and stamps the committed key with a monotonic
1314+ generation so a poll that snapshotted the old word before this write
1315+ cannot restore it afterwards.
12521316 """
12531317 encoded = encode_filtration_speed (speed )
12541318 # Serialize the read-compute-write-back against the poll so a read_all
@@ -1266,6 +1330,8 @@ async def async_set_filtration_speed(
12661330 FILTRATION_CONF_REGISTER , new_value , apply = apply
12671331 )
12681332 self ._cached_result ["MBF_PAR_FILTRATION_CONF" ] = new_value
1333+ self ._rmw_generation += 1
1334+ self ._rmw_key_generation ["MBF_PAR_FILTRATION_CONF" ] = self ._rmw_generation
12691335 return result
12701336
12711337 async def async_start_backwash (self , apply : bool = False ) -> dict [str , Any ] | None :
@@ -1444,8 +1510,9 @@ async def async_set_masked_register(
14441510 from the updated word.
14451511
14461512 The read-compute-write-back is serialized against concurrent polls
1447- and other RMW writes, so a poll cannot restore a stale packed word
1448- between the cache read and the write-back.
1513+ and other RMW writes, and stamps the committed key with a monotonic
1514+ generation so a poll that snapshotted the old word before this write
1515+ cannot restore it afterwards.
14491516
14501517 Returns an optimistic-update dict of the coordinator-data key
14511518 the caller can merge into its own cache without knowing the
@@ -1459,6 +1526,8 @@ async def async_set_masked_register(
14591526 new_value = (current & ~ mask ) | ((value << shift ) & mask )
14601527 await self .async_write_register (register , new_value , apply = True )
14611528 self ._cached_result [data_key ] = new_value
1529+ self ._rmw_generation += 1
1530+ self ._rmw_key_generation [data_key ] = self ._rmw_generation
14621531 _LOGGER .debug ("Masked flag %s written: %s" , flag .name , value )
14631532 return {data_key : new_value }
14641533
@@ -1620,8 +1689,9 @@ async def async_set_bitmask_flag(
16201689 (before the next poll) starts from the updated word.
16211690
16221691 The read-compute-write-back is serialized against concurrent polls
1623- and other RMW writes, so a poll cannot restore a stale packed word
1624- between the cache read and the write-back.
1692+ and other RMW writes, and stamps the committed key with a monotonic
1693+ generation so a poll that snapshotted the old word before this write
1694+ cannot restore it afterwards.
16251695
16261696 Returns an optimistic-update dict of the
16271697 ``MBF_PAR_HIDRO_COVER_ENABLE`` coordinator-data key with the new
@@ -1637,6 +1707,10 @@ async def async_set_bitmask_flag(
16371707 HIDRO_COVER_ENABLE_REGISTER , new_value , apply = True
16381708 )
16391709 self ._cached_result ["MBF_PAR_HIDRO_COVER_ENABLE" ] = new_value
1710+ self ._rmw_generation += 1
1711+ self ._rmw_key_generation ["MBF_PAR_HIDRO_COVER_ENABLE" ] = (
1712+ self ._rmw_generation
1713+ )
16401714 _LOGGER .debug ("Bitmask flag %s set to %s" , flag .name , on )
16411715 return {"MBF_PAR_HIDRO_COVER_ENABLE" : new_value }
16421716
0 commit comments