Skip to content

Verified: Modbus TCP

Each claim the Modbus TCP page makes, with the tests that prove it in the last green CI run: 19 of 49 verified, 6 with no test yet. The claims live in docs/claims/modbus.yaml.

Last green CI run 16 Oct 2026, 14:48 UTC on c7618d3 · all runtime claims

MB-001 verified

A modbus driver without a `manifest:` key is rejected at project load (so `naut check` fails), and a misspelled manifest key or an unknown source reference is a load error, not a silently dropped binding.

where the docs say so

  • pass TestLoadModbusErrors in internal/project (5 of 5 passed)
  • pass TestParseManifestRejectsUnknownKeys in modbus (6 of 6 passed)
  • pass TestSchemaRequiresWhatLoaderRequires in internal/project

MB-002 verified

`New` never dials: it validates the manifest and computes the block plan offline, so a project with a modbus driver loads and checks with no device present, and an invalid manifest fails in `New`.

where the docs say so

MB-003 verified

`naut modbus import` emits a manifest and a tag file that are byte-identical on every re-run.

where the docs say so

MB-004 partly verified

The device map's per-type defaults and per-instance overrides are applied to the generated manifest, and `--instance` and the `--tags` / `--writable` globs select and mark the right instances and tags.

where the docs say so · Type-level settings and instance-only fields (host, unit-id, enable-tag) are asserted, but the sample map never has an instance override a type-level value, so per-instance overrides are not asserted.

MB-005 verified

The generated tag file gives each tag the right role (input or output) and `--tags-skip` leaves hand-declared tags out of it.

where the docs say so

MB-006 verified

`naut modbus tags` re-derives the tag file from a committed manifest byte for byte with `--map`, and without `--map` keeps names and roles and drops the unit/desc/init columns.

where the docs say so

  • pass TestModbusTagsRederives in cmd/naut

MB-007 verified

The planner coalesces bindings on the same source, table and scan class into one block read, so adjacent registers of mixed formats become a single request; sources, tables and classes never share a block.

where the docs say so

  • pass TestPlanCoalescesAdjacent in modbus
  • pass TestPlanMixedTablesAndClasses in modbus
  • pass TestPlanPerSourceGrouping in modbus

MB-008 verified

A block spans up to 8 unaddressed registers rather than split, a hole beyond that splits it, and `WithBlockGap(0)` never bridges a hole.

where the docs say so

  • pass TestPlanGapTolerance in modbus

MB-009 partly verified

Without `max-block`, a block holds at most 125 registers (FC3/FC4) or 2000 coils (FC1/FC2); `max-block` lowers the cap, and a multi-register value is never split across two blocks.

where the docs say so · The caps are exercised on the holding (FC3) and coil (FC1) tables only; the input (FC4) and discrete (FC2) caps are not.

  • pass TestPlanRegisterCap in modbus
  • pass TestPlanCoilCap in modbus
  • pass TestPlanMaxBlockOverride in modbus
  • pass TestPlanMultiRegisterValueNeverSplits in modbus

MB-010 partly verified

The reserved scan class `none` catalogs a tag without ever polling it; `write-only` bindings are also left out of the read plan while a writable binding that can be read back is still polled.

where the docs say so · The plan leaving out `none` and `write-only` bindings while keeping a readable writable one is asserted; that a `none` tag stays in the driver's catalog (declared, still writable) is not.

  • pass TestPlanSkipsNoPollAndWriteOnly in modbus

MB-011 partly verified

Scan classes poll at their own rates: a binding's `scan-class` places it in its own poll group and unassigned bindings use the default class at `scan-rate`.

where the docs say so · Delivery per class and class membership are asserted; the actual poll periods (500ms vs 2.5s) are not measured.

  • pass TestDriverDeliversTagsPerClass in modbus

MB-012 no test yet

`tag-classes:` globs in `nautilus.yaml` override the generated scan class, so re-running the import never erases polling policy.

where the docs say so · WithTagClass / tag-classes glob override has no test asserting it.

MB-013 partly verified

Addresses are 0-based PDU addresses end to end (datasheet 40001 is holding address 0), identical in the map, manifest, `browse` and plan output.

where the docs say so · The client's request frames carry the binding address as the PDU address (TestClientReadsAndDecodes through a recording proxy, TestWriteCoalescingAndBaseline), our slave serves a seeded address there and not one below, and manifest addresses read a foreign server's seeded PDU addresses. `browse` and the plan output are not asserted.

  • pass TestWriteCoalescingAndBaseline in modbus
  • pass TestForeignDecodesEverySeededValue in modbus
  • pass TestClientReadsAndDecodes in modbus

MB-014 verified

Marking a binding on a read-only table (input or discrete) `writable` is a validation error.

where the docs say so

  • pass TestValidateErrors/writable input table in modbus
  • pass TestValidateErrors/writable discrete table in modbus

MB-015 partly verified

The formats int16, uint16, int32, uint32, float32, float64, bool and `bit:N` decode correctly, and bit tables accept only `bool`.

where the docs say so · "Bit tables take only bool" is asserted for the coil table only; a register format on the discrete table is not.

  • pass TestDecodeGolden in modbus (17 of 17 passed)
  • pass TestParseFormat in modbus
  • pass TestValidateErrors/register format on coil table in modbus

MB-016 verified

Scaling is `engineering = raw * scale + offset` on read, inverted on write, and `scale: 0` means 1 (an unset scale is the identity).

where the docs say so

  • pass TestDecodeGolden/scaled uint16 in modbus
  • pass TestDecodeGolden/offset only in modbus
  • pass TestEncodeInvertsScaling in modbus
  • pass TestForeignWriteRoundTrip in modbus

MB-017 verified

Word order and byte order are per source: `wordorder: little` reverses the register sequence of a multi-register value and `byteorder: little` swaps the bytes inside each register, in all four combinations.

where the docs say so

  • pass TestDecodeGolden/float32 word little in modbus
  • pass TestDecodeGolden/uint16 byte little in modbus
  • pass TestDecodeGolden/float32 both little in modbus
  • pass TestForeignDecodesEverySeededValue in modbus
  • pass TestForeignWriteRoundTrip in modbus

MB-018 verified

Each source gets a `<source>__Online` BOOL input that is true while its connection is up and false while the source is dead or parked.

where the docs say so

  • pass TestDriverDeliversTagsPerClass in modbus
  • pass TestSlaveDeathHoldsValuesQueuesWritesRecovers in modbus
  • pass TestEnableTagParksSource in modbus

MB-019 partly verified

The `__Online` companion is present from `Start`, before any poll has landed, and data tags stay absent from the snapshot until their block has been read at least once.

where the docs say so · A tag whose block never answers is asserted absent from the snapshot; no test reads the snapshot between Start and the first poll to see `__Online` already present.

  • pass TestForeignExceptionMarksOnlyThatBlock in modbus

MB-020 partly verified

A source with an `enable:` tag that is false is parked: no connection is attempted, `__Online` is false, its tags hold their last values and report Stale, and setting the tag true wakes the source.

where the docs say so · A never-delivered tag reporting NotConnected while parked, that no connection is attempted while parked, and the enable tag acting while the write gate is closed are not asserted.

  • pass TestEnableTagParksSource in modbus

MB-021 partly verified

The enable tag is a command to the driver, not a register write: it appears in `OutputNames()` and never goes on the wire.

where the docs say so · `OutputNames()` membership and the tag acting on the source are asserted; no wire recorder checks that writing it sends no register write.

  • pass TestEnableTagParksSource in modbus

MB-022 verified

A Modbus exception response marks only that block's tags Bad while sibling blocks on the same device stay Good, the source stays online and polling continues without reconnecting, and the block recovers when the fault clears.

where the docs say so

  • pass TestExceptionOnOneBlockLeavesOthersGood in modbus
  • pass TestForeignExceptionMarksOnlyThatBlock in modbus

MB-023 partly verified

Three consecutive exceptions park just that block for one `retrymin` period (no requests for it, siblings keep polling), after which one probe goes out; a refused block is counted as `badBlocks`.

where the docs say so · Parking, sibling polling, the single probe and `badBlocks` are asserted, but both tests wait for the driver's own exceptionsToPark constant, so the threshold of three is not pinned.

  • pass TestBlockParksAfterConsecutiveExceptionsAndUnparks in modbus
  • pass TestExceptionOnOneBlockLeavesOthersGood in modbus

MB-024 partly verified

A transport failure (dial refused, timeout, short read, wrong unit id) takes the whole source down: the driver drops the connection and reconnects, values hold, tags delivered at least once report Stale, and `__Online` goes false.

where the docs say so · Dial refused (slave death) and timeout take the source down with values held; wrong unit id and short read are asserted only as transport errors at the request level, not as taking the source down.

  • pass TestSlaveDeathHoldsValuesQueuesWritesRecovers in modbus
  • pass TestTimeoutOnOneBlockLeavesOthersCoherent in modbus
  • pass TestForeignLatencyAboveTimeoutReconnects in modbus (3 of 3 passed)
  • pass TestRequestWrongUnitIDIsTransportError in modbus
  • pass TestRequestShortReadIsUnexpectedEOF in modbus

MB-025 no test yet

Reconnect backoff is exponential from `retrymin` (1s) to `retrymax` (60s) and resets on success.

where the docs say so · Tests shorten RetryMin/RetryMax and assert recovery, not the exponential growth, the cap or the reset.

MB-026 verified

A tag never delivered reports NotConnected rather than a made-up value, and nothing is zeroed or invented while a source is down.

where the docs say so · A never-delivered tag is NotConnected and absent from the inputs through repeated timeouts (TestForeignLatencyAboveTimeoutReconnects); values are held while a source is down (TestSlaveDeathHoldsValuesQueuesWritesRecovers).

  • pass TestSlaveDeathHoldsValuesQueuesWritesRecovers in modbus
  • pass TestForeignLatencyAboveTimeoutReconnects in modbus (3 of 3 passed)

MB-027 partly verified

Quality is reported non-Good only (a healthy plant reports an empty map) and is source-scoped: a dead device affects only its own tags.

where the docs say so · No test runs two sources and kills one to show the other stays Good.

  • pass TestDriverDeliversTagsPerClass in modbus

MB-028 verified

A `writable: true` binding on a holding or coil table becomes an output whose writes reach the device as FC 5/6/15/16, and the value lands in the device register.

where the docs say so

  • pass TestWriteCoalescingAndBaseline in modbus
  • pass TestForeignWriteRoundTrip in modbus

MB-029 verified

The first output snapshot after `Start` is a baseline that writes nothing, and thereafter only a value that changed goes on the wire, once.

where the docs say so

  • pass TestWriteCoalescingAndBaseline in modbus
  • pass TestRewriteFiresOnPeriodAndStopsOnGate in modbus

MB-030 partly verified

A command raised while the source is down is queued per source (latest value per tag), flushed on reconnect, and counted as `queuedWrites`.

where the docs say so · Queueing one tag and flush on reconnect are asserted; latest-value-wins coalescing of several queued writes to one tag is not.

  • pass TestSlaveDeathHoldsValuesQueuesWritesRecovers in modbus

MB-031 partly verified

A binding with `rewrite:` re-asserts its last commanded value on that period even when nothing changed, and its baseline value is written on connect, while non-rewrite bindings stay change-only.

where the docs say so · The baseline write on connect, repeated re-asserts and change-only plain bindings are asserted; the re-assert interval is not measured (any rate giving four writes within the 5s wait passes).

  • pass TestRewriteFiresOnPeriodAndStopsOnGate in modbus

MB-032 partly verified

A `write-only` register is taken out of the read plan; without it a writable binding is also polled and the read-back is the source of truth.

where the docs say so · Write-only leaving the plan and a readable writable binding staying in it are asserted; no test changes a writable register on the device and checks the polled read-back wins over the last command.

  • pass TestPlanSkipsNoPollAndWriteOnly in modbus

MB-033 partly verified

Exactly adjacent register writes in one flush merge into a single FC16, adjacent coils into one FC15, and a lone register or coil goes as FC6 or FC5.

where the docs say so · Adjacent pairs merging to FC16/FC15 and lone writes going as FC6/FC5 are asserted; the lone writes sit far from the pairs, so a write run bridging a small hole (not "exactly adjacent") is not ruled out.

  • pass TestWriteCoalescingAndBaseline in modbus

MB-035 no test yet

Requests on a source are strictly sequential: one outstanding request at a time, never pipelined.

where the docs say so · No test asserts a single outstanding request.

MB-036 partly verified

`/api/drivers` reports Kind `modbus` with one device row per source, and the driver state steps connecting, error (nothing answers), degraded (a source dark or any block refused), connected, or waiting when every source is parked.

where the docs say so · The "N blocks · 1 refused" detail string and the exact metric list are not asserted.

MB-037 partly verified

`Extra["sources"]` carries per-source rows (state, blocks, badBlocks, queuedWrites, rttMs, retries, exceptions) and a queued-writes gauge appears only when a command is parked.

where the docs say so · Presence of the rows and the gauge is asserted; the individual field names and the RTT EWMA are not.

MB-038 verified

`naut modbus serve` stands in for a plant from the manifest alone: a `--values` seed in engineering units is stored with the binding's scaling and word order inverted.

where the docs say so

  • pass TestModbusServeSeedSmoke in cmd/naut

MB-039 verified

The bench slave serves the whole manifest on one listener, multi-unit like a real gateway.

where the docs say so

  • pass TestModbusServeSeedSmoke in cmd/naut
  • pass TestSlaveMultiUnit in modbus

MB-040 no test yet

`--ramp` drifts the numeric inputs and `--listen` defaults to `127.0.0.1:5020`.

where the docs say so · The ramp and the default listen address have no test.

MB-041 no test yet

`naut modbus browse` reads a range from a device and prints every address raw and decoded, with every format side by side when `--format` is absent.

where the docs say so · The browse command has no test.

MB-042 verified

`drivers:` lists several drivers: reads fan out and merge, a write goes to the one driver that claims the tag, and names default to the type, deduped.

where the docs say so

  • pass TestMultiRoutesAndMerges in io
  • pass TestDriversBuildAMultiWithStatusRows in internal/project

MB-043 partly verified

A tag delivered by two drivers is a load error naming both, reported offline by `naut check`; setting `driver:` and `drivers:` together is also an error.

where the docs say so · A tag writable through two drivers is refused in TestMultiDuplicateTagIsAnError, but only the tag name is checked in that error; naming both drivers is asserted for the delivered case only.

  • pass TestMultiDuplicateTagIsAnError in io
  • pass TestDriversDuplicateTagNamesBothDrivers in internal/project
  • pass TestDriverAndDriversBothSetIsAnError in internal/project
  • pass TestCheckReportsDuplicateTagAcrossDrivers in cmd/naut

MB-044 partly verified

In a multi-driver set each driver keeps its own `/api/drivers` row, quality merges per tag, and on a `sparkplug:` edge node each driver is its own Sparkplug device, born and killed by its own connection, so one dead bus takes only its own tags offline.

where the docs say so · Rows per driver, the per-tag quality merge and per-driver device health are asserted: with one broker up and one down only the connected driver's device reads healthy. A device dying when its health falls is asserted for a single device (TestDeviceBirthWaitsForFirstRead); an edge node keeping the other devices born while one dies is not.

  • pass TestDriversBuildAMultiWithStatusRows in internal/project
  • pass TestMultiQualityMerges in io
  • pass TestDeviceBirthWaitsForFirstRead in sparkplug

MB-045 verified

The `memory` loopback cannot join a `drivers:` list.

where the docs say so

  • pass TestDriversMemoryCannotShareAScan in internal/project
  • pass TestMultiRefusesTheUnroutable in io

MB-046 partly verified

With a `redundancy:` section the write gate is wired to leadership so commands leave the driver only on the leader, reads are unaffected, and a closed gate also stops `rewrite:` re-asserts (resuming when reopened).

where the docs say so · The gate stopping re-asserts is asserted; plain writes under a closed gate, reads being unaffected, and the leadership wiring in `naut run` are not.

  • pass TestRewriteFiresOnPeriodAndStopsOnGate in modbus

MB-047 no test yet

`naut test` with a `memory.yaml` loopback runs the acceptance suites without opening a socket, and `given:` writes the input image so the same `*_test.yaml` passes unchanged against the real driver.

where the docs say so · The memory-loopback workflow is exercised by example projects but no test asserts the claim as stated.

MB-048 partly verified

Against a foreign pymodbus server the driver decodes every seeded value of every format in both word orders across all four tables, round-trips every writable format, marks an unimplemented range (exception 0x02) bad while siblings stay Good, and a slow response drives the reconnect path with values held.

where the docs say so · The slow-response test never delivers a value before the timeouts start, so "values held" through the reconnect is not asserted by the foreign run.

  • pass TestForeignDecodesEverySeededValue in modbus
  • pass TestForeignWriteRoundTrip in modbus
  • pass TestForeignExceptionMarksOnlyThatBlock in modbus
  • pass TestForeignLatencyAboveTimeoutReconnects in modbus (3 of 3 passed)

MB-049 partly verified

Modbus RTU/ASCII, BCD, int64 and string formats, one-based addressing and pipelined requests are unsupported: unknown formats are rejected.

where the docs say so · Only rejection of an unknown format is checked; the other items are absences with nothing to assert.

  • pass TestParseFormat in modbus

MB-050 partly verified

`WithDialer` substitutes the TCP dial, `driver.Plan()` returns the computed block plan, and `Stop` closes every source's connection.

where the docs say so · `Stop` closing every connection is not asserted.

  • pass TestNewNeverDialsAndReadsFaultBeforeStart in modbus
  • pass TestTimeoutOnOneBlockLeavesOthersCoherent in modbus