Skip to content

ADR-0002: Modbus TCP Implementation

Status: Accepted
Date: 2026-07-24


Context

Modbus TCP is one of the most widely deployed protocols in industrial automation. It wraps the classic Modbus PDU (Protocol Data Unit) in a TCP/IP framing layer (MBAP header), making it a natural fit for SMOLPy's existing Ethernet/IP simulation model.

Unlike MQTT, which is a fire-and-forget publish/subscribe protocol, Modbus TCP is strictly request/response: a Master initiates all communication by polling one or more Slaves, each of which replies with the requested register data. This round-trip nature makes it possible to measure poll latency (RTT) as a first-class metric.

Per ADR-0001, the Adapter in core/ is the protocol-agnostic endpoint that accumulates traffic-generation methods as protocols are added. A new modbus/ package provides the infrastructure node (ModbusSlave).


Decision

Package layout

src/smolpy/
└── modbus/
    ├── __init__.py
    └── slave.py        # ModbusSlave node

ModbusSlave is the only infrastructure node — the master role is carried by a regular Adapter via the new polls() method, consistent with how publishes() handles MQTT.

DSL API

plc    = net.adapter("plc",    ip="10.0.0.1")
sensor = net.modbus_slave("sensor", ip="10.0.0.10", unit_id=1)

net.link(plc, sw1, speed=100, length=5)
net.link(sensor, sw1, speed=100, length=10)

# Poll 10 holding registers at 1 Hz
plc.polls(sensor, register=40001, count=10, rate=1.0)

# Optional delayed start
plc.polls(sensor, register=30001, count=5, rate=2.0, delay_ms=500)

net.observe("modbus_latency", on=plc, every=500)

ModbusSlave node (modbus/slave.py)

Attribute Type Description
name str Node name
ip str IPv4 address
mac str MAC address (auto-derived if omitted)
unit_id int Modbus unit identifier (1–247)

ModbusSpec (added to core/adapter.py)

Stored in Adapter.modbus_specs: list[ModbusSpec].

Attribute Type Description
slave ModbusSlave Target slave node
register int Starting register address
count int Number of registers to read
rate_hz float Poll rate in Hz
delay_ms float Simulation time before first poll
request_frame_size int Computed — see Frame sizes below
response_frame_size int Computed — see Frame sizes below

Frame sizes

Modbus TCP uses a 6-byte MBAP header (Transaction ID, Protocol ID, Length, Unit ID) prepended to the standard Modbus PDU.

Request  = Ethernet/IP/TCP (54 B) + MBAP (6 B) + FC (1 B) + Start addr (2 B) + Quantity (2 B)
         = 65 bytes  (constant regardless of register count)

Response = Ethernet/IP/TCP (54 B) + MBAP (6 B) + FC (1 B) + Byte count (1 B) + Data (2 × count B)
         = 62 + 2 × count  bytes

New _Frame fields (sim/engine.py)

Field Type Default Purpose
modbus_unit_id int \| None None Identifies Modbus frames; None = not Modbus
modbus_register_count int 0 Carried in request so slave can size the response
modbus_is_response bool False Distinguishes slave response from master request

Simulation processes

_modbus_poll_gen — runs per ModbusSpec on the master adapter.
Emits a request _Frame at rate_hz, optionally after delay_ms.
created_at_us is stamped at send time and preserved through the network for RTT calculation.

_modbus_slave_responder — runs per ModbusSlave.
Reads its inbound store, ignores non-Modbus frames and frames not addressed to its unit_id, then enqueues a response frame back toward the master, copying created_at_us from the request so the master can measure RTT on arrival.

_modbus_latency_sampler — runs when modbus_latency is observed on an Adapter.
Samples _AdapterCounters.modbus_rtt_us (a new list populated by _adapter_receiver when it sees modbus_is_response=True frames) and appends (time_ms, avg_rtt_µs) to the metric series.

New metric

Metric Unit Observed on
modbus_latency µs Adapter (master)

Reports the average round-trip time per polling interval — from when the master sends a request to when the slave's response is delivered back.

ModbusSlave routing in the engine

ModbusSlave is treated the same as Adapter and MQTTBroker for link-channel assignment — it gets an adapter_out channel and is pre-seeded into the MAC table of any directly connected switch. This requires adding ModbusSlave to the isinstance checks in the link-building loop.


Consequences

Positive - Modbus TCP simulation is now possible, including accurate frame sizing and RTT measurement. - The request/response model is correctly represented — unlike MQTT, both request and response frames traverse the network and contend for bandwidth. - Adding further Modbus function codes (Write Single Register, Write Multiple Registers) requires only extending ModbusSpec and _modbus_poll_gen — no structural changes.

Negative / trade-offs - _Frame grows three more fields; all explicit _Frame(...) construction sites must be updated. - The current implementation models only Read Holding Registers (FC 03). Write operations and other function codes are deferred. - Slave processing time (the time a real PLC takes to prepare a response) is not modelled — response is sent immediately on frame arrival. This can be added later via a configurable processing_delay_ms on ModbusSlave.