@@ -62,6 +62,12 @@ def __init__(self, hass, entry_id: str, inverter_config: InverterConfig, serial_
6262 # Serializes protocol switches: _health_tick, the protocol select entity, and its
6363 # restore path can all call async_set_active concurrently.
6464 self ._switch_lock = asyncio .Lock ()
65+ # Guards against overlapping _health_tick runs: a probe of a dead standby link
66+ # (connect() + read) can take longer than HEALTH_CHECK_INTERVAL_SECONDS, and
67+ # async_track_time_interval fires on schedule regardless of whether the previous
68+ # callback finished, so a second tick could otherwise race the first's in-flight
69+ # probe and its (unlocked) _primary_healthy_since/_last_switch bookkeeping.
70+ self ._health_tick_running = False
6571
6672 # --- lifecycle -----------------------------------------------------------------
6773
@@ -88,7 +94,7 @@ def _start_retrieval(self, protocol: str):
8894 if protocol == PROTOCOL_MODBUS :
8995 from custom_components .solisconnect .data_retrieval import DataRetrieval
9096
91- self ._retrieval = DataRetrieval (self .hass , self .modbus )
97+ self ._retrieval = DataRetrieval (self .hass , self .modbus , hub = self )
9298 else :
9399 from custom_components .solisconnect .cloud .cloud_retrieval import CloudDataRetrieval
94100
@@ -142,23 +148,30 @@ async def _probe_primary(self) -> bool:
142148 return False
143149
144150 async def _health_tick (self , now = None ):
145- dwell = (datetime .now (UTC ) - self ._last_switch ).total_seconds ()
146- secondary = PROTOCOL_CLOUD if self .failover_primary == PROTOCOL_MODBUS else PROTOCOL_MODBUS
147-
148- if self ._active_name == self .failover_primary :
149- if not self ._is_active_healthy () and dwell >= MIN_DWELL_SECONDS and self ._controller_for (secondary ) is not None :
150- await self .async_set_active (secondary , reason = "primary unhealthy" )
151+ if self ._health_tick_running :
152+ _LOGGER .debug ("(%s) Skipping health tick: a previous probe is still running" , self .cache_scope )
151153 return
154+ self ._health_tick_running = True
155+ try :
156+ dwell = (datetime .now (UTC ) - self ._last_switch ).total_seconds ()
157+ secondary = PROTOCOL_CLOUD if self .failover_primary == PROTOCOL_MODBUS else PROTOCOL_MODBUS
152158
153- # Running on the secondary: probe the primary and auto-return with hysteresis
154- if await self ._probe_primary ():
155- if self ._primary_healthy_since is None :
156- self ._primary_healthy_since = datetime .now (UTC )
157- healthy_for = (datetime .now (UTC ) - self ._primary_healthy_since ).total_seconds ()
158- if healthy_for >= PRIMARY_RECOVERY_SECONDS and dwell >= MIN_DWELL_SECONDS :
159- await self .async_set_active (self .failover_primary , reason = "primary recovered" )
160- else :
161- self ._primary_healthy_since = None
159+ if self ._active_name == self .failover_primary :
160+ if not self ._is_active_healthy () and dwell >= MIN_DWELL_SECONDS and self ._controller_for (secondary ) is not None :
161+ await self .async_set_active (secondary , reason = "primary unhealthy" )
162+ return
163+
164+ # Running on the secondary: probe the primary and auto-return with hysteresis
165+ if await self ._probe_primary ():
166+ if self ._primary_healthy_since is None :
167+ self ._primary_healthy_since = datetime .now (UTC )
168+ healthy_for = (datetime .now (UTC ) - self ._primary_healthy_since ).total_seconds ()
169+ if healthy_for >= PRIMARY_RECOVERY_SECONDS and dwell >= MIN_DWELL_SECONDS :
170+ await self .async_set_active (self .failover_primary , reason = "primary recovered" )
171+ else :
172+ self ._primary_healthy_since = None
173+ finally :
174+ self ._health_tick_running = False
162175
163176 # --- delegated surface (entities and services talk to the hub) ---
164177
@@ -207,13 +220,27 @@ def last_modbus_success(self):
207220 return self .active .last_modbus_success
208221
209222 def replace_sensor_group (self , old_group , new_groups ):
210- self .active .replace_sensor_group (old_group , new_groups )
223+ """Apply a recovery edit to every configured controller, not just the active one.
224+
225+ Modbus and cloud controllers are handed the same sensor-group list objects at
226+ setup, but replace_sensor_group reassigns `_sensor_groups` to a new list on
227+ whichever controller instance it's called on — so applying it to only the
228+ active controller silently loses the recovered layout on the standby protocol
229+ once a switch happens.
230+ """
231+ for controller in (self .modbus , self .cloud ):
232+ if controller is not None :
233+ controller .replace_sensor_group (old_group , new_groups )
211234
212235 def enable_connection (self ):
213- self .active .enable_connection ()
236+ for controller in (self .modbus , self .cloud ):
237+ if controller is not None :
238+ controller .enable_connection ()
214239
215240 def disable_connection (self ):
216- self .active .disable_connection ()
241+ for controller in (self .modbus , self .cloud ):
242+ if controller is not None :
243+ controller .disable_connection ()
217244
218245 def close_connection (self ):
219246 for controller in (self .modbus , self .cloud ):
0 commit comments