def start(self, host, port): srv_ctl = cpppo.dotdict() srv_ctl.control = cpppo.apidict(timeout=self.config.timeout) srv_ctl.control["done"] = False srv_ctl.control["disable"] = False srv_ctl.control.setdefault("latency", self.config.latency) options = cpppo.dotdict() options.setdefault("enip_process", logix.process) kwargs = dict(options, tags=self.tags, server=srv_ctl) tcp_mode = True if self.config.mode == "tcp" else False udp_mode = True if self.config.mode == "udp" else False self.control = srv_ctl.control self.start_event.set() logger.debug( "ENIP server started on: %s:%d, mode: %s" % (host, port, self.config.mode) ) while not self.control["done"]: network.server_main( address=(host, port), target=self.handle, kwargs=kwargs, udp=udp_mode, tcp=tcp_mode, )
def stats_for(self, peer): if peer is None: return None, None connkey = "%s_%d" % (peer[0].replace('.', '_'), peer[1]) stats = self.connections.get(connkey) if stats is not None: return stats, connkey stats = cpppo.apidict(timeout=self.config.timeout) self.connections[connkey] = stats stats['requests'] = 0 stats['received'] = 0 stats['eof'] = False stats['interface'] = peer[0] stats['port'] = peer[1] return stats, connkey
def stats_for(self, peer): if peer is None: return None, None connkey = "%s_%d" % (peer[0].replace(".", "_"), peer[1]) stats = self.connections.get(connkey) if stats is not None: return stats, connkey stats = cpppo.apidict(timeout=self.config.timeout) self.connections[connkey] = stats stats["requests"] = 0 stats["received"] = 0 stats["eof"] = False stats["interface"] = peer[0] stats["port"] = peer[1] return stats, connkey
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 start(self, host, port): srv_ctl = cpppo.dotdict() srv_ctl.control = cpppo.apidict(timeout=self.config.timeout) srv_ctl.control['done'] = False srv_ctl.control['disable'] = False srv_ctl.control.setdefault('latency', self.config.latency) options = cpppo.dotdict() options.setdefault('enip_process', logix.process) kwargs = dict(options, tags=self.tags, server=srv_ctl) tcp_mode = True if self.config.mode == 'tcp' else False udp_mode = True if self.config.mode == 'udp' else False logger.debug('ENIP server started on: %s:%d, mode: %s' % (host, port, self.config.mode)) while not self.stopped: network.server_main(address=(host, port), target=self.handle, kwargs=kwargs, idle_service=None, udp=udp_mode, tcp=tcp_mode, thread_factory=network.server_thread)
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 test_logix_remote( count=100 ): """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). """ svraddr = ('localhost', 12345) kwargs = cpppo.dotdict({ '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 }), }, }) # 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={ 'count': count, 'svraddr': svraddr, 'kwargs': kwargs } ) logixthread.daemon = True logixthread.start() enip.main( **kwargs ) logixthread.join()
def test_logix_remote(count=100): """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). """ svraddr = ('localhost', 12345) kwargs = cpppo.dotdict({ '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}), }, }) # 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={ 'count': count, 'svraddr': svraddr, 'kwargs': kwargs }) logixthread.daemon = True logixthread.start() enip.main(**kwargs) logixthread.join()
def main( argv=None, attribute_class=device.Attribute, identity_class=None, idle_service=None, **kwds ): """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. If a cpppo.apidict() is passed for kwds['server']['control'], we'll use it to transmit server control signals via its .done, .disable, .timeout and .latency attributes. Uses the provided attribute_class (default: device.Attribute) to process all EtherNet/IP attribute I/O (eg. Read/Write Tag [Fragmented]) requests. By default, device.Attribute stores and retrieves the supplied data. To perform other actions (ie. forward the data to your own application), derive from device.Attribute, and override the __getitem__ and __setitem__ methods. If an idle_service function is provided, it will be called after a period of latency between incoming requests. """ global address global options global tags global srv_ctl global latency global timeout ap = argparse.ArgumentParser( description = "Provide an EtherNet/IP Server", epilog = "" ) ap.add_argument( '-v', '--verbose', default=0, action="count", help="Display logging information." ) ap.add_argument( '-a', '--address', default=( "%s:%d" % address ), help="EtherNet/IP interface[:port] to bind to (default: %s:%d)" % ( address[0], 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( '-w', '--web', default="", help="Web API [interface]:[port] to bind to (default: %s, port 80)" % ( address[0] )) ap.add_argument( '-d', '--delay', help="Delay response to each request by a certain number of seconds (default: 0.0)", default="0.0" ) ap.add_argument( '-s', '--size', help="Limit EtherNet/IP encapsulated request size to the specified number of bytes (default: None)", default=None ) ap.add_argument( '-P', '--profile', help="Output profiling data to a file (default: None)", default=None ) ap.add_argument( 'tags', nargs="+", help="Any tags, their type (default: INT), and number (default: 1), eg: tag=INT[1000]") args = ap.parse_args( argv ) # Deduce interface:port address to bind, and correct types (default is address, above) bind = args.address.split(':') assert 1 <= len( bind ) <= 2, "Invalid --address [<interface>]:[<port>}: %s" % args.address bind = ( str( bind[0] ) if bind[0] else address[0], int( bind[1] ) if len( bind ) > 1 and bind[1] else 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 ) # Chain any provided idle_service function with log rotation; these may (also) consult global # signal flags such as logrotate_request, so execute supplied functions before logrotate_perform idle_service = [ idle_service ] if idle_service else [] if args.log: # Output logging to a file, and handle UNIX-y log file rotation via 'logrotate', which sends # signals to indicate that a service's log file has been moved/renamed and it should re-open cpppo.log_cfg['filename']= args.log signal.signal( signal.SIGHUP, logrotate_request ) idle_service.append( logrotate_perform ) logging.basicConfig( **cpppo.log_cfg ) # Pull out a 'server.control...' supplied in the keywords, and make certain it's a # cpppo.apidict. We'll use this to transmit control signals to the server thread. Set the # current values to sane initial defaults/conditions. if 'server' in kwds: assert 'control' in kwds['server'], "A 'server' keyword provided without a 'control' attribute" srv_ctl = cpppo.dotdict( kwds.pop( 'server' )) assert isinstance( srv_ctl['control'], cpppo.apidict ), "The server.control... must be a cpppo.apidict" else: srv_ctl.control = cpppo.apidict( timeout=timeout ) srv_ctl.control['done'] = False srv_ctl.control['disable'] = False srv_ctl.control.setdefault( 'latency', latency ) # Global options data. Copy any remaining keyword args supplied to main(). This could # include an alternative enip_process, for example, instead of defaulting to logix.process. options.update( kwds ) # Specify a response delay. The options.delay is another dotdict() layer, so it's attributes # (eg. .value, .range) are available to the web API for manipulation. Therefore, they can be # set to arbitrary values at random times! However, the type will be retained. def delay_range( *args, **kwds ): """If a delay.range like ".1-.9" is specified, then change the delay.value every second to something in that range.""" assert 'delay' in kwds and 'range' in kwds['delay'] and '-' in kwds['delay']['range'], \ "No delay=#-# specified" log.normal( "Delaying all responses by %s seconds", kwds['delay']['range'] ) while True: # Once we start, changes to delay.range will be re-evaluated each loop time.sleep( 1 ) try: lo,hi = map( float, kwds['delay']['range'].split( '-' )) kwds['delay']['value'] = random.uniform( lo, hi ) log.info( "Mutated delay == %g", kwds['delay']['value'] ) except Exception as exc: log.warning( "No delay=#[.#]-#[.#] range specified: %s", exc ) options.delay = cpppo.dotdict() try: options.delay.value = float( args.delay ) log.normal( "Delaying all responses by %r seconds" , options.delay.value ) except: assert '-' in args.delay, \ "Unrecognized --delay=%r option" % args.delay # A range #-#; set up a thread to mutate the option.delay.value over the .range options.delay.range = args.delay options.delay.value = 0.0 mutator = threading.Thread( target=delay_range, kwargs=options ) mutator.daemon = True mutator.start() # Create all the specified tags/Attributes. The enip_process function will (somehow) assign the # given tag name to reference the specified Attribute. We'll define an Attribute to print # I/O if args.print is specified; reads will only be logged at logging.NORMAL and above. class Attribute_print( attribute_class ): def __getitem__( self, key ): value = super( Attribute_print, self ).__getitem__( key ) if log.isEnabledFor( logging.NORMAL ): print( "%20s[%5s-%-5s] == %s" % ( self.name, key.indices( len( self ))[0] if isinstance( key, slice ) else key, key.indices( len( self ))[1]-1 if isinstance( key, slice ) else key, value )) return value def __setitem__( self, key, value ): super( Attribute_print, self ).__setitem__( key, value ) print( "%20s[%5s-%-5s] <= %s" % ( self.name, key.indices( len( self ))[0] if isinstance( key, slice ) else key, key.indices( len( self ))[1]-1 if isinstance( key, slice ) else key, value )) for t in args.tags: tag_name, rest = t, '' if '=' in tag_name: tag_name, rest = tag_name.split( '=', 1 ) tag_type, rest = rest or 'INT', '' tag_size = 1 if '[' in tag_type: tag_type, rest = tag_type.split( '[', 1 ) assert ']' in rest, "Invalid tag; mis-matched [...]" tag_size, rest = rest.split( ']', 1 ) assert not rest, "Invalid tag specified; expected tag=<type>[<size>]: %r" % t tag_type = str( tag_type ).upper() typenames = {"INT": parser.INT, "DINT": parser.DINT, "SINT": parser.SINT, "REAL": parser.REAL } assert tag_type in typenames, "Invalid tag type; must be one of %r" % list( typenames.keys() ) tag_default = 0.0 if tag_type == "REAL" else 0 try: tag_size = int( tag_size ) except: raise AssertionError( "Invalid tag size: %r" % tag_size ) # Ready to create the tag and its Attribute (and error code to return, if any). If tag_size # is 1, it will be a scalar Attribute. Since the tag_name may contain '.', we don't want # the normal dotdict.__setitem__ resolution to parse it; use plain dict.__setitem__. log.normal( "Creating tag: %s=%s[%d]", tag_name, tag_type, tag_size ) tag_entry = cpppo.dotdict() tag_entry.attribute = ( Attribute_print if args.print else attribute_class )( tag_name, typenames[tag_type], default=( tag_default if tag_size == 1 else [tag_default] * tag_size )) tag_entry.error = 0x00 dict.__setitem__( tags, tag_name, tag_entry ) # Use the Logix simulator by default (unless some other one was supplied as a keyword options to # main(), loaded above into 'options'). This key indexes an immutable value (not another # dotdict layer), so is not available for the web API to report/manipulate. options.setdefault( 'enip_process', logix.process ) options.setdefault( 'identity_class', identity_class ) # The Web API # Deduce web interface:port address to bind, and correct types (default is address, above). # Default to the same interface as we're bound to, port 80. We'll only start if non-empty --web # was provided, though (even if it's just ':', to get all defaults). Usually you'll want to # specify at least --web :[<port>]. http = args.web.split(':') assert 1 <= len( http ) <= 2, "Invalid --web [<interface>]:[<port>}: %s" % args.web http = ( str( http[0] ) if http[0] else bind[0], int( http[1] ) if len( http ) > 1 and http[1] else 80 ) if args.web: assert 'web' in sys.modules, "Failed to import web API module; --web option not available. Run 'pip install web.py'" logging.normal( "EtherNet/IP Simulator Web API Server: %r" % ( http, )) webserver = threading.Thread( target=web_api, kwargs={'http': http} ) webserver.daemon = True webserver.start() # The EtherNet/IP Simulator. Pass all the top-level options keys/values as keywords, and pass # the entire tags dotdict as a tags=... keyword. The server_main server.control signals (.done, # .disable) are also passed as the server= keyword. We are using an cpppo.apidict with a long # timeout; this will block the web API for several seconds to allow all threads to respond to # the signals delivered via the web API. logging.normal( "EtherNet/IP Simulator: %r" % ( bind, )) kwargs = dict( options, latency=latency, size=args.size, tags=tags, server=srv_ctl ) tf = network.server_thread tf_kwds = dict() if args.profile: tf = network.server_thread_profiling tf_kwds['filename'] = args.profile disabled = False # Recognize toggling between en/disabled while not srv_ctl.control.done: if not srv_ctl.control.disable: if disabled: logging.detail( "EtherNet/IP Server enabled" ) disabled= False network.server_main( address=bind, target=enip_srv, kwargs=kwargs, idle_service=lambda: map( lambda f: f(), idle_service ), thread_factory=tf, **tf_kwds ) else: if not disabled: logging.detail( "EtherNet/IP Server disabled" ) disabled= True time.sleep( latency ) # Still disabled; wait a bit return 0
def enip_srv( conn, addr, enip_process=None, delay=None, **kwds ): """Serve one Ethernet/IP client 'til EOF; then close the socket. Parses headers and encapsulated EtherNet/IP request data 'til either the parser fails (the Client has submitted an un-parsable request), or the request handler fails. Otherwise, encodes the data.response in an EtherNet/IP packet and sends it back to the client. Use the supplied enip_process function to process each parsed EtherNet/IP frame, returning True if a data.response is formulated, False if the session has ended cleanly, or raise an Exception if there is a processing failure (eg. an unparsable request, indicating that the Client is speaking an unknown dialect and the session must close catastrophically.) If a partial EtherNet/IP header is parsed and an EOF is received, the enip_header parser will raise an AssertionError, and we'll simply drop the connection. If we receive a valid header and request, the supplied enip_process function is expected to formulate an appropriate error response, and we'll continue processing requests. An option numeric delay value (or any delay object with a .value attribute evaluating to a numeric value) may be specified; every response will be delayed by the specified number of seconds. We assume that such a value may be altered over time, so we access it afresh for each use. All remaining keywords are passed along to the supplied enip_process function. """ global latency global timeout name = "enip_%s" % addr[1] log.normal( "EtherNet/IP Server %s begins serving peer %s", name, addr ) source = cpppo.rememberable() with parser.enip_machine( name=name, context='enip' ) as enip_mesg: # We can be provided a dotdict() to contain our stats. If one has been passed in, then this # means that our stats for this connection will be available to the web API; it may set # stats.eof to True at any time, terminating the connection! The web API will try to coerce # its input into the same type as the variable, so we'll keep it an int (type bool doesn't # handle coercion from strings). We'll use an apidict, to ensure that attribute values set # via the web API thread (eg. stats.eof) are blocking 'til this thread wakes up and reads # them. Thus, the web API will block setting .eof, and won't return to the caller until the # thread is actually in the process of shutting down. Internally, we'll use __setitem__ # indexing to change stats values, so we don't block ourself! stats = cpppo.apidict( timeout=timeout ) connkey = ( "%s_%d" % addr ).replace( '.', '_' ) connections[connkey] = stats try: assert enip_process is not None, \ "Must specify an EtherNet/IP processing function via 'enip_process'" stats['requests'] = 0 stats['received'] = 0 stats['eof'] = False stats['interface'] = addr[0] stats['port'] = addr[1] while not stats.eof: data = cpppo.dotdict() source.forget() # If no/partial EtherNet/IP header received, parsing will fail with a NonTerminal # Exception (dfa exits in non-terminal state). Build data.request.enip: begun = cpppo.timer() log.detail( "Transaction begins" ) for mch,sta in enip_mesg.run( path='request', source=source, data=data ): if sta is None: # No more transitions available. Wait for input. EOF (b'') will lead to # termination. We will simulate non-blocking by looping on None (so we can # check our options, in case they've been changed). If we still have input # available to process right now in 'source', we'll just check (0 timeout); # otherwise, use the specified server.control.latency. msg = None while msg is None and not stats.eof: wait=( kwds['server']['control']['latency'] if source.peek() is None else 0 ) brx = cpppo.timer() msg = network.recv( conn, timeout=wait ) now = cpppo.timer() log.detail( "Transaction receive after %7.3fs (%5s bytes in %7.3f/%7.3fs)" % ( now - begun, len( msg ) if msg is not None else "None", now - brx, wait )) # After each block of input (or None), check if the server is being # signalled done/disabled; we need to shut down so signal eof. Assumes # that (shared) server.control.{done,disable} dotdict be in kwds. We do # *not* read using attributes here, to avoid reporting completion to # external APIs (eg. web) awaiting reception of these signals. if kwds['server']['control']['done'] or kwds['server']['control']['disable']: log.detail( "%s done, due to server done/disable", enip_mesg.name_centered() ) stats['eof'] = True if msg is not None: stats['received']+= len( msg ) stats['eof'] = stats['eof'] or not len( msg ) log.detail( "%s recv: %5d: %s", enip_mesg.name_centered(), len( msg ) if msg is not None else 0, cpppo.reprlib.repr( msg )) source.chain( msg ) else: # No input. If we have symbols available, no problem; continue. # This can occur if the state machine cannot make a transition on # the input symbol, indicating an unacceptable sentence for the # grammar. If it cannot make progress, the machine will terminate # in a non-terminal state, rejecting the sentence. if source.peek() is not None: break # We're at a None (can't proceed), and no input is available. This # is where we implement "Blocking"; just loop. log.detail( "Transaction parsed after %7.3fs" % ( cpppo.timer() - begun )) # Terminal state and EtherNet/IP header recognized, or clean EOF (no partial # message); process and return response if 'request' in data: stats['requests'] += 1 try: # enip_process must be able to handle no request (empty data), indicating the # clean termination of the session if closed from this end (not required if # enip_process returned False, indicating the connection was terminated by # request.) delayseconds= 0 # response delay (if any) if enip_process( addr, data=data, **kwds ): # Produce an EtherNet/IP response carrying the encapsulated response data. # If no encapsulated data, ensure we also return a non-zero EtherNet/IP # status. A non-zero status indicates the end of the session. assert 'response.enip' in data, "Expected EtherNet/IP response; none found" if 'input' not in data.response.enip or not data.response.enip.input: log.warning( "Expected EtherNet/IP response encapsulated message; none found" ) assert data.response.enip.status, "If no/empty response payload, expected non-zero EtherNet/IP status" rpy = parser.enip_encode( data.response.enip ) log.detail( "%s send: %5d: %s %s", enip_mesg.name_centered(), len( rpy ), cpppo.reprlib.repr( rpy ), ("delay: %r" % delay) if delay else "" ) if delay: # A delay (anything with a delay.value attribute) == #[.#] (converible # to float) is ok; may be changed via web interface. try: delayseconds = float( delay.value if hasattr( delay, 'value' ) else delay ) if delayseconds > 0: time.sleep( delayseconds ) except Exception as exc: log.detail( "Unable to delay; invalid seconds: %r", delay ) try: conn.send( rpy ) except socket.error as exc: log.detail( "Session ended (client abandoned): %s", exc ) stats['eof'] = True if data.response.enip.status: log.warning( "Session ended (server EtherNet/IP status: 0x%02x == %d)", data.response.enip.status, data.response.enip.status ) stats['eof'] = True else: # Session terminated. No response, just drop connection. log.detail( "Session ended (client initiated): %s", parser.enip_format( data )) stats['eof'] = True log.detail( "Transaction complete after %7.3fs (w/ %7.3fs delay)" % ( cpppo.timer() - begun, delayseconds )) except: log.error( "Failed request: %s", parser.enip_format( data )) enip_process( addr, data=cpppo.dotdict() ) # Terminate. raise stats['processed'] = source.sent except: # Parsing failure. We're done. Suck out some remaining input to give us some context. stats['processed'] = source.sent memory = bytes(bytearray(source.memory)) pos = len( source.memory ) future = bytes(bytearray( b for b in source )) where = "at %d total bytes:\n%s\n%s (byte %d)" % ( stats.processed, repr(memory+future), '-' * (len(repr(memory))-1) + '^', pos ) log.error( "EtherNet/IP error %s\n\nFailed with exception:\n%s\n", where, ''.join( traceback.format_exception( *sys.exc_info() ))) raise finally: # Not strictly necessary to close (network.server_main will discard the socket, # implicitly closing it), but we'll do it explicitly here in case the thread doesn't die # for some other reason. Clean up the connections entry for this connection address. connections.pop( connkey, None ) log.normal( "%s done; processed %3d request%s over %5d byte%s/%5d received (%d connections remain)", name, stats.requests, " " if stats.requests == 1 else "s", stats.processed, " " if stats.processed == 1 else "s", stats.received, len( connections )) sys.stdout.flush() conn.close()
def main(argv=None, **kwds): """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. If a cpppo.apidict() is passed for kwds['server']['control'], we'll use it to transmit server control signals via its .done, .disable, .timeout and .latency attributes. """ global address global options global tags global srv_ctl global latency global timeout ap = argparse.ArgumentParser(description="Provide an EtherNet/IP Server", epilog="") ap.add_argument('-v', '--verbose', default=0, action="count", help="Display logging information.") ap.add_argument( '-a', '--address', default=("%s:%d" % address), help="EtherNet/IP interface[:port] to bind to (default: %s:%d)" % (address[0], address[1])) ap.add_argument('-l', '--log', help="Log file, if desired") ap.add_argument( '-w', '--web', default="", help="Web API [interface]:[port] to bind to (default: %s, port 80)" % (address[0])) ap.add_argument( '-d', '--delay', help= "Delay response to each request by a certain number of seconds (default: 0.0)", default="0.0") ap.add_argument('-p', '--profile', help="Output profiling data to a file (default: None)", default=None) ap.add_argument( 'tags', nargs="+", help= "Any tags, their type (default: INT), and number (default: 1), eg: tag=INT[1000]" ) args = ap.parse_args(argv) # Deduce interface:port address to bind, and correct types (default is address, above) bind = args.address.split(':') assert 1 <= len( bind ) <= 2, "Invalid --address [<interface>]:[<port>}: %s" % args.address bind = (str(bind[0]) if bind[0] else address[0], int(bind[1]) if len(bind) > 1 and bind[1] else 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) idle_service = None if args.log: # Output logging to a file, and handle UNIX-y log file rotation via 'logrotate', which sends # signals to indicate that a service's log file has been moved/renamed and it should re-open cpppo.log_cfg['filename'] = args.log signal.signal(signal.SIGHUP, logrotate_request) idle_service = logrotate_perform logging.basicConfig(**cpppo.log_cfg) # Pull out a 'server.control...' supplied in the keywords, and make certain it's a # cpppo.apidict. We'll use this to transmit control signals to the server thread. Set the # current values to sane initial defaults/conditions. if 'server' in kwds: assert 'control' in kwds[ 'server'], "A 'server' keyword provided without a 'control' attribute" srv_ctl = cpppo.dotdict(kwds.pop('server')) assert isinstance( srv_ctl['control'], cpppo.apidict), "The server.control... must be a cpppo.apidict" else: srv_ctl.control = cpppo.apidict(timeout=timeout) srv_ctl.control['done'] = False srv_ctl.control['disable'] = False srv_ctl.control.setdefault('latency', latency) # Global options data. Copy any remaining keyword args supplied to main(). This could # include an alternative enip_process, for example, instead of defaulting to logix.process. options.update(kwds) # Specify a response delay. The options.delay is another dotdict() layer, so it's attributes # (eg. .value, .range) are available to the web API for manipulation. Therefore, they can be # set to arbitrary values at random times! However, the type will be retained. def delay_range(*args, **kwds): """If a delay.range like ".1-.9" is specified, then change the delay.value every second to something in that range.""" assert 'delay' in kwds and 'range' in kwds['delay'] and '-' in kwds['delay']['range'], \ "No delay=#-# specified" log.normal("Delaying all responses by %s seconds", kwds['delay']['range']) while True: # Once we start, changes to delay.range will be re-evaluated each loop time.sleep(1) try: lo, hi = map(float, kwds['delay']['range'].split('-')) kwds['delay']['value'] = random.uniform(lo, hi) log.info("Mutated delay == %g", kwds['delay']['value']) except Exception as exc: log.warning("No delay=#[.#]-#[.#] range specified: %s", exc) options.delay = cpppo.dotdict() try: options.delay.value = float(args.delay) log.normal("Delaying all responses by %r seconds", options.delay.value) except: assert '-' in args.delay, \ "Unrecognized --delay=%r option" % args.delay # A range #-#; set up a thread to mutate the option.delay.value over the .range options.delay.range = args.delay options.delay.value = 0.0 mutator = threading.Thread(target=delay_range, kwargs=options) mutator.daemon = True mutator.start() # Create all the specified tags/Attributes. The enip_process function will (somehow) assign the # given tag name to reference the specified Attribute. for t in args.tags: tag_name, rest = t, '' if '=' in tag_name: tag_name, rest = tag_name.split('=', 1) tag_type, rest = rest or 'INT', '' tag_size = 1 if '[' in tag_type: tag_type, rest = tag_type.split('[', 1) assert ']' in rest, "Invalid tag; mis-matched [...]" tag_size, rest = rest.split(']', 1) assert not rest, "Invalid tag specified; expected tag=<type>[<size>]: %r" % t tag_type = str(tag_type).upper() typenames = { "INT": parser.INT, "DINT": parser.DINT, "SINT": parser.SINT, "REAL": parser.REAL } assert tag_type in typenames, "Invalid tag type; must be one of %r" % list( typenames.keys()) try: tag_size = int(tag_size) except: raise AssertionError("Invalid tag size: %r" % tag_size) # Ready to create the tag and its Attribute (and error code to return, if any). If tag_size # is 1, it will be a scalar Attribute. log.normal("Creating tag: %s=%s[%d]", tag_name, tag_type, tag_size) tags[tag_name] = cpppo.dotdict() tags[tag_name].attribute = device.Attribute( tag_name, typenames[tag_type], default=(0 if tag_size == 1 else [0] * tag_size)) tags[tag_name].error = 0x00 # Use the Logix simulator by default (unless some other one was supplied as a keyword options to # main(), loaded above into 'options'). This key indexes an immutable value (not another dotdict # layer), so is not available for the web API to report/manipulate. options.setdefault('enip_process', logix.process) # The Web API # Deduce web interface:port address to bind, and correct types (default is address, above). # Default to the same interface as we're bound to, port 80. We'll only start if non-empty --web # was provided, though (even if it's just ':', to get all defaults). Usually you'll want to # specify at least --web :[<port>]. http = args.web.split(':') assert 1 <= len( http) <= 2, "Invalid --web [<interface>]:[<port>}: %s" % args.web http = (str(http[0]) if http[0] else bind[0], int(http[1]) if len(http) > 1 and http[1] else 80) if args.web: assert 'web' in sys.modules, "Failed to import web API module; --web option not available. Run 'pip install web.py'" logging.normal("EtherNet/IP Simulator Web API Server: %r" % (http, )) webserver = threading.Thread(target=web_api, kwargs={'http': http}) webserver.daemon = True webserver.start() # The EtherNet/IP Simulator. Pass all the top-level options keys/values as keywords, and pass # the entire tags dotdict as a tags=... keyword. The server_main server.control signals (.done, # .disable) are also passed as the server= keyword. We are using an cpppo.apidict with a long # timeout; this will block the web API for several seconds to allow all threads to respond to # the signals delivered via the web API. logging.normal("EtherNet/IP Simulator: %r" % (bind, )) kwargs = dict(options, latency=latency, tags=tags, server=srv_ctl) tf = network.server_thread tf_kwds = dict() if args.profile: tf = network.server_thread_profiling tf_kwds['filename'] = args.profile disabled = False # Recognize toggling between en/disabled while not srv_ctl.control.done: if not srv_ctl.control.disable: if disabled: logging.detail("EtherNet/IP Server enabled") disabled = False network.server_main(address=bind, target=enip_srv, kwargs=kwargs, idle_service=idle_service, thread_factory=tf, **tf_kwds) else: if not disabled: logging.detail("EtherNet/IP Server disabled") disabled = True time.sleep(latency) # Still disabled; wait a bit return 0
def enip_srv(conn, addr, enip_process=None, delay=None, **kwds): """Serve one Ethernet/IP client 'til EOF; then close the socket. Parses headers and encapsulated EtherNet/IP request data 'til either the parser fails (the Client has submitted an un-parsable request), or the request handler fails. Otherwise, encodes the data.response in an EtherNet/IP packet and sends it back to the client. Use the supplied enip_process function to process each parsed EtherNet/IP frame, returning True if a data.response is formulated, False if the session has ended cleanly, or raise an Exception if there is a processing failure (eg. an unparsable request, indicating that the Client is speaking an unknown dialect and the session must close catastrophically.) If a partial EtherNet/IP header is parsed and an EOF is received, the enip_header parser will raise an AssertionError, and we'll simply drop the connection. If we receive a valid header and request, the supplied enip_process function is expected to formulate an appropriate error response, and we'll continue processing requests. An option numeric delay value (or any delay object with a .value attribute evaluating to a numeric value) may be specified; every response will be delayed by the specified number of seconds. We assume that such a value may be altered over time, so we access it afresh for each use. All remaining keywords are passed along to the supplied enip_process function. """ global latency global timeout name = "enip_%s" % addr[1] log.normal("EtherNet/IP Server %s begins serving peer %s", name, addr) source = cpppo.rememberable() with parser.enip_machine(name=name, context='enip') as enip_mesg: # We can be provided a dotdict() to contain our stats. If one has been passed in, then this # means that our stats for this connection will be available to the web API; it may set # stats.eof to True at any time, terminating the connection! The web API will try to coerce # its input into the same type as the variable, so we'll keep it an int (type bool doesn't # handle coercion from strings). We'll use an apidict, to ensure that attribute values set # via the web API thread (eg. stats.eof) are blocking 'til this thread wakes up and reads # them. Thus, the web API will block setting .eof, and won't return to the caller until the # thread is actually in the process of shutting down. Internally, we'll use __setitem__ # indexing to change stats values, so we don't block ourself! stats = cpppo.apidict(timeout=timeout) connkey = ("%s_%d" % addr).replace('.', '_') connections[connkey] = stats try: assert enip_process is not None, \ "Must specify an EtherNet/IP processing function via 'enip_process'" stats['requests'] = 0 stats['received'] = 0 stats['eof'] = False stats['interface'] = addr[0] stats['port'] = addr[1] while not stats.eof: data = cpppo.dotdict() source.forget() # If no/partial EtherNet/IP header received, parsing will fail with a NonTerminal # Exception (dfa exits in non-terminal state). Build data.request.enip: begun = misc.timer() log.detail("Transaction begins") states = 0 for mch, sta in enip_mesg.run(path='request', source=source, data=data): states += 1 if sta is None: # No more transitions available. Wait for input. EOF (b'') will lead to # termination. We will simulate non-blocking by looping on None (so we can # check our options, in case they've been changed). If we still have input # available to process right now in 'source', we'll just check (0 timeout); # otherwise, use the specified server.control.latency. msg = None while msg is None and not stats.eof: wait = (kwds['server']['control']['latency'] if source.peek() is None else 0) brx = misc.timer() msg = network.recv(conn, timeout=wait) now = misc.timer() log.detail( "Transaction receive after %7.3fs (%5s bytes in %7.3f/%7.3fs)" % (now - begun, len(msg) if msg is not None else "None", now - brx, wait)) # After each block of input (or None), check if the server is being # signalled done/disabled; we need to shut down so signal eof. Assumes # that (shared) server.control.{done,disable} dotdict be in kwds. We do # *not* read using attributes here, to avoid reporting completion to # external APIs (eg. web) awaiting reception of these signals. if kwds['server']['control']['done'] or kwds[ 'server']['control']['disable']: log.detail( "%s done, due to server done/disable", enip_mesg.name_centered()) stats['eof'] = True if msg is not None: stats['received'] += len(msg) stats['eof'] = stats['eof'] or not len(msg) log.detail("%s recv: %5d: %s", enip_mesg.name_centered(), len(msg) if msg is not None else 0, reprlib.repr(msg)) source.chain(msg) else: # No input. If we have symbols available, no problem; continue. # This can occur if the state machine cannot make a transition on # the input symbol, indicating an unacceptable sentence for the # grammar. If it cannot make progress, the machine will terminate # in a non-terminal state, rejecting the sentence. if source.peek() is not None: break # We're at a None (can't proceed), and no input is available. This # is where we implement "Blocking"; just loop. log.detail("Transaction parsed after %7.3fs" % (misc.timer() - begun)) # Terminal state and EtherNet/IP header recognized, or clean EOF (no partial # message); process and return response log.info("%s req. data (%5d states): %s", enip_mesg.name_centered(), states, parser.enip_format(data)) if 'request' in data: stats['requests'] += 1 try: # enip_process must be able to handle no request (empty data), indicating the # clean termination of the session if closed from this end (not required if # enip_process returned False, indicating the connection was terminated by # request.) delayseconds = 0 # response delay (if any) if enip_process(addr, data=data, **kwds): # Produce an EtherNet/IP response carrying the encapsulated response data. assert 'response' in data, "Expected EtherNet/IP response; none found" assert 'enip.input' in data.response, \ "Expected EtherNet/IP response encapsulated message; none found" rpy = parser.enip_encode(data.response.enip) log.detail("%s send: %5d: %s %s", enip_mesg.name_centered(), len(rpy), reprlib.repr(rpy), ("delay: %r" % delay) if delay else "") if delay: # A delay (anything with a delay.value attribute) == #[.#] (converible # to float) is ok; may be changed via web interface. try: delayseconds = float(delay.value if hasattr( delay, 'value') else delay) if delayseconds > 0: time.sleep(delayseconds) except Exception as exc: log.detail( "Unable to delay; invalid seconds: %r", delay) try: conn.send(rpy) except socket.error as exc: log.detail( "%s session ended (client abandoned): %s", enip_mesg.name_centered(), exc) eof = True else: # Session terminated. No response, just drop connection. log.detail("%s session ended (client initiated): %s", enip_mesg.name_centered(), parser.enip_format(data)) eof = True log.detail( "Transaction complete after %7.3fs (w/ %7.3fs delay)" % (misc.timer() - begun, delayseconds)) except: log.error("Failed request: %s", parser.enip_format(data)) enip_process(addr, data=cpppo.dotdict()) # Terminate. raise stats['processed'] = source.sent except: # Parsing failure. We're done. Suck out some remaining input to give us some context. stats['processed'] = source.sent memory = bytes(bytearray(source.memory)) pos = len(source.memory) future = bytes(bytearray(b for b in source)) where = "at %d total bytes:\n%s\n%s (byte %d)" % ( stats.processed, repr(memory + future), '-' * (len(repr(memory)) - 1) + '^', pos) log.error("EtherNet/IP error %s\n\nFailed with exception:\n%s\n", where, ''.join(traceback.format_exception(*sys.exc_info()))) raise finally: # Not strictly necessary to close (network.server_main will discard the socket, # implicitly closing it), but we'll do it explicitly here in case the thread doesn't die # for some other reason. Clean up the connections entry for this connection address. connections.pop(connkey, None) log.normal( "%s done; processed %3d request%s over %5d byte%s/%5d received (%d connections remain)", name, stats.requests, " " if stats.requests == 1 else "s", stats.processed, " " if stats.processed == 1 else "s", stats.received, len(connections)) sys.stdout.flush() conn.close()
def main(argv=None, attribute_class=device.Attribute, idle_service=None, identity_class=None, UCMM_class=None, message_router_class=None, connection_manager_class=None, **kwds): """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. If a cpppo.apidict() is passed for kwds['server']['control'], we'll use it to transmit server control signals via its .done, .disable, .timeout and .latency attributes. Uses the provided attribute_class (default: device.Attribute) to process all EtherNet/IP attribute I/O (eg. Read/Write Tag [Fragmented]) requests. By default, device.Attribute stores and retrieves the supplied data. To perform other actions (ie. forward the data to your own application), derive from device.Attribute, and override the __getitem__ and __setitem__ methods. If an idle_service function is provided, it will be called after a period of latency between incoming requests. """ global address global options global tags global srv_ctl global latency global timeout ap = argparse.ArgumentParser(description="Provide an EtherNet/IP Server", epilog="") ap.add_argument('-v', '--verbose', default=0, action="count", help="Display logging information.") ap.add_argument( '-a', '--address', default=("%s:%d" % address), help="EtherNet/IP interface[:port] to bind to (default: %s:%d)" % (address[0], 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( '-w', '--web', default="", help="Web API [interface]:[port] to bind to (default: %s, port 80)" % (address[0])) ap.add_argument( '-d', '--delay', default="0.0", help= "Delay response to each request by a certain number of seconds (default: 0.0)" ) ap.add_argument( '-s', '--size', help= "Limit EtherNet/IP encapsulated request size to the specified number of bytes (default: None)", default=None) ap.add_argument( '--route-path', default=None, help= "Route Path, in JSON, eg. %r (default: None); 0/false to accept only empty route_path" % (str(json.dumps(route_path_default)))) ap.add_argument('-P', '--profile', default=None, help="Output profiling data to a file (default: None)") ap.add_argument( 'tags', nargs="+", help= "Any tags, their type (default: INT), and number (default: 1), eg: tag=INT[1000]" ) args = ap.parse_args(argv) # Deduce interface:port address to bind, and correct types (default is address, above) bind = args.address.split(':') assert 1 <= len( bind ) <= 2, "Invalid --address [<interface>]:[<port>}: %s" % args.address bind = (str(bind[0]) if bind[0] else address[0], int(bind[1]) if len(bind) > 1 and bind[1] else 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) # Chain any provided idle_service function with log rotation; these may (also) consult global # signal flags such as logrotate_request, so execute supplied functions before logrotate_perform idle_service = [idle_service] if idle_service else [] if args.log: # Output logging to a file, and handle UNIX-y log file rotation via 'logrotate', which sends # signals to indicate that a service's log file has been moved/renamed and it should re-open cpppo.log_cfg['filename'] = args.log signal.signal(signal.SIGHUP, logrotate_request) idle_service.append(logrotate_perform) logging.basicConfig(**cpppo.log_cfg) # Pull out a 'server.control...' supplied in the keywords, and make certain it's a # cpppo.apidict. We'll use this to transmit control signals to the server thread. Set the # current values to sane initial defaults/conditions. if 'server' in kwds: assert 'control' in kwds[ 'server'], "A 'server' keyword provided without a 'control' attribute" srv_ctl = cpppo.dotdict(kwds.pop('server')) assert isinstance( srv_ctl['control'], cpppo.apidict), "The server.control... must be a cpppo.apidict" else: srv_ctl.control = cpppo.apidict(timeout=timeout) srv_ctl.control['done'] = False srv_ctl.control['disable'] = False srv_ctl.control.setdefault('latency', latency) # Global options data. Copy any remaining keyword args supplied to main(). This could # include an alternative enip_process, for example, instead of defaulting to logix.process. options.update(kwds) # Specify a response delay. The options.delay is another dotdict() layer, so it's attributes # (eg. .value, .range) are available to the web API for manipulation. Therefore, they can be # set to arbitrary values at random times! However, the type will be retained. def delay_range(*args, **kwds): """If a delay.range like ".1-.9" is specified, then change the delay.value every second to something in that range.""" assert 'delay' in kwds and 'range' in kwds['delay'] and '-' in kwds['delay']['range'], \ "No delay=#-# specified" log.normal("Delaying all responses by %s seconds", kwds['delay']['range']) while True: # Once we start, changes to delay.range will be re-evaluated each loop time.sleep(1) try: lo, hi = map(float, kwds['delay']['range'].split('-')) kwds['delay']['value'] = random.uniform(lo, hi) log.info("Mutated delay == %g", kwds['delay']['value']) except Exception as exc: log.warning("No delay=#[.#]-#[.#] range specified: %s", exc) options.delay = cpppo.dotdict() try: options.delay.value = float(args.delay) log.normal("Delaying all responses by %r seconds", options.delay.value) except: assert '-' in args.delay, \ "Unrecognized --delay=%r option" % args.delay # A range #-#; set up a thread to mutate the option.delay.value over the .range options.delay.range = args.delay options.delay.value = 0.0 mutator = threading.Thread(target=delay_range, kwargs=options) mutator.daemon = True mutator.start() # Create all the specified tags/Attributes. The enip_process function will (somehow) assign the # given tag name to reference the specified Attribute. We'll define an Attribute to print # I/O if args.print is specified; reads will only be logged at logging.NORMAL and above. class Attribute_print(attribute_class): def __getitem__(self, key): value = super(Attribute_print, self).__getitem__(key) if log.isEnabledFor(logging.NORMAL): print("%20s[%5s-%-5s] == %s" % (self.name, key.indices(len(self))[0] if isinstance( key, slice) else key, key.indices(len(self))[1] - 1 if isinstance(key, slice) else key, value)) return value def __setitem__(self, key, value): super(Attribute_print, self).__setitem__(key, value) print("%20s[%5s-%-5s] <= %s" % (self.name, key.indices(len(self))[0] if isinstance( key, slice) else key, key.indices(len(self))[1] - 1 if isinstance(key, slice) else key, value)) for t in args.tags: tag_name, rest = t, '' if '=' in tag_name: tag_name, rest = tag_name.split('=', 1) tag_type, rest = rest or 'INT', '' tag_size = 1 if '[' in tag_type: tag_type, rest = tag_type.split('[', 1) assert ']' in rest, "Invalid tag; mis-matched [...]" tag_size, rest = rest.split(']', 1) assert not rest, "Invalid tag specified; expected tag=<type>[<size>]: %r" % t tag_type = str(tag_type).upper() typenames = { "BOOL": (parser.BOOL, 0), "INT": (parser.INT, 0), "DINT": (parser.DINT, 0), "SINT": (parser.SINT, 0), "REAL": (parser.REAL, 0.0), "SSTRING": (parser.SSTRING, ''), } assert tag_type in typenames, "Invalid tag type; must be one of %r" % list( typenames) tag_class, tag_default = typenames[tag_type] try: tag_size = int(tag_size) except: raise AssertionError("Invalid tag size: %r" % tag_size) # Ready to create the tag and its Attribute (and error code to return, if any). If tag_size # is 1, it will be a scalar Attribute. Since the tag_name may contain '.', we don't want # the normal dotdict.__setitem__ resolution to parse it; use plain dict.__setitem__. log.normal("Creating tag: %s=%s[%d]", tag_name, tag_type, tag_size) tag_entry = cpppo.dotdict() tag_entry.attribute = ( Attribute_print if args.print else attribute_class)( tag_name, tag_class, default=(tag_default if tag_size == 1 else [tag_default] * tag_size)) tag_entry.error = 0x00 dict.__setitem__(tags, tag_name, tag_entry) # Use the Logix simulator and all the basic required default CIP message processing classes by # default (unless some other one was supplied as a keyword options to main(), loaded above into # 'options'). This key indexes an immutable value (not another dotdict layer), so is not # available for the web API to report/manipulate. By default, we'll specify no route_path, so # any request route_path will be accepted. Otherwise, we'll create a UCMM-derived class with # the specified route_path, which will filter only requests w/ the correct route_path. options.setdefault('enip_process', logix.process) if identity_class: options.setdefault('identity_class', identity_class) assert not UCMM_class or not args.route_path, \ "Specify either a route-path, or a custom UCMM_class; not both" if args.route_path is not None: # Must be JSON, eg. '[{"link"...}]', or '0'/'false' to explicitly specify no route_path # accepted (must be empty in request) class UCMM_class_with_route(device.UCMM): route_path = json.loads(args.route_path) UCMM_class = UCMM_class_with_route if UCMM_class: options.setdefault('UCMM_class', UCMM_class) if message_router_class: options.setdefault('message_router_class', message_router_class) if connection_manager_class: options.setdefault('connection_manager_class', connection_manager_class) # The Web API # Deduce web interface:port address to bind, and correct types (default is address, above). # Default to the same interface as we're bound to, port 80. We'll only start if non-empty --web # was provided, though (even if it's just ':', to get all defaults). Usually you'll want to # specify at least --web :[<port>]. http = args.web.split(':') assert 1 <= len( http) <= 2, "Invalid --web [<interface>]:[<port>}: %s" % args.web http = (str(http[0]) if http[0] else bind[0], int(http[1]) if len(http) > 1 and http[1] else 80) if args.web: assert 'web' in sys.modules, "Failed to import web API module; --web option not available. Run 'pip install web.py'" logging.normal("EtherNet/IP Simulator Web API Server: %r" % (http, )) webserver = threading.Thread(target=web_api, kwargs={'http': http}) webserver.daemon = True webserver.start() # The EtherNet/IP Simulator. Pass all the top-level options keys/values as keywords, and pass # the entire tags dotdict as a tags=... keyword. The server_main server.control signals (.done, # .disable) are also passed as the server= keyword. We are using an cpppo.apidict with a long # timeout; this will block the web API for several seconds to allow all threads to respond to # the signals delivered via the web API. logging.normal("EtherNet/IP Simulator: %r" % (bind, )) kwargs = dict(options, latency=latency, size=args.size, tags=tags, server=srv_ctl) tf = network.server_thread tf_kwds = dict() if args.profile: tf = network.server_thread_profiling tf_kwds['filename'] = args.profile disabled = False # Recognize toggling between en/disabled while not srv_ctl.control.done: if not srv_ctl.control.disable: if disabled: logging.detail("EtherNet/IP Server enabled") disabled = False network.server_main( address=bind, target=enip_srv, kwargs=kwargs, idle_service=lambda: map(lambda f: f(), idle_service), thread_factory=tf, **tf_kwds) else: if not disabled: logging.detail("EtherNet/IP Server disabled") disabled = True time.sleep(latency) # Still disabled; wait a bit return 0