Skip to content

Changelog

Changelog

All notable changes to anymodbus are documented here.

The format follows Keep a Changelog, and the project adheres to Semantic Versioning.

Unreleased

0.3.0 - 2026-09-28

Hardening driven by the device libraries built on anymodbus (fujilib, servomexlib, watlowlib): each item below replaces a workaround those libraries had to carry. The public API is backwards-compatible; the behaviour changes are listed under Changed and Fixed.

Added

  • Late-reply window. TimingConfig.late_reply_window (default 0, off). After an attempt whose outcome is uncertain — a timeout, a cancellation, a checksum or framing error, or a reply that does not answer the request — the bus sends nothing until the window has passed, reading and discarding whatever arrives, and waits for the line to be quiet. Without it, a reply that arrives after its request timed out or was cancelled can be taken as the answer to the next request. estimate_late_reply_window(baudrate=..., max_turnaround=..., max_reply_bytes=256, latency=0.0) sizes it.
  • Transaction observers. Bus.add_transaction_observer(callback) (and Bus(on_transaction=...), and the sync Bus.add_transaction_observer) reports every attempt as a TransactionInfo: slave address, function code, request_id, attempt / max_attempts, will_retry, a TransactionOutcome, the error, started_at / sent_at / ended_at on the AnyIO clock and sent_at_ns / ended_at_ns on time.monotonic_ns(), and the late bytes discarded before it. sent_at is taken after the inter-frame gap, the send and the drain, so readings can be timestamped with when the request actually went out. Retries and recovered errors can be counted from the reports without turning off the bus's own retries.
  • TransportError (ConnectionLostError, OSError): an OS-level failure of the stream other than a disconnect or a close.
  • Server-side building blocks, for test slaves and simulators:
  • Request decoders in anymodbus.pdu (decode_read_holding_registers_request, decode_write_multiple_registers_request, ... one per implemented FC) returning frozen dataclasses (ReadRequest, WriteSingleCoilRequest, WriteSingleRegisterRequest, WriteMultipleCoilsRequest, WriteMultipleRegistersRequest), and response encoders (encode_read_holding_registers_response, ..., encode_exception_response).
  • read_request_adu(stream, *, inter_char_idle) on both framers (and the Framer protocol): reads one request frame for any address, framed by request length, with the checksum verified.
  • anymodbus.testing.MockServer: several MockSlaves on one line, routed by address. client_server_pair(*slaves, ...) wires one to a bus.
  • MockSlave.handle(request_pdu) as the public extension point, the public ServerException(code) to answer with an exception response, MockSlave.send_response(stream, pdu), and MockSlave.response_count.
  • QuantityLimits: per-request quantity caps on a MockSlave (and client_slave_pair(limits=...)), e.g. a device limited to 64 registers.
  • anymodbus.stream.SupportsDrain / SupportsResetInputBuffer: the optional stream methods the bus uses, as runtime-checkable protocols.

Changed

  • Replies are checked against the request. A register read whose reply carries a different number of registers, a coil / discrete-input read whose byte count does not fit the requested count, and a write (FC 0x05, 0x06, 0x0F, 0x10) whose echoed address, value or quantity differs from the request now raise UnexpectedResponseError. Before, a well-formed reply of the wrong length was returned as a shorter or longer tuple, and write echoes were not compared. decode_read_holding_registers_response / decode_read_input_registers_response accept a keyword-only expected_count for the same check.
  • The check runs inside each attempt, so a reply that fails it is retried, reported to observers, and opens a late-reply window like any other bad reply.
  • Default RetryPolicy.retry_on is now {ProtocolError, FrameTimeoutError} (was {ChecksumError, FrameTimeoutError}). Reads are now also retried on a malformed frame (FrameError) and on a reply that does not answer the request (UnexpectedResponseError); writes are still not retried by default (retry_idempotent_only=True). As in 0.2.0, only set membership changed for subclasses: ChecksumError in cfg.retries.retry_on is now False, though checksum errors are still retried.
  • Bad arguments raise ConfigurationError instead of a bare ValueError throughout the PDU codec, both ADU encoders, the register decoders and encoders, the broadcast path and MockSlave. ConfigurationError is a ValueError, so existing except ValueError handlers still catch it.
  • The bus uses any stream's drain() and reset_input_buffer(), not only an anyserial.SerialPort's, when they are async methods. A wrapper around a serial port keeps drain-after-send and the input reset by forwarding them.
  • MockSlave answers a request with a function code it does not implement with exception code 0x01 (ILLEGAL FUNCTION), as a device should, instead of stopping its serve loop. MockSlave.serve runs through MockServer.

Fixed

  • Port failures are ModbusErrors. An OSError from the stream that is not a disconnect or a close (for example an anyserial.SerialError for an unmapped errno or Windows error code, or a failing drain) now raises TransportError, which is still an OSError, instead of escaping raw. The input reset before a request now runs inside the transaction's error handling, so its failures are translated too, and the next request still gets its inter-frame gap.
  • A reply whose function code the client never sends is line damage, not "unsupported". A reply carrying FC 0x07, 0x0B, 0x0C, 0x11, 0x14, 0x15, 0x18 or 0x2B raised ModbusUnsupportedFunctionError, which is not retried and reads as "the device lacks this function" (on a write, as "the write was refused"). It is now read to the idle gap and checked: a damaged frame (06 → 07, 03 → 0B, ... are one bit apart) raises CRCError, and a checksum-valid one raises UnexpectedResponseError; reads retry either. ModbusUnsupportedFunctionError remains for the send side.
  • Documentation: FaultPlan.corrupt_crc_after_n / drop_response_after_n fire once, at the response with that 0-based index; client_slave_pair uses real serial ports, so the baud rate, drain and input reset all apply.

0.2.1 - 2026-09-28

Fixed

  • Inter-frame idle gap after a failed transaction. inter_frame_idle is now measured from the end of the previous transaction however it ended. Before, it was measured from the end only after a normal reply. After an exception response, a CRC/LRC or framing error, an unexpected response, a timeout or a cancellation, it was measured from when the previous request was sent. A reply slower than the gap (as most real devices' are) had already used it up, so the next request went out straight after the error. A cancelled broadcast now also leaves the gap in place. The first request after an error may now wait up to inter_frame_idle longer than before. Found by fujilib.

0.2.0 - 2026-05-30

Adds Modbus-ASCII framing, FC08 diagnostic loopback, FC04 input-register typed reads, and RS485 cold-start ergonomics — the capabilities requested by servomexlib for its [modbus-ascii] / AUTO milestone. The public API is additive and backwards-compatible; the internal framing layer was restructured behind a narrow Framer strategy seam so RTU / ASCII / (future) TCP share one response interpreter.

Added

  • Modbus-ASCII framing. Bus(stream, framing=Framing.ASCII) selects the :…hex…LRC…CRLF wire format, sharing the existing PDU/register codec unchanged. New Framing enum (RTU / ASCII) and a thin open_modbus_ascii(path, *, baudrate, parity, data_bits=8) convenience opener (data_bits=7 for the classic 7E1 wire). Sync mirrors included.
  • Pure LRC primitives at anymodbus.lrc: lrc8, lrc8_bytes, verify_lrc — mirroring anymodbus.crc (submodule-level, not top-level).
  • FC08 diagnostic loopback. Slave.diagnostic_loopback(data=b"\x00\x00") (FC 0x08 sub-function 0x0000, Return Query Data) — a side-effect-free liveness / round-trip probe. PDU codec in anymodbus.pdu; sync mirror.
  • FC04 input registers in the typed reads. Slave.read_float / read_int32 / read_string (and sync mirrors) accept source=RegisterSource.INPUT (FC04) — defaults to HOLDING (FC03).
  • TimingConfig.startup_settle — a one-shot idle wait applied once before the first transaction, to absorb USB-RS485 adapter warm-up.
  • ChecksumError base exception (parent of CRCError and the new LRCError).
  • MockSlave parity: answers FC08 sub-0 (echo) and can speak ASCII (MockSlave(framing=...), client_slave_pair(framing=...)), so one register bank backs both an RTU and an ASCII bus in hardware-free CI.

Changed

  • Default RetryPolicy.retry_on widened from {CRCError, FrameTimeoutError} to {ChecksumError, FrameTimeoutError}, so ASCII LRCError retries by default. RTU behaviour is unchanged (CRCError ⊂ ChecksumError; retry uses isinstance). Note: a direct membership check CRCError in cfg.retries.retry_on is now False by default even though CRCError is still retried — only the set membership changed, not the behaviour.
  • FC 0x08 reclassified from "known-unsupported" (raised ModbusUnsupportedFunctionError) to supported (sub-0 only). CRCError reparented from ProtocolError to ChecksumError (still a ProtocolError transitively — except CRCError / except ProtocolError unaffected).
  • Internal: the RTU framer was split into RtuFramer.read_adu (reads one raw frame) plus a shared interpret_response_pdu; the module-level read_response_adu is preserved as a compatibility wrapper.

No breaking changes to documented public API.

0.1.1 - 2026-04-26

First post-release polish, driven by the REVIEW.md notes against v0.1.0 ahead of the first downstream consumers (watlowlib + planned alicatlib / sartoriuslib). The package is still alpha; backwards-compatibility shims were not kept.

Added

  • RegisterType enum (int16 / uint16 / int32 / uint32 / int64 / uint64 / float32 / float64 / string) and a type-dispatched decode(words, *, type=RegisterType.X, ...) / encode(value, *, type=RegisterType.X, ...) pair in anymodbus.decoders. Recommended entry point for downstream device libraries that load a register schema from configuration. register_count_for(type) helper for schema callers.
  • New per-type helpers: decode_int16 / encode_int16, decode_int64 / encode_int64, decode_float64 / encode_float64.
  • decode_string / encode_string gained a byte_order keyword for devices that store strings byte-swapped within each register. encode_string now accepts either register_count or byte_count — the latter is convenient for spec'd-in-bytes fields whose length isn't a multiple of two.
  • Slave.read_string / Slave.write_string (and their sync mirrors) accept either register_count or byte_count, plus byte_order.
  • FC short alias for FunctionCode at the top-level package surface, fulfilling the long-standing docstring promise.

Changed

  • TimingConfig.inter_char_timeout renamed to TimingConfig.inter_char_idle to match the spec term used throughout DESIGN.md. The framer's keyword argument is renamed to match.
  • ModbusUnknownExceptionError is now a subclass of ModbusExceptionResponse, so callers wanting "any slave-returned exception" can except ModbusExceptionResponse and be done. The raw byte is exposed on exception_code (was: code). code_to_exception now returns ModbusExceptionResponse instead of ModbusError.

Removed

  • BusBusyError from DESIGN.md — it was documented but never implemented. The bus lock serializes transactions; the right resource-busy signal already comes from anyio.BusyResourceError on the underlying stream.

0.1.0 - 2026-04-25

Initial release. See DESIGN.md for the full plan.

Added

  • Repository skeleton: pyproject.toml, Makefile, CI/docs/publish workflows, pre-commit config, src/tests/docs layout.
  • Foundational pure-Python pieces: anymodbus._types (enums + small dataclasses), anymodbus.exceptions (full ModbusError tree + code_to_exception translator), anymodbus.crc (CRC-16/Modbus, 256-entry table), anymodbus.config (frozen dataclasses with validation).
  • WordOrder and ByteOrder named enums for 32-bit value layout, with defaults matching the Modbus Application Protocol spec example (high-word-first, big-endian within word).
  • Bus (half-duplex single-master client) and per-slave Slave handle, with open_modbus_rtu(...) convenience opener over anyserial.
  • Length-aware RTU Framer with timing-gap fallback for unknown function codes and tx-side 3.5-character pre-tx idle gap.
  • PDU encode/decode for the v0.1 function code set (FC 1–6, 15, 16) and decoders for 32-bit float / int values across two registers.
  • Capability model (SUPPORTED/UNSUPPORTED/UNKNOWN) populated from probe results.
  • Sync wrapper (anymodbus.sync) using the same shared-portal pattern as anyserial.sync.
  • anymodbus.testing with MockSlave, FaultPlan, and client_slave_pair() for hardware-free integration tests.