def logix_test_once( obj, req ): req_source = cpppo.peekable( req ) req_data = cpppo.dotdict() with obj.parser as machine: for m,s in machine.run( source=req_source, data=req_data ): pass if log.isEnabledFor( logging.NORMAL ): log.normal( "Logix Request parsed: %s", enip.enip_format( req_data )) # If we ask a Logix Object to process the request, it should respond. processed = obj.request( req_data ) if log.isEnabledFor( logging.NORMAL ): log.normal( "Logix Request processed: %s", enip.enip_format( req_data )) # And, the same object should be able to parse the request's generated reply rpy_source = cpppo.peekable( bytes( req_data.input )) rpy_data = cpppo.dotdict() with obj.parser as machine: for i,(m,s) in enumerate( machine.run( source=rpy_source, data=rpy_data )): if log.isEnabledFor( logging.INFO ): log.info( "%s #%3d -> %10.10s; next byte %3d: %-10.10r: %r", m.name_centered(), i, s, rpy_source.sent, rpy_source.peek(), rpy_data ) if log.isEnabledFor( logging.NORMAL ): log.normal( "Logix Reply processed: %s", enip.enip_format( rpy_data )) return processed,req_data,rpy_data
def logix_test_once(obj, req): req_source = cpppo.peekable(req) req_data = cpppo.dotdict() with obj.parser as machine: for m, s in machine.run(source=req_source, data=req_data): pass if log.isEnabledFor(logging.NORMAL): log.normal("Logix Request parsed: %s", enip.enip_format(req_data)) # If we ask a Logix Object to process the request, it should respond. processed = obj.request(req_data) if log.isEnabledFor(logging.NORMAL): log.normal("Logix Request processed: %s", enip.enip_format(req_data)) # And, the same object should be able to parse the request's generated reply rpy_source = cpppo.peekable(bytes(req_data.input)) rpy_data = cpppo.dotdict() with obj.parser as machine: for i, (m, s) in enumerate(machine.run(source=rpy_source, data=rpy_data)): if log.isEnabledFor(logging.INFO): log.info("%s #%3d -> %10.10s; next byte %3d: %-10.10r: %r", m.name_centered(), i, s, rpy_source.sent, rpy_source.peek(), rpy_data) if log.isEnabledFor(logging.NORMAL): log.normal("Logix Reply processed: %s", enip.enip_format(rpy_data)) return processed, req_data, rpy_data
def test_once(): source = cpppo.peekable(req_1) data = cpppo.dotdict() with Obj.parser as machine: for m, w in machine.run(source=source, data=data): pass log.normal("Logix Request parsed: %s", enip.enip_format(data)) # If we ask a Logix Object to process the request, it should respond. processed = Obj.request(data) log.normal("Logix Request processed: %s", enip.enip_format(data)) return processed, data
def test_once(): source = cpppo.peekable( req_1 ) data = cpppo.dotdict() with Obj.parser as machine: for m,w in machine.run( source=source, data=data ): pass log.normal( "Logix Request parsed: %s", enip.enip_format( data )) # If we ask a Logix Object to process the request, it should respond. processed = Obj.request( data ) log.normal( "Logix Request processed: %s", enip.enip_format( data )) return processed, data
def search_lan(address, broadcast=True, timeout=1.0): """Discover the number of CIP chassis and backplane devices in the address/network.""" chassis, devices = 0, 0 for target in list_identity(address=address, broadcast=broadcast, timeout=timeout): identity_object = target.enip.CIP.list_identity.CPF.item[ 0].identity_object chassis += 1 print(enip.enip_format(identity_object)) print() print("%-32s @ %s:%s" % (identity_object.product_name, identity_object.sin_addr, identity_object.sin_port)) for route_path, module in scan_backplane( address=(identity_object.sin_addr, identity_object.sin_port)): print(" %24s: %s" % (route_path, module)) if route_path: print(" Slot %3s: %-32s (Ser. #%s)" % (route_path[0]['link'], str(module[6]), module[5])) else: # No route_path; Must be a simple non-routing device (eg. MicroLogix) print(" : %-32s (Ser. #%s)" % (str(module[6]), module[5])) devices += 1 return chassis, devices
def test_hart_pass_thru_simulated(simulated_hart_gateway): """Simulated HART I/O card; always returns Pass-thru Init handle 99 (won't work on a real device)""" command, address = simulated_hart_gateway #address = ('127.0.0.1',44818) # If you run: python3 ./hart_test.py try: assert address, "Unable to detect HART EtherNet/IP CIP Gateway IP address" hio = client.connector(host=address[0], port=address[1]) operations = [ { "method": "service_code", "code": HART.PT_INI_REQ, "data": [1, 0], # HART: Read primary variable "data_size": 4 + 2, # Known response size: command,status,<payload> "path": '@0x%X/8' % (HART.class_id), # Instance 1-8 ==> HART Channel 0-7 }, { "method": "service_code", "code": HART.PT_QRY_REQ, "data": [99], # HART: Pass-thru Query handle "data_size": 4 + 5, # Known response size: 5 (units + 4-byte real in network order) "path": '@0x%X/8' % (HART.class_id), # Instance 1-8 ==> HART Channel 0-7 }, ] # Now, use the underlying client.connector to issue a HART "Read Dynamic Variable" Service Code cmdbuf = '' with hio: results = [] failures = 0 for idx, dsc, req, rpy, sts, val in hio.pipeline( operations=client.parse_operations(operations), **hart_kwds): log.normal("Client %s: %s --> %r: %s", hio, dsc, val, enip.enip_format(rpy)) if not val: log.warning( "Client %s harvested %d/%d results; failed request: %s", hio, len(results), len(operations), rpy) failures += 1 results.append((dsc, val, rpy)) #cmdbuf = command_logging( command, cmdbuf ) # assert failures == 0 # statuses represent HART I/O status, not CIP response status assert results[0][-1].init.status in ( 32, 33, 35) # 32 busy, 33 initiated, 35 device offline assert results[1][-1].query.status in ( 0, 34, 35) # 0 success, 34 running, 35 dead except Exception as exc: log.warning("Test terminated with exception: %s", exc) raise
def unconnected_send(self, request, route_path=None, send_path=None, timeout=None): if route_path is None: # Default to the CPU in chassis (link 0), port 1 route_path = [{"link": 0, "port": 1}] if send_path is None: # Default to the Connection Manager send_path = [{"class": 6}, {"instance": 1}] assert isinstance(request, dict) data = cpppo.dotdict() data.enip = {} data.enip.session_handle = self.session data.enip.options = 0 data.enip.status = 0 data.enip.sender_context = {} data.enip.sender_context.input = bytearray([0x00] * 8) data.enip.CIP = {} data.enip.CIP.send_data = {} sd = data.enip.CIP.send_data sd.interface = 0 sd.timeout = 0 sd.CPF = {} sd.CPF.item = [cpppo.dotdict(), cpppo.dotdict()] sd.CPF.item[0].type_id = 0 sd.CPF.item[1].type_id = 178 sd.CPF.item[1].unconnected_send = {} us = sd.CPF.item[1].unconnected_send us.service = 82 us.status = 0 us.priority = 5 us.timeout_ticks = 157 us.path = {"segment": [cpppo.dotdict(d) for d in send_path]} us.route_path = {"segment": [cpppo.dotdict(d) for d in route_path]} us.request = request log.detail("Client Unconnected Send: %s", enip.enip_format(data)) us.request.input = bytearray(logix.Logix.produce(us.request)) sd.input = bytearray(enip.CPF.produce(sd.CPF)) data.enip.input = bytearray(enip.CIP.produce(data.enip)) data.input = bytearray(enip.enip_encode(data.enip)) self.send(data.input, timeout=timeout) return data
def main(argv=None): """Read the specified tag(s). Pass the desired argv (excluding the program name in sys.arg[0]; typically pass argv=None, which is equivalent to argv=sys.argv[1:], the default for argparse. Requires at least one tag to be defined. """ ap = argparse.ArgumentParser(description="An EtherNet/IP Client", epilog="") ap.add_argument('-v', '--verbose', default=0, action="count", help="Display logging information.") ap.add_argument( '-a', '--address', default=("%s:%d" % enip.address), help="EtherNet/IP interface[:port] to connect to (default: %s:%d)" % (enip.address[0], enip.address[1])) ap.add_argument('-l', '--log', help="Log file, if desired") ap.add_argument('-t', '--timeout', default=5.0, help="EtherNet/IP timeout (default: 5s)") ap.add_argument('-r', '--repeat', default=1, help="Repeat EtherNet/IP request (default: 1)") ap.add_argument('tags', nargs="+", help="Any tags to read/write, eg: SCADA[1]") args = ap.parse_args(argv) addr = args.address.split(':') assert 1 <= len( addr ) <= 2, "Invalid --address [<interface>]:[<port>}: %s" % args.address addr = (str(addr[0]) if addr[0] else enip.address[0], int(addr[1]) if len(addr) > 1 and addr[1] else enip.address[1]) # Set up logging level (-v...) and --log <file> levelmap = { 0: logging.WARNING, 1: logging.NORMAL, 2: logging.DETAIL, 3: logging.INFO, 4: logging.DEBUG, } cpppo.log_cfg['level'] = (levelmap[args.verbose] if args.verbose in levelmap else logging.DEBUG) if args.log: cpppo.log_cfg['filename'] = args.log logging.basicConfig(**cpppo.log_cfg) timeout = float(args.timeout) repeat = int(args.repeat) begun = misc.timer() cli = client(host=addr[0], port=addr[1]) assert cli.writable(timeout=timeout) elapsed = misc.timer() - begun log.normal("Client Connected in %7.3f/%7.3fs" % (elapsed, timeout)) # Register, and harvest EtherNet/IP Session Handle begun = misc.timer() request = cli.register(timeout=timeout) elapsed = misc.timer() - begun log.normal("Client Register Sent %7.3f/%7.3fs: %s" % (elapsed, timeout, enip.enip_format(request))) data = None # In case nothing is returned by cli iterable for data in cli: elapsed = misc.timer() - begun log.detail("Client Register Resp %7.3f/%7.3fs: %s" % (elapsed, timeout, enip.enip_format(data))) if data is None: if elapsed <= timeout: cli.readable(timeout=timeout - elapsed) continue break elapsed = misc.timer() - begun log.normal("Client Register Rcvd %7.3f/%7.3fs: %s" % (elapsed, timeout, enip.enip_format(data))) assert data is not None, "Failed to receive any response" assert 'enip.status' in data, "Failed to receive EtherNet/IP response" assert data.enip.status == 0, "EtherNet/IP response indicates failure: %s" % data.enip.status assert 'enip.CIP.register' in data, "Failed to receive Register response" cli.session = data.enip.session_handle # Parse each EtherNet/IP Tag Read or Write; only write operations will have 'data' # TAG[0] read 1 value index 0 (default) # TAG[1-5] read 5 values from indices 1 to 5 # TAG[4-7]=1,2,3,4 write 4 values from indices 4 to 7 operations = [] for tag in args.tags: # Compute tag, elm, end and cnt (default elm is 0, cnt is 1) val = '' if '=' in tag: tag, val = tag.split('=', 1) if '[' in tag: tag, elm = tag.split('[', 1) elm, _ = elm.split(']') end = elm if '-' in elm: elm, end = elm.split('-') elm, end = int(elm), int(end) else: elm, end = 0, 0 cnt = end + 1 - elm opr = { 'path': [{ 'symbolic': tag }, { 'element': elm }], 'elements': cnt, } if val: if '.' in val: opr['tag_type'] = enip.REAL.tag_type cast = lambda x: float(x) else: opr['tag_type'] = enip.INT.tag_type cast = lambda x: int(x) # Allow an optional (TYPE)value,value,... if ')' in val: def int_validate(x, lo, hi): res = int(x) assert lo <= res <= hi, "Invalid %d; not in range (%d,%d)" % ( res, lo, hi) return res typ, val = val.split(')') _, typ = typ.split('(') opr['tag_type'], cast = { 'REAL': (enip.REAL.tag_type, lambda x: float(x)), 'DINT': (enip.DINT.tag_type, lambda x: int_validate(x, -2**31, 2**31 - 1)), 'INT': (enip.INT.tag_type, lambda x: int_validate(x, -2**15, 2**15 - 1)), 'SINT': (enip.SINT.tag_type, lambda x: int_validate(x, -2**7, 2**7 - 1)), }[typ.upper()] opr['data'] = list(map(cast, val.split(','))) assert len( opr['data'] ) == cnt, \ "Number of data values (%d) doesn't match element count (%d): %s=%s" % ( len( opr['data'] ), cnt, tag, val ) operations.append(opr) # Perform all specified tag operations, the specified number of repeat times. Doesn't handle # fragmented reads yet. If any operation fails, return a non-zero exit status. status = 0 start = misc.timer() for i in range(repeat): for op in operations: # {'path': [...], 'elements': #} begun = misc.timer() if 'data' in op: descr = "Write Frag" request = cli.write(offset=0, timeout=timeout, **op) else: descr = "Read Frag" request = cli.read(offset=0, timeout=timeout, **op) elapsed = misc.timer() - begun log.normal("Client %s Sent %7.3f/%7.3fs: %s" % (descr, elapsed, timeout, enip.enip_format(request))) response = None for response in cli: elapsed = misc.timer() - begun log.normal( "Client %s Resp %7.3f/%7.3fs: %s" % (descr, elapsed, timeout, enip.enip_format(response))) if response is None: if elapsed <= timeout: cli.readable(timeout=timeout - elapsed) continue break elapsed = misc.timer() - begun log.normal("Client %s Rcvd %7.3f/%7.3fs: %s" % (descr, elapsed, timeout, enip.enip_format(response))) tag = op['path'][0]['symbolic'] elm = op['path'][1]['element'] cnt = op['elements'] val = [] # data values read/written res = None # result of request act = "??" # denotation of request action try: # The response should contain either an status code (possibly with an extended # status), or the read_frag request's data. Remember; a successful response may # carry read_frag.data, but report a status == 6 indicating that more data remains # to return via a subsequent fragmented read request. request = response.enip.CIP.send_data.CPF.item[ 1].unconnected_send.request if 'read_frag' in request: act = "==" val = request.read_frag.data elif 'write_frag' in request: act = "<=" val = op['data'] if not request.status: res = "OK" else: res = "Status %d %s" % (request.status, repr(request.status_ext.data) if 'status_ext' in request and request.status_ext.size else "") if request.status: if not status: status = request.status log.warning("Client %s returned non-zero status: %s", descr, res) except AttributeError as exc: status = 1 res = "Client %s Response missing data: %s" % (descr, exc) except Exception as exc: status = 1 res = "Client %s Exception: %s" % exc log.warning("%10s[%5d-%-5d] %s %r: %r" % (tag, elm, elm + cnt - 1, act, val, res)) duration = misc.timer() - start log.warning("Client ReadFrg. Average %7.3f TPS (%7.3fs ea)." % (repeat / duration, duration / repeat)) return status
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. """ Obj = enip.device.lookup( enip.device.Message_Router.class_id, instance_id=1 ) if not isinstance( Obj, logix.Logix ): if Obj is not None: del enip.device.directory['2']['1'] 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_hart_simple(simulated_hart_gateway): # No Multiple Service Packet supported by HART I/O Card simulator command, address = simulated_hart_gateway #address = ("127.0.0.1", 44818) #address = ("100.100.102.10", 44818) route_path = None route_path = [{'link': 2, 'port': 1}] try: assert address, "Unable to detect HART EtherNet/IP CIP Gateway IP address" hio = client.connector(host=address[0], port=address[1]) # Establish an Implicit EtherNet/IP CIP connection using Forward Open #hio = client.implicit( host=address[0], port=address[1], connection_path=None ) PV = 1.23 operations = [ { "method": "service_code", "code": HART.RD_VAR_REQ, "data": [], # No payload "data_size": 4 + 36, # Known response size: command,status,<payload> "path": '@0x%X/8' % (HART.class_id), # Instance 1-8 ==> HART Channel 0-7 }, 'HART_7_Data.PV = (REAL)0', # would fail 'til first HART Read Dynamic Variable is done { "method": "service_code", "code": HART.RD_VAR_REQ, "data": [], # No payload "data_size": 4 + 36, # Known response size: command,status,<payload> "path": '@0x%X/8' % (HART.class_id), # Instance 1-8 ==> HART Channel 0-7 }, 'HART_7_Data.PV = (REAL)%s' % PV, { "method": "service_code", "code": HART.RD_VAR_REQ, "data": [], # No payload "data_size": 4 + 36, # Known response size: command,status,<payload> "path": '@0x%X/8' % (HART.class_id), # Instance 1-8 ==> HART Channel 0-7 }, ] # Now, use the underlying client.connector to issue a HART "Read Dynamic Variable" Service Code simout = '' with hio: results = [] failures = 0 for idx, dsc, req, rpy, sts, val in hio.pipeline( operations=client.parse_operations(operations, route_path=route_path), **hart_kwds): log.normal("Client %s: %s --> %r: %s", hio, dsc, val, enip.enip_format(rpy)) if not val: log.warning( "Client %s harvested %d/%d results; failed request: %s", hio, len(results), len(operations), rpy) failures += 1 results.append((dsc, val, rpy)) rpylast = results[-1][-1] assert failures in (0, 1) assert near(rpylast.read_var.PV, PV) except Exception as exc: log.warning("Test terminated with exception: %s", exc) 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 )
"""python -m cpppo.server.enip.list_identity_simple <hostname> Returns any List Identity responses from the given hostname or IP address (default: 255.255.255.255), received before timeout (default: 1.0 second) expires. """ from __future__ import print_function import sys from cpppo.server import enip from cpppo.server.enip import client timeout = 1.0 host = sys.argv[1] if sys.argv[1:] else '255.255.255.255' with client.client( host=host, udp=True, broadcast=True ) as conn: conn.list_identity( timeout=timeout ) while True: response,elapsed= client.await( conn, timeout=timeout ) if response: print( enip.enip_format( response )) else: break # No response (None) w'in timeout or EOF ({})
def test_logix_multiple(): """Test the Multiple Request Service. Ensure multiple requests can be successfully handled, and invalid tags are correctly rejected. """ size = 1000 Obj = logix.Logix() Obj_a1 = Obj.attribute['1'] = enip.device.Attribute( 'parts', enip.parser.DINT, default=[n for n in range( size )]) Obj_a2 = Obj.attribute['2'] = enip.device.Attribute( 'ControlWord', enip.parser.DINT, default=[n for n in range( size )]) Obj_a3 = Obj.attribute['3'] = enip.device.Attribute( 'SCADA_40001', enip.parser.INT, default=[n for n in range( size )]) assert len( Obj_a1 ) == size assert len( Obj_a2 ) == size Obj_a1[0] = 42 Obj_a2[0] = 476 # 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 } # 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() ] 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 request = Obj.produce( data ) req_1 = bytes(bytearray([ 0x0A, 0x02, 0x20, 0x02, 0x24, 0x01, 0x02, 0x00, 0x06, 0x00, 0x12, 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, ])) 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 == device.Message_Router.MULTIPLE_REQ, \ "Expected a Multiple Request Service request: %s" % enip.enip_format( data ) assert data.multiple.number == 2, \ "Expected 2 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 == device.Message_Router.MULTIPLE_RPY, \ "Expected a Multiple Request Service reply: %s" % enip.enip_format( data ) rpy_1 = bytearray([ 0x8A, 0x00, 0x00, 0x00, 0x02, 0x00, 0x06, 0x00, 0x10, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xC4, 0x00, 0x2A, 0x00, 0x00, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xC4, 0x00, 0xDC, 0x01, 0x00, 0x00, ]) 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 main(argv=None): """Read the specified tag(s). Pass the desired argv (excluding the program name in sys.arg[0]; typically pass argv=None, which is equivalent to argv=sys.argv[1:], the default for argparse. Requires at least one tag to be defined. """ ap = argparse.ArgumentParser( description="An EtherNet/IP Client", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""\ One or more EtherNet/IP CIP Tags may be read or written. The full format for specifying a tag and an operation is: Tag[<first>-<last>]+<offset>=(SINT|INT|DINT|REAL)<value>,<value> All components except Tag are optional. Specifying a +<offset> (in bytes) forces the use of the Fragmented command, regardless of whether --[no-]fragment was specified. If an element range [<first>] or [<first>-<last>] was specified and --no-fragment selected, then the exact correct number of elements must be provided.""", ) ap.add_argument("-v", "--verbose", default=0, action="count", help="Display logging information.") ap.add_argument( "-a", "--address", default=("%s:%d" % enip.address), help="EtherNet/IP interface[:port] to connect to (default: %s:%d)" % (enip.address[0], enip.address[1]), ) ap.add_argument("-p", "--print", default=False, action="store_true", help="Print a summary of operations to stdout") ap.add_argument("-l", "--log", help="Log file, if desired") ap.add_argument("-t", "--timeout", default=5.0, help="EtherNet/IP timeout (default: 5s)") ap.add_argument("-r", "--repeat", default=1, help="Repeat EtherNet/IP request (default: 1)") ap.add_argument( "-m", "--multiple", action="store_true", help="Use Multiple Service Packet request (default: False)" ) ap.add_argument( "-f", "--fragment", dest="fragment", action="store_true", help="Use Read/Write Tag Fragmented requests (default: True)", ) ap.add_argument( "-n", "--no-fragment", dest="fragment", action="store_false", help="Use Read/Write Tag requests (default: False)", ) ap.set_defaults(fragment=False) ap.add_argument("tags", nargs="+", help="Tags to read/write, eg: SCADA[1], SCADA[2-10]+4=(DINT)3,4,5") args = ap.parse_args(argv) addr = args.address.split(":") assert 1 <= len(addr) <= 2, "Invalid --address [<interface>]:[<port>}: %s" % args.address addr = ( str(addr[0]) if addr[0] else enip.address[0], int(addr[1]) if len(addr) > 1 and addr[1] else enip.address[1], ) # Set up logging level (-v...) and --log <file> levelmap = {0: logging.WARNING, 1: logging.NORMAL, 2: logging.DETAIL, 3: logging.INFO, 4: logging.DEBUG} cpppo.log_cfg["level"] = levelmap[args.verbose] if args.verbose in levelmap else logging.DEBUG if args.log: cpppo.log_cfg["filename"] = args.log logging.basicConfig(**cpppo.log_cfg) timeout = float(args.timeout) repeat = int(args.repeat) begun = misc.timer() cli = client(host=addr[0], port=addr[1]) assert cli.writable(timeout=timeout) elapsed = misc.timer() - begun log.normal("Client Connected in %7.3f/%7.3fs" % (elapsed, timeout)) # Register, and harvest EtherNet/IP Session Handle begun = misc.timer() request = cli.register(timeout=timeout) elapsed = misc.timer() - begun log.detail("Client Register Sent %7.3f/%7.3fs: %s" % (elapsed, timeout, enip.enip_format(request))) data = None # In case nothing is returned by cli iterable for data in cli: elapsed = misc.timer() - begun log.info("Client Register Resp %7.3f/%7.3fs: %s" % (elapsed, timeout, enip.enip_format(data))) if data is None: if elapsed <= timeout: cli.readable(timeout=timeout - elapsed) continue break elapsed = misc.timer() - begun log.detail("Client Register Rcvd %7.3f/%7.3fs: %s" % (elapsed, timeout, enip.enip_format(data))) assert data is not None, "Failed to receive any response" assert "enip.status" in data, "Failed to receive EtherNet/IP response" assert data.enip.status == 0, "EtherNet/IP response indicates failure: %s" % data.enip.status assert "enip.CIP.register" in data, "Failed to receive Register response" cli.session = data.enip.session_handle # Parse each EtherNet/IP Tag Read or Write; only write operations will have 'data' # TAG read 1 value (no element index) # TAG[0] read 1 value from element index 0 # TAG[1-5] read 5 values from element indices 1 to 5 # TAG[1-5]+4 read 5 values from element indices 1 to 5, beginning at byte offset 4 # TAG[4-7]=1,2,3,4 write 4 values from indices 4 to 7 # # To support access to scalar attributes (no element index allowed in path), we cannot default # to 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 # (regardless of whether --[no-]fragment was specified) operations = [] for tag in args.tags: # Compute tag, elm, end and cnt (default elm is None (no element index), cnt is 1) val = "" off = None elm, lst = None, None cnt = 1 if "=" in tag: # A write; strip off the values into 'val' tag, val = tag.split("=", 1) if "+" in tag: # A byte offset (valid for Fragmented) tag, off = tag.split("+", 1) if "[" in tag: tag, elm = tag.split("[", 1) elm, _ = elm.split("]") lst = elm if "-" in elm: elm, lst = elm.split("-") elm, lst = int(elm), int(lst) cnt = lst + 1 - elm opr = {} opr["path"] = [{"symbolic": tag}] if elm is not None: opr["path"] += [{"element": elm}] opr["elements"] = cnt if off: opr["offset"] = int(off) if val: if "." in val: opr["tag_type"] = enip.REAL.tag_type size = enip.REAL().calcsize cast = lambda x: float(x) else: opr["tag_type"] = enip.INT.tag_type size = enip.INT().calcsize cast = lambda x: int(x) # Allow an optional (TYPE)value,value,... if ")" in val: def int_validate(x, lo, hi): res = int(x) assert lo <= res <= hi, "Invalid %d; not in range (%d,%d)" % (res, lo, hi) return res typ, val = val.split(")") _, typ = typ.split("(") opr["tag_type"], size, cast = { "REAL": (enip.REAL.tag_type, enip.REAL().calcsize, lambda x: float(x)), "DINT": ( enip.DINT.tag_type, enip.DINT().calcsize, lambda x: int_validate(x, -2 ** 31, 2 ** 31 - 1), ), "INT": (enip.INT.tag_type, enip.INT().calcsize, lambda x: int_validate(x, -2 ** 15, 2 ** 15 - 1)), "SINT": (enip.SINT.tag_type, enip.SINT().calcsize, lambda x: int_validate(x, -2 ** 7, 2 ** 7 - 1)), }[typ.upper()] opr["data"] = list(map(cast, val.split(","))) if "offset" not in opr and not args.fragment: # Non-fragment write. The exact correct number of data elements must be provided assert len(opr["data"]) == cnt, "Number of data values (%d) doesn't match element count (%d): %s=%s" % ( len(opr["data"]), cnt, tag, val, ) elif elm != lst: # Fragmented write, to an identified range of indices, hence we can check length. # If the byte offset + data provided doesn't match the number of elements, then a # subsequent Write Tag Fragmented command will be required to write the balance. byte = opr.get("offset") or 0 assert byte % size == 0, "Invalid byte offset %d for elements of size %d bytes" % (byte, size) beg = byte // size end = beg + len(opr["data"]) assert end <= cnt, ( "Number of elements (%d) provided and byte offset %d / %d-byte elements exceeds element count %d: " % (len(opr["data"]), byte, size, cnt) ) if beg != 0 or end != cnt: log.normal("Partial Write Tag Fragmented; elements %d-%d of %d", beg, end - 1, cnt) operations.append(opr) def output(out): log.normal(out) if args.print: print(out) # Perform all specified tag operations, the specified number of repeat times. Doesn't handle # fragmented reads yet. If any operation fails, return a non-zero exit status. If --multiple # specified, perform all operations in a single Multiple Service Packet request. status = 0 start = misc.timer() for i in range(repeat): requests = [] # If --multiple, collects all requests, else one at at time for o in range(len(operations)): op = operations[o] # {'path': [...], 'elements': #} begun = misc.timer() if "offset" not in op: op["offset"] = 0 if args.fragment else None if "data" in op: descr = "Write " req = cli.write(timeout=timeout, send=not args.multiple, **op) else: descr = "Read " req = cli.read(timeout=timeout, send=not args.multiple, **op) descr += "Frag" if op["offset"] is not None else "Tag " if args.multiple: # Multiple requests; each request is returned simply, not in an Unconnected Send requests.append(req) if o != len(operations) - 1: continue # No more operations! Issue the Multiple Service Packet containing all operations descr = "Multiple " cli.multiple(request=requests, timeout=timeout) else: # Single request issued requests = [req] # Issue the request(s), and get the response elapsed = misc.timer() - begun log.detail("Client %s Sent %7.3f/%7.3fs: %s" % (descr, elapsed, timeout, enip.enip_format(request))) response = None for response in cli: elapsed = misc.timer() - begun log.debug("Client %s Resp %7.3f/%7.3fs: %s" % (descr, elapsed, timeout, enip.enip_format(response))) if response is None: if elapsed <= timeout: cli.readable(timeout=timeout - elapsed) continue break elapsed = misc.timer() - begun log.detail("Client %s Rcvd %7.3f/%7.3fs: %s" % (descr, elapsed, timeout, enip.enip_format(response))) # Find the replies in the response; could be single or multiple; should match requests! replies = [] if response.enip.status != 0: status = 1 output("Client %s Response EtherNet/IP status: %d" % (descr, response.enip.status)) elif ( args.multiple and "enip.CIP.send_data.CPF.item[1].unconnected_send.request.multiple.request" in response ): # Multiple Service Packet; request.multiple.request is an array of read/write_tag/frag replies = response.enip.CIP.send_data.CPF.item[1].unconnected_send.request.multiple.request elif "enip.CIP.send_data.CPF.item[1].unconnected_send.request" in response: # Single request; request is a read/write_tag/frag replies = [response.enip.CIP.send_data.CPF.item[1].unconnected_send.request] else: status = 1 output("Client %s Response Unrecognized: " % (descr, enip.enip_format(response))) for request, reply in zip(requests, replies): log.detail("Client %s Request: %s", descr, enip.enip_format(request)) log.detail(" Yields Reply: %s", enip.enip_format(reply)) val = [] # data values read/written res = None # result of request act = "??" # denotation of request action try: tag = request.path.segment[0].symbolic try: elm = request.path.segment[1].element # array access except IndexError: elm = None # scalar access # The response should contain either an status code (possibly with an extended # status), or the read_frag request's data. Remember; a successful response may # carry read_frag.data, but report a status == 6 indicating that more data remains # to return via a subsequent fragmented read request. if "read_frag" in reply: act = "==" val = reply.read_frag.data cnt = request.read_frag.elements elif "read_tag" in reply: act = "==" val = reply.read_tag.data cnt = request.read_tag.elements elif "write_frag" in reply: act = "<=" val = request.write_frag.data cnt = request.write_frag.elements elif "write_tag" in reply: act = "<=" val = request.write_tag.data cnt = request.write_tag.elements if not reply.status: res = "OK" else: res = "Status %d %s" % ( reply.status, repr(reply.status_ext.data) if "status_ext" in reply and reply.status_ext.size else "", ) if reply.status: if not status: status = reply.status log.warning("Client %s returned non-zero status: %s", descr, res) except AttributeError as exc: status = 1 res = "Client %s Response missing data: %s" % (descr, exc) log.detail("%s: %s", res, "".join(traceback.format_exception(*sys.exc_info()))) except Exception as exc: status = 1 res = "Client %s Exception: %s" % (descr, exc) log.detail("%s: %s", res, "".join(traceback.format_exception(*sys.exc_info()))) if elm is None: output("%20s %s %r: %r" % (tag, act, val, res)) # scalar access else: output("%20s[%5d-%-5d] %s %r: %r" % (tag, elm, elm + cnt - 1, act, val, res)) duration = misc.timer() - start log.normal( "Client Tag I/O Average %7.3f TPS (%7.3fs ea)." % (repeat * len(operations) / duration, duration / repeat / len(operations)) ) return status
def main( argv=None ): """Read the specified tag(s). Pass the desired argv (excluding the program name in sys.arg[0]; typically pass argv=None, which is equivalent to argv=sys.argv[1:], the default for argparse. Requires at least one tag to be defined. """ ap = argparse.ArgumentParser( description = "An EtherNet/IP Client", epilog = "" ) ap.add_argument( '-v', '--verbose', default=0, action="count", help="Display logging information." ) ap.add_argument( '-a', '--address', default=( "%s:%d" % enip.address ), help="EtherNet/IP interface[:port] to connect to (default: %s:%d)" % ( enip.address[0], enip.address[1] )) ap.add_argument( '-l', '--log', help="Log file, if desired" ) ap.add_argument( '-t', '--timeout', default=5.0, help="EtherNet/IP timeout (default: 5s)" ) ap.add_argument( '-r', '--repeat', default=1, help="Repeat EtherNet/IP request (default: 1)" ) ap.add_argument( 'tags', nargs="+", help="Any tags to read/write, eg: SCADA[1]") args = ap.parse_args( argv ) addr = args.address.split(':') assert 1 <= len( addr ) <= 2, "Invalid --address [<interface>]:[<port>}: %s" % args.address addr = ( str( addr[0] ) if addr[0] else enip.address[0], int( addr[1] ) if len( addr ) > 1 and addr[1] else enip.address[1] ) # Set up logging level (-v...) and --log <file> levelmap = { 0: logging.WARNING, 1: logging.NORMAL, 2: logging.DETAIL, 3: logging.INFO, 4: logging.DEBUG, } cpppo.log_cfg['level'] = ( levelmap[args.verbose] if args.verbose in levelmap else logging.DEBUG ) if args.log: cpppo.log_cfg['filename'] = args.log logging.basicConfig( **cpppo.log_cfg ) timeout = float( args.timeout ) repeat = int( args.repeat ) begun = misc.timer() cli = client( host=addr[0], port=addr[1] ) assert cli.writable( timeout=timeout ) elapsed = misc.timer() - begun log.normal( "Client Connected in %7.3f/%7.3fs" % ( elapsed, timeout )) # Register, and harvest EtherNet/IP Session Handle begun = misc.timer() request = cli.register( timeout=timeout ) elapsed = misc.timer() - begun log.normal( "Client Register Sent %7.3f/%7.3fs: %s" % ( elapsed, timeout, enip.enip_format( request ))) data = None # In case nothing is returned by cli iterable for data in cli: elapsed = misc.timer() - begun log.detail( "Client Register Resp %7.3f/%7.3fs: %s" % ( elapsed, timeout, enip.enip_format( data ))) if data is None: if elapsed <= timeout: cli.readable( timeout=timeout - elapsed ) continue break elapsed = misc.timer() - begun log.normal( "Client Register Rcvd %7.3f/%7.3fs: %s" % ( elapsed, timeout, enip.enip_format( data ))) assert data is not None, "Failed to receive any response" assert 'enip.status' in data, "Failed to receive EtherNet/IP response" assert data.enip.status == 0, "EtherNet/IP response indicates failure: %s" % data.enip.status assert 'enip.CIP.register' in data, "Failed to receive Register response" cli.session = data.enip.session_handle # Parse each EtherNet/IP Tag Read or Write; only write operations will have 'data' # TAG[0] read 1 value index 0 (default) # TAG[1-5] read 5 values from indices 1 to 5 # TAG[4-7]=1,2,3,4 write 4 values from indices 4 to 7 operations = [] for tag in args.tags: # Compute tag, elm, end and cnt (default elm is 0, cnt is 1) val = '' if '=' in tag: tag,val = tag.split( '=', 1 ) if '[' in tag: tag,elm = tag.split( '[', 1 ) elm,_ = elm.split( ']' ) end = elm if '-' in elm: elm,end = elm.split( '-' ) elm,end = int(elm), int(end) else: elm,end = 0,0 cnt = end + 1 - elm opr = { 'path': [{'symbolic': tag}, {'element': elm}], 'elements': cnt, } if val: if '.' in val: opr['tag_type'] = enip.REAL.tag_type cast = lambda x: float( x ) else: opr['tag_type'] = enip.INT.tag_type cast = lambda x: int( x ) # Allow an optional (TYPE)value,value,... if ')' in val: def int_validate( x, lo, hi ): res = int( x ) assert lo <= res <= hi, "Invalid %d; not in range (%d,%d)" % ( res, lo, hi) return res typ,val = val.split( ')' ) _,typ = typ.split( '(' ) opr['tag_type'],cast = { 'REAL': (enip.REAL.tag_type, lambda x: float( x )), 'DINT': (enip.DINT.tag_type, lambda x: int_validate( x, -2**31, 2**31-1 )), 'INT': (enip.INT.tag_type, lambda x: int_validate( x, -2**15, 2**15-1 )), 'SINT': (enip.SINT.tag_type, lambda x: int_validate( x, -2**7, 2**7-1 )), }[typ.upper()] opr['data'] = list( map( cast, val.split( ',' ))) assert len( opr['data'] ) == cnt, \ "Number of data values (%d) doesn't match element count (%d): %s=%s" % ( len( opr['data'] ), cnt, tag, val ) operations.append( opr ) # Perform all specified tag operations, the specified number of repeat times. Doesn't handle # fragmented reads yet. If any operation fails, return a non-zero exit status. status = 0 start = misc.timer() for i in range( repeat ): for op in operations: # {'path': [...], 'elements': #} begun = misc.timer() if 'data' in op: descr = "Write Frag" request = cli.write( offset=0, timeout=timeout, **op ) else: descr = "Read Frag" request = cli.read( offset=0, timeout=timeout, **op ) elapsed = misc.timer() - begun log.normal( "Client %s Sent %7.3f/%7.3fs: %s" % ( descr, elapsed, timeout, enip.enip_format( request ))) response = None for response in cli: elapsed = misc.timer() - begun log.normal( "Client %s Resp %7.3f/%7.3fs: %s" % ( descr, elapsed, timeout, enip.enip_format( response ))) if response is None: if elapsed <= timeout: cli.readable( timeout=timeout - elapsed ) continue break elapsed = misc.timer() - begun log.normal( "Client %s Rcvd %7.3f/%7.3fs: %s" % ( descr, elapsed, timeout, enip.enip_format( response ))) tag = op['path'][0]['symbolic'] elm = op['path'][1]['element'] cnt = op['elements'] val = [] # data values read/written res = None # result of request act = "??" # denotation of request action try: # The response should contain either an status code (possibly with an extended # status), or the read_frag request's data. Remember; a successful response may # carry read_frag.data, but report a status == 6 indicating that more data remains # to return via a subsequent fragmented read request. request = response.enip.CIP.send_data.CPF.item[1].unconnected_send.request if 'read_frag' in request: act = "==" val = request.read_frag.data elif 'write_frag' in request: act = "<=" val = op['data'] if not request.status: res = "OK" else: res = "Status %d %s" % ( request.status, repr( request.status_ext.data ) if 'status_ext' in request and request.status_ext.size else "" ) if request.status: if not status: status = request.status log.warning( "Client %s returned non-zero status: %s", descr, res ) except AttributeError as exc: status = 1 res = "Client %s Response missing data: %s" % ( descr, exc ) except Exception as exc: status = 1 res = "Client %s Exception: %s" % exc log.warning( "%10s[%5d-%-5d] %s %r: %r" % ( tag, elm, elm + cnt - 1, act, val, res )) duration = misc.timer() - start log.warning( "Client ReadFrg. Average %7.3f TPS (%7.3fs ea)." % ( repeat / duration, duration / repeat )) return status
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_hart_pass_thru_poll(simulated_hart_gateway): r"""To test a remote C*Logix w/ a HART card, set up a remote port forward from another host in the same LAN. Here's a windows example, using putty. This windows machine (at 100.100.102.1) forwards a port 44818 on fat2.kundert.ca, to the PLC at 100.100.102.10:44818: C:\Users\Engineer\Desktop\putty.exe -R 44818:100.100.102.10:44818 [email protected] Now, from another host that can see fat2.kundert.ca: $ python -m cpppo.server.enip.list_services --list-identity -a fat2.kundert.ca:44818 { "peer": [ "fat2.kundert.ca", 44818 ], ... "enip.status": 0, "enip.CIP.list_services.CPF.count": 1, "enip.CIP.list_services.CPF.item[0].communications_service.capability": 288, "enip.CIP.list_services.CPF.item[0].communications_service.service_name": "Communications", } { ... "enip.status": 0, "enip.CIP.list_identity.CPF.item[0].identity_object.sin_addr": "100.100.102.10", "enip.CIP.list_identity.CPF.item[0].identity_object.status_word": 96, "enip.CIP.list_identity.CPF.item[0].identity_object.vendor_id": 1, "enip.CIP.list_identity.CPF.item[0].identity_object.product_name": "1756-EN2T/D", "enip.CIP.list_identity.CPF.item[0].identity_object.sin_port": 44818, "enip.CIP.list_identity.CPF.item[0].identity_object.state": 3, "enip.CIP.list_identity.CPF.item[0].identity_object.version": 1, "enip.CIP.list_identity.CPF.item[0].identity_object.device_type": 12, "enip.CIP.list_identity.CPF.item[0].identity_object.sin_family": 2, "enip.CIP.list_identity.CPF.item[0].identity_object.serial_number": 11866067, "enip.CIP.list_identity.CPF.item[0].identity_object.product_code": 166, "enip.CIP.list_identity.CPF.item[0].identity_object.product_revision": 1802, } """ command, address = simulated_hart_gateway # For testing, we'll hit a specific device #address = ("fat2.kundert.ca", 44818) #address = ("100.100.102.10", 44818) #address = ("localhost", 44818) route_path = None route_path = [{'link': 2, 'port': 1}] try: assert address, "Unable to detect HART EtherNet/IP CIP Gateway IP address" #hio = client.implicit( host=address[0], port=address[1] ) hio = client.connector(host=address[0], port=address[1]) # Just get the primary variable, to see if the HART device is there. operations = [ { "method": "service_code", "code": HART.RD_VAR_REQ, "data": [], # No payload "data_size": 4 + 36, # Known response size: command,status,<payload> "path": '@0x%X/8' % (HART.class_id), # Instance 1-8 ==> HART Channel 0-7 "route_path": route_path, }, ] with hio: for idx, dsc, req, rpy, sts, val in hio.pipeline( operations=client.parse_operations(operations), **hart_kwds): log.normal("Client %s: %s --> %r: %s", hio, dsc, val, enip.enip_format(rpy)) path = '@0x%X/8' % (HART.class_id) data = hart_pass_thru(hio, path=path, hart_data=[1, 0], route_path=route_path, data_size=4) # with no size # The small response carries the 4-byte value, the long CIP MSG response additionally # carries the data type We receive the long response. value = None if data and len(data) >= 4: units = data[0] if len(data) > 4 else None packer = struct.Struct(enip.REAL_network.struct_format) value, = packer.unpack_from(buffer=bytearray(data[-4:])) log.normal("Read primary variable Value: %s (units: %s), from: %r", value, units, data) # HART Command 3 gets all 4 variables data = hart_pass_thru(hio, path=path, hart_data=[3, 0], route_path=route_path, data_size=4 * 4) # with no size # small response carries PV, SV, TV, FV values, no data types value = [] if data and len(data) == 4 * 4: # Short packer = struct.Struct(enip.REAL_network.struct_format) for i in range(0, len(data), 4): value += packer.unpack_from(buffer=bytearray(data[i:i + 4])) elif data and len(data) >= 24: # Long packer = struct.Struct(enip.REAL_network.struct_format) value = cpppo.dotdict() value.current, = packer.unpack_from(buffer=bytearray(data[0:])) value.PV_units = data[4] value.PV, = packer.unpack_from(buffer=bytearray(data[5:])) value.SV_units = data[10] value.SV, = packer.unpack_from(buffer=bytearray(data[11:])) value.TV_units = data[14] value.TV, = packer.unpack_from(buffer=bytearray(data[15:])) value.FV_units = data[19] value.FV, = packer.unpack_from(buffer=bytearray(data[20:])) log.normal("Read all variables Values: %s, from: %r", enip.enip_format(value), data) # HART Command 12 gets the 24-character Message data = hart_pass_thru(hio, path=path, hart_data=[12, 0], route_path=route_path, data_size=4 * 4) # with no size value = None if data and len(data): try: value = bytes(data).decode('ascii') except: value = hexdump(data) log.normal("Read Message: \n%s\nfrom: %r", value, data) # HART Command 0 gets the identity data = hart_pass_thru(hio, path=path, hart_data=[0, 0], route_path=route_path, data_size=4 * 4) # with no size value = None if data and len(data): value = hexdump(data) log.normal("Read Identity: \n%s\nfrom: %r", value, data) # HART Command 13 gets the Tag data = hart_pass_thru(hio, path=path, hart_data=[13, 0], route_path=route_path, data_size=4 * 4) # with no size value = None if data and len(data): value = hexdump(data) log.normal("Read Tag: \n%s\nfrom: %r", value, data) except Exception as exc: log.warning("Test terminated with exception: %s", exc) raise
def hart_pass_thru(io, path, hart_data, data_size, route_path=None): """For eg. hart_data=[1, 0], data_size=4 for HART command 1. Returns None on failure, or the HART command response data payload. Harvests a Pass-thru Init handle, and issues Query on it 'til successs. """ # Try to start the Pass-thru command, with the Pass-thru Init, and get handle operations = [ { "method": "service_code", "code": HART.PT_INI_REQ, "data": hart_data, "data_size": 4 + 2, # Known response size: command,status,<payload> "path": path, # Instance 1-8 ==> HART Channel 0-7 "route_path": route_path, }, ] # Look for a reply init.status of 33 initiated. Actually, it appears that status 0 indicates success. handle = None while handle is None: time.sleep(.1) with io: for idx, dsc, req, rpy, sts, val in io.pipeline( operations=client.parse_operations(operations), **hart_kwds): log.detail("Client %s: %s --> %r: request: %s\nreply:%s", io, dsc, val, enip.enip_format(req), enip.enip_format(rpy)) if rpy.status == 0 and rpy.init.status in ( 33, ): # 32 busy, 33 initiated, 35 device offline handle = rpy.init.handle log.normal("HART Pass-thru command Handle: %s", handle) # Query for success/failure (loop on running) operations = [ { "method": "service_code", "code": HART.PT_QRY_REQ, "data": [handle], # HART: Pass-thru Query handle "data_size": 4 + data_size, # Known response size: 5 (units + 4-byte real in network order) "path": path, # Instance 1-8 ==> HART Channel 0-7 "route_path": route_path, }, ] reply = {} while not reply or (reply.status == 0 and reply.query.status == 34): # 0 success, 34 running, 35 dead time.sleep(.1) with io: for idx, dsc, req, rpy, sts, val in io.pipeline( operations=client.parse_operations(operations), **hart_kwds): log.detail("Client %s: %s --> %r: %s", io, dsc, val, enip.enip_format(rpy)) reply = rpy log.normal("HART pass-thru command Status: %s", reply.get('query.status')) return reply.get('query.reply_data.data', None)