def logix_performance(repeat=1000): """Characterize the performance of the logix module, parsing and processing a large request, and then parsing the reply. No network I/O is involved. """ enip.lookup_reset() # Flush out any existing CIP Objects for a fresh start Obj = logix.Logix(instance_id=1) size = 1000 Obj_a1 = Obj.attribute['1'] = enip.device.Attribute( 'Something', enip.parser.INT, default=[n for n in range(size)]) assert len(Obj_a1) == size # Set up a symbolic tag referencing the Logix Object's Attribute enip.device.symbol['SCADA'] = { 'class': Obj.class_id, 'instance': Obj.instance_id, 'attribute': 1 } # Lets get it to parse a request, resulting in a 200 element response: # 'service': 0x52, # 'path.segment': [{'symbolic': 'SCADA', 'length': 5}], # 'read_frag.elements': 201, # 'read_frag.offset': 2, req_1 = bytes( bytearray([ 0x52, 0x04, 0x91, 0x05, 0x53, 0x43, 0x41, 0x44, #/* R...SCAD */ 0x41, 0x00, 0xC9, 0x00, 0x02, 0x00, 0x00, 0x00, #/* A....... */ ])) proc, req_data, rpy_data = False, None, None while repeat > 0: proc, req_data, rpy_data = logix_test_once(Obj, req_1) repeat -= 1 assert rpy_data.status == 0 assert len(rpy_data.read_frag.data) == 200 assert rpy_data.read_frag.data[0] == 1 assert rpy_data.read_frag.data[-1] == 200
def test_logix_remote_pylogix( count=100 ): """Performance of pylogix executing an operation a number of times on a socket connected Logix simulator, within the same Python interpreter (ie. all on a single CPU thread). Only connects on the standard port. """ #logging.getLogger().setLevel( logging.NORMAL ) enip.lookup_reset() # Flush out any existing CIP Objects for a fresh start svraddr = ('localhost', 44828) kwargs = { 'argv': [ #'-v', #'--log', '/tmp/pylogix.log', #'--profile', '/tmp/plogix.prof', '--address', '%s:%d' % svraddr, 'SCADA=INT[1000]' ], 'server': { 'control': cpppo.apidict( enip.timeout, { 'done': False } ), }, } logixthread_kwargs = { 'count': count, 'svraddr': svraddr, 'kwargs': kwargs } log.normal( "test_logix_remote_pylogix w/ server.control in object %s", id( kwargs['server']['control'] )) # This is sort of "inside-out". This thread will run logix_remote, which will signal the # enip_main (via the kwargs.server...) to shut down. However, to do line-based performance # measurement, we need to be running enip.main in the "Main" thread... logixthread = threading.Thread( target=logix_remote_pylogix, kwargs=logixthread_kwargs ) logixthread.daemon = True logixthread.start() try: enip_main( **kwargs ) finally: kwargs['server']['control'].done = True # Signal the server to terminate logixthread.join() log.normal( "Shutdown of server complete" )
def test_logix_remote( count=50 ): """Performance of executing an operation a number of times on a socket connected Logix simulator, within the same Python interpreter (ie. all on a single CPU thread). """ #logging.getLogger().setLevel( logging.NORMAL ) enip.lookup_reset() # Flush out any existing CIP Objects for a fresh start svraddr = ('localhost', 12345) kwargs = { 'argv': [ #'-v', #'--log', '/tmp/logix.log', #'--profile', '/tmp/logix.prof', '--address', '%s:%d' % svraddr, 'SCADA=INT[1000]' ], 'server': { 'control': cpppo.apidict( enip.timeout, { 'done': False } ), }, } logixthread_kwargs = { 'count': count, 'svraddr': svraddr, 'kwargs': kwargs } log.normal( "test_logix_remote w/ server.control in object %s", id( kwargs['server']['control'] )) # This is sort of "inside-out". This thread will run logix_remote, which will signal the # enip.main (via the kwargs.server...) to shut down. However, to do line-based performance # measurement, we need to be running enip.main in the "Main" thread... logixthread = threading.Thread( target=logix_remote, kwargs=logixthread_kwargs ) logixthread.daemon = True logixthread.start() enip.main( **kwargs ) logixthread.join()
def logix_performance( repeat=1000 ): """Characterize the performance of the logix module, parsing and processing a large request, and then parsing the reply. No network I/O is involved. """ enip.lookup_reset() # Flush out any existing CIP Objects for a fresh start Obj = logix.Logix( instance_id=1 ) size = 1000 Obj_a1 = Obj.attribute['1'] = enip.device.Attribute( 'Something', enip.parser.INT, default=[n for n in range( size )]) assert len( Obj_a1 ) == size # Set up a symbolic tag referencing the Logix Object's Attribute enip.device.symbol['SCADA'] = {'class': Obj.class_id, 'instance': Obj.instance_id, 'attribute':1 } # Lets get it to parse a request, resulting in a 200 element response: # 'service': 0x52, # 'path.segment': [{'symbolic': 'SCADA', 'length': 5}], # 'read_frag.elements': 201, # 'read_frag.offset': 2, req_1 = bytes(bytearray([ 0x52, 0x04, 0x91, 0x05, 0x53, 0x43, 0x41, 0x44, #/* R...SCAD */ 0x41, 0x00, 0xC9, 0x00, 0x02, 0x00, 0x00, 0x00, #/* A....... */ ])) proc,req_data,rpy_data = False,None,None while repeat > 0: proc,req_data,rpy_data = logix_test_once( Obj, req_1 ) repeat -= 1 assert rpy_data.status == 0 assert len( rpy_data.read_frag.data ) == 200 assert rpy_data.read_frag.data[ 0] == 1 assert rpy_data.read_frag.data[-1] == 200
def test_logix_multiple(): """Test the Multiple Request Service. Ensure multiple requests can be successfully handled, and invalid tags are correctly rejected. The Logix is a Message_Router instance, and is expected to be at Class 2, Instance 1. Eject any non-Logix Message_Router that presently exist. """ enip.lookup_reset() # Flush out any existing CIP Objects for a fresh start Obj = logix.Logix(instance_id=1) # Create some Attributes to test, but mask the big ones from Get Attributes All. size = 1000 Obj_a1 = Obj.attribute['1'] = enip.device.Attribute( 'parts', enip.parser.DINT, default=[n for n in range(size)], mask=enip.device.Attribute.MASK_GA_ALL) Obj_a2 = Obj.attribute['2'] = enip.device.Attribute('ControlWord', enip.parser.DINT, default=[0, 0]) Obj_a3 = Obj.attribute['3'] = enip.device.Attribute( 'SCADA_40001', enip.parser.INT, default=[n for n in range(size)], mask=enip.device.Attribute.MASK_GA_ALL) Obj_a4 = Obj.attribute['4'] = enip.device.Attribute('number', enip.parser.REAL, default=0.0) # Set up a symbolic tag referencing the Logix Object's Attribute enip.device.symbol['parts'] = { 'class': Obj.class_id, 'instance': Obj.instance_id, 'attribute': 1 } enip.device.symbol['ControlWord'] \ = {'class': Obj.class_id, 'instance': Obj.instance_id, 'attribute':2 } enip.device.symbol['SCADA_40001'] \ = {'class': Obj.class_id, 'instance': Obj.instance_id, 'attribute':3 } enip.device.symbol['number'] \ = {'class': Obj.class_id, 'instance': Obj.instance_id, 'attribute':4 } assert len(Obj_a1) == size assert len(Obj_a3) == size assert len(Obj_a4) == 1 Obj_a1[0] = 42 Obj_a2[0] = 476 Obj_a4[0] = 1.0 # Ensure that the basic CIP Object requests work on a derived Class. for description, original, produced, parsed, result, response in GA_tests: request = cpppo.dotdict(original) log.warning("%s; request: %s", description, enip.enip_format(request)) encoded = Obj.produce(request) assert encoded == produced, "%s: Didn't produce correct encoded request: %r != %r" % ( description, encoded, produced) # Now, use the Message_Router's parser to decode the encoded bytes source = cpppo.rememberable(encoded) decoded = cpppo.dotdict() with Obj.parser as machine: for m, s in enumerate(machine.run(source=source, data=decoded)): pass for k, v in cpppo.dotdict(parsed).items(): assert decoded[ k] == v, "%s: Didn't parse expected value: %s != %r in %s" % ( description, k, v, enip.enip_format(decoded)) # Process the request into a reply, and ensure we get the expected result (some Attributes # are filtered from Get Attributes All; only a 2-element DINT array and a single REAL should # be produced) Obj.request(request) logging.warning("%s: reply: %s", description, enip.enip_format(request)) for k, v in cpppo.dotdict(result).items(): assert k in request and request[k] == v, \ "%s: Didn't result in expected response: %s != %r; got %r" % ( description, k, v, request[k] if k in request else "(not found)" ) # Finally, produce the encoded response encoded = Obj.produce(request) assert encoded == response, "%s: Didn't produce correct encoded response: %r != %r" % ( description, encoded, response) # Test that we correctly compute beg,end,endactual for various Read Tag Fragmented scenarios, # with 2-byte and 4-byte types. For the purposes of this test, we only look at path...elements. data = cpppo.dotdict() data.service = Obj.RD_FRG_RPY data.path = { 'segment': [cpppo.dotdict(d) for d in [ { 'element': 0 }, ]] } data.read_frag = {} data.read_frag.elements = 1000 data.read_frag.offset = 0 # Reply maximum size limited beg, end, endactual = Obj.reply_elements(Obj_a1, data, 'read_frag') assert beg == 0 and end == 125 and endactual == 1000 # DINT == 4 bytes beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'read_frag') assert beg == 0 and end == 250 and endactual == 1000 # INT == 2 bytes data.read_frag.offset = 125 * 4 # OK, second request; begin after byte offset of first beg, end, endactual = Obj.reply_elements(Obj_a1, data, 'read_frag') assert beg == 125 and end == 250 and endactual == 1000 # DINT == 4 bytes # Request elements limited; 0 offset data.read_frag.elements = 30 data.read_frag.offset = 0 beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'read_frag') assert beg == 0 and end == 30 and endactual == 30 # INT == 2 bytes # Request elements limited; +'ve offset data.read_frag.elements = 70 data.read_frag.offset = 80 beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'read_frag') assert beg == 40 and end == 70 and endactual == 70 # INT == 2 bytes # Request limited by size of data provided (Write Tag [Fragmented]) data = cpppo.dotdict() data.service = Obj.WR_FRG_RPY data.path = { 'segment': [cpppo.dotdict(d) for d in [ { 'element': 0 }, ]] } data.write_frag = {} data.write_frag.data = [0] * 100 # 100 elements provided in this request data.write_frag.elements = 200 # Total request is to write 200 elements data.write_frag.offset = 16 # request starts 16 bytes in (8 INTs) beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'write_frag') assert beg == 8 and end == 108 and endactual == 200 # INT == 2 bytes # ... same, but lets say request started somewhere in the middle of the array data.path = { 'segment': [cpppo.dotdict(d) for d in [ { 'element': 222 }, ]] } beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'write_frag') assert beg == 8 + 222 and end == 108 + 222 and endactual == 200 + 222 # INT == 2 bytes # Ensure correct computation of (beg,end] that are byte-offset and data/size limited data = cpppo.dotdict() data.service = Obj.WR_FRG_RPY data.path = {'segment': []} data.write_frag = {} data.write_frag.data = [3, 4, 5, 6] data.write_frag.offset = 6 beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'write_frag') assert beg == 3 and end == 7 and endactual == 1000 # INT == 2 bytes # Trigger the error cases only accessible via write # Too many elements provided for attribute capacity data.write_frag.offset = (1000 - 3) * 2 try: beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'write_frag') assert False, "Should have raised Exception due to capacity" except Exception as exc: assert "capacity exceeded" in str(exc) data = cpppo.dotdict() data.service = Obj.RD_FRG_RPY data.path = {'segment': []} data.read_frag = {} data.read_frag.offset = 6 beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'read_frag') assert beg == 3 and end == 253 and endactual == 1000 # INT == 2 bytes # And we should be able to read with an offset right up to the last element data.read_frag.offset = 1998 beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'read_frag') assert beg == 999 and end == 1000 and endactual == 1000 # INT == 2 bytes # Trigger all the remaining error cases # Unknown service data.service = Obj.RD_FRG_REQ try: beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'read_frag') assert False, "Should have raised Exception due to service" except Exception as exc: assert "unknown service" in str(exc) # Offset indivisible by element size data.service = Obj.RD_FRG_RPY data.read_frag.offset = 7 try: beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'read_frag') assert False, "Should have raised Exception due to odd byte offset" except Exception as exc: assert "element boundary" in str(exc) # Initial element outside bounds data.read_frag.offset = 2000 try: beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'read_frag') assert False, "Should have raised Exception due to initial element" except Exception as exc: assert "initial element invalid" in str(exc) # Ending element outside bounds data.read_frag.offset = 0 data.read_frag.elements = 1001 try: beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'read_frag') assert False, "Should have raised Exception due to ending element" except Exception as exc: assert "ending element invalid" in str(exc) # Beginning element after ending (should be no way to trigger). This request doesn't specify an # element in the path, hence defaults to element 0, and asks for a number of elements == 2. # Thus, there is no 6-byte offset possible (a 2-byte offset is, though). data.read_frag.offset = 6 data.read_frag.elements = 2 try: beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'read_frag') assert False, "Should have raised Exception due to ending element order" except Exception as exc: assert "ending element before beginning" in str(exc) data.read_frag.offset = 2 data.read_frag.elements = 2 beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'read_frag') assert beg == 1 and end == 2 and endactual == 2 # INT == 2 bytes # Test an example valid multiple request data = cpppo.dotdict() data.multiple = {} data.multiple.request = [ cpppo.dotdict(), cpppo.dotdict(), cpppo.dotdict(), cpppo.dotdict(), cpppo.dotdict() ] req = data.multiple.request req[0].path = { 'segment': [cpppo.dotdict(d) for d in [{ 'symbolic': 'parts' }]] } req[0].read_tag = {} req[0].read_tag.elements = 1 req[1].path = { 'segment': [cpppo.dotdict(d) for d in [{ 'symbolic': 'ControlWord' }]] } req[1].read_tag = {} req[1].read_tag.elements = 1 req[2].path = { 'segment': [cpppo.dotdict(d) for d in [{ 'symbolic': 'number' }]] } req[2].read_tag = {} req[2].read_tag.elements = 1 req[3].path = { 'segment': [cpppo.dotdict(d) for d in [{ 'symbolic': 'number' }]] } req[3].write_tag = {} req[3].write_tag.elements = 1 req[3].write_tag.type = 0x00ca req[3].write_tag.data = [1.25] req[4].path = { 'segment': [cpppo.dotdict(d) for d in [{ 'symbolic': 'number' }]] } req[4].read_tag = {} req[4].read_tag.elements = 1 request = Obj.produce(data) req_1 = bytes( bytearray([ 0x0A, 0x02, 0x20, 0x02, 0x24, 0x01, 0x05, 0x00, 0x0c, 0x00, 0x18, 0x00, 0x2a, 0x00, 0x36, 0x00, 0x48, 0x00, 0x4C, 0x04, 0x91, 0x05, 0x70, 0x61, 0x72, 0x74, 0x73, 0x00, 0x01, 0x00, 0x4C, 0x07, 0x91, 0x0B, 0x43, 0x6F, 0x6E, 0x74, 0x72, 0x6F, 0x6C, 0x57, 0x6F, 0x72, 0x64, 0x00, 0x01, 0x00, b'L'[0], 0x04, 0x91, 0x06, b'n'[0], b'u'[0], b'm'[0], b'b'[0], b'e'[0], b'r'[0], 0x01, 0x00, b'M'[0], 0x04, 0x91, 0x06, b'n'[0], b'u'[0], b'm'[0], b'b'[0], b'e'[0], b'r'[0], 0xca, 0x00, 0x01, 0x00, 0x00, 0x00, 0xa0, 0x3f, b'L'[0], 0x04, 0x91, 0x06, b'n'[0], b'u'[0], b'm'[0], b'b'[0], b'e'[0], b'r'[0], 0x01, 0x00, ])) assert request == req_1, \ "Unexpected result from Multiple Request Service; got: \n%r\nvs.\n%r " % ( request, req_1 ) # Now, use the Message_Router's parser source = cpppo.rememberable(request) data = cpppo.dotdict() with Obj.parser as machine: for i, (m, s) in enumerate(machine.run(source=source, data=data)): pass log.normal("Multiple Request: %s", enip.enip_format(data)) assert 'multiple' in data, \ "No parsed multiple found in data: %s" % enip.enip_format( data ) assert data.service == enip.device.Message_Router.MULTIPLE_REQ, \ "Expected a Multiple Request Service request: %s" % enip.enip_format( data ) assert data.multiple.number == 5, \ "Expected 5 requests in request.multiple: %s" % enip.enip_format( data ) # And ensure if we re-encode the parsed result, we get the original encoded request back assert Obj.produce(data) == req_1 # Process the request into a reply. Obj.request(data) log.normal("Multiple Response: %s", enip.enip_format(data)) assert data.service == enip.device.Message_Router.MULTIPLE_RPY, \ "Expected a Multiple Request Service reply: %s" % enip.enip_format( data ) rpy_1 = bytearray([ 0x8A, 0x00, 0x00, 0x00, 0x05, 0x00, 0x0c, 0x00, 0x16, 0x00, 0x20, 0x00, 0x2a, 0x00, 0x2e, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xC4, 0x00, 0x2A, 0x00, 0x00, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xC4, 0x00, 0xDC, 0x01, 0x00, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xCA, 0x00, 0x00, 0x00, 0x80, 0x3F, 0xcd, 0x00, 0x00, 0x00, 0xcc, 0x00, 0x00, 0x00, 0xca, 0x00, 0x00, 0x00, 0xa0, 0x3f, ]) assert data.input == rpy_1, \ "Unexpected reply from Multiple Request Service request; got: \n%r\nvs.\n%r " % ( data.input, rpy_1 ) # Now lets try some valid and invalid requests data = cpppo.dotdict() data.multiple = {} data.multiple.request = req = [cpppo.dotdict()] req[0].path = { 'segment': [cpppo.dotdict(d) for d in [{ 'symbolic': 'SCADA_40001' }]] } req[0].read_tag = {} req[0].read_tag.elements = 1 data.multiple.number = len(data.multiple.request) request = Obj.produce(data) req_good = bytearray([ 0x0A, 0x02, 0x20, 0x02, ord('$'), 0x01, 0x01, 0x00, 0x04, 0x00, 0x4C, 0x07, 0x91, 0x0b, ord('S'), ord('C'), ord('A'), ord('D'), ord('A'), ord('_'), ord('4'), ord('0'), ord('0'), ord('0'), ord('1'), 0x00, 0x01, 0x00, ]) assert request == req_good, \ "Unexpected result from Multiple Request Service request for SCADA_40001; got: \n%r\nvs.\n%r " % ( request, req_good ) Obj.request(data) rpy_good = bytearray([ 0x8A, 0x00, 0x00, 0x00, 0x01, 0x00, 0x04, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xC3, 0x00, 0x00, 0x00, ]) assert data.input == rpy_good, \ "Unexpected reply from Multiple Request Service request for SCADA_40001; got: \n%r\nvs.\n%r " % ( data.input, rpy_good ) # Add an invalid request data = cpppo.dotdict() data.multiple = {} data.multiple.request = req = [cpppo.dotdict(), cpppo.dotdict()] req[0].path = { 'segment': [cpppo.dotdict(d) for d in [{ 'symbolic': 'SCADA_40001' }]] } req[0].read_tag = {} req[0].read_tag.elements = 1 req[1].path = { 'segment': [cpppo.dotdict(d) for d in [{ 'symbolic': 'SCADA_40002' }]] } req[1].read_tag = {} req[1].read_tag.elements = 1 data.multiple.number = len(data.multiple.request) request = Obj.produce(data) req_bad = bytearray([ 0x0A, 0x02, 0x20, 0x02, ord('$'), 0x01, 0x02, 0x00, 0x06, 0x00, 0x18, 0x00, 0x4C, 0x07, 0x91, 0x0b, ord('S'), ord('C'), ord('A'), ord('D'), ord('A'), ord('_'), ord('4'), ord('0'), ord('0'), ord('0'), ord('1'), 0x00, 0x01, 0x00, 0x4C, 0x07, 0x91, 0x0b, ord('S'), ord('C'), ord('A'), ord('D'), ord('A'), ord('_'), ord('4'), ord('0'), ord('0'), ord('0'), ord('2'), 0x00, 0x01, 0x00, ]) assert request == req_bad, \ "Unexpected result from Multiple Request Service request for SCADA_40001/2; got: \n%r\nvs.\n%r " % ( request, req_bad ) Obj.request(data) rpy_bad = bytearray([ 0x8A, 0x00, 0x00, 0x00, 0x02, 0x00, 0x06, 0x00, 0x0e, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xC3, 0x00, 0x00, 0x00, 0xCC, 0x00, 0x05, 0x01, # Status code 0x05 (invalid path) 0x00, 0x00, ]) assert data.input == rpy_bad, \ "Unexpected reply from Multiple Request Service request for SCADA_40001/2; got: \n%r\nvs.\n%r " % ( data.input, rpy_bad )
def test_CIP_HART(repeat=1): """HART protocol enip CIP messages """ enip.lookup_reset() # Flush out any existing CIP Objects for a fresh start ENIP = enip.enip_machine(context='enip') CIP = enip.CIP() # We'll use a HART Message Router, to handle its expanded porfolio of commands MR = HART(instance_id=1) for pkt, tst in client.recycle(CIP_HART_tests, times=repeat): # Parse just the CIP portion following the EtherNet/IP encapsulation header data = cpppo.dotdict() source = cpppo.chainable(pkt) with ENIP as machine: for i, (m, s) in enumerate(machine.run(source=source, data=data)): log.detail("%s #%3d -> %10.10s; next byte %3d: %-10.10r: %r", machine.name_centered(), i, s, source.sent, source.peek(), data) # In a real protocol implementation, an empty header (EOF with no input at all) is # acceptable; it indicates a session closed by the client. if not data: log.normal("EtherNet/IP Request: Empty (session terminated): %s", enip.enip_format(data)) continue if log.isEnabledFor(logging.NORMAL): log.normal("EtherNet/IP Request: %s", enip.enip_format(data)) # Parse the encapsulated .input with CIP as machine: for i, (m, s) in enumerate( machine.run(path='enip', source=cpppo.peekable( data.enip.get('input', b'')), data=data)): log.detail("%s #%3d -> %10.10s; next byte %3d: %-10.10r: %r", machine.name_centered(), i, s, source.sent, source.peek(), data) if log.isEnabledFor(logging.NORMAL): log.normal("EtherNet/IP CIP Request: %s", enip.enip_format(data)) # Assume the request in the CIP's CPF items are HART requests. # Now, parse the encapsulated message(s). We'll assume it is destined for a HART Object. if 'enip.CIP.send_data' in data: for item in data.enip.CIP.send_data.CPF.item: if 'unconnected_send.request' in item: # An Unconnected Send that contained an encapsulated request (ie. not just a Get # Attribute All) with MR.parser as machine: if log.isEnabledFor(logging.NORMAL): log.normal( "Parsing %3d bytes using %s.parser, from %s", len(item.unconnected_send.request.input), MR, enip.enip_format(item)) # Parse the unconnected_send.request.input octets, putting parsed items into the # same request context for i, (m, s) in enumerate( machine.run( source=cpppo.peekable( item.unconnected_send.request.input), data=item.unconnected_send.request)): log.detail( "%s #%3d -> %10.10s; next byte %3d: %-10.10r: %r", machine.name_centered(), i, s, source.sent, source.peek(), data) # Post-processing of some parsed items is only performed after lock released! if log.isEnabledFor(logging.NORMAL): log.normal( "Parsed %3d bytes using %s.parser, into %s", len(item.unconnected_send.request.input), MR, enip.enip_format(data)) try: for k, v in tst.items(): assert data[k] == v, ("data[%r] == %r\n" "expected: %r" % (k, data[k], v)) except: log.warning("%r not in data, or != %r: %s", k, v, enip.enip_format(data)) raise # Ensure that we can get the original EtherNet/IP CIP back for k in list(data.keys()): if k.endswith('input') and 'sender_context' not in k: log.detail("del data[%r]", k) del data[k] try: # First reconstruct any SendRRData CPF items, containing encapsulated requests/responses if 'enip.CIP.send_data' in data: cpf = data.enip.CIP.send_data for item in cpf.CPF.item: if 'unconnected_send' in item: item.unconnected_send.request.input = bytearray( MR.produce(item.unconnected_send.request)) log.normal("Produce HART message from: %r", item.unconnected_send.request) # Next, reconstruct the CIP Register, ListIdentity, ListServices, or SendRRData. The CIP.produce must # be provided the EtherNet/IP header, because it contains data (such as .command) # relevant to interpreting the .CIP... contents. data.enip.input = bytearray(enip.CIP.produce(data.enip)) # And finally the EtherNet/IP encapsulation itself data.input = bytearray(enip.enip_encode(data.enip)) log.detail("EtherNet/IP CIP Request produced payload: %r", bytes(data.input)) assert data.input == pkt, "original:\n" + hexdump( pkt) + "\nproduced:\n" + hexdump(data.input) except Exception as exc: log.warning( "Exception %s; Invalid packet produced from EtherNet/IP CIP data: %s", exc, enip.enip_format(data)) raise
def test_logix_multiple(): """Test the Multiple Request Service. Ensure multiple requests can be successfully handled, and invalid tags are correctly rejected. The Logix is a Message_Router instance, and is expected to be at Class 2, Instance 1. Eject any non-Logix Message_Router that presently exist. """ enip.lookup_reset() # Flush out any existing CIP Objects for a fresh start Obj = logix.Logix( instance_id=1 ) # Create some Attributes to test, but mask the big ones from Get Attributes All. size = 1000 Obj_a1 = Obj.attribute['1'] = enip.device.Attribute( 'parts', enip.parser.DINT, default=[n for n in range( size )], mask=enip.device.Attribute.MASK_GA_ALL ) Obj_a2 = Obj.attribute['2'] = enip.device.Attribute( 'ControlWord', enip.parser.DINT, default=[0,0]) Obj_a3 = Obj.attribute['3'] = enip.device.Attribute( 'SCADA_40001', enip.parser.INT, default=[n for n in range( size )], mask=enip.device.Attribute.MASK_GA_ALL ) Obj_a4 = Obj.attribute['4'] = enip.device.Attribute( 'number', enip.parser.REAL, default=0.0) # Set up a symbolic tag referencing the Logix Object's Attribute enip.device.symbol['parts'] = {'class': Obj.class_id, 'instance': Obj.instance_id, 'attribute':1 } enip.device.symbol['ControlWord'] \ = {'class': Obj.class_id, 'instance': Obj.instance_id, 'attribute':2 } enip.device.symbol['SCADA_40001'] \ = {'class': Obj.class_id, 'instance': Obj.instance_id, 'attribute':3 } enip.device.symbol['number'] \ = {'class': Obj.class_id, 'instance': Obj.instance_id, 'attribute':4 } assert len( Obj_a1 ) == size assert len( Obj_a3 ) == size assert len( Obj_a4 ) == 1 Obj_a1[0] = 42 Obj_a2[0] = 476 Obj_a4[0] = 1.0 # Ensure that the basic CIP Object requests work on a derived Class. for description,original,produced,parsed,result,response in GA_tests: request = cpppo.dotdict( original ) log.warning( "%s; request: %s", description, enip.enip_format( request )) encoded = Obj.produce( request ) assert encoded == produced, "%s: Didn't produce correct encoded request: %r != %r" % ( description, encoded, produced ) # Now, use the Message_Router's parser to decode the encoded bytes source = cpppo.rememberable( encoded ) decoded = cpppo.dotdict() with Obj.parser as machine: for m,s in enumerate( machine.run( source=source, data=decoded )): pass for k,v in cpppo.dotdict( parsed ).items(): assert decoded[k] == v, "%s: Didn't parse expected value: %s != %r in %s" % ( description, k, v, enip.enip_format( decoded )) # Process the request into a reply, and ensure we get the expected result (some Attributes # are filtered from Get Attributes All; only a 2-element DINT array and a single REAL should # be produced) Obj.request( request ) logging.warning("%s: reply: %s", description, enip.enip_format( request )) for k,v in cpppo.dotdict( result ).items(): assert k in request and request[k] == v, \ "%s: Didn't result in expected response: %s != %r; got %r" % ( description, k, v, request[k] if k in request else "(not found)" ) # Finally, produce the encoded response encoded = Obj.produce( request ) assert encoded == response, "%s: Didn't produce correct encoded response: %r != %r" % ( description, encoded, response ) # Test that we correctly compute beg,end,endactual for various Read Tag Fragmented scenarios, # with 2-byte and 4-byte types. For the purposes of this test, we only look at path...elements. data = cpppo.dotdict() data.service = Obj.RD_FRG_RPY data.path = { 'segment': [ cpppo.dotdict( d ) for d in [ {'element': 0 }, ]] } data.read_frag = {} data.read_frag.elements = 1000 data.read_frag.offset = 0 # Reply maximum size limited beg,end,endactual = Obj.reply_elements( Obj_a1, data, 'read_frag' ) assert beg == 0 and end == 125 and endactual == 1000 # DINT == 4 bytes beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert beg == 0 and end == 250 and endactual == 1000 # INT == 2 bytes data.read_frag.offset = 125*4 # OK, second request; begin after byte offset of first beg,end,endactual = Obj.reply_elements( Obj_a1, data, 'read_frag' ) assert beg == 125 and end == 250 and endactual == 1000 # DINT == 4 bytes # Request elements limited; 0 offset data.read_frag.elements = 30 data.read_frag.offset = 0 beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert beg == 0 and end == 30 and endactual == 30 # INT == 2 bytes # Request elements limited; +'ve offset data.read_frag.elements = 70 data.read_frag.offset = 80 beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert beg == 40 and end == 70 and endactual == 70 # INT == 2 bytes # Request limited by size of data provided (Write Tag [Fragmented]) data = cpppo.dotdict() data.service = Obj.WR_FRG_RPY data.path = { 'segment': [ cpppo.dotdict( d ) for d in [ {'element': 0 }, ]] } data.write_frag = {} data.write_frag.data = [0] * 100 # 100 elements provided in this request data.write_frag.elements = 200 # Total request is to write 200 elements data.write_frag.offset = 16 # request starts 16 bytes in (8 INTs) beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'write_frag' ) assert beg == 8 and end == 108 and endactual == 200 # INT == 2 bytes # ... same, but lets say request started somewhere in the middle of the array data.path = { 'segment': [ cpppo.dotdict( d ) for d in [ {'element': 222 }, ]] } beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'write_frag' ) assert beg == 8+222 and end == 108+222 and endactual == 200+222 # INT == 2 bytes # Ensure correct computation of (beg,end] that are byte-offset and data/size limited data = cpppo.dotdict() data.service = Obj.WR_FRG_RPY data.path = { 'segment': [] } data.write_frag = {} data.write_frag.data = [3,4,5,6] data.write_frag.offset = 6 beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'write_frag' ) assert beg == 3 and end == 7 and endactual == 1000 # INT == 2 bytes # Trigger the error cases only accessible via write # Too many elements provided for attribute capacity data.write_frag.offset = ( 1000 - 3 ) * 2 try: beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'write_frag' ) assert False, "Should have raised Exception due to capacity" except Exception as exc: assert "capacity exceeded" in str( exc ) data = cpppo.dotdict() data.service = Obj.RD_FRG_RPY data.path = { 'segment': [] } data.read_frag = {} data.read_frag.offset = 6 beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert beg == 3 and end == 253 and endactual == 1000 # INT == 2 bytes # And we should be able to read with an offset right up to the last element data.read_frag.offset = 1998 beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert beg == 999 and end == 1000 and endactual == 1000 # INT == 2 bytes # Trigger all the remaining error cases # Unknown service data.service = Obj.RD_FRG_REQ try: beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert False, "Should have raised Exception due to service" except Exception as exc: assert "unknown service" in str( exc ) # Offset indivisible by element size data.service = Obj.RD_FRG_RPY data.read_frag.offset = 7 try: beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert False, "Should have raised Exception due to odd byte offset" except Exception as exc: assert "element boundary" in str( exc ) # Initial element outside bounds data.read_frag.offset = 2000 try: beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert False, "Should have raised Exception due to initial element" except Exception as exc: assert "initial element invalid" in str( exc ) # Ending element outside bounds data.read_frag.offset = 0 data.read_frag.elements = 1001 try: beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert False, "Should have raised Exception due to ending element" except Exception as exc: assert "ending element invalid" in str( exc ) # Beginning element after ending (should be no way to trigger). This request doesn't specify an # element in the path, hence defaults to element 0, and asks for a number of elements == 2. # Thus, there is no 6-byte offset possible (a 2-byte offset is, though). data.read_frag.offset = 6 data.read_frag.elements = 2 try: beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert False, "Should have raised Exception due to ending element order" except Exception as exc: assert "ending element before beginning" in str( exc ) data.read_frag.offset = 2 data.read_frag.elements = 2 beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert beg == 1 and end == 2 and endactual == 2 # INT == 2 bytes # Test an example valid multiple request data = cpppo.dotdict() data.multiple = {} data.multiple.request = [ cpppo.dotdict(), cpppo.dotdict(), cpppo.dotdict(), cpppo.dotdict(), cpppo.dotdict() ] req = data.multiple.request req[0].path = { 'segment': [ cpppo.dotdict( d ) for d in [{'symbolic': 'parts'}]] } req[0].read_tag = {} req[0].read_tag.elements = 1 req[1].path = { 'segment': [ cpppo.dotdict( d ) for d in [{'symbolic': 'ControlWord'}]] } req[1].read_tag = {} req[1].read_tag.elements = 1 req[2].path = { 'segment': [ cpppo.dotdict( d ) for d in [{'symbolic': 'number'}]] } req[2].read_tag = {} req[2].read_tag.elements = 1 req[3].path = { 'segment': [ cpppo.dotdict( d ) for d in [{'symbolic': 'number'}]] } req[3].write_tag = {} req[3].write_tag.elements = 1 req[3].write_tag.type = 0x00ca req[3].write_tag.data = [1.25] req[4].path = { 'segment': [ cpppo.dotdict( d ) for d in [{'symbolic': 'number'}]] } req[4].read_tag = {} req[4].read_tag.elements = 1 request = Obj.produce( data ) req_1 = bytes(bytearray([ 0x0A, 0x02, 0x20, 0x02, 0x24, 0x01, 0x05, 0x00, 0x0c, 0x00, 0x18, 0x00, 0x2a, 0x00, 0x36, 0x00, 0x48, 0x00, 0x4C, 0x04, 0x91, 0x05, 0x70, 0x61, 0x72, 0x74, 0x73, 0x00, 0x01, 0x00, 0x4C, 0x07, 0x91, 0x0B, 0x43, 0x6F, 0x6E, 0x74, 0x72, 0x6F, 0x6C, 0x57, 0x6F, 0x72, 0x64, 0x00, 0x01, 0x00, b'L'[0], 0x04, 0x91, 0x06, b'n'[0], b'u'[0], b'm'[0], b'b'[0], b'e'[0], b'r'[0], 0x01, 0x00, b'M'[0], 0x04, 0x91, 0x06, b'n'[0], b'u'[0], b'm'[0], b'b'[0], b'e'[0], b'r'[0], 0xca, 0x00, 0x01, 0x00, 0x00, 0x00, 0xa0, 0x3f, b'L'[0], 0x04, 0x91, 0x06, b'n'[0], b'u'[0], b'm'[0], b'b'[0], b'e'[0], b'r'[0], 0x01, 0x00, ])) assert request == req_1, \ "Unexpected result from Multiple Request Service; got: \n%r\nvs.\n%r " % ( request, req_1 ) # Now, use the Message_Router's parser source = cpppo.rememberable( request ) data = cpppo.dotdict() with Obj.parser as machine: for i,(m,s) in enumerate( machine.run( source=source, data=data )): pass log.normal( "Multiple Request: %s", enip.enip_format( data )) assert 'multiple' in data, \ "No parsed multiple found in data: %s" % enip.enip_format( data ) assert data.service == enip.device.Message_Router.MULTIPLE_REQ, \ "Expected a Multiple Request Service request: %s" % enip.enip_format( data ) assert data.multiple.number == 5, \ "Expected 5 requests in request.multiple: %s" % enip.enip_format( data ) # And ensure if we re-encode the parsed result, we get the original encoded request back assert Obj.produce( data ) == req_1 # Process the request into a reply. Obj.request( data ) log.normal( "Multiple Response: %s", enip.enip_format( data )) assert data.service == enip.device.Message_Router.MULTIPLE_RPY, \ "Expected a Multiple Request Service reply: %s" % enip.enip_format( data ) rpy_1 = bytearray([ 0x8A, 0x00, 0x00, 0x00, 0x05, 0x00, 0x0c, 0x00, 0x16, 0x00, 0x20, 0x00, 0x2a, 0x00, 0x2e, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xC4, 0x00, 0x2A, 0x00, 0x00, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xC4, 0x00, 0xDC, 0x01, 0x00, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xCA, 0x00, 0x00, 0x00, 0x80, 0x3F, 0xcd, 0x00, 0x00, 0x00, 0xcc, 0x00, 0x00, 0x00, 0xca, 0x00, 0x00, 0x00, 0xa0, 0x3f, ]) assert data.input == rpy_1, \ "Unexpected reply from Multiple Request Service request; got: \n%r\nvs.\n%r " % ( data.input, rpy_1 ) # Now lets try some valid and invalid requests data = cpppo.dotdict() data.multiple = {} data.multiple.request = req = [ cpppo.dotdict() ] req[0].path = { 'segment': [ cpppo.dotdict( d ) for d in [{'symbolic': 'SCADA_40001'}]] } req[0].read_tag = {} req[0].read_tag.elements = 1 data.multiple.number = len( data.multiple.request ) request = Obj.produce( data ) req_good = bytearray([ 0x0A, 0x02, 0x20, 0x02, ord('$'), 0x01, 0x01, 0x00, 0x04, 0x00, 0x4C, 0x07, 0x91, 0x0b, ord('S'), ord('C'), ord('A'), ord('D'), ord('A'), ord('_'), ord('4'), ord('0'), ord('0'), ord('0'), ord('1'), 0x00, 0x01, 0x00, ]) assert request == req_good, \ "Unexpected result from Multiple Request Service request for SCADA_40001; got: \n%r\nvs.\n%r " % ( request, req_good ) Obj.request( data ) rpy_good = bytearray([ 0x8A, 0x00, 0x00, 0x00, 0x01, 0x00, 0x04, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xC3, 0x00, 0x00, 0x00, ]) assert data.input == rpy_good, \ "Unexpected reply from Multiple Request Service request for SCADA_40001; got: \n%r\nvs.\n%r " % ( data.input, rpy_good ) # Add an invalid request data = cpppo.dotdict() data.multiple = {} data.multiple.request = req = [ cpppo.dotdict(), cpppo.dotdict() ] req[0].path = { 'segment': [ cpppo.dotdict( d ) for d in [{'symbolic': 'SCADA_40001'}]] } req[0].read_tag = {} req[0].read_tag.elements = 1 req[1].path = { 'segment': [ cpppo.dotdict( d ) for d in [{'symbolic': 'SCADA_40002'}]] } req[1].read_tag = {} req[1].read_tag.elements = 1 data.multiple.number = len( data.multiple.request ) request = Obj.produce( data ) req_bad = bytearray([ 0x0A, 0x02, 0x20, 0x02, ord('$'), 0x01, 0x02, 0x00, 0x06, 0x00, 0x18, 0x00, 0x4C, 0x07, 0x91, 0x0b, ord('S'), ord('C'), ord('A'), ord('D'), ord('A'), ord('_'), ord('4'), ord('0'), ord('0'), ord('0'), ord('1'), 0x00, 0x01, 0x00, 0x4C, 0x07, 0x91, 0x0b, ord('S'), ord('C'), ord('A'), ord('D'), ord('A'), ord('_'), ord('4'), ord('0'), ord('0'), ord('0'), ord('2'), 0x00, 0x01, 0x00, ]) assert request == req_bad, \ "Unexpected result from Multiple Request Service request for SCADA_40001/2; got: \n%r\nvs.\n%r " % ( request, req_bad ) Obj.request( data ) rpy_bad = bytearray([ 0x8A, 0x00, 0x00, 0x00, 0x02, 0x00, 0x06, 0x00, 0x0e, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xC3, 0x00, 0x00, 0x00, 0xCC, 0x00, 0x05, 0x01, # Status code 0x05 (invalid path) 0x00, 0x00, ]) assert data.input == rpy_bad, \ "Unexpected reply from Multiple Request Service request for SCADA_40001/2; got: \n%r\nvs.\n%r " % ( data.input, rpy_bad )