Skip to content

Commit 513cb77

Browse files
ahinkoclaude
andcommitted
feat(modbus): let the connection be kept between polls, off by default
Dialling a fresh connection for every read is the unusual choice for modbus TCP; one connection held open is the normal one. datalogger.persistent_ connection does that, and is off by default because it is a trade rather than an improvement: this datalogger accepts one connection at a time, so holding ours keeps everything else -- Solis Cloud included -- off the stick, where hanging up leaves a gap in every poll cycle where something else can get in. The client is now the app's rather than the poll's. ensure_connected is the only thing that dials, drop_connection the only thing that throws one away, and release_connection at the end of a good poll is the single place the setting is read. That also means a read that raised redials here instead of leaving pymodbus to do it out of sight, and a poll that could not read the whole span drops the connection so the next one starts fresh -- which is what every poll did before, so turning the setting on cannot make a bad morning worse than it already was. ensure_connected checks whether the client still holds a socket before reusing it. pymodbus closes its own socket when the peer hangs up mid-read (_handle_abrupt_socket_close) and its next request dials again silently, which is precisely the case being measured here: it would have counted as zero reconnects, on a socket carrying none of the keepalive set below. Keepalive is set on every connection: SO_KEEPALIVE, 30 seconds idle, then three probes five seconds apart, so a datalogger that vanished while the app slept is declared dead by the kernel inside 45 seconds instead of by the next read timing out CHUNK_ATTEMPTS times over. The option names are Linux's and the container is Alpine, but the tests run on macOS, so each is applied only where the platform has it. Every connection logs one numbered line saying why the previous one ended, at info when the setting is on and debug when it is off -- 2880 lines a day otherwise, saying only that the app is working as configured. That line is the point of shipping the setting at all: these sticks are widely reported to hang up on an idle connection after a minute or two, and at a 30 second poll interval that would mean reconnecting nearly every poll and gaining nothing. It cannot be settled from here, only measured. Turn it on, leave it a day, count the lines. Then the setting and the losing branch both go, and that removal is the breaking change worth a 4.0.0. The online transition needed no change: #181 already moved it off the handshake and onto the first chunk of registers that arrives, which is the only thing a reused connection could have said it from anyway. A poll whose reads are all refused still counts against the retry budget, kept connection or not, and a test pins it. The stub datalogger and the query/polls fixtures moved from test_modbus_read to conftest so the new connection lifetime tests poll the same stub. Closes #172 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BizgAhMgYcmMXjHFX7pbbs
1 parent 45eeab0 commit 513cb77

9 files changed

Lines changed: 613 additions & 141 deletions

File tree

‎CLAUDE.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,22 @@ read that table, so a new read type is an entry plus a decoder, and never a bran
5353
either of them. `app/sensors.py` decides which names `sensors.yaml` may use, and
5454
`tests/test_decode.py` fails if the two lists drift apart.
5555

56+
### The modbus connection
57+
58+
The client belongs to the app (`self.client`), not to a poll. `ensure_connected()` is
59+
the only thing that dials one and `drop_connection(reason)` the only thing that throws
60+
one away, which is what lets a read that raised redial mid-poll instead of writing
61+
into a dead socket. `release_connection()` at the end of a good poll is the single
62+
place the `datalogger.persistent_connection` setting is read.
63+
64+
That setting is off by default and expected to be temporary. The stick accepts one
65+
connection at a time, so holding ours keeps everything else — Solis Cloud included —
66+
off it. Whether holding it even helps depends on whether this stick hangs up on an
67+
idle connection, which can only be measured: every connection logs one numbered line
68+
saying why the previous one ended, at info when the setting is on and debug when it is
69+
off. Once there are enough of those lines to decide, the setting and the losing branch
70+
both go, and that removal is the breaking change worth a 4.0.0.
71+
5672
`VERSION` in `app/app.py` is hand-maintained and ships in the discovery payload as
5773
the device's `sw_version`. Bump it with a release.
5874

‎app/app.py‎

Lines changed: 187 additions & 49 deletions
Large diffs are not rendered by default.

‎app/config.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@ class DataLoggerConfig(Schema):
2020
poll_interval_if_off = fields.Int(required=False, load_default=600)
2121
poll_retries = fields.Int(required=False, load_default=10)
2222
register_chunks = fields.Int(required=False, load_default=80)
23+
# Keep the modbus connection open between polls instead of dialling a new one
24+
# every time. Off by default, and deliberately so: this datalogger accepts one
25+
# connection at a time, so holding it means nothing else -- Solis Cloud included
26+
# -- can talk to the stick, where closing it leaves a gap in every poll cycle.
27+
#
28+
# Expected to be temporary. These sticks are widely reported to close an idle
29+
# connection themselves after a minute or two, which at a 30 second poll interval
30+
# would mean reconnecting nearly every poll and gaining nothing. The reconnect
31+
# counts this logs are how that gets measured; once it is measured, one of the two
32+
# behaviours goes and this setting goes with it.
33+
persistent_connection = fields.Bool(required=False, load_default=False)
2334
http = fields.Nested(HttpConfig(), required=False)
2435

2536

‎config.example.yaml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ datalogger:
88
poll_interval_if_off: 600
99
poll_retries: 10
1010
register_chunks: 20
11+
# Keep the modbus connection open between polls instead of dialling a new one every
12+
# poll. The datalogger accepts one connection at a time, so with this on nothing
13+
# else can reach it -- including Solis Cloud. Leave it off unless you are measuring
14+
# what it does; the app logs a line for every connection it opens.
15+
persistent_connection: False
1116
http:
1217
enabled: True
1318
user: admin

‎readme.md‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ Because it works! If I for some reason would stop using this myself then I will
4141

4242
### Does Solis Cloud still work?
4343

44-
Most other integrations that I found all stated that Solis Cloud would not work when polling the data logger over MODBUS. In my brief and short testing Solid Cloud still works, but not perfectly. My guess is that most other integrations keeps the connection with the data logger alive while this project closes the connection when its done and then reconnects when it's time for the next update. By "not perfectly" I mean that I can see that Solis Cloud is not always updated every 5 minutes. Sometimes it takes 15-20 minutes between updates but I've always seen that the data logger sooner or later are able to send data to Solis Cloud. And if I stop the script/Docker container I always seen an update in Solid cloud within 10 minutes without having to restart the data logger.
44+
Most other integrations that I found all stated that Solis Cloud would not work when polling the data logger over MODBUS. In my brief and short testing Solid Cloud still works, but not perfectly. My guess is that most other integrations keeps the connection with the data logger alive while this project closes the connection when its done and then reconnects when it's time for the next update. That is still the default, and `datalogger.persistent_connection` is the setting that changes it — see [Keeping the connection open](#keeping-the-connection-open). By "not perfectly" I mean that I can see that Solis Cloud is not always updated every 5 minutes. Sometimes it takes 15-20 minutes between updates but I've always seen that the data logger sooner or later are able to send data to Solis Cloud. And if I stop the script/Docker container I always seen an update in Solid cloud within 10 minutes without having to restart the data logger.
4545

4646
### Is it possible to control the inverter?
4747

@@ -62,6 +62,28 @@ Maybe, I have no plans for it at the moment. My main goal is to get the data int
6262
## Getting started
6363
Prepare a config file. Use `config.example.yaml` and modify it to your needs and save it as `config.yaml`. Most values should be self-explanatory. `register_chunks` is set to 20 by default but during my testing I've been able to query my datalogger for more than 80 registers at the same time.
6464

65+
### Keeping the connection open
66+
By default the app dials a connection to the data logger, reads, and hangs up again on every poll. For MODBUS TCP that's the unusual choice — one connection held open is the normal one — and `datalogger.persistent_connection: True` does that instead:
67+
68+
```yaml
69+
datalogger:
70+
persistent_connection: True
71+
```
72+
73+
It's off by default because it's a trade, not an improvement. The data logger accepts **one connection at a time**, so while this app holds it, nothing else can talk to the stick — Solis Cloud included. Hanging up after each poll leaves a gap in every cycle where something else can get in.
74+
75+
Whether it's worth the trade is a question about your stick, not about this app: these WiFi sticks are widely reported to close an idle connection themselves after a minute or two, and at a 30 second poll interval that would mean reconnecting nearly every poll and gaining nothing. So the app logs a line for every connection it opens, numbered and saying why the previous one ended:
76+
77+
```
78+
Connected to datalogger, connection 12 since startup, previous one ended because a read raised OSError: Connection reset by peer
79+
```
80+
81+
Turn the setting on, leave it a day, and count those lines. One or two means the connection is surviving. One per poll means the stick is hanging up and the setting is buying nothing.
82+
83+
Either way the app sets TCP keepalive on the socket, so a data logger that vanishes while the app is sleeping is noticed by the kernel within about 45 seconds instead of by the next read timing out three times over.
84+
85+
Expect this setting to go away once there are enough measurements to pick a winner. Whichever behaviour loses will be removed along with it.
86+
6587
### Upgrading to 3.0
6688
**`inverter.max_power_kw` is now required and the container won't start without it.** Set it to the nameplate rating of your inverter, in kW:
6789

‎tests/conftest.py‎

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,90 @@ def total_power(sensors_config):
3838
return next(s for s in sensors_config if s["name"] == "total_power")
3939

4040

41+
# The registers sensors.yaml actually asks for: 3004 to 3077 inclusive.
42+
SPAN = 74
43+
FIRST, LAST = 3004, 3077
44+
45+
46+
class Response:
47+
def __init__(self, registers=None, error=False):
48+
self.registers = registers or []
49+
self.error = error
50+
51+
def isError(self):
52+
return self.error
53+
54+
55+
class FakeSocket:
56+
"""Records the socket options the app sets, and can refuse them."""
57+
58+
def __init__(self, refuse=False):
59+
self.options = []
60+
self.refuse = refuse
61+
62+
def setsockopt(self, level, option, value):
63+
if self.refuse:
64+
raise OSError("Protocol not available")
65+
66+
self.options.append((level, option, value))
67+
68+
69+
class StubClient:
70+
"""A datalogger that answers with whatever the test queued up.
71+
72+
One object stands in for every client the app builds, so that dialling again
73+
shows up as another connect() rather than as another object.
74+
"""
75+
76+
def __init__(
77+
self, *answers, refuse_socket_options=False, hangs_up_after_read=False
78+
):
79+
self.answers = list(answers)
80+
self.reads = []
81+
self.closes = 0
82+
self.connects = 0
83+
self.connected = False
84+
self.socket = None
85+
# Every socket this client has had, since close() drops the current one and a
86+
# test still wants to see what was set on it.
87+
self.sockets = []
88+
self.refuse_socket_options = refuse_socket_options
89+
# The datalogger hanging up on its own: pymodbus closes its socket when the
90+
# peer goes away mid-read and says nothing if the answer arrived first.
91+
self.hangs_up_after_read = hangs_up_after_read
92+
93+
def connect(self):
94+
self.connects += 1
95+
self.connected = True
96+
self.socket = FakeSocket(refuse=self.refuse_socket_options)
97+
self.sockets.append(self.socket)
98+
return True
99+
100+
def close(self):
101+
self.closes += 1
102+
self.connected = False
103+
self.socket = None
104+
105+
def read_input_registers(self, device_id, address, count):
106+
self.reads.append((address, count))
107+
answer = self.answers.pop(0) if self.answers else Response(error=True)
108+
109+
if isinstance(answer, Exception):
110+
raise answer
111+
112+
if self.hangs_up_after_read:
113+
self.connected = False
114+
self.socket = None
115+
116+
return answer
117+
118+
119+
def live_response(address=FIRST, count=SPAN):
120+
# Register 3041 is the inverter temperature, the only thing that has to be non
121+
# zero for the response not to count as dead.
122+
return Response([250 if address + i == 3041 else 0 for i in range(count)])
123+
124+
41125
class Clock:
42126
"""Stand in for time.monotonic so tests can span days in an instant."""
43127

@@ -64,7 +148,13 @@ def make_app(sensors_config):
64148
assertions look at.
65149
"""
66150

67-
def _make(day="2026-07-28", max_power_kw=15, poll_retries=3, register_chunks=80):
151+
def _make(
152+
day="2026-07-28",
153+
max_power_kw=15,
154+
poll_retries=3,
155+
register_chunks=80,
156+
persistent_connection=False,
157+
):
68158
app = App.__new__(App)
69159
app.config = {
70160
"mqtt": {"enabled": False, "topic_prefix": "tcpsolis2mqtt"},
@@ -77,6 +167,7 @@ def _make(day="2026-07-28", max_power_kw=15, poll_retries=3, register_chunks=80)
77167
"poll_interval_if_off": 600,
78168
"poll_retries": poll_retries,
79169
"register_chunks": register_chunks,
170+
"persistent_connection": persistent_connection,
80171
"http": {"enabled": False},
81172
},
82173
}
@@ -95,6 +186,10 @@ def _make(day="2026-07-28", max_power_kw=15, poll_retries=3, register_chunks=80)
95186
app.register_span_start = 0
96187
app.register_span_end = 0
97188

189+
app.client = None
190+
app.connections_opened = 0
191+
app.connection_closed_reason = "nothing has been connected yet"
192+
98193
app.day = day
99194
app.local_date = lambda: app.day
100195

@@ -106,3 +201,46 @@ def _make(day="2026-07-28", max_power_kw=15, poll_retries=3, register_chunks=80)
106201
return app
107202

108203
return _make
204+
205+
206+
@pytest.fixture
207+
def query(make_app, clock, monkeypatch):
208+
"""Run one poll against a stub datalogger and return what it produced."""
209+
210+
def _query(*answers, client=None, **config):
211+
client = client or StubClient(*answers)
212+
app = make_app(**config)
213+
app.get_register_interval()
214+
215+
def build(*args, **kwargs):
216+
app.client_kwargs = kwargs
217+
return client
218+
219+
monkeypatch.setattr(app_module, "ModbusTcpClient", build)
220+
return app, client, app.query_modbus()
221+
222+
return _query
223+
224+
225+
@pytest.fixture
226+
def polls(make_app, clock, monkeypatch):
227+
"""One App polled repeatedly, so state that carries between polls is visible."""
228+
229+
def _polls(*rounds, client=None, **config):
230+
app = make_app(**config)
231+
app.get_register_interval()
232+
client = client or StubClient()
233+
app.stub_client = client
234+
235+
def build(*args, **kwargs):
236+
return client
237+
238+
monkeypatch.setattr(app_module, "ModbusTcpClient", build)
239+
240+
for answers in rounds:
241+
client.answers = list(answers)
242+
app.query_modbus()
243+
244+
return app
245+
246+
return _polls

‎tests/test_config.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,28 @@ def test_a_fractional_rating_is_kept():
5555
)
5656

5757

58+
def test_the_connection_is_not_kept_unless_it_is_asked_for():
59+
# The datalogger accepts one connection at a time, so holding ours keeps
60+
# everything else -- Solis Cloud included -- off the stick. Anyone who wants that
61+
# trade has to say so.
62+
assert AppConfig().load(config())["datalogger"]["persistent_connection"] is False
63+
64+
65+
def test_the_connection_can_be_kept():
66+
kept = config()
67+
kept["datalogger"]["persistent_connection"] = True
68+
69+
assert AppConfig().load(kept)["datalogger"]["persistent_connection"] is True
70+
71+
72+
def test_the_shipped_example_leaves_the_connection_per_poll_behaviour_alone():
73+
import yaml
74+
75+
example = yaml.safe_load(open("config.example.yaml"))
76+
77+
assert not example["datalogger"].get("persistent_connection")
78+
79+
5880
def test_the_shipped_example_names_a_rating():
5981
import yaml
6082

‎tests/test_modbus_read.py‎

Lines changed: 4 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -9,98 +9,12 @@
99
register count was incremented once per attempt rather than once per chunk.
1010
"""
1111

12-
import pytest
13-
1412
import app as app_module
1513

16-
# The registers sensors.yaml actually asks for: 3004 to 3077 inclusive. This used to
17-
# be 80, the chunk size, because whole chunks were read whatever the span was.
18-
SPAN = 74
19-
FIRST, LAST = 3004, 3077
20-
21-
22-
class Response:
23-
def __init__(self, registers=None, error=False):
24-
self.registers = registers or []
25-
self.error = error
26-
27-
def isError(self):
28-
return self.error
29-
30-
31-
class StubClient:
32-
"""A datalogger that answers with whatever the test queued up."""
33-
34-
def __init__(self, *answers):
35-
self.answers = list(answers)
36-
self.reads = []
37-
self.closes = 0
38-
self.connected = True
39-
40-
def connect(self):
41-
self.connected = True
42-
return True
43-
44-
def close(self):
45-
self.closes += 1
46-
self.connected = False
47-
48-
def read_input_registers(self, device_id, address, count):
49-
self.reads.append((address, count))
50-
answer = self.answers.pop(0) if self.answers else Response(error=True)
51-
52-
if isinstance(answer, Exception):
53-
raise answer
54-
55-
return answer
56-
57-
58-
def live_response(address=FIRST, count=SPAN):
59-
# Register 3041 is the inverter temperature, the only thing that has to be non
60-
# zero for the response not to count as dead.
61-
return Response([250 if address + i == 3041 else 0 for i in range(count)])
62-
63-
64-
@pytest.fixture
65-
def query(make_app, clock, monkeypatch):
66-
"""Run one poll against a stub datalogger and return what it produced."""
67-
68-
def _query(*answers):
69-
client = StubClient(*answers)
70-
app = make_app()
71-
app.get_register_interval()
72-
73-
def build(*args, **kwargs):
74-
app.client_kwargs = kwargs
75-
return client
76-
77-
monkeypatch.setattr(app_module, "ModbusTcpClient", build)
78-
return app, client, app.query_modbus()
79-
80-
return _query
81-
82-
83-
@pytest.fixture
84-
def polls(make_app, clock, monkeypatch):
85-
"""One App polled repeatedly, so state that carries between polls is visible."""
86-
87-
def _polls(*rounds):
88-
app = make_app()
89-
app.get_register_interval()
90-
client = StubClient()
91-
92-
def build(*args, **kwargs):
93-
return client
94-
95-
monkeypatch.setattr(app_module, "ModbusTcpClient", build)
96-
97-
for answers in rounds:
98-
client.answers = list(answers)
99-
app.query_modbus()
100-
101-
return app
102-
103-
return _polls
14+
# The stub datalogger, the register span and the query/polls fixtures live in
15+
# conftest.py, because the connection lifetime tests in test_persistent_connection.py
16+
# poll the same stub.
17+
from conftest import FIRST, LAST, SPAN, Response, StubClient, live_response
10418

10519

10620
def test_a_chunk_is_read_once_when_it_answers(query):

0 commit comments

Comments
 (0)