Skip to content

feat(modbus): let the connection be kept between polls, off by default - #188

Merged
ahinko merged 1 commit into
mainfrom
feat/persistent-connection
Aug 20, 2026
Merged

ahinko merged 1 commit into
mainfrom
feat/persistent-connection

Conversation

@ahinko

@ahinko ahinko commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Closes #172.

Adds datalogger.persistent_connection, off by default, which keeps the modbus connection open between polls instead of dialling a new one every time.

Why off by default

The datalogger accepts one connection at a time, so holding ours keeps everything else — Solis Cloud included — off the stick. Hanging up after each poll leaves a gap in every cycle where something else can get in. That is a trade, not an improvement, so it has to be asked for.

The client is now the app's, not the poll's

method job
ensure_connected() the only thing that dials; hands back the held connection otherwise
drop_connection(reason) the only thing that throws one away, recording why
release_connection() end of a good poll — the single place the setting is read
enable_keepalive() / keepalive_options() SO_KEEPALIVE, 30s idle, 5s probes, 3 probes
  • read_chunk takes the client per attempt, so a read that raised drops the connection and the next attempt dials a fresh one — the redial is the app's now, not one pymodbus does out of sight.
  • A poll that could not read the whole span drops the connection, so the next poll starts fresh. Every poll did that 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. This matters more than it looks: pymodbus's _handle_abrupt_socket_close closes its own socket when the peer hangs up mid-read, and its next request dials again silently — which is exactly the idle-hangup case being measured. It would have counted as zero reconnects, on a socket carrying none of the keepalive.

TCP keepalive

On every connection, kept or not. 30 seconds idle plus three probes five seconds apart means 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 option is applied only where the platform has it.

One line per connection

Info when the setting is on, debug when it is off — 2880 lines a day otherwise, saying only that the app is working as configured.

Connected to datalogger, connection 3 since startup, previous one ended because a read raised OSError: Connection reset by peer

This 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; 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 behaviour change #172 flagged

None needed. #181 already moved the online transition 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 test_a_reused_connection_is_not_evidence_the_datalogger_is_there pins that.

Tests

New tests/test_persistent_connection.py — connection lifetime both ways, drop on a failed poll, mid-poll redial, the silent-hangup case, the log lines, and keepalive (including a socket that refuses the options). The stub datalogger and the query/polls fixtures moved to conftest.py so both test files poll the same stub; test_modbus_read.py imports them and is otherwise untouched.

174 passed; ruff format --check and ruff check clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BizgAhMgYcmMXjHFX7pbbs

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
@ahinko
ahinko force-pushed the feat/persistent-connection branch from 513cb77 to c343d39 Compare August 20, 2026 18:30
@ahinko
ahinko merged commit a523d75 into main Aug 20, 2026
2 checks passed
@ahinko
ahinko deleted the feat/persistent-connection branch August 20, 2026 18:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Keep the connection to the data logger open

1 participant