Timing¶
anymodbus enforces the inter-frame and inter-character timings that the Modbus over Serial Line v1.02 spec requires of an RTU master. Defaults are spec-derived; everything is configurable via TimingConfig.
The two gaps¶
| Gap | Symbol | Spec value | Where it's used | Config field |
|---|---|---|---|---|
| Inter-frame idle | t3.5 | ≥ 3.5 char-times of silence between frames | Held before every tx to satisfy serial §2.5.1.1 | inter_frame_idle |
| Inter-character idle | t1.5 | ≤ 1.5 char-times within a frame | Used by the unknown-FC gap-based reader and the unexpected-slave drain branch in framing.md | inter_char_idle |
Both default to the sentinel "auto", which computes from the stream's current baud:
inter_frame_idle = max(3.5 * 11 / baudrate, 0.00175) # 1.75 ms floor
inter_char_idle = max(1.5 * 11 / baudrate, 0.00075) # 0.75 ms floor
Per-baud floors of 1.75 ms / 0.75 ms come from serial §2.5.1.1: at baud > 19200 the per-character interrupt load otherwise becomes prohibitive, so the spec recommends fixed values.
Why 11 bits per character?¶
The 11 in both formulas is the on-wire bit count: 1 start + 8 data + (1 parity OR 1 extra stop) + 1 stop = 11 bits. This is correct for the spec-compliant RTU character formats: 8E1, 8O1, 8N2.
8N1 is non-compliant per spec but exists in the wild. Using 11 bits unconditionally makes the gap ~10% longer than the wire actually requires under 8N1 — harmless, and it errs on the safe (longer) side.
Per-baud table¶
Reference values for the spec-recommended baudrates:
| Baud | t3.5 (3.5 × 11/baud) | Effective t3.5 (with floor) | t1.5 |
|---|---|---|---|
| 1200 | 32.08 ms | 32.08 ms | 13.75 ms |
| 2400 | 16.04 ms | 16.04 ms | 6.88 ms |
| 4800 | 8.02 ms | 8.02 ms | 3.44 ms |
| 9600 | 4.01 ms | 4.01 ms | 1.72 ms |
| 19200 | 2.01 ms | 2.01 ms | 0.86 ms |
| 38400 | 1.00 ms | 1.75 ms (floor) | 0.75 ms (floor) |
| 57600 | 0.67 ms | 1.75 ms (floor) | 0.75 ms (floor) |
| 115200 | 0.33 ms | 1.75 ms (floor) | 0.75 ms (floor) |
Tx-side enforcement¶
The bus records _last_io_monotonic when each transaction ends, however it ended: a reply, an exception response, a checksum or framing error, a timeout or a cancellation. After a broadcast it is recorded once the turnaround delay has passed. Before the next tx:
elapsed = anyio.current_time() - last_io_monotonic
if elapsed < inter_frame_idle:
await anyio.sleep(inter_frame_idle - elapsed)
This is why anymodbus is "tx-side correct" — the master never violates t3.5, even if the slave's reply was late or the host scheduler was busy. pymodbus relies on the bus lock alone for this and does not enforce a pre-tx sleep.
Broadcast turnaround¶
Per serial §2.4.1, after a broadcast (slave address 0) the master must hold the bus idle long enough for every slave to finish processing the write. This is separate from t3.5:
- t3.5 is wire-level framing (microseconds at 19200).
- The turnaround delay is "let slaves catch up" (typically 100–200 ms).
Configured via TimingConfig.broadcast_turnaround (default 0.1 s = 100 ms). The Bus.broadcast_* methods hold the bus lock for this long after sending; the next unicast can't preempt slave processing.
Post-tx settling¶
TimingConfig.post_tx_settle (default 0) inserts a fixed wait between stream.send returning and the start of the rx loop. Most setups don't need it; some RS-485 transceivers benefit from a small (~ 0.5 ms) settling delay between de-asserting RTS and starting to listen.
Late-reply window¶
After an attempt whose outcome is uncertain — a timeout, a cancellation, a CRC or framing error, or a reply that does not answer the request — the slave's real reply may still arrive. TimingConfig.late_reply_window (default 0, off) holds the next request back until that long after the uncertain attempt ended, reading and discarding anything that arrives, and until the line has been quiet for inter_frame_idle. Size it with estimate_late_reply_window(baudrate=..., max_turnaround=..., max_reply_bytes=..., latency=...). See Cancellation → Late replies for the hazard it closes.
When "auto" falls back¶
"auto" reads the baud rate from the stream's SerialStreamAttribute.config typed attribute. An anyserial.SerialPort always publishes it — including both ends of anyserial.testing.serial_port_pair(), which client_slave_pair uses, so tests get the baud rate they configure. A wrapper stream can publish it too by forwarding its port's extra_attributes.
If the stream doesn't publish it (an in-memory stream, a wrapper that doesn't forward extra_attributes, the future Modbus TCP transport), the lookup returns the fallback constant — the equivalent of 19200 baud. Override explicitly when you know better:
from anymodbus import BusConfig, TimingConfig
cfg = BusConfig(timing=TimingConfig(inter_frame_idle=0.001, inter_char_idle=0.0005))
RS-485 considerations¶
When the kernel handles RTS-toggle (Linux RS-485 ioctl, modern USB-RS485 chips), the timing is hardware-tight and anymodbus's defaults are correct. When userspace toggles RTS manually, the t3.5 gap also has to cover the bus turnaround. See troubleshooting.md for the diagnosis flow if your reads time out on bus-idle hardware.
RS-485 cold-start & multidrop¶
The first transaction after opening a port (or after a long idle) on a multidrop RS-485 bus often times out spuriously: the USB-RS485 adapter's direction-control line and the slave's receiver both need a beat to settle, and some adapters drop the leading bytes of the very first frame.
TimingConfig.startup_settle (default 0) inserts a one-shot idle wait before the first transaction only — unlike inter_frame_idle, it isn't paid on every subsequent frame. Combined with the idempotent-read retry that already fires on a first-frame timeout, it absorbs adapter warm-up.
Two recommended configs:
from anymodbus import BusConfig, RetryPolicy, TimingConfig
# (a) Fast, fail-fast probe — e.g. an AUTO/liveness sweep that must give up quickly:
probe = BusConfig(
request_timeout=0.3,
retries=RetryPolicy(retries=1),
timing=TimingConfig(startup_settle=0.05),
)
# (b) Robust steady-state poll:
poll = BusConfig(
retries=RetryPolicy(retries=2),
timing=TimingConfig(post_tx_settle=0.0005), # bump for slow RS-485 transceivers
)
Shared-port caveat¶
If your code owns a serial port shared across protocols (e.g. an AUTO mode that sniffs raw bytes before deciding which protocol is live) and parks bytes for another reader, set reset_input_buffer_before_request=False. The default True flushes the OS input buffer before each request — fine for a single reader, but it would discard bytes you intended for a different reader on a shared port.
For the same reason, leave late_reply_window at 0 on a shared port: the bus would read and discard the other reader's bytes during the window.
Wrapped streams: drain and input reset¶
drain_after_send and reset_input_buffer_before_request act on any stream that has an async drain() / reset_input_buffer() method (described by anymodbus.stream.SupportsDrain and SupportsResetInputBuffer), not only on an anyserial.SerialPort. If you pass the bus a wrapper around a serial port, forward both methods — and extra_attributes, for the baud rate — or the bus loses drain-after-send (which matters for half-duplex RS-485) and the input reset. A wrapper with its own read buffer should clear it in reset_input_buffer() as well.