Skip to content

Commit 35f9dca

Browse files
committed
Correctly handle Modbus addressing, IPv6 addresses
1 parent 55beb89 commit 35f9dca

File tree

8 files changed

+128
-58
lines changed

8 files changed

+128
-58
lines changed

bin/modbus_sim.py

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,9 @@ def register_parse( txt ):
134134

135135

136136
def register_decode( txt, default=None ):
137-
"""Parse the supplied <beg>[-<end>][=<val>[,...]] and return beg,end,val. If no ...=<val> portion
138-
is found, the returned 'val' is empty unless a non-None 'default' is provided.
137+
"""Parse the supplied <beg>[-<end>][=<val>[,...]] and return beg,end,val. If no ...=<val>
138+
portion is found, the returned 'val' is empty unless a non-None 'default' is provided. Any
139+
value(s) provided are duplicated to the length of the range.
139140
140141
"""
141142
prs = register_parse( txt )
@@ -194,7 +195,7 @@ def register_definitions( registers, default=None ):
194195
195196
produces:
196197
197-
{ ..., hr: { 0: 999 }, ... }
198+
{ ..., hr: { 1: 999 }, ... }
198199
199200
Incoming registers are standard one-based Modbus address ranges, output register: value
200201
dictionaries are zero-based.
@@ -207,32 +208,43 @@ def register_definitions( registers, default=None ):
207208
Allow:
208209
<begin>[-<end>][=<val>[,<val>]] ...
209210
211+
212+
Note that pymodbus assumes that all DataBlock indices follow the Modbus addressing philosophy of
213+
1-based addressing -- the first register/coil/... is addressed as 1, the 2nd as 2, ...
214+
215+
Only the Modbus wire-protocol uses zero-based addressing, where the first register/coil
216+
addressed uses address 0. These are converted in pymodbus/datastore/context.py L119 after
217+
address parsing and before indexing the datastore back to Modbus 1-based addresses.
218+
219+
So, when validating datastore address ranges below, ensure we use an offset that retains 1-based
220+
addresses.
221+
210222
"""
211223
# Parse register ranges
212-
# 0 10001 30001 40001
224+
# 1 10001 30001 40001
213225
cod, did, ird, hrd = {}, {}, {}, {}
214226
for txt in registers:
215227
beg,end,val = register_decode( txt, default=0 )
216228

217229
for reg in range( beg, end + 1 ):
218-
dct, off = ( ( hrd, 40001 ) if 40001 <= reg <= 99999
219-
else ( hrd, 400001 ) if 400001 <= reg <= 465536
220-
else ( ird, 30001 ) if 30001 <= reg <= 39999
221-
else ( ird, 300001 ) if 300001 <= reg <= 365536
222-
else ( did, 10001 ) if 10001 <= reg <= 19999
223-
else ( did, 100001 ) if 100001 <= reg <= 165536
224-
else ( cod, 1 ) if 1 <= reg <= 9999
230+
dct, off = ( ( hrd, 40000 ) if 40001 <= reg <= 99999
231+
else ( hrd, 400000 ) if 400001 <= reg <= 465536
232+
else ( ird, 30000 ) if 30001 <= reg <= 39999
233+
else ( ird, 300000 ) if 300001 <= reg <= 365536
234+
else ( did, 10000 ) if 10001 <= reg <= 19999
235+
else ( did, 100000 ) if 100001 <= reg <= 165536
236+
else ( cod, 0 ) if 1 <= reg <= 9999
225237
else ( None, None ))
226238
assert dct is not None, "Invalid Modbus register: %d" % ( reg )
227239
dct[reg - off] = val[reg - beg]
228240
log.info( "Holding Registers: %5d, %6d-%6d; %s", len( hrd ),
229-
400001 + min( hrd ) if hrd else 0, 400001 + max( hrd ) if hrd else 0, cpppo.reprlib.repr( hrd ))
241+
400000 + min( hrd ) if hrd else 0, 400000 + max( hrd ) if hrd else 0, cpppo.reprlib.repr( hrd ))
230242
log.info( "Input Registers: %5d, %6d-%6d; %s", len( ird ),
231-
300001 + min( ird ) if ird else 0, 300001 + max( ird ) if ird else 0, cpppo.reprlib.repr( ird ))
243+
300000 + min( ird ) if ird else 0, 300000 + max( ird ) if ird else 0, cpppo.reprlib.repr( ird ))
232244
log.info( "Output Coils: %5d, %6d-%6d; %s", len( cod ),
233-
1 + min( cod ) if cod else 0, 1 + max( cod ) if cod else 0, cpppo.reprlib.repr( cod ))
245+
0 + min( cod ) if cod else 0, 0 + max( cod ) if cod else 0, cpppo.reprlib.repr( cod ))
234246
log.info( "Discrete Inputs: %5d, %6d-%6d; %s", len( did ),
235-
100001 + min( did ) if did else 0, 100001 + max( did ) if did else 0, cpppo.reprlib.repr( did ))
247+
100000 + min( did ) if did else 0, 100000 + max( did ) if did else 0, cpppo.reprlib.repr( did ))
236248

237249
return dict( co=cod, di=did, ir=ird, hr=hrd )
238250

misc.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -796,10 +796,11 @@ def parse_ip_port( netloc, default=(None,None) ):
796796
except:
797797
pass
798798
except:
799-
# "<hostname>[:<port>]" (anything other than a rew IP will be returned as a str)
800-
addr_port = netloc.split( ':' )
801-
assert 1 <= len( addr_port ) <= 2, \
802-
"Expected <host>[:<port>], found {netloc!r}"
799+
# "<hostname>[:<port>]" or even the degenerate and non-deterministic "::1:12345"
800+
# (anything other than a rew IP will be returned as a str)
801+
addr_port = netloc.rsplit( ':', 1 )
802+
assert 1 <= len( addr_port ) <= 2 and not addr_port[0].endswith( ':' ), \
803+
"Expected <host>[:<port>], found {netloc!r}".format( netloc=netloc )
803804
addr = addr_port[0]
804805
port = None if len( addr_port ) < 2 else addr_port[1]
805806

misc_test.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ def test_parse_ip_port():
172172
"::192.168.0.1": ( ip("::c0a8:1"), None ),
173173
"2605:2700:0:3::4713:93e3": ( ip("2605:2700:0:3::4713:93e3"), None ),
174174
"[2605:2700:0:3::4713:93e3]:80":( ip("2605:2700:0:3::4713:93e3"), 80),
175+
"::1:12345": ( "::1", 12345 ),
175176
"boogaloo.cash:443": ( "boogaloo.cash", 443 ),
176177
"('host', None)": ( "host", None ),
177178
"('host', 443)": ( "host", 443 ),
@@ -188,6 +189,7 @@ def test_parse_ip_port():
188189
"::192.168.0.1": ( ip("::c0a8:1"), 123 ),
189190
"2605:2700:0:3::4713:93e3": ( ip("2605:2700:0:3::4713:93e3"), 123 ),
190191
"[2605:2700:0:3::4713:93e3]:80":( ip("2605:2700:0:3::4713:93e3"), 80),
192+
"::1:12345": ( "::1", 12345 ),
191193
"boogaloo.cash:443": ( "boogaloo.cash", 443 ),
192194
"('host', None)": ( "host", 123 ),
193195
"('host', 443)": ( "host", 443 ),

remote/plc_modbus.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ def _write( self, address, value, **kwargs ):
293293
value = list( value ) if multi else [ value ]
294294
writer = None
295295
unit = kwargs.pop( 'unit', self.unit )
296-
kwargs.update( slave_id=unit )
296+
kwargs.update( dev_id=unit )
297297
if 400001 <= address <= 465536:
298298
# 400001-465536: Holding Registers
299299
writer = ( WriteMultipleRegistersRequest if multi or self.multi
@@ -377,7 +377,7 @@ def _read( self, address, count=1, **kwargs ):
377377
raise ParameterException( "Invalid Modbus address for read: %d" % ( address ))
378378

379379
unit = kwargs.pop( 'unit', self.unit )
380-
request = reader( address=xformed, count=count, slave_id=unit, **kwargs )
380+
request = reader( address=xformed, count=count, dev_id=unit, **kwargs )
381381
log.debug( "%s/%6d-%6d transformed to %s", self.description, address, address + count - 1,
382382
request )
383383

remote/pymodbus_fixes.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,10 @@ def _eintr_retry(func, *args):
6868
from .. import misc
6969
from ..server import network
7070

71-
from pymodbus import __version__ as pymodbus_version
72-
from pymodbus.server import ModbusTcpServer, ModbusSerialServer
7371
from pymodbus.client import ModbusTcpClient, ModbusSerialClient
74-
from pymodbus.exceptions import ConnectionException
75-
from pymodbus.pdu import ExceptionResponse
7672
from pymodbus.datastore.store import ModbusSparseDataBlock
73+
from pymodbus.framer import FramerType, FramerBase
74+
from pymodbus.server import ModbusTcpServer, ModbusSerialServer
7775

7876

7977
# Historically part of pymodbus to contain global defaults; now hosted here
@@ -91,6 +89,23 @@ class modbus_communication_monitor( object ):
9189
interface is invalid.)
9290
9391
"""
92+
def __init__(
93+
self,
94+
*args,
95+
framer: FramerType | type[FramerBase],
96+
**kwds ):
97+
"""Allow custom framer classes by instantiating with a FramerType Enum value, and
98+
substituting the supplied Framer class after instantiation. The framer may be an int or str
99+
(something convertible to a FramerType Enum), but if it's a FramerBase class,
100+
101+
"""
102+
if not isinstance(framer, type) or not issubclass(framer, FramerBase):
103+
super( modbus_communication_monitor, self ).__init__( *args, framer=framer, **kwds )
104+
else:
105+
super( modbus_communication_monitor, self ).__init__( *args, framer=FramerType.RTU, **kwds )
106+
logging.warning( "Supplying alternate framer {framer!r}".format( framer=framer ))
107+
self.framer = framer
108+
94109
async def connect( self ) -> bool:
95110
logging.warning( "Connect to {comm_name}...".format(
96111
comm_name = self.comm_params.comm_name,
@@ -139,12 +154,19 @@ def callback_communication( self, established ):
139154
140155
The message printed to stdout must match the RE in server/network.py soak.
141156
157+
IPv6 addresses must be formatted correctly for unambiguous parsing of port.
158+
142159
"""
143160
super( modbus_server_tcp_printing, self ).callback_communication( established )
144161
if established:
145-
self.server_address = self.server.transport.sockets[0].getsockname()
146-
print( "Success; Started Modbus/TCP Simulator; PID = %d; address = %s:%s" % (
147-
os.getpid(), self.server_address[0], self.server_address[1] ))
162+
for addr in self.transport.sockets:
163+
address = (
164+
"[{}]:{}"
165+
if addr.family == socket.AF_INET6 else
166+
"{}:{}"
167+
).format( *addr.getsockname() )
168+
print( "Success; Started Modbus/TCP Simulator; PID = {pid}; address = {address}".format(
169+
pid=os.getpid(), address=address ))
148170
sys.stdout.flush()
149171

150172

@@ -163,8 +185,8 @@ class modbus_server_rtu_printing( modbus_server_rtu ):
163185
def callback_communication( self, established ):
164186
super( modbus_server_rtu_printing, self ).callback_communication( established )
165187
if established:
166-
print( "Success; Started Modbus/RTU Simulator; PID = %d; address = %s" % (
167-
os.getpid(), self.comm_params.source_address[0] ))
188+
print( "Success; Started Modbus/RTU Simulator; PID = {pid}; address = {address}".format(
189+
pid=os.getpid(), address=self.comm_params.source_address[0] ))
168190
sys.stdout.flush()
169191

170192

remote_test.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def simulated_modbus_tcp( request ):
5959
6060
"""
6161
command,(iface,port) = start_modbus_simulator(
62-
'-v', '--log', 'remote_test.modbus_sim.log.localhost:0',
62+
'-vvv', '--log', 'remote_test.modbus_sim.log.localhost:0',
6363
'--evil', 'delay:.25',
6464
'--address', 'localhost:0',
6565
#'--range', '10',

requirements-modbus.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
minimalmodbus >=2.1, <3
2-
pymodbus >=3.7, <4
2+
pymodbus >=3.8, <4

serial_test.py

Lines changed: 59 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@
5757

5858
PORT_STOPBITS = 1
5959
PORT_BYTESIZE = 8
60-
PORT_PARITY = None
61-
PORT_BAUDRATE = 300 # 9600 # 19200 # 115200 # use slow serial to get some contention
60+
PORT_PARITY = "N"
61+
PORT_BAUDRATE = 19200 # 115200 # use slow serial to get some contention
6262
PORT_TIMEOUT = 1.5
6363

6464
has_pyserial = False
@@ -73,12 +73,6 @@
7373
try:
7474
# Configure minimalmodbus to use the specified port serial framing
7575
import minimalmodbus
76-
minimalmodbus.STOPBITS = PORT_STOPBITS
77-
minimalmodbus.BYTESIZE = PORT_BYTESIZE
78-
minimalmodbus.PARITY = PORT_PARITY
79-
minimalmodbus.BAUDRATE = PORT_BAUDRATE
80-
minimalmodbus.TIMEOUT = PORT_TIMEOUT
81-
8276
has_minimalmodbus = True
8377
except ImportError:
8478
logging.warning( "Failed to import minimalmodbus; skipping some tests" )
@@ -120,18 +114,20 @@ def test_pymodbus_version():
120114
)
121115
)
122116
def test_pymodbus_rs485_sync():
123-
"""Raw pymodbus API to communicate via ttyS0 client --> ttyS{1,2,...} servers. Supports the client
124-
and at least one server."""
117+
"""Raw pymodbus API to communicate via ttyS0 client --> ttyS{1,2,...} servers. Supported when
118+
the client and at least one server RS-485 port is available.
119+
120+
"""
125121
from pymodbus.client import ModbusSerialClient
126122
from pymodbus.framer import FramerType
127123

128124
serial_args = dict(
129125
timeout=.5,
130126
# retries=3,
131-
baudrate = 19200,
132-
bytesize = 8,
133-
parity = "N",
134-
stopbits = 2,
127+
baudrate = PORT_BAUDRATE,
128+
bytesize = PORT_BYTESIZE,
129+
parity = PORT_PARITY,
130+
stopbits = PORT_STOPBITS,
135131
# handle_local_echo=False,
136132
)
137133

@@ -145,7 +141,7 @@ async def server_start( port, unit ):
145141
slaves = {
146142
unit: ModbusSlaveContext(
147143
di=ModbusSparseDataBlock({a:v for a,v in enumerate(range(100))}),
148-
co=ModbusSparseDataBlock({a:v%len(SERVER_ttyS) for a,v in enumerate(range(100))}),
144+
co=ModbusSparseDataBlock({a:v%2 for a,v in enumerate(range(100))}),
149145
hr=ModbusSparseDataBlock({a:v for a,v in enumerate(range(100))}),
150146
ir=ModbusSparseDataBlock({a:v for a,v in enumerate(range(100))}),
151147
)
@@ -154,8 +150,8 @@ async def server_start( port, unit ):
154150
logging.warning( "Starting Modbus Serial server for unit {unit} on {port} w/ {context}".format(
155151
unit=unit, port=port, context=context ))
156152

157-
from pymodbus.server import ModbusSerialServer as modbus_server_rtu
158-
#from .remote.pymodbus_fixes import modbus_server_rtu
153+
#from pymodbus.server import ModbusSerialServer as modbus_server_rtu
154+
from .remote.pymodbus_fixes import modbus_server_rtu
159155
server = modbus_server_rtu(
160156
port = port,
161157
context = context,
@@ -222,6 +218,8 @@ def reader():
222218
unit = 1+a%len(SERVER_ttyS)
223219
expect = not bool( a%2 )
224220
rr = client.read_coils( a, count=1, slave=unit )
221+
if not rr.isError():
222+
logging.warning( "unit {unit} coil {a} == {value!r}".format( unit=unit, a=a, value=rr.bits ))
225223
if rr.isError() or rr.bits[0] != expect:
226224
logging.warning( "Expected unit {unit} coil {a} == {expect}, got {val}".format(
227225
unit=unit, a=a, expect=expect, val=( rr if rr.isError() else rr.bits[0] )))
@@ -257,12 +255,12 @@ def simulated_modbus_rtu( tty ):
257255
258256
"""
259257
return start_modbus_simulator(
260-
'-vvv', '--log', '.'.join( [
258+
'-vvvv', '--log', '.'.join( [
261259
'serial_test', 'modbus_sim', 'log', os.path.basename( tty )] ),
262260
'--evil', 'delay:.01-.1',
263261
'--address', tty,
264-
' 1 - 1000 = 0',
265-
'40001 - 41000 = 0',
262+
' 1 - 1000 = 1,0',
263+
'40001 - 41000 = 1,2,3,4,5,6,7,8,9,0',
266264
# Configure Modbus/RTU simulator to use specified port serial framing
267265
'--config', json.dumps( {
268266
'stopbits': PORT_STOPBITS,
@@ -295,18 +293,53 @@ def simulated_modbus_rtu_ttyS2( request ):
295293
or not os.path.exists(PORT_MASTER) or not os.path.exists("ttyS1"),
296294
reason="Needs SERIAL_TEST and fcntl/O_NONBLOCK and minimalmodbus and pyserial, and ttyS{0,1}" )
297295
def test_rs485_basic( simulated_modbus_rtu_ttyS1 ):
298-
"""Use MinimalModbus to test RS485 read/write. """
296+
"""Use MinimalModbus to test RS485 read/write. The minimalmodbus API doesn't use 1-based Modbus data
297+
addressing, but zero-based Modbus/RTU command addressing."""
299298

300299
command,address = simulated_modbus_rtu_ttyS1
301300

302301
comm = minimalmodbus.Instrument( port=PORT_MASTER, slaveaddress=1 )
302+
comm.serial.timeout = PORT_TIMEOUT
303+
comm.serial.stopbits = PORT_STOPBITS
304+
comm.serial.bytesize = PORT_BYTESIZE
305+
comm.serial.parity = PORT_PARITY
306+
comm.serial.baudrate = PORT_BAUDRATE
307+
comm.serial.timeout = PORT_TIMEOUT
308+
309+
logging.warning( "{instrument!r}".format( instrument=comm ))
303310
comm.debug = True
304-
val = comm.read_register( 1 )
305-
assert val == 0
306-
comm.write_register( 1, 99 )
307-
val = comm.read_register( 1 )
311+
val = comm.read_register( 0 )
312+
assert val == 1
313+
comm.write_register( 0, 99 )
314+
val = comm.read_register( 0 )
308315
assert val == 99
309-
comm.write_register( 1, 0 )
316+
comm.write_register( 0, 1 )
317+
318+
319+
@pytest.mark.skipif(
320+
'SERIAL_TEST' not in os.environ or not has_o_nonblock or not has_minimalmodbus or not has_pyserial
321+
or not os.path.exists(PORT_MASTER) or not os.path.exists("ttyS1"),
322+
reason="Needs SERIAL_TEST and fcntl/O_NONBLOCK and minimalmodbus and pyserial, and ttyS{0,1}" )
323+
def test_rs485_read( simulated_modbus_rtu_ttyS1 ):
324+
"""Use pymodbus to test RS485 read/write to a simulated device. """
325+
326+
command,address = simulated_modbus_rtu_ttyS1
327+
Defaults.Timeout = PORT_TIMEOUT
328+
client = modbus_client_rtu(
329+
port=PORT_MASTER, stopbits=PORT_STOPBITS, bytesize=PORT_BYTESIZE,
330+
parity=PORT_PARITY, baudrate=PORT_BAUDRATE,
331+
)
332+
333+
for a in range( 10 ):
334+
unit = 1
335+
expect = bool( a%2 )
336+
rr = client.read_coils( a, count=1, slave=unit )
337+
if not rr.isError():
338+
logging.warning( "unit {unit} coil {a} == {value!r}".format( unit=unit, a=a, value=rr.bits ))
339+
assert (not rr.isError()) and rr.bits[0] == expect, \
340+
"Expected unit {unit} coil {a} == {expect}, got {val}".format(
341+
unit=unit, a=a, expect=expect, val=( rr if rr.isError() else rr.bits[0] ))
342+
310343

311344

312345
@pytest.mark.skipif(

0 commit comments

Comments
 (0)