Skip to content

Commit 73a90c4

Browse files
committed
Add support for under-specified CIP addresses
o Default Attribute=1 for C*Logix requests specifying only Class, Instance (and Element).
1 parent a14dc5b commit 73a90c4

File tree

4 files changed

+86
-9
lines changed

4 files changed

+86
-9
lines changed

server/enip/device.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -201,18 +201,28 @@ def resolve( path, attribute=False ):
201201
Other valid paths segments (eg. {'port':...}, {'connection':...}) are not presently usable in
202202
our Controller communication simulation.
203203
204-
Call with attribute=True to force resolving to the Attribute level; otherwise, always returns
205-
None for the attribute.
204+
Call with attribute=<Truthy> to force resolving to the Attribute level; otherwise, always returns
205+
None for the attribute. If an attribute is required, but is not supplied in the path, then we
206+
can default one. This is an (unexpected) feature of the C*Logix PLCs; you can do a Read Tag
207+
Fragmented asking for eg. @0x6b/0x0008[0] (a Class, Instance and Element but no Attribute!).
208+
Presumably there is an implied default Attribute number; we're guessing it's 1? Undocumented.
206209
207210
"""
208211

209212
result = { 'class': None, 'instance': None, 'attribute': None }
210213
tag = '' # developing symbolic tag "Symbol.Subsymbol"
211214

212215
for term in path['segment']:
213-
if ( result['class'] is not None and result['instance'] is not None
214-
and ( not attribute or result['attribute'] is not None )):
215-
break # All desired terms specified; done! (ie. ignore 'element')
216+
if ( result['class'] is not None # Got Class already
217+
and result['instance'] is not None # Got Instance already
218+
and (
219+
result['attribute'] is not None # Got Attribute already
220+
or not attribute # or no Attribute desired (must return None)
221+
or ( attribute is not True # or a default attribute is supplied
222+
and 'attribute' not in term ) # and the term didn't contain a supplied one
223+
)
224+
):
225+
break # All desired terms specified; done! (ie. ignore subsequent 'element')
216226
working = dict( term )
217227
while working:
218228
# Each term is something like {'class':5}, {'instance':1}, or (from symbol table):
@@ -240,6 +250,11 @@ def resolve( path, attribute=False ):
240250
assert not tag, \
241251
"Unrecognized symbolic name %r found in path %r" % ( tag, path['segment'] )
242252

253+
# Handle the case where a default Attribute value was provided, and none was supplied
254+
if result['attribute'] is None and attribute and attribute is not True:
255+
assert isinstance( attribute, int )
256+
result['attribute'] = attribute
257+
# Make sure we got everything we required
243258
assert ( result['class'] is not None and result['instance'] is not None
244259
and ( not attribute or result['attribute'] is not None )), \
245260
"Failed to resolve required Class (%r), Instance (%r) %s Attribute(%r) from path: %r" % (

server/enip/logix.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ def request( self, data, addr=None ):
316316
# We need to find the attribute for all requests, and it better be ours!
317317
data.status = 0x05 # On Failure: Request Path destination unknown
318318
data.status_ext = {'size': 1, 'data':[0x0000]}
319-
clid, inid, atid = resolve( data.path, attribute=True )
319+
clid, inid, atid = resolve( data.path, attribute=1 ) # eg. @<cls>/<ins>[<elm>] defaults to Attribute 1!
320320
attribute = lookup( clid, inid, atid )
321321
assert clid == self.class_id and inid == self.instance_id, \
322322
"Path %r processed by wrong Object %r" % ( data.path['segment'], self )
@@ -697,7 +697,7 @@ def setup_tag( key, val ):
697697
# doesn't exist. Then, find the Attribute, ensuring it is consistent if it exists.
698698
cls,ins,att = 0x02,1,None # The (Logix?) Message Router, by default
699699
if 'path' in val and val['path']:
700-
cls,ins,att = resolve( val['path'], attribute=True )
700+
cls,ins,att = resolve( val['path'], attribute=True ) # No default Attribute for new Tags
701701
# See if the tag's Instance exists. If not, we'll need to create it. If the Class'
702702
# "meta" Instance exists, we'll use it to create the Instance (its always at
703703
# Instance 0). Otherwise, we'll create an Object class with the appropriate

server/enip/parser.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -684,7 +684,7 @@ def enip_format( data, sort_keys=False, indent=4 ):
684684
beg,end = 'bytearray(',')'
685685
else:
686686
beg,end = 'bytes(',')'
687-
result += "{beg}hexload('''".format( beg=beg )
687+
result += "{beg}hexload(r'''".format( beg=beg )
688688
result += ''.join( newline + prefix + row for row in misc.hexdumper( val ))
689689
result += newline + "'''){end},".format( end=end )
690690
continue

server/enip_test.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
logging.basicConfig( **log_cfg )
3838

3939
import cpppo
40-
from cpppo.misc import hexdump
40+
from cpppo.misc import hexdump, hexload
4141
from cpppo.server import network, enip
4242
from cpppo.server.enip import parser, device, logix, client, pccc
4343
from cpppo.server.enip.main import main as enip_main
@@ -743,6 +743,30 @@ def test_enip_TYPES_STRUCT():
743743
0x00, 0x00, 0x80, 0x3e, 0x00, 0x00, 0xf0, 0x55, 0x00, 0x00, 0xf0, 0x55, 0x00, 0x00, 0xf0, 0x55,
744744
0x00, 0x00, 0x00, 0x00, 0xe0, 0x40, 0x00, 0x00, 0x20, 0x41, 0x00, 0x00, 0x20, 0x41,
745745
]))
746+
747+
# A Symbolic unconnected_send.path is supplied:
748+
#
749+
# 'enip.CIP.send_data.CPF.item[1].unconnected_send.service': 82,
750+
# 'enip.CIP.send_data.CPF.item[1].unconnected_send.priority': 104,
751+
# 'enip.CIP.send_data.CPF.item[1].unconnected_send.path.size': 13,
752+
# 'enip.CIP.send_data.CPF.item[1].unconnected_send.path.segment[0].symbolic': 'GasGuardSensorDATAARRAY',
753+
#
754+
# instead of the Connection Manager @6/1. This is a PLC misconfiguration, and isn't a valid request.
755+
#
756+
# "CPF.item[1].unconnected_send.service": 82,
757+
# "CPF.item[1].unconnected_send.priority": 5,
758+
# "CPF.item[1].unconnected_send.path.size": 2,
759+
# "CPF.item[1].unconnected_send.path.segment[0].class": 6,
760+
# "CPF.item[1].unconnected_send.path.segment[1].instance": 1,
761+
rfg_gg1_req = bytes(bytearray([
762+
0x6f, 0x00, 0x32, 0x00, 0x73, 0x6b, 0x0d, 0x31, 0x00, 0x00, 0x00, 0x00,
763+
0xc0, 0xa8, 0x12, 0x02, 0x00, 0x00, 0x5c, 0x7a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
764+
0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0xb2, 0x00, 0x22, 0x00, 0x52, 0x0d, 0x91, 0x17,
765+
0x47, 0x61, 0x73, 0x47, 0x75, 0x61, 0x72, 0x64, 0x53, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x44, 0x41,
766+
0x54, 0x41, 0x41, 0x52, 0x52, 0x41, 0x59, 0x00, 0x68, 0x01, 0x00, 0x00, 0x00, 0x00,
767+
]))
768+
769+
746770
eip_tests = [
747771
( b'', {} ), # test that parsers handle/reject empty/EOF
748772
( rss_004_request, { 'enip.command': 0x0065, 'enip.length': 4 }),
@@ -770,6 +794,7 @@ def test_enip_TYPES_STRUCT():
770794
( msp_001_reply, {} ),
771795
( rfg_gg0_req, {} ),
772796
( rfg_gg0_rpy, {} ),
797+
( rfg_gg1_req, {} ),
773798
]
774799

775800
def test_enip_header():
@@ -1847,7 +1872,21 @@ def test_enip_CPF():
18471872
(
18481873
# An empty request (usually indicates termination of session)
18491874
'empty_req', enip.Message_Router, {}
1875+
# ), (
1876+
# # Not a valid unconnected_send.path (symbolic tag name, instead of @6/1); can't reconstruct...
1877+
# 'rfg_gg1_req', logix.Logix,
1878+
# {
1879+
# 'enip.session_handle': 822963059,
1880+
# 'enip.sender_context.input': array.array( cpppo.type_bytes_array_symbol, hexload(r'''
1881+
# 00000000: c0 a8 12 02 00 00 5c 7a |......\z|
1882+
# ''')),
1883+
# "enip.options": 0,
1884+
# }
18501885
), (
1886+
# This is a strange request; it's a Class/Instance request path of @0x006b/0x0008
1887+
# with a 16-bit Instance number (eg. vs. an 8-bit), and NO Attribute number, and an Element #0. It must assume that the PLC defaults
1888+
# to a certain Attribute number? This is a Read Tag Fragmented request for a UDT.
1889+
# We must be able to construct such numeric CIP addresses, eg. "@0x0086/0x0008[0]"?
18511890
'rfg_gg0_req', logix.Logix,
18521891
{
18531892
"enip.session_handle": 369295182,
@@ -1873,6 +1912,8 @@ def test_enip_CPF():
18731912
"enip.CIP.send_data.CPF.count": 2,
18741913
}
18751914
), (
1915+
# Here's the response of partial data from the UDT, to the above request. We don't know
1916+
# the Attribute number, of course, but the PLC replies.
18761917
'rfg_gg0_rpy', logix.Logix,
18771918
{
18781919
"enip.command": 112,
@@ -3481,6 +3522,27 @@ def test_enip_device_symbolic():
34813522
}
34823523
assert enip.device.resolve( path, attribute=True ) == (0x401,1,3)
34833524

3525+
# Attribute defaults for CIP addressing; default not used
3526+
path = {
3527+
'segment':[{'class': 0x6B}, {'instance': 0x0008}, {'attribute':99}, {'element':4}]
3528+
}
3529+
assert enip.device.resolve( path, attribute=True ) == (0x6B,8,99)
3530+
assert enip.device.resolve( path ) == (0x6B,8,None)
3531+
3532+
# Attribute defaults for CIP addressing; no attribute in path, default used
3533+
path = {
3534+
'segment':[{'class': 0x6B}, {'instance': 0x0008}, {'element':4}]
3535+
}
3536+
try:
3537+
result = enip.device.resolve( path, attribute=True )
3538+
assert False, "Should not have succeeded: %r" % result
3539+
except AssertionError as exc:
3540+
assert "Invalid term" in str(exc)
3541+
assert enip.device.resolve( path ) == (0x6B,8,None)
3542+
assert enip.device.resolve( path, attribute=22 ) == (0x6B,8,22)
3543+
assert enip.device.resolve( path, attribute=1 ) == (0x6B,8,1)
3544+
3545+
# Erroneous requests
34843546
try:
34853547
result = enip.device.resolve(
34863548
{'segment':[{'class':5},{'symbolic':'SCADA'},{'element':4}]} )

0 commit comments

Comments
 (0)