Skip to content

Commit

Permalink
# This is a combination of 12 commits.
Browse files Browse the repository at this point in the history
# This is the 1st commit message:

Initial foray in to support for generic CIP Service Code requests

# This is the commit message #2:

No requirement for existence of .multiple segment in failed responses

# This is the commit message #3:

Correct handling of service_code operations in client connector I/O

# This is the commit message #4:

HART Requests almost working
o Cannot derive HART from Logix; service codes overlap

# This is the commit message #5:

Initial working HART I/O card request

# This is the commit message #6:

Support intermixed Tags and already parsed operation in parse_operations

# This is the commit message #7:

Test and decode the Read primary variable response, however:
o Still broken; the CIP Encapsulation path is still suppsed to be to the
  Connection Manager @0x06/1!  The 0x52 Route Path is Port 1, Address 2,
  and the message path should be to @0x035D/8.

# This is the commit message #8:

Success.  Still needs cleanup

# This is the commit message #9:

Further attempts to refine HART pass-thru.
o HART I/O card is not responding as defined in documentation

# This is the commit message #10:

Cleanups for python3, source analysis, unit tests

# This is the commit message #11:

Attempt to parse Read Dynamic Variables reply; 3 unrecognzied bytes?

# This is the commit message #12:

Update to attempt to parse real HART I/O card response
o Minimal Read Dynamic Variables status response?  Not successful
o Implement minimal simulated pass-thru Init/Query, HART commands 1,2,3
o Minor changes to client.py Send RR Data, to have timeout and ticks
  compatible with RSLogix; no difference
  • Loading branch information
pjkundert committed Jan 30, 2018
1 parent 1285374 commit f4901cc
Show file tree
Hide file tree
Showing 12 changed files with 1,500 additions and 81 deletions.
6 changes: 5 additions & 1 deletion README.org
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@
is required:
: <tag>=<type>[<length>] # eg. SCADA=INT[1000]

You may specifiy a CIP Class, Instance and Attribute number for the Tag to be
associated with:
: Motor_Velocity@0x93/3/10=REAL

The available types are SINT (8-bit), INT (16-bit), DINT (32-bit) integer,
and REAL (32-bit float). BOOL (8-bit, bit #0), SSTRING and STRING are also
supported.
Expand Down Expand Up @@ -781,7 +785,7 @@
'elements': 2,
'method': 'write',
'path': [{'symbolic': 'A_Tag'},{'element': 1}],
'tag_type': 202
'tag_type': 202
}]
#+END_EXAMPLE

Expand Down
5 changes: 3 additions & 2 deletions modbus_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,9 @@ def kill( self ):
else:
logging.info( "Waiting for command (PID [%d]) to terminate", self.process.pid )
self.process.wait()

logging.info("Command (PID [%d]) finished with status [%d]: %s", self.process.pid, self.process.returncode, self.command )
# Process may exit with a non-numeric returncode (eg. None)
logging.info( "Command (PID [%d]) finished with status %r: %s",
self.process.pid, self.process.returncode, self.command )

__del__ = kill

Expand Down
155 changes: 112 additions & 43 deletions server/enip/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,9 @@ def bool_validate( b ):
}

def parse_operations( tags, fragment=False, int_type=None, **kwds ):
"""
Given a sequence of tags, deduce the set of I/O desired operations, yielding each one. Any
additional keyword parameters are added to each operation (eg. route_path=[{'link':0,'port':0}])
"""Given a sequence of (string) tags, deduce the set of I/O desired operations, yielding each one.
If a dict is seen, it is passed through. Any additional keyword parameters are added to each
operation (eg. route_path = [{'link':0,'port':0}])
Parse each EtherNet/IP Tag Read or Write; only write operations will have 'data'; default
'method' is considered 'read':
Expand All @@ -181,23 +180,29 @@ def parse_operations( tags, fragment=False, int_type=None, **kwds ):
supply an element index of 0; default is no element in path, and a data value count of 1. If a
byte offset is specified, the request is forced to use Read/Write Tag Fragmented.
Default CIP int_type for int data (data with no '.' in it, by default) is CIP 'INT'.
Default CIP int_type for int data (data with no '.' in it, by default) is CIP 'INT'.
"""
if int_type is None:
int_type = 'INT'
for tag in tags:
# Compute tag (stripping val and off)
# Pass-thru (already parsed) operation?
if isinstance( tag, dict ):
tag.update( kwds )
yield tag
continue

# Compute tag (stripping val and off), discarding whitespace around tag and val/off.
val = ''
opr = {}
if '=' in tag:
# A write; strip off the values into 'val'
tag,val = tag.split( '=', 1 )
tag,val = [s.strip() for s in tag.split( '=', 1 )]
opr['method'] = 'write'

if '+' in tag:
# A byte offset (valid for Fragmented)
tag,off = tag.split( '+', 1 )
tag,off = [s.strip() for s in tag.split( '+', 1 )]
if off:
opr['offset'] = int( off )

Expand Down Expand Up @@ -280,11 +285,15 @@ class client( object ):
Provide an alternative enip.device.Message_Router Object class instead of the (default) Logix,
to parse alternative sub-dialects of EtherNet/IP.
The first client created also sets the global "device.dialect"; all connections must use the
same dialect. The default is Logix.
"""
route_path_default = enip.route_path_default
send_path_default = enip.send_path_default

def __init__( self, host, port=None, timeout=None, dialect=logix.Logix, profiler=None,
def __init__( self, host, port=None, timeout=None, dialect=None, profiler=None,
udp=False, broadcast=False ):
"""Connect to the EtherNet/IP client, waiting up to 'timeout' for a connection. Avoid using
the host OS platform default if 'host' is empty; this will be different on Mac OS-X, Linux,
Expand Down Expand Up @@ -350,10 +359,11 @@ def __init__( self, host, port=None, timeout=None, dialect=logix.Logix, profiler
self.frame = enip.enip_machine( terminal=True )
self.cip = enip.CIP( terminal=True ) # Parses a CIP request in an EtherNet/IP frame

# Ensure the requested dialect matches the globally selected dialect
# Ensure the requested dialect matches the globally selected dialect; Default to Logix
if device.dialect is None:
device.dialect = dialect
assert device.dialect is dialect, \
device.dialect = logix.Logix if dialect is None else dialect
if dialect is not None:
assert device.dialect is dialect, \
"Inconsistent EtherNet/IP dialect requested: %r (vs. default: %r)" % ( dialect, device.dialect )
# If provided, we'll disable/enable a profiler around the I/O code, to avoid corrupting the
# profile data with arbitrary I/O related delays
Expand Down Expand Up @@ -587,9 +597,57 @@ def legacy( self, command, cip=None, timeout=None, sender_context=b'' ):
If CIP is None, then no CIP payload will be generated.
"""
return self.cip_send( cip=cip)
return self.cip_send( cip=cip )

# CIP SendRRData Requests; may be deferred (eg. for Multiple Service Packet)
def service_code( self, code, path, data=None, elements=None, tag_type=None,
route_path=None, send_path=None, timeout=None, send=True,
sender_context=b'', data_size=None ): # response data_size estimation
"""Generic CIP Service Code, with path to target CIP Object, and supplied data payload (converted
to USINTs, if necessary). Minimally, we require the service, and an indication that it is a
bare Service Code request w/ no data:
data.service = 0x??
data.service_code = True
if a data payload is required, supply 'data' (and optionally a tag_type), and this will produce:
data.service = 0x??
data.service_code.data = [0, 1, 2, 3]
"""
req = cpppo.dotdict()
req.path = { 'segment': [ cpppo.dotdict( d ) for d in parse_path( path ) ]}
req.service = code
if data is None:
req.service_code = True # indicate a payload-free Service Code request
else:
# If a tag_type has been specified, then we need to convert the data to SINT/USINT.
if elements is None:
elements = len( data )
else:
assert elements == len( data ), \
"Inconsistent elements: %d doesn't match data length: %d" % ( elements, len( data ))
if tag_type not in (None,enip.SINT.tag_type,enip.USINT.tag_type):
usints = [ v for v in bytearray(
parser.typed_data.produce( data={'tag_type': tag_type, 'data': data } )) ]
log.detail( "Converted %s[%d] to USINT[%d]",
parser.typed_data.TYPES_SUPPORTED[tag_type], elements, len( usints ))
data,elements = usints,len( usints )
req.service_code = {}
req.service_code.data = data

# We always render the transmitted data payload for Service Code
req.input = bytearray()
if data is not None:
req.data = data
req.input += bytearray( parser.typed_data.produce( req, tag_type=enip.USINT.tag_type ))
if send:
self.unconnected_send(
request=req, route_path=route_path, send_path=send_path, timeout=timeout,
sender_context=sender_context )
return req

def get_attributes_all( self, path,
route_path=None, send_path=None, timeout=None, send=True,
sender_context=b'',
Expand Down Expand Up @@ -745,7 +803,7 @@ class or per-instance basis by changing the {route,send}_path_default attributes

sd = cip.send_data
sd.interface = 0
sd.timeout = 0
sd.timeout = 8 # 0 # was 0; unknown functionality...
sd.CPF = {}
sd.CPF.item = [ cpppo.dotdict(), cpppo.dotdict() ]
sd.CPF.item[0].type_id = 0
Expand All @@ -754,14 +812,15 @@ class or per-instance basis by changing the {route,send}_path_default attributes

# If a non-empty send_path or route_path is desired, we'll need to use a Logix-style service
# 0x52 Unconnected Send within the SendRRData to carry these details. Only Originating
# Devices and devices that route between links need to implement this. Otherwise, just go
# straight to the command payload.
# Devices and devices that route between links need to implement this. Otherwise, for
# simple non-routing CIP devices (eg. MicroLogix, AB PowerFlex, ...) just go straight to the
# command payload.
us = sd.CPF.item[1].unconnected_send
if send_path or route_path:
us.service = 82
us.service = 0x52 # == 82
us.status = 0
us.priority = 5
us.timeout_ticks = 157
us.timeout_ticks = 247 # 157
us.path = { 'segment': [ cpppo.dotdict( s ) for s in parse_path( send_path ) ]}
if route_path: # May be None/0/False or empty
us.route_path = { 'segment': [ cpppo.dotdict( s ) for s in route_path ]} # must be {link/port}
Expand Down Expand Up @@ -934,6 +993,7 @@ def issue( self, operations, index=0, fragment=False, multiple=0, timeout=None )
requests = [] # If we're collecting for a Multiple Service Packet
requests_paths = {} # Also, must collect all op route/send_paths
for op in operations:
op = op.copy() # We'll be altering the dict, so make a shallow copy
# Chunk up requests if using Multiple Service Request, otherwise send immediately. Also
# handle Get Attribute(s) Single/All, but don't include ...All in Multiple Service Packet.
op['sender_context']= sender_context
Expand Down Expand Up @@ -985,16 +1045,25 @@ def issue( self, operations, index=0, fragment=False, multiple=0, timeout=None )
rpyest = 0
if op.get( 'data_size' ):
rpyest += op.get( 'data_size' )
elif op.get( 'tag_type' ) and op.get( 'elements' ):
elif op.get( 'tag_type' ):
rpyest += parser.typed_data.datasize(
tag_type=op.get( 'tag_type' ) or enip.DINT.tag_type, size=op.get( 'elements', 1 ))
else:
rpyest = multiple # Completely unknown; prevent merging...
rpyest = multiple
elif method == "service_code":
req = self.service_code( timeout=timeout, send=not multiple, **op )
reqest = 1 + len( req.input ) # We've rendered the Service Request payload
rpyest = 0
if op.get( 'data_size' ): # Only explicit reply data_size is used; tag_type/element is for request
rpyest += op.get( 'data_size' )
else:
rpyest = multiple
else:
log.detail( "Unrecognized operation method %s: %r", method, op )
assert False, "Unrecognized operation method %s: %r" % ( method, op )
elapsed = cpppo.timer() - begun
descr += ' ' if 'offset' not in op else 'Frag' if op['offset'] is not None else 'Tag '
descr += ' ' + format_path( op['path'], count=op.get( 'elements' ))
if 'path' in op:
descr += ' ' + format_path( op['path'], count=op.get( 'elements' ))

if multiple:
if (( not requests or max( reqsiz + reqest, rpysiz + rpyest ) < multiple )
Expand Down Expand Up @@ -1111,23 +1180,18 @@ def collect( self, timeout=None ):
for reply in replies:
val = None
sts = reply.status # sts = # or (#,[#...])
if reply.status in (0x00,0x06): # Success or Partial Data; val is Truthy
if 'read_frag' in reply:
# Success or read w/ Partial Data; val is Truthy
if reply.status in (0x00,0x06) and 'read_frag' in reply:
val = reply.read_frag.data
elif 'read_tag' in reply:
elif reply.status in (0x00,0x06) and 'read_tag' in reply:
val = reply.read_tag.data
elif 'set_attribute_single' in reply:
val = True
elif 'get_attribute_single' in reply:
elif reply.status in (0x00,0x06) and 'get_attribute_single' in reply:
val = reply.get_attribute_single.data
elif 'get_attributes_all' in reply:
elif reply.status in (0x00,0x06) and 'get_attributes_all' in reply:
val = reply.get_attributes_all.data
elif 'write_frag' in reply:
val = True
elif 'write_tag' in reply:
val = True
else:
raise Exception( "Reply Unrecognized: %s" % ( enip.enip_format( reply )))
elif reply.status in (0x00,):
# eg. 'set_attribute_single', 'write_{tag,frag}', 'service_code', etc...
val = True
else: # Failure; val is Falsey
if 'status_ext' in reply and reply.status_ext.size:
sts = (reply.status,reply.status_ext.data)
Expand Down Expand Up @@ -1249,15 +1313,20 @@ def validate( self, harvested, printing=False ):
log.detail( "Client %s Request: %s", descr, enip.enip_format( request ))
log.detail( " Yields Reply: %s", enip.enip_format( reply ))
res = None # result of request
act = "??" # denotation of request action
act = "??" # denotation of request action; may be unrecognized (eg. service_code)
try:
# Get a symbolic "Tag" or numeric "@<class>/<inst>/<attr>" into 'tag', and optional
# element into 'elm'. Assumes the leading path.segment elements will be either
# 'symbolic' or 'class', 'instance', 'attribute', and the last may be 'element'.
tag = format_path( request.path.segment )
elm = None # scalar access
if 'element' in request.path.segment[-1]:
elm = request.path.segment[-1].element # array access
if 'path' in request:
# Get a symbolic "Tag" or numeric "@<class>/<inst>/<attr>" into 'tag', and optional
# element into 'elm'. Assumes the leading path.segment elements will be either
# 'symbolic' or 'class', 'instance', 'attribute', and the last may be 'element'.
tag = format_path( request.path.segment )
elm = None # scalar access
if 'element' in request.path.segment[-1]:
elm = request.path.segment[-1].element # array access
else:
tag = 'Service Code 0x%02X%s' % (
request.service & 0x7f, ' Reply' if request.service & 0x80 else '' )
elm = None

# The response should contain either a status code (possibly with an extended
# status), or the read_frag request's data. Remember; a successful response may
Expand Down
Loading

0 comments on commit f4901cc

Please sign in to comment.