Skip to content

Commit 7356be7

Browse files
authored
Merge pull request #22 from fboundy/patch
Fix dual-protocol robustness gaps (#13)
2 parents 9d1c6f8 + 919b0a0 commit 7356be7

11 files changed

Lines changed: 247 additions & 47 deletions

‎custom_components/solisconnect/__init__.py‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -487,8 +487,25 @@ def get_old_id(uid, ctrl):
487487

488488
# --- CONFIG ENTRY LOGIC ---
489489
if entry.unique_id != inverter_serial:
490-
_LOGGER.info("Migrating Config Entry ID from %s to %s", entry.unique_id, inverter_serial)
491-
hass.config_entries.async_update_entry(entry, unique_id=inverter_serial)
490+
# async_update_entry does not enforce unique_id uniqueness the way the config-flow's
491+
# _abort_if_unique_id_configured() does, so a leftover duplicate entry for this same
492+
# inverter (e.g. an old cloud-only entry alongside a new dual-protocol one) could
493+
# otherwise migrate to an identical unique_id and cross-write the shared cache.
494+
existing = next(
495+
(e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id != entry.entry_id and e.unique_id == inverter_serial),
496+
None,
497+
)
498+
if existing is not None:
499+
_LOGGER.warning(
500+
"Cannot migrate config entry %s to serial %s: entry %s already uses this serial. "
501+
"Remove the duplicate entry for this inverter before reconfiguring.",
502+
entry.entry_id,
503+
inverter_serial,
504+
existing.entry_id,
505+
)
506+
else:
507+
_LOGGER.info("Migrating Config Entry ID from %s to %s", entry.unique_id, inverter_serial)
508+
hass.config_entries.async_update_entry(entry, unique_id=inverter_serial)
492509

493510
return True
494511

‎custom_components/solisconnect/data_retrieval.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,13 @@
2424

2525

2626
class DataRetrieval:
27-
def __init__(self, hass: HomeAssistant, controller: ModbusController):
27+
def __init__(self, hass: HomeAssistant, controller: ModbusController, hub=None):
2828
self._spike_counter = {}
2929
self.controller: ModbusController = controller
30+
# When this retrieval is hub-managed, sensor-group recovery edits are applied through
31+
# the hub so they also land on the standby protocol controller (issue #13) instead of
32+
# being lost the next time the hub switches protocols.
33+
self.hub = hub
3034
self.hass = hass
3135
self.poll_lock = asyncio.Lock()
3236
self.connection_check = False
@@ -137,7 +141,7 @@ async def _recover_sensor_group_after_modbus_failure(
137141
clusters = cluster_sensors_by_contiguous_registers(remaining)
138142
new_groups = [SolisSensorGroup.from_sensors(c, sensor_group.poll_speed, sensor_group.identification) for c in clusters]
139143

140-
self.controller.replace_sensor_group(sensor_group, new_groups)
144+
(self.hub or self.controller).replace_sensor_group(sensor_group, new_groups)
141145

142146
disabled_names = ", ".join(s.name for s in disabled_sensors) or "(unknown)"
143147
_LOGGER.warning(
@@ -413,7 +417,7 @@ async def get_modbus_updates(self, groups: list[SolisSensorGroup], speed: PollSp
413417
total_duration = time.perf_counter() - total_start_time
414418
_LOGGER.debug(f"✅ {speed.name} update completed in {total_duration:.4f}s")
415419
except Exception as e:
416-
_LOGGER.debug("exception caught: %s", e)
420+
_LOGGER.warning("(%s.%s) Unexpected error during %s poll: %s", self.controller.host, self.controller.slave, speed.name, e, exc_info=True)
417421
finally:
418422
del self.poll_updating[speed][group_hash] # ✅ Reset only this group set
419423

‎custom_components/solisconnect/inverter_hub.py‎

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -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):

‎custom_components/solisconnect/sensors/solis_binary_sensor.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def handle_modbus_update(self, data):
7474
if self._register == 5:
7575
self._attr_is_on = self._modbus_controller.enabled
7676
self._attr_available = True
77+
self.schedule_update_ha_state()
7778
return self._attr_is_on
7879

7980
value = updated_value
@@ -88,6 +89,7 @@ def handle_modbus_update(self, data):
8889
if self._on_value is not None:
8990
self._attr_is_on = value == self._on_value
9091
_LOGGER.debug(f"switch {self.unique_id} set to {self._attr_is_on}, value = {updated_value}")
92+
self.schedule_update_ha_state()
9193

9294
@property
9395
def is_on(self):

‎custom_components/solisconnect/sensors/solis_number_sensor.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ def handle_modbus_update(self, data):
125125
self._attr_native_value = new_value
126126
self.schedule_update_ha_state()
127127

128-
def set_native_value(self, value):
128+
async def async_set_native_value(self, value):
129129
"""Update the current value."""
130130
if self._attr_native_value == value:
131131
return
@@ -139,8 +139,9 @@ def set_native_value(self, value):
139139
register_value = max(-32768, min(32767, register_value))
140140
register_value &= 0xFFFF
141141

142-
# Write to Modbus controller
143-
self._hass.create_task(self.base_sensor.controller.async_write_holding_register(self._write_register, int(register_value)))
142+
# Await the write so a failure raises to the caller instead of silently
143+
# optimistic-updating the entity for a write that never happened.
144+
await self.base_sensor.controller.async_write_holding_register(self._write_register, int(register_value))
144145

145146
self._attr_native_value = value
146147
self.schedule_update_ha_state()

‎tests/test_inverter_hub.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,49 @@ async def test_write_fallback_requires_healthy_other(hass):
218218
with pytest.raises(HomeAssistantError, match="no mapping"):
219219
await hub.async_write_holding_register(43000, 26)
220220
hub.modbus.async_write_holding_register.assert_not_awaited()
221+
222+
223+
def test_replace_sensor_group_applies_to_both_controllers(hass):
224+
"""Bug #13: a recovery edit applied only to the active controller is lost across a switch."""
225+
hub = _hub(hass, PROTO_BOTH_MANUAL)
226+
hub.modbus.replace_sensor_group = MagicMock()
227+
hub.cloud.replace_sensor_group = MagicMock()
228+
229+
old_group, new_groups = object(), [object(), object()]
230+
hub.replace_sensor_group(old_group, new_groups)
231+
232+
hub.modbus.replace_sensor_group.assert_called_once_with(old_group, new_groups)
233+
hub.cloud.replace_sensor_group.assert_called_once_with(old_group, new_groups)
234+
235+
236+
def test_enable_disable_connection_applies_to_both_controllers(hass):
237+
"""Bug #13: toggling the connection switch must not leave the standby protocol untouched."""
238+
hub = _hub(hass, PROTO_BOTH_MANUAL)
239+
240+
hub.enable_connection()
241+
hub.modbus.enable_connection.assert_called_once()
242+
hub.cloud.enable_connection.assert_called_once()
243+
244+
hub.disable_connection()
245+
hub.modbus.disable_connection.assert_called_once()
246+
hub.cloud.disable_connection.assert_called_once()
247+
248+
249+
async def test_health_tick_skips_when_previous_probe_still_running(hass):
250+
"""Bug #13: overlapping timer fires must not race the in-flight probe's state bookkeeping."""
251+
hub = _hub(hass, PROTO_BOTH_FAILOVER, failover_primary=PROTOCOL_MODBUS)
252+
modbus_patch, cloud_patch = _patch_retrievals()
253+
with modbus_patch as modbus_retrieval, cloud_patch:
254+
modbus_retrieval.return_value.async_stop = AsyncMock()
255+
await hub.async_start()
256+
hub._health_tick_running = True
257+
try:
258+
hub.modbus.last_modbus_success = datetime.now(UTC) - timedelta(seconds=600)
259+
hub._last_switch = datetime.now(UTC) - timedelta(seconds=MIN_DWELL_SECONDS + 1)
260+
await hub._health_tick()
261+
# The tick returned immediately without evaluating failover at all
262+
assert hub.active_protocol == PROTOCOL_MODBUS
263+
finally:
264+
hub._health_tick_running = False
265+
266+
await hub.async_stop()

‎tests/test_migration.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,22 @@ async def test_missing_serial(self, mock_get_registry):
8181

8282
# Should be False because migration is deferred when serial is missing
8383
assert result is False
84+
85+
@patch("homeassistant.helpers.entity_registry.async_get")
86+
async def test_migrate_skips_when_serial_already_used_by_another_entry(self, mock_get_registry):
87+
"""Bug #13: migrating to a unique_id already claimed by a different entry must not collide."""
88+
mock_get_registry.return_value = self.registry
89+
self.registry.async_get_entity_id.return_value = None
90+
91+
other_entry = MagicMock()
92+
other_entry.entry_id = "other_entry_id"
93+
other_entry.unique_id = "SN123456" # same serial the current entry is migrating to
94+
self.entry.entry_id = "this_entry_id"
95+
self.hass.config_entries.async_entries.return_value = [self.entry, other_entry]
96+
97+
result = await async_migrate_entry(self.hass, self.entry)
98+
99+
assert result is True
100+
# version bump still happens, but the unique_id update must be skipped
101+
for call in self.hass.config_entries.async_update_entry.call_args_list:
102+
assert "unique_id" not in call.kwargs

‎tests/test_number.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ async def test_solis_number_entity(hass, mock_base_sensor, mock_controller):
5353

5454
# Test setting value
5555
entity.schedule_update_ha_state = MagicMock()
56-
entity.set_native_value(60)
56+
await entity.async_set_native_value(60)
5757
await hass.async_block_till_done()
5858
mock_controller.async_write_holding_register.assert_called_with(100, 60)
5959

@@ -102,7 +102,7 @@ async def test_solis_number_entity_updates(hass, mock_controller):
102102
# Coverage report showed missing lines in write logic mostly.
103103

104104
entity.schedule_update_ha_state = MagicMock()
105-
entity.set_native_value(55)
105+
await entity.async_set_native_value(55)
106106
await hass.async_block_till_done()
107107
# write_register is None, so return.
108108
assert not mock_controller.async_write_holding_register.called

‎tests/test_register_group_recovery.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,56 @@ async def read_blk(start, count, is_holding):
145145
self.assertEqual(1, len(values))
146146

147147

148+
class TestRecoveryRoutesThroughHub(unittest.IsolatedAsyncioTestCase):
149+
"""Bug #13: a recovery edit must land on both protocol controllers, not just Modbus's."""
150+
151+
async def asyncSetUp(self):
152+
self.hass = MagicMock()
153+
self.hass.is_running = False
154+
self.hass.data = {DOMAIN: {VALUES: {}, SENSOR_ENTITIES: [], NUMBER_ENTITIES: []}}
155+
self.controller = MagicMock()
156+
self.controller.host = "192.168.1.1"
157+
self.controller.slave = 1
158+
self.controller.device_id = 1
159+
self.controller.enabled = True
160+
self.controller.connected = MagicMock(return_value=True)
161+
self.hub = MagicMock()
162+
self.dr = DataRetrieval(self.hass, self.controller, hub=self.hub)
163+
164+
async def test_recovery_calls_hub_not_bare_controller(self):
165+
bad = 302
166+
s_wide = _mock_sensor([301, 302], name="wide")
167+
s_ok = _mock_sensor([303], name="ok")
168+
group = MagicMock(spec=SolisSensorGroup)
169+
group.sensors = [s_wide, s_ok]
170+
group.poll_speed = PollSpeed.FAST
171+
group.identification = "test"
172+
group.start_register = 301
173+
group.registrar_count = 3
174+
175+
async def detailed(start, count, quiet=False):
176+
regs = range(start, start + count)
177+
if bad in regs:
178+
return None, 2
179+
return [7] * count, None
180+
181+
self.controller._async_read_input_register_raw_detailed = AsyncMock(side_effect=detailed)
182+
183+
async def read_blk(start, count, is_holding):
184+
if start == 303 and count == 1:
185+
return ([42], None)
186+
return (None, 2)
187+
188+
with (
189+
patch.object(self.dr, "_read_register_block_with_exception", new=AsyncMock(side_effect=read_blk)),
190+
patch("custom_components.solisconnect.data_retrieval.mark_platform_entities_unavailable_for_base_sensors"),
191+
):
192+
await self.dr._recover_sensor_group_after_modbus_failure(group, 301, 3, False, [])
193+
194+
self.hub.replace_sensor_group.assert_called_once()
195+
self.controller.replace_sensor_group.assert_not_called()
196+
197+
148198
class TestModbusControllerReplaceGroup(unittest.TestCase):
149199
def test_replace_sensor_group_preserves_order(self):
150200
from custom_components.solisconnect.modbus_controller import ModbusController

0 commit comments

Comments
 (0)