Skip to content

Bump modbus-connection to 4.3.0 and adopt its new APIs - #15

Closed
balloobbot wants to merge 7 commits into
Tom-Bom-badil:developfrom
balloobbot:bump-modbus-connection-4.0.0a3
Closed

balloobbot wants to merge 7 commits into
Tom-Bom-badil:developfrom
balloobbot:bump-modbus-connection-4.0.0a3

Conversation

@balloobbot

@balloobbot balloobbot commented Aug 5, 2026 •

Copy link
Copy Markdown

Bumps modbus-connection from >=3.6 to >=4.3 and adopts what 4.0.0 through 4.3.0 offer. The pin keeps the repo's usual >=MAJOR.MINOR form, which also stops it opting in to prereleases.

Why the migration is small

4.0.0 is deliberately breaking, but the breaks are about owning a connection, and this library never owns one — the caller injects a ModbusUnit. The two model-layer breaks miss us too:

  • Readable ranges now shift by base_offset and the per-instance shift. Every Trovis component declares absolute addresses at the default base_offset=0 (circuit_heating.py documents using a field-level stride rather than repeating_group/base_offset), so the shift is a no-op.
  • ComponentGroup now merges its members' resolved ranges and raises when they conflict. Trovis557x.__init__ hands the same ranges_for_model(model) map to all twelve components, so the merge collapses to one map.

The pin bump alone was green before any source change.

What changed

script/query.py builds its connection from params. 4.0 demotes connect_tcp / connect_serial — a connection that carries its own params is what lets it re-establish the link on its own. _open() becomes _connection(), constructs ModbusConnection(ModbusTcpParams(...)) / ModbusSerialParams(...), and performs no I/O. The script still calls connect() eagerly, which 4.0 retains for exactly this: a one-shot dump gains nothing from connect-on-demand, and connecting up front keeps Could not connect: distinguishable from Error reading device:. The CLI surface is unchanged.

Component.declared_fields (new in 4.0.0a2) replaces private access. The release notes call out this exact case: "a consumer had to reach into Component._register_fields". TrovisComponent.metadata_for did precisely that, twice, to cover registers and bits — declared_fields is that union, in one lookup. The same swap removes _register_fields / _bit_fields from five test modules.

Two of those are audits (test_all_writable_numbers_have_complete_ranges, test_all_writable_temporal_values_have_complete_ranges) and one is the canonical-parity sweep. They iterated the instance layout, so they only covered fields the default model happens to read. On declared_fields they cover every declared field — parity picks up 4 more cases, and all pass.

Sensors.detected_sensor_names and Clock.write keep using the narrowed instance set: they specifically mean "fields this model serves", which is not what declared_fields describes.

The read-layout guard was watching dead names. _ensure_read_layout_is_configurable checked for register_items, bit_items, _register_blocks and _bit_blocks. None of those exist in 4.0; only _read_items and _plan do.

Two tests for the connection lifetime 4.0 guarantees, which a2 taught the mock to model: a dropped link fires on_connection_lost and then heals on the next update over the same unit handles, and close() is permanent and raises ClientClosedError. Worth pinning here — an integration built on this library needs to know a drop does not require rebuilding Trovis557x.

4.1.0: the mock logs reads

MockModbusUnit records every dispatched read as a ReadEvent(register_type, address, count). That is exactly what _CountingUnit in test_device.py existed to do — and the release notes name the case: "Several libraries built on this one carried a near-identical hand-rolled wrapper for exactly this."

Dropping it made the two tests that used it stronger, not just shorter:

  • The unreadable-gap test asserts per block again rather than per address, and no longer reaches through Trovis557x._group for async_read_raw(). It also covers coil blocks, which it never did — 18 of the 35 blocks a full update issues are coils, and COIL_RANGES has gaps of its own. Verified the new assertion has teeth: against a deliberately wrong coil map, all 18 blocks fail it.
  • Both tests take the trovis fixture instead of rebuilding a device over the same stores, and neither needs a # type: ignore[arg-type] for a unit that isn't a ModbusUnit.

Net −25 lines in test_device.py with wider coverage.

The rest of 4.1.0 has no candidate here. TROVIS models every boolean as a real coil — the one value_kind="boolean" in the tree is the coil() helper itself — so the new generic boolean() register field finds nothing, and there are no flags() fields for the CLI helper's new name-rendering to reach. field_rows dropping the unit from an unread field does change script/query.py output (— °C becomes —), which is the intended improvement and needs nothing on our side.

4.2.0: two write paths that were untestable before

WriteEvent now carries the function code, which pins a sequence this library was silently exposed on. Clock.write deliberately sends the year and the packed date as two FC06 writes, year first, rather than one FC16 across both words — the order the 55Pro uses. Asserting the resulting register values cannot tell the two apart, since a single FC16 leaves the same two words behind.

So the override was unprotected. Verified by removing it: 644 tests still pass and only the new one fails, with the mock reporting (100, [201, 2027], 16) — one FC16 — instead of the two FC06s. This is precisely the hazard the release notes describe for force_fc16.

fail_requests() models a device that answers nothing, without a test having to guess which address its caller happens to reach first. That closes a real gap: the four TrovisWriteAccessError wrappers around HR40145 had no tests at all. It is armed with GatewayTargetError() — one of the new typed exception-response subclasses, constructed with its code implied, and the realistic failure for a TROVIS behind an RTU-over-TCP bridge.

disconnect(), the release's headline, is covered alongside the existing drop test: the owner recycling a stuck link keeps the same unit handles and components, and — unlike a real drop — fires no on_connection_lost callbacks.

Nothing else in 4.2.0 has a candidate here: TROVIS declares no repeating_group (so the new print_component rendering and the clash message have nothing to reach), no flags() fields, and no multi-register numeric fields for the new nan= to apply to. The mock now starting disconnected breaks nothing — this suite makes no connected assertions.

4.3.0: one error taxonomy

4.3.0 folds BlockReadError into the typed exception model — an aborted component update now raises the class its exception code names, carrying the refused block as data on .block. Nothing here referenced BlockReadError, so there is no migration: the whole suite passes against 4.3.0 untouched.

What it makes testable is a gap. This library's entire readable-range machinery exists so a pooled block never covers an address the controller refuses — and there was no test for what a caller sees when one is refused anyway. Arming fail_read mid-block (VF1 at 12, inside the pooled 9..28 sensor read rather than at its start) now yields IllegalDataAddressError with .block naming the span, and the update applies nothing.

Verified against 4.2.0 as well, where the test fails exactly as it should — the planner there raises BlockReadError, which is not an IllegalDataAddressError.

Cleanup that needed nothing from the library

While checking what still reaches into modbus-connection internals, five things turned out to be avoidable with the library exactly as it is:

  • _register_field_is_readable reimplemented Component._scale_address verbatim, down to the scale_in_block branch. It now calls the library's helper, so the two cannot drift apart — the old copy would have silently misjudged readability if the library ever changed how a scale register resolves.
  • test_ranges_must_be_configured_before_read_layout still branched on hasattr(type(functions), "register_items"). That attribute is gone in 4.0, so the branch was dead.
  • Eight tests reached through a component for the unit to assert on (trovis.rk1._unit). The trovis fixture is built from mock_modbus_unit, so a unit fixture hands them the same object by a public route.
  • test_full_update_never_reads_across_an_unreadable_gap wrapped the unit to capture block spans. The public async_read_raw() reports every address a pooled read touched, which is exactly what the test asserts on.
  • test_consolidated_reads_decode_correctly wrapped the unit and never read the counts.

_CountingUnit stays for test_full_update_consolidates_reads, which needs block boundaries the library does not expose.

What the later 4.0 releases added

The alphas after a1 layered on Component.declared_fields, the mock's connect-on-demand modelling, multi-value nan, and a batch of SunSpec ergonomics. Only the first two apply here — TROVIS is not SunSpec, and every sentinel in its register map is 0x7FFF via NAN_INT16, so there is no second "no value" code for multi-value nan to catch.

Two smaller things do reach us:

  • The default request timeout rose from 3s to 10s. script/query.py inherits it (verified: the constructed connection reports timeout=10), which suits an RTU-over-TCP gateway better than the old 3s. Pass timeout=3 to any factory to keep the old behaviour if that turns out to be wrong for this device.
  • PymodbusConnection under its own name instead of aliasing the backend's ModbusConnection — the backend names became canonical in the same 4.0 rework.

Nothing in the final release needs action either: notify=False on Component.async_update and the _verify_read hook it enables are for callers that fire their own listeners or check a model header after a read, and this library does neither. NumberField's per-field warning dedup applies to the enum(...) fields here with no change on our side.

NumberField(convert=...) was worth a look for the packed HHMM/DDMM fields, but convert is decode-only and those fields need a custom encode with domain validation, which is why they subclass RegisterField — a documented extension point. Every from modbus_connection import in this repo already used a public namespace, so the newly exported field classes change nothing.

Considered, not adopted

Component.restrict_fields() as a replacement for configure_readable_ranges / configure_readable_fields. It reshapes the component's readable ranges around the dropped fields, and those narrowed maps then conflict inside ComponentGroup:

ValueError: every holding-space component in a ComponentGroup must agree on
register_ranges where their maps overlap, but got conflicting values: ...

It does work if the device-wide map is restored right after the call — I prototyped that and got a byte-identical read plan (35 blocks) and identical per-component field sets. But "call it, then undo one of its side effects" is not obviously better than the current code, and it would change behaviour: reconfiguring after the first read would stop raising, and already-read values for dropped fields would be cleared. Left alone deliberately.

Multi-value nan (4.0.0a3). Every Trovis sentinel is 0x7FFF via NAN_INT16; there is no second "no value" code in the register map to catch. Available if one ever turns up.

Verification

  • pytest: 648 passed, 17 skipped (638 before — the widened audits and the new 4.2.0/4.3.0 tests account for the difference)
  • ruff format --check ., ruff check ., compileall, python -m build: all clean
  • pip install -e . resolves 4.3.0 in a clean 3.13 venv — this is the path CI takes
  • script/query.py --help and both params paths construct; the connect-failure path prints Could not connect: could not connect to 127.0.0.1:1

Targeting develop: enforce-develop.yml rejects PRs to main that do not come from the local develop branch, and CI only runs on PRs to develop.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WYw6yc9R1sgG2qybAp6p5j

balloob and others added 4 commits August 5, 2026 01:53
4.0.0 rebuilds connection management, but nothing in this library owns a
connection, so the migration is small: every component declares absolute
addresses at base_offset 0, so the breaking readable-range shift is a no-op,
and all twelve components share one range map, so ComponentGroup's new
range merge collapses to a single map.

- script/query.py builds its connection from ModbusTcpParams /
  ModbusSerialParams instead of the connect_* factories, which 4.0 demotes.
  Construction performs no I/O; the script still connects eagerly so a
  connect failure stays distinguishable from a device that refuses a read.
- Component.declared_fields (new in 4.0.0a2) replaces reaching into
  Component._register_fields / _bit_fields in metadata_for() and across the
  tests. The two metadata audits and the canonical-parity sweep now cover
  every declared field instead of only those the default model reads.
- The read-layout guard watched four cache attributes that no longer exist
  in 4.0; only _read_items and _plan remain.
- Cover the connection lifetime 4.0 guarantees: a dropped link heals on the
  next update over the same handles, and close() is permanent
  (ClientClosedError), both now modelled by the mock backend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYw6yc9R1sgG2qybAp6p5j
- Move connect() out of the read try/except in query.py and drop the
  explanatory comment. A failed connect returns before there is anything
  to close, so the read path keeps its own try/finally.
- Revert the README paragraph on connection lifetime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYw6yc9R1sgG2qybAp6p5j
Follow-up cleanup on the 4.0.0a3 bump, all of it possible with the library
as it stands today:

- _register_field_is_readable reimplemented Component._scale_address
  verbatim, down to the scale_in_block branch. Call the library's helper so
  the two cannot drift apart.
- test_ranges_must_be_configured_before_read_layout still branched on
  hasattr(type(functions), "register_items"). That attribute went away in
  4.0, so the branch was dead.
- Eight tests reached through a component for the unit to assert on
  (trovis.rk1._unit). The trovis fixture is built from mock_modbus_unit, so
  a `unit` fixture hands them the same object by a public route.
- test_full_update_never_reads_across_an_unreadable_gap wrapped the unit to
  capture block spans. async_read_raw() reports every address a pooled read
  touched, which is what the test actually asserts on.
- test_consolidated_reads_decode_correctly wrapped the unit without ever
  reading the counts.

_CountingUnit stays for test_full_update_consolidates_reads, which needs
block boundaries the library does not expose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYw6yc9R1sgG2qybAp6p5j
a4 is a small release for this library. Its headline items — sunspec.boolean,
float-typed repeating_group counts, the scan() lookup helper — are SunSpec,
which TROVIS is not, and the newly exported field classes (NumberField,
FloatField, RawField, StringField) are ones nothing here constructs. The
model-layer changes are type widening and exports, with no behaviour change.

What does reach us:

- The default request timeout rose from 3s to 10s, which script/query.py
  inherits. Better suited to an RTU-over-TCP gateway than the old 3s.
- Import PymodbusConnection under its own name rather than aliasing the
  backend's ModbusConnection, now that the backend names are canonical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYw6yc9R1sgG2qybAp6p5j
@balloobbot balloobbot changed the title Bump modbus-connection to 4.0.0a3 and adopt its new APIs Bump modbus-connection to 4.0.0a4 and adopt its new APIs Aug 6, 2026
The pin drops the prerelease suffix and goes back to the repo's usual
>=MAJOR.MINOR form, which also stops it opting in to prereleases.

Nothing new to adopt since 4.0.0a4: notify=False on Component.async_update
and the _verify_read hook it enables are for callers that fire their own
listeners or check a model header after a read, and this library does
neither. NumberField's per-field warning dedup applies to the enum() fields
here with no change on our side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYw6yc9R1sgG2qybAp6p5j
@balloobbot balloobbot changed the title Bump modbus-connection to 4.0.0a4 and adopt its new APIs Bump modbus-connection to 4.0.0 and adopt its new APIs Aug 7, 2026
MockModbusUnit now logs every dispatched read as a ReadEvent(register_type,
address, count), which is what _CountingUnit existed to do. The release notes
name this case: several libraries built on modbus-connection carried a
near-identical wrapper.

Removing it makes the two tests that used it stronger, not just shorter:

- The unreadable-gap test went back to asserting per block rather than per
  address, and no longer reaches through Trovis557x._group for async_read_raw.
  It also covers coil blocks now, which it never did — 18 of the 35 blocks a
  full update issues are coils, and COIL_RANGES has gaps of its own.
- Both tests take the trovis fixture instead of rebuilding a device with the
  same stores, and neither needs a type: ignore for a unit that is not a
  ModbusUnit.

4.1.0's other additions do not apply: TROVIS models every boolean as a real
coil, so the new generic boolean() register field has no candidate here, and
there are no flags() fields for the CLI helper's new rendering to reach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYw6yc9R1sgG2qybAp6p5j
@balloobbot balloobbot changed the title Bump modbus-connection to 4.0.0 and adopt its new APIs Bump modbus-connection to 4.1.0 and adopt its new APIs Aug 7, 2026
4.2.0's mock upgrades close two coverage gaps that were previously
untestable here.

WriteEvent now carries the function code. Clock.write deliberately sends the
year and the packed date as two FC06 writes, year first, instead of one FC16
across both words — the sequence the 55Pro uses. Asserting the resulting
register values cannot tell the two apart, so deleting that override passed
the whole suite and would have failed on hardware. Verified: with the
override removed, 644 tests still pass and only the new one fails.

fail_requests() models a device that answers nothing, without a test having
to guess which address its caller reaches first. That covers the four
TrovisWriteAccessError wrappers around HR40145, which had no tests at all.
It is armed with GatewayTargetError — one of the new typed exception-response
subclasses, and the realistic failure for a TROVIS behind an RTU-over-TCP
bridge.

Also cover disconnect(), 4.2.0's headline addition: the owner recycling a
stuck link keeps the same unit handles and components, and does not fire the
on_connection_lost callbacks.

Not applicable: TROVIS declares no repeating_group, no flags(), and no
multi-register numeric fields, and every boolean is a real coil.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYw6yc9R1sgG2qybAp6p5j
@balloobbot balloobbot changed the title Bump modbus-connection to 4.1.0 and adopt its new APIs Bump modbus-connection to 4.2.0 and adopt its new APIs Aug 8, 2026
@Tom-Bom-badil

Copy link
Copy Markdown
Owner

Thanks, appreciate the ongoing efforts! :)

I integrated the changes manually into develop_tom, which already contains additional ongoing development. I also updated the dependency to modbus-connection 4.2 and verified the combined state with the full test suite plus live TCP + RTU tests on TROVIS 5578 and 5579. Closing this PR without merge.

@balloobbot balloobbot changed the title Bump modbus-connection to 4.2.0 and adopt its new APIs Bump modbus-connection to 4.3.0 and adopt its new APIs Aug 8, 2026
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.

3 participants