Skip to content

Commit 468c5d4

Browse files
authored
Merge pull request #2200 from rosenrot00/fix/atomic-poll-snapshots
Publish polling data as consistent snapshots
2 parents 0ec04fb + 8e1f7e9 commit 468c5d4

2 files changed

Lines changed: 289 additions & 26 deletions

File tree

custom_components/solax_modbus/__init__.py

Lines changed: 61 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ def empty_hub_device_group_lambda() -> SimpleNamespace:
181181
holdingBlocks={},
182182
readPreparation=None, # function to call before read group
183183
readFollowUp=None, # function to call after read group
184+
publish_updates=False,
184185
)
185186

186187

@@ -543,6 +544,7 @@ def __init__(
543544
# Fallback dummy client for unrecognized interface types
544545
self._client = SimpleNamespace(connected=False, comm_params=SimpleNamespace(host="", port=""))
545546
self._lock = asyncio.Lock()
547+
self._poll_data_lock = asyncio.Lock()
546548
self._name: str = name
547549
# following call will modify and extend client in case old modbus API needs to be used
548550
_LOGGER.debug(f"{name}: using pymodbus version {pymodbus_version_info()}")
@@ -1053,9 +1055,10 @@ async def _refresh_interval_group_once(self, interval_group: Any, bypass_slowdow
10531055
if self.slowdown > 1:
10541056
_LOGGER.debug(f"{self._name}: communication restored, resuming normal speed after slowdown")
10551057
self.slowdown = 1
1056-
for sensor in group.sensors:
1057-
sensor.modbus_data_updated()
1058-
updated_sensors += len(group.sensors)
1058+
if getattr(group, "publish_updates", True):
1059+
for sensor in group.sensors:
1060+
sensor.modbus_data_updated()
1061+
updated_sensors += len(group.sensors)
10591062
else:
10601063
if self.slowdown <= 1:
10611064
_LOGGER.debug(f"{self._name}: modbus group read failed - assuming sleep mode - slowing down by factor 10")
@@ -1535,8 +1538,10 @@ async def async_write_registers_multi(self, unit: int, address: int, payload: li
15351538

15361539
async def async_read_modbus_data(self, group: Any) -> bool:
15371540
res = True
1541+
group.publish_updates = False
15381542
try:
1539-
res = await self.async_read_modbus_registers_all(group)
1543+
async with self._poll_data_lock:
1544+
res = await self.async_read_modbus_registers_all(group)
15401545
except ConnectionException as ex:
15411546
_LOGGER.error(f"Reading data failed! Inverter is offline. {ex}")
15421547
res = False
@@ -1677,7 +1682,7 @@ def treat_address(self, data: dict[str, Any], regs: list[int], idx: int, descr:
16771682
# if (descr.sleepmode != SLEEPMODE_LASTAWAKE) or self.awakeplugin(self.data): self.data[descr.key] = return_value
16781683
if (
16791684
(self.tmpdata_expiry.get(descr.key, 0) == 0)
1680-
and ((descr.sleepmode != SLEEPMODE_LASTAWAKE) or self.plugin.isAwake(self.data))
1685+
and ((descr.sleepmode != SLEEPMODE_LASTAWAKE) or self.plugin.isAwake(data))
16811686
and (self.localsLoaded or not descr.read_scale_exceptions) # ignore as long as read scale is not adapted; may delay real startup a bit
16821687
):
16831688
data[descr.key] = return_value # case prevent_update number
@@ -1767,16 +1772,34 @@ async def async_read_modbus_block(self, data: dict[str, Any], block: Any, typ: s
17671772
)
17681773
return False
17691774

1775+
def _commit_poll_snapshot(self, previous_data: dict[str, Any], new_data: dict[str, Any]) -> None:
1776+
"""Commit polling changes without replacing the shared data dictionary."""
1777+
missing = object()
1778+
1779+
for key in previous_data.keys() - new_data.keys():
1780+
if self.data.get(key, missing) == previous_data[key]:
1781+
self.data.pop(key, None)
1782+
1783+
for key, value in new_data.items():
1784+
previous_value = previous_data.get(key, missing)
1785+
if previous_value is not missing and value == previous_value:
1786+
continue
1787+
1788+
current_value = self.data.get(key, missing)
1789+
if current_value is missing or current_value == previous_value:
1790+
self.data[key] = value
1791+
17701792
async def async_read_modbus_registers_all(self, group: Any) -> bool:
1793+
group.publish_updates = False
17711794
if group.readPreparation is not None:
17721795
if not await group.readPreparation(self.data):
17731796
_LOGGER.info(f"{self._name}: device group read cancel")
17741797
return True
17751798
else:
17761799
_LOGGER.debug(f"{self._name}: device group inverter")
17771800

1778-
# data = {"_repeatUntil": self.data["_repeatUntil"]} # remove for issue #1440 but then does not recognize comm errors
1779-
data = self.data # is an alias, not a copy (issue #1440)
1801+
previous_data = self.data.copy()
1802+
data = previous_data.copy()
17801803
res = True
17811804
for block in group.holdingBlocks:
17821805
_LOGGER.debug(f"{self._name}: ** trying to read holding block 0x{block.start:x} previous res:{res}")
@@ -1789,32 +1812,44 @@ async def async_read_modbus_registers_all(self, group: Any) -> bool:
17891812
res = res and block_res
17901813
_LOGGER.debug(f"{self._name}: input block 0x{block.start:x} read done; new res: {res}")
17911814

1815+
local_callback_needed = self.localsUpdated
17921816
if self.localsUpdated:
17931817
await self._hass.async_add_executor_job(self.saveLocalData)
17941818
self.plugin.localDataCallback(self)
17951819
if not self.localsLoaded:
17961820
await self._hass.async_add_executor_job(self.loadLocalData)
1797-
for key, descr in list(self.computedSensors.items()):
1798-
try:
1799-
data[key] = descr.value_function(0, descr, data)
1800-
except Exception as ex:
1801-
_LOGGER.debug(f"{self._name}: cannot compute value for {key}: {ex}")
1802-
continue
1803-
sens = self.sensorEntities.get(key)
1804-
_LOGGER.debug(f"{self._name}: quickly updating state for computed sensor {sens} {key} {data.get(descr.key)} ")
1805-
if sens and (not descr.internal):
1806-
try:
1807-
sens.modbus_data_updated() # publish state to GUI and automations faster - assuming enabled, otherwise exception
1808-
except Exception:
1809-
_LOGGER.debug(f"{self._name}: cannot send update for {key} - probably disabled ")
1821+
local_callback_needed = local_callback_needed or self.localsLoaded
18101822

1811-
if group.readFollowUp is not None:
1812-
if not await group.readFollowUp(self.data, data):
1813-
_LOGGER.warning("device group check not success")
1814-
return True
1823+
# Local controls can change independently while a Modbus group is being read.
1824+
for key in self.writeLocals:
1825+
if key in self.data:
1826+
data[key] = self.data[key]
18151827

1816-
# for key, value in data.items(): # remove for issue #1440, but then does not recognize communication errors anymore
1817-
# self.data[key] = value # remove for issue #1440, but then comm errors are not detected
1828+
if res:
1829+
for key, descr in list(self.computedSensors.items()):
1830+
try:
1831+
data[key] = descr.value_function(0, descr, data)
1832+
except Exception as ex:
1833+
_LOGGER.debug(f"{self._name}: cannot compute value for {key}: {ex}")
1834+
1835+
if group.readFollowUp is not None:
1836+
if not await group.readFollowUp(previous_data, data):
1837+
_LOGGER.warning(f"{self._name}: device group validation failed; discarding polling snapshot")
1838+
return True
1839+
1840+
self._commit_poll_snapshot(previous_data, data)
1841+
if local_callback_needed:
1842+
self.plugin.localDataCallback(self)
1843+
1844+
for key, descr in list(self.computedSensors.items()):
1845+
sens = self.sensorEntities.get(key)
1846+
_LOGGER.debug(f"{self._name}: quickly updating state for computed sensor {sens} {key} {self.data.get(descr.key)} ")
1847+
if sens and (not descr.internal):
1848+
try:
1849+
sens.modbus_data_updated()
1850+
except Exception:
1851+
_LOGGER.debug(f"{self._name}: cannot send update for {key} - probably disabled ")
1852+
group.publish_updates = True
18181853

18191854
if res and self.writequeue and self.plugin.isAwake(self.data): # self.awakeplugin(self.data):
18201855
# process outstanding write requests

tests/unit/test_poll_snapshot.py

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
"""Tests for atomic polling snapshots."""
2+
3+
import asyncio
4+
from types import SimpleNamespace
5+
from typing import Any, cast
6+
from unittest.mock import AsyncMock, Mock
7+
8+
import pytest
9+
10+
from custom_components.solax_modbus import SolaXModbusHub
11+
12+
13+
def make_hub() -> Any:
14+
"""Build the minimal hub state required by polling tests."""
15+
hub = cast(Any, object.__new__(SolaXModbusHub))
16+
hub._name = "test"
17+
hub.data = {"_repeatUntil": {}, "raw": 1}
18+
hub.computedSensors = {}
19+
hub.computedEntities = {}
20+
hub.sensorEntities = {}
21+
hub.writeLocals = {}
22+
hub.writequeue = {}
23+
hub.localsUpdated = False
24+
hub.localsLoaded = True
25+
hub.plugin = SimpleNamespace(
26+
isAwake=Mock(return_value=True),
27+
localDataCallback=Mock(return_value=True),
28+
)
29+
hub._poll_data_lock = asyncio.Lock()
30+
hub.slowdown = 1
31+
return hub
32+
33+
34+
def make_group(*, follow_up: Any = None) -> Any:
35+
"""Build a polling group with two holding-register blocks."""
36+
return SimpleNamespace(
37+
holdingBlocks=[
38+
SimpleNamespace(start=1),
39+
SimpleNamespace(start=2),
40+
],
41+
inputBlocks=[],
42+
readPreparation=None,
43+
readFollowUp=follow_up,
44+
publish_updates=False,
45+
sensors=[],
46+
)
47+
48+
49+
@pytest.mark.asyncio
50+
async def test_failed_group_discards_all_partial_values() -> None:
51+
hub = make_hub()
52+
group = make_group()
53+
computed_sensor = Mock()
54+
hub.computedSensors["computed"] = SimpleNamespace(
55+
key="computed",
56+
internal=False,
57+
value_function=lambda initval, descr, data: data["raw"] * 2,
58+
)
59+
hub.sensorEntities["computed"] = computed_sensor
60+
61+
async def read_block(data: dict[str, Any], block: Any, typ: str) -> bool:
62+
data["raw"] = block.start * 10
63+
return bool(block.start == 1)
64+
65+
hub.async_read_modbus_block = read_block
66+
original_data_object = hub.data
67+
68+
result = await hub.async_read_modbus_registers_all(group)
69+
70+
assert result is False
71+
assert hub.data is original_data_object
72+
assert hub.data["raw"] == 1
73+
assert "computed" not in hub.data
74+
assert group.publish_updates is False
75+
computed_sensor.modbus_data_updated.assert_not_called()
76+
77+
78+
@pytest.mark.asyncio
79+
async def test_tolerated_block_failure_commits_rest_of_snapshot() -> None:
80+
hub = make_hub()
81+
hub.data["unavailable"] = 5
82+
group = make_group()
83+
84+
async def read_block(data: dict[str, Any], block: Any, typ: str) -> bool:
85+
if block.start == 1:
86+
data["raw"] = 10
87+
else:
88+
data.pop("unavailable", None)
89+
return True
90+
91+
hub.async_read_modbus_block = read_block
92+
93+
result = await hub.async_read_modbus_registers_all(group)
94+
95+
assert result is True
96+
assert hub.data["raw"] == 10
97+
assert "unavailable" not in hub.data
98+
assert group.publish_updates is True
99+
100+
101+
@pytest.mark.asyncio
102+
async def test_successful_group_commits_raw_and_computed_values_together() -> None:
103+
hub = make_hub()
104+
computed_sensor = Mock()
105+
follow_up_observations: list[tuple[int, int, int]] = []
106+
107+
async def follow_up(old_data: dict[str, Any], new_data: dict[str, Any]) -> bool:
108+
follow_up_observations.append((old_data["raw"], new_data["raw"], hub.data["raw"]))
109+
return True
110+
111+
group = make_group(follow_up=follow_up)
112+
hub.computedSensors["computed"] = SimpleNamespace(
113+
key="computed",
114+
internal=False,
115+
value_function=lambda initval, descr, data: data["raw"] * 2,
116+
)
117+
hub.sensorEntities["computed"] = computed_sensor
118+
119+
async def read_block(data: dict[str, Any], block: Any, typ: str) -> bool:
120+
data["raw"] = block.start
121+
return True
122+
123+
hub.async_read_modbus_block = read_block
124+
original_data_object = hub.data
125+
126+
result = await hub.async_read_modbus_registers_all(group)
127+
128+
assert result is True
129+
assert follow_up_observations == [(1, 2, 1)]
130+
assert hub.data is original_data_object
131+
assert hub.data["raw"] == 2
132+
assert hub.data["computed"] == 4
133+
assert group.publish_updates is True
134+
computed_sensor.modbus_data_updated.assert_called_once_with()
135+
136+
137+
@pytest.mark.asyncio
138+
async def test_failed_follow_up_discards_snapshot_without_publishing() -> None:
139+
hub = make_hub()
140+
computed_sensor = Mock()
141+
group = make_group(follow_up=AsyncMock(return_value=False))
142+
hub.computedSensors["computed"] = SimpleNamespace(
143+
key="computed",
144+
internal=False,
145+
value_function=lambda initval, descr, data: data["raw"] * 2,
146+
)
147+
hub.sensorEntities["computed"] = computed_sensor
148+
149+
async def read_block(data: dict[str, Any], block: Any, typ: str) -> bool:
150+
data["raw"] = 9
151+
return True
152+
153+
hub.async_read_modbus_block = read_block
154+
155+
result = await hub.async_read_modbus_registers_all(group)
156+
157+
assert result is True
158+
assert hub.data["raw"] == 1
159+
assert "computed" not in hub.data
160+
assert group.publish_updates is False
161+
computed_sensor.modbus_data_updated.assert_not_called()
162+
163+
164+
def test_snapshot_commit_preserves_concurrent_local_change() -> None:
165+
hub = make_hub()
166+
previous_data = {"_repeatUntil": {}, "raw": 1, "removed": 5}
167+
new_data = {"_repeatUntil": {}, "raw": 2, "added": 7}
168+
hub.data = {"_repeatUntil": {}, "raw": 99, "removed": 5}
169+
170+
hub._commit_poll_snapshot(previous_data, new_data)
171+
172+
assert hub.data["raw"] == 99
173+
assert hub.data["added"] == 7
174+
assert "removed" not in hub.data
175+
176+
177+
@pytest.mark.asyncio
178+
async def test_group_reads_are_serialized() -> None:
179+
hub = make_hub()
180+
active_reads = 0
181+
maximum_active_reads = 0
182+
183+
async def read_group(group: Any) -> bool:
184+
nonlocal active_reads, maximum_active_reads
185+
active_reads += 1
186+
maximum_active_reads = max(maximum_active_reads, active_reads)
187+
await asyncio.sleep(0)
188+
active_reads -= 1
189+
group.publish_updates = True
190+
return True
191+
192+
hub.async_read_modbus_registers_all = read_group
193+
first_group = make_group()
194+
second_group = make_group()
195+
196+
first_result, second_result = await asyncio.gather(
197+
hub.async_read_modbus_data(first_group),
198+
hub.async_read_modbus_data(second_group),
199+
)
200+
201+
assert first_result is True
202+
assert second_result is True
203+
assert maximum_active_reads == 1
204+
205+
206+
@pytest.mark.asyncio
207+
async def test_successful_but_discarded_snapshot_does_not_publish_group() -> None:
208+
hub = make_hub()
209+
sensor = Mock()
210+
group = make_group()
211+
group.sensors = [sensor]
212+
interval_group = SimpleNamespace(device_groups={"test": group})
213+
hub.blocks_changed = False
214+
hub.cyclecount = 1
215+
hub.sleepnone = []
216+
hub.sleepzero = []
217+
218+
async def read_group(current_group: Any) -> bool:
219+
current_group.publish_updates = False
220+
return True
221+
222+
hub.async_read_modbus_data = read_group
223+
224+
result, updated_sensors = await hub._refresh_interval_group_once(interval_group)
225+
226+
assert result is True
227+
assert updated_sensors == 0
228+
sensor.modbus_data_updated.assert_not_called()

0 commit comments

Comments
 (0)