Skip to content

Commit ac57ed1

Browse files
committed
Mostly operational pymodbus integration (ex. RS-485 multi-drop)
1 parent 35f9dca commit ac57ed1

File tree

11 files changed

+321
-118
lines changed

11 files changed

+321
-118
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,5 @@ dist/
2626
.cache/
2727
*.aux
2828
*.toc
29+
/ttyS[0-9]
30+
/ttyV[0-9]

bin/modbus_sim.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -502,8 +502,8 @@ def main( argv=None ):
502502
'stopbits': 1,
503503
'bytesize': 8,
504504
'parity': serial.PARITY_NONE,
505-
'baudrate': 4800,
506-
'timeout': 0.5,
505+
'baudrate': 9600,
506+
'timeout': 0.05,
507507
'slaves': None,
508508
'ignore_missing_slaves': True,
509509
}

default.nix

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,16 +70,22 @@ in
7070
];
7171
};
7272

73+
# Configure ~/.config/nixpkgs/config.nix to allow:
74+
# {
75+
# allowUnfree = true;
76+
# permittedInsecurePackages = [
77+
# "python-2.7.18.8"
78+
# ];
79+
# }
7380
py27 = stdenv.mkDerivation rec {
74-
name = "python2-with-pytest";
81+
name = "python27-with-pip";
7582

7683
buildInputs = [
7784
cacert
7885
git
7986
gnumake
8087
openssh
8188
python27
82-
python27Packages.pytest
8389
python27Packages.pip
8490
];
8591
};

misc.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -764,9 +764,10 @@ def network( a ):
764764
return ip_network( unicode( a ))
765765

766766
def parse_ip_port( netloc, default=(None,None) ):
767-
"""Parse an <interface>[:<port>] with the supplied defaults, returning <host>,<port|None>. A
768-
Truthy host portion is required (ie. non-empty); port is optional. Returns ip as an ip_address
769-
(if possible), otherwise as a str; either form can be converted to str, if desired.
767+
"""Parse an <interface>[:<port>] with the supplied defaults, returning <host>,<port|None>.
768+
769+
A Truthy host portion is required (ie. non-empty); port is optional. Returns ip as an
770+
ip_address (if possible), otherwise as a str; either form can be converted to str, if desired.
770771
771772
"""
772773
try:
@@ -780,29 +781,44 @@ def parse_ip_port( netloc, default=(None,None) ):
780781
addr = ip( addr.hostname )
781782
except:
782783
pass
784+
logging.info( "{addr!r}:{port!r} from {netloc!r}: found a Python actual or literal tuple".format(
785+
addr=addr, port=port, netloc=netloc ))
783786
except Exception:
784787
try:
785788
# Raw IPv{4,6} address, eg "1.2.3.4", "::1"
786789
addr = ip( netloc )
787790
port = None
791+
logging.info( "{addr!r}:{port!r} from {netloc!r}: found a bare IP address".format(
792+
addr=addr, port=port, netloc=netloc ))
788793
except ValueError:
789-
# IPv{4,6} address:port, eg "1.2.3.4:80", "[::1]:80" (raw IP only returned as an ip_address)
794+
# IPv{4,6} address:port, eg "1.2.3.4:80", "[::1]:80" (raw IP only returned as an
795+
# ip_address). Retains case, if no port supplied (eg. for entities that are not hosts,
796+
# such as "ttyS1".)
790797
try:
791798
parsed = urlparse( '//{}'.format( netloc ))
792-
addr = parsed.hostname
793799
port = parsed.port # will be None or int
800+
addr = parsed.netloc if port is None else parsed.hostname
794801
try:
795802
addr = ip( parsed.hostname )
796803
except:
797804
pass
805+
logging.info( "{addr!r}:{port!r} from {netloc!r}: found a URL".format(
806+
addr=addr, port=port, netloc=netloc ))
798807
except:
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)
808+
# "<hostname>[:<port>]" or even the degenerate and non-deterministic "::1:12345" --
809+
# use deterministic [<IPv6>]:<port> instead! Anything other than a rew IP will be
810+
# returned as a str.
801811
addr_port = netloc.rsplit( ':', 1 )
802812
assert 1 <= len( addr_port ) <= 2 and not addr_port[0].endswith( ':' ), \
803813
"Expected <host>[:<port>], found {netloc!r}".format( netloc=netloc )
804814
addr = addr_port[0]
815+
try:
816+
addr = ip( addr )
817+
except:
818+
pass
805819
port = None if len( addr_port ) < 2 else addr_port[1]
820+
logging.info( "{addr!r}:{port!r} from {netloc!r}: found a colon-separated string".format(
821+
addr=addr, port=port, netloc=netloc ))
806822

807823
# An empty ip is overridden by a non-None default[0], but either could still be '', which is a
808824
# valid i'face designation.

misc_test.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,10 +172,12 @@ 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 ),
176-
"boogaloo.cash:443": ( "boogaloo.cash", 443 ),
175+
"::1:12345": ( ip("::1"), 12345 ),
176+
"Boogaloo.cash:443": ( "boogaloo.cash", 443 ),
177+
"Boogaloo.cash": ( "Boogaloo.cash", None),
177178
"('host', None)": ( "host", None ),
178179
"('host', 443)": ( "host", 443 ),
180+
"ttyS1": ( "ttyS1", None ),
179181
}.items():
180182
r = parse_ip_port( t )
181183
assert r == (a,p), \
@@ -189,10 +191,12 @@ def test_parse_ip_port():
189191
"::192.168.0.1": ( ip("::c0a8:1"), 123 ),
190192
"2605:2700:0:3::4713:93e3": ( ip("2605:2700:0:3::4713:93e3"), 123 ),
191193
"[2605:2700:0:3::4713:93e3]:80":( ip("2605:2700:0:3::4713:93e3"), 80),
192-
"::1:12345": ( "::1", 12345 ),
193-
"boogaloo.cash:443": ( "boogaloo.cash", 443 ),
194+
"::1:12345": ( ip("::1"), 12345 ),
195+
"Boogaloo.cash:443": ( "boogaloo.cash", 443 ),
196+
"Boogaloo.cash": ( "Boogaloo.cash", 123 ),
194197
"('host', None)": ( "host", 123 ),
195198
"('host', 443)": ( "host", 443 ),
199+
"ttyS1": ( "ttyS1", 123 ),
196200
}.items():
197201
r = parse_ip_port( t, default=(None,123) )
198202
assert r == (a,p), \

modbus_test.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,11 @@ def start_modbus_simulator( *options ):
203203

204204

205205
def run_plc_modbus_polls( plc ):
206+
""" Assumes:
207+
coils 1 == 1,0,...
208+
hregs 40001 == 1,2,3,4,5,6,7,8,9,0,...
209+
210+
"""
206211
# Initial conditions (in case PLC is persistent between tests)
207212
plc.write( 1, 0 )
208213
plc.write( 40001, 0 )
@@ -217,18 +222,18 @@ def run_plc_modbus_polls( plc ):
217222
success,elapsed = waitfor( lambda: plc.read( 40001 ) is not None, "40001 polled", **wfkw )
218223
assert success
219224
assert elapsed < 1.0
220-
assert plc.read( 40001 ) == 0
225+
assert plc.read( 40001 ) == 0 # Initial condition set above
221226

222227
assert plc.read( 1 ) == None
223228
assert plc.read( 40002 ) == None
224229
success,elapsed = waitfor( lambda: plc.read( 40002 ) is not None, "40002 polled", **wfkw )
225230
assert success
226231
assert elapsed < 1.0
227-
assert plc.read( 40002 ) == 0
232+
assert plc.read( 40002 ) == 2 # Default condition of simulator
228233
success,elapsed = waitfor( lambda: plc.read( 1 ) is not None, "00001 polled", **wfkw )
229234
assert success
230235
assert elapsed < 1.0
231-
assert plc.read( 1 ) == 0
236+
assert plc.read( 1 ) == 0 # Initial condition set above
232237

233238
# Now add a bunch of new stuff to poll, and ensure polling occurs. As we add registers the
234239
# number of distinct poll ranges will increase, and then decrease as we in-fill and the

remote/plc_modbus.py

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +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+
# The count is deduced from the size of .registers/.bits
296297
kwargs.update( dev_id=unit )
297298
if 400001 <= address <= 465536:
298299
# 400001-465536: Holding Registers
@@ -326,8 +327,8 @@ def _write( self, address, value, **kwargs ):
326327
pass
327328
if not writer:
328329
raise ParameterException( "Invalid Modbus address for write: %d" % ( address ))
329-
330-
result = self.client.execute( no_response_expected=False, request=writer( **kwargs ))
330+
request = writer( **kwargs )
331+
result = self.client.execute( no_response_expected=False, request=request )
331332
if isinstance( result, ExceptionResponse ):
332333
raise ModbusException( str( result ))
333334
assert isinstance( result, ModbusResponse ), "Unexpected non-ModbusResponse: %r" % result
@@ -346,49 +347,71 @@ def _read( self, address, count=1, **kwargs ):
346347
raise PlcOffline( "Modbus Read of PLC %s/%6d failed: Offline; Connect failure" % (
347348
self.description, address ))
348349

350+
unit = kwargs.pop( 'unit', self.unit )
351+
kwargs.update( dev_id=unit, count=count )
352+
349353
# Use address to deduce Holding/Input Register or Coil/Status.
350354
reader = None
351-
xformed = address
352355
if 400001 <= address <= 465536:
353356
reader = ReadHoldingRegistersRequest
354-
xformed -= 400001
357+
kwargs.update(
358+
address = address - 400001,
359+
)
355360
elif 300001 <= address <= 365536:
356361
reader = ReadInputRegistersRequest
357-
xformed -= 300001
362+
kwargs.update(
363+
address = address - 300001,
364+
)
358365
elif 100001 <= address <= 165536:
359366
reader = ReadDiscreteInputsRequest
360-
xformed -= 100001
367+
kwargs.update(
368+
address = address - 100001,
369+
)
361370
elif 40001 <= address <= 99999:
362371
reader = ReadHoldingRegistersRequest
363-
xformed -= 40001
372+
kwargs.update(
373+
address = address - 40001,
374+
)
364375
elif 30001 <= address <= 39999:
365376
reader = ReadInputRegistersRequest
366-
xformed -= 30001
377+
kwargs.update(
378+
address = address - 30001,
379+
)
367380
elif 10001 <= address <= 19999:
368381
reader = ReadDiscreteInputsRequest
369-
xformed -= 10001
382+
kwargs.update(
383+
address = address - 10001,
384+
)
370385
elif 1 <= address <= 9999:
371386
reader = ReadCoilsRequest
372-
xformed -= 1
387+
kwargs.update(
388+
address = address - 1,
389+
)
373390
else:
374391
# Invalid address
375392
pass
376393
if not reader:
377394
raise ParameterException( "Invalid Modbus address for read: %d" % ( address ))
378395

379-
unit = kwargs.pop( 'unit', self.unit )
380-
request = reader( address=xformed, count=count, dev_id=unit, **kwargs )
396+
request = reader( **kwargs )
381397
log.debug( "%s/%6d-%6d transformed to %s", self.description, address, address + count - 1,
382398
request )
383399

384400
result = self.client.execute( no_response_expected=False, request=request )
401+
log.debug( "%s/%6d-%6d responded w/: %s", self.description, address, address + count - 1,
402+
result )
385403
if isinstance( result, ExceptionResponse ):
386404
# The remote PLC returned a response indicating it encountered an
387405
# error processing the request. Convert it to raise a ModbusException.
388406
raise ModbusException( str( result ))
389407
assert isinstance( result, ModbusResponse ), "Unexpected non-ModbusResponse: %r" % result
390408

391-
# The result may contain .bits or .registers, 1 or more values
392-
values = result.bits if hasattr( result, 'bits' ) else result.registers
393-
return values if len( values ) > 1 else values[0]
409+
# The result may contain .bits or .registers, 1 or more values. Unfortunately, pymodbus
410+
# puts the data in .bits or .registers, and doesn't supply a method to know which, unless we
411+
# decode the protocol here. Truncate response to requested count, as bits is unpacked
412+
# from bytes, and padded w/ undefined data.
413+
values = result.registers or result.bits
414+
log.debug( "%s/%6d-%6d received: %r", self.description, address, address + count - 1,
415+
values )
416+
return values[:count] if count > 1 else values[0]
394417

0 commit comments

Comments
 (0)