def start_powerflex_simulator( *options, **kwds ): """Start a simple EtherNet/IP CIP simulator (execute this file as __main__), optionally with Tag=<type>[<size>] (or other) positional arguments appended to the command-line. Return the command-line used, and the detected (host,port) address bound. Looks for something like: 11-11 11:46:16.301 7fff7a619000 network NORMAL server_mai enip_srv server PID [ 7573] running on ('', 44818) containing a repr of the (<host>,<port>) tuple. Recover this address using the safe ast.literal_eval. Use the -A to provide this on stdout, or just -v if stderr is redirected to stdout (the default, w/o a stderr parameter to nonblocking_command) At least one positional parameter containing a Tag=<type>[<size>] must be provided. Note that the output of this file's interpreter is not *unbuffered* (above), so we can receive and parse the 'running on ...'! We assume that server/network.py flushes stdout when printing the bindings. We could use #!/usr/bin/env -S python3 -u instead to have all output unbuffered. """ command = nonblocking_command( [ sys.executable, os.path.abspath( __file__ ), '-a', ':0', '-A', '-p', '-v', '--no-udp', ] + list( options ), stderr=None, bufsize=0, blocking=None ) # For python 2/3 compatibility (can't mix positional wildcard, keyword parameters in Python 2) CMD_WAIT = kwds.pop( 'CMD_WAIT', 10.0 ) CMD_LATENCY = kwds.pop( 'CMD_LATENCY', 0.1 ) assert not kwds, "Unrecognized keyword parameter: %s" % ( ", ".join( kwds )) begun = timer() address = None data = '' while address is None and timer() - begun < CMD_WAIT: # On Python2, socket will raise IOError/EAGAIN; on Python3 may return None 'til command started. raw = None try: raw = command.stdout.read() logging.debug( "Socket received: %r", raw) if raw: data += raw.decode( 'utf-8', 'backslashreplace' ) except IOError as exc: logging.debug( "Socket blocking...: {exc}".format( exc=exc )) assert exc.errno == errno.EAGAIN, "Expected only Non-blocking IOError" except Exception as exc: logging.warning("Socket read return Exception: %s", exc) if not raw: # got nothing; wait a bit time.sleep( CMD_LATENCY ) while data.find( '\n' ) >= 0: line,data = data.split( '\n', 1 ) logging.info( "%s", line ) m = re.search( r"running on (\([^)]*\))", line ) if m: address = ast.literal_eval( m.group(1).strip() ) logging.normal( "EtherNet/IP CIP Simulator started after %7.3fs on %s:%d", timer() - begun, address[0], address[1] ) break return command,address
def start_powerflex_simulator(*options, **kwds): """Start a simple EtherNet/IP CIP simulator (execute this file as __main__), optionally with Tag=<type>[<size>] (or other) positional arguments appended to the command-line. Return the command-line used, and the detected (host,port) address bound. Looks for something like: 11-11 11:46:16.301 7fff7a619000 network NORMAL server_mai enip_srv server PID [ 7573] running on ('', 44818) containing a repr of the (<host>,<port>) tuple. Recover this address using the safe ast.literal_eval. At least one positional parameter containing a Tag=<type>[<size>] must be provided. """ command = nonblocking_command([ 'python', os.path.abspath(__file__), '-v', ] + list(options)) # For python 2/3 compatibility (can't mix positional wildcard, keyword parameters in Python 2) CMD_WAIT = kwds.pop('CMD_WAIT', 10.0) CMD_LATENCY = kwds.pop('CMD_LATENCY', 0.1) assert not kwds, "Unrecognized keyword parameter: %s" % (", ".join(kwds)) begun = timer() address = None data = '' while address is None and timer() - begun < CMD_WAIT: # On Python2, socket will raise IOError/EAGAIN; on Python3 may return None 'til command started. try: raw = command.stdout.read() logging.debug("Socket received: %r", raw) if raw: data += raw.decode('utf-8') except IOError as exc: logging.debug("Socket blocking...") assert exc.errno == errno.EAGAIN, "Expected only Non-blocking IOError" except Exception as exc: logging.warning("Socket read return Exception: %s", exc) if not data: time.sleep(CMD_LATENCY) while data.find('\n') >= 0: line, data = data.split('\n', 1) logging.info("%s", line) m = re.search(r"running on (\([^)]*\))", line) if m: address = ast.literal_eval(m.group(1).strip()) logging.normal( "EtherNet/IP CIP Simulator started after %7.3fs on %s:%d", timer() - begun, address[0], address[1]) break return command, address
def start_powerflex_simulator( *options, **kwds ): """Start a simple EtherNet/IP CIP simulator (execute this file as __main__), optionally with Tag=<type>[<size>] (or other) positional arguments appended to the command-line. Return the command-line used, and the detected (host,port) address bound. Looks for something like: 11-11 11:46:16.301 7fff7a619000 network NORMAL server_mai enip_srv server PID [ 7573] running on ('', 44818) containing a repr of the (<host>,<port>) tuple. Recover this address using the safe ast.literal_eval. At least one positional parameter containing a Tag=<type>[<size>] must be provided. """ command = nonblocking_command( [ 'python', os.path.abspath( __file__ ), '-v', ] + list( options )) # For python 2/3 compatibility (can't mix positional wildcard, keyword parameters in Python 2) CMD_WAIT = kwds.pop( 'CMD_WAIT', 10.0 ) CMD_LATENCY = kwds.pop( 'CMD_LATENCY', 0.1 ) assert not kwds, "Unrecognized keyword parameter: %s" % ( ", ".join( kwds )) begun = timer() address = None data = '' while address is None and timer() - begun < CMD_WAIT: # On Python2, socket will raise IOError/EAGAIN; on Python3 may return None 'til command started. try: raw = command.stdout.read() logging.debug( "Socket received: %r", raw) if raw: data += raw.decode( 'utf-8' ) except IOError as exc: logging.debug( "Socket blocking...") assert exc.errno == errno.EAGAIN, "Expected only Non-blocking IOError" except Exception as exc: logging.warning("Socket read return Exception: %s", exc) if not data: time.sleep( CMD_LATENCY ) while data.find( '\n' ) >= 0: line,data = data.split( '\n', 1 ) logging.info( "%s", line ) m = re.search( "running on (\([^)]*\))", line ) if m: address = ast.literal_eval( m.group(1).strip() ) logging.normal( "EtherNet/IP CIP Simulator started after %7.3fs on %s:%d", timer() - begun, address[0], address[1] ) break return command,address
def await (pred, what="predicate", delay=1.0, intervals=10): """Await the given predicate, returning: (success,elapsed)""" begun = misc.timer() truth = False for _ in range(intervals): truth = pred() if truth: break time.sleep(delay / intervals) now = misc.timer() elapsed = now - begun log.info("After %7.3f/%7.3f %s %s" % (elapsed, delay, "detected" if truth else "missed ", what)) return truth, elapsed
def simulated_modbus_plc(wait=2.0, latency=.05): """Start a simulator over a range of ports; parse the port successfully bound.""" command = misc.nonblocking_command([ os.path.join('.', 'bin', 'modbus_sim.py'), '-vvv', '--log', 'remote_test.modbus_sim.log', '--evil', 'delay:.25', '--address', 'localhost:11502', '--range', '10', '1-1000=0', '40001-41000=0', ]) begun = misc.timer() iface = '' port = None data = '' while port is None and misc.timer() - begun < wait: # On Python2, socket will raise IOError/EAGAIN; on Python3 may return None 'til command started. try: raw = command.stdout.read() logging.debug("Socket received: %r", raw) if raw: data += raw.decode('utf-8') except IOError as exc: logging.debug("Socket blocking...") assert exc.errno == errno.EAGAIN, "Expected only Non-blocking IOError" except Exception as exc: logging.warning("Socket read return Exception: %s", exc) if not data: time.sleep(latency) while data.find('\n') >= 0: line, data = data.split('\n', 1) m = re.search("address = ([^:]*):(\d*)", line) if m: iface, port = m.group(1), int(m.group(2)) log.normal( "Modbus/TCP Simulator started after %7.3fs on %s:%s", misc.timer() - begun, iface, port) break return command, (iface, port)
def failure( exc ): logging.normal( "failed: %s", exc ) elapsed = int(( timer() - failure.start ) * 1000 ) # ms. failed[elapsed] = str( exc )
def test_powerflex_poll_failure(): """No PowerFlex simulator alive; should see exponential back-off. Test that the poll.poll API can withstand gateway failures, and robustly continue polling. """ #logging.getLogger().setLevel( logging.INFO ) def null_server( conn, addr, server=None ): """Fake up an EtherNet/IP server that just sends a canned EtherNet/IP CIP Register and Identity string response, to fake the poll client into sending a poll request into a closed socket. Immediately does a shutdown of the incoming half of the socket, and then closes the connection after sending the fake replies, usually resulting in an excellent EPIPE/SIGPIPE on the client. Use port 44819, to avoid interference by (possibly slow-to-exit) simulators running on port 44818. """ logging.normal( "null_server on %s starting" % ( addr, )) conn.shutdown( socket.SHUT_RD ) time.sleep( 0.1 ) conn.send( b'e\x00\x04\x00\xc9wH\x81\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00' ) conn.send( b'c\x00;\x00\xd4/\x9dm\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x0c\x005\x00\x01\x00\x00\x02\xaf\x12\n\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x0e\x006\x00\x14\x0b`1\x1a\x06l\x00\x13PowerFlex/20-COMM-E\xff' ) conn.close() while server and not server.control.done: time.sleep( .1 ) logging.normal( "null_server on %s done" % ( addr, )) try: values = {} # { <parameter>: <value> } failed = {} # { <time>: <exc> } control = dotdict() control.done = False for _ in range( 3 ): server = threading.Thread( target=network.server_main, kwargs={ 'address': ('',44819), 'target': null_server, 'kwargs': { 'server': dotdict({ 'control': control }) }, 'udp': False, # no UDP server in this test }) server.daemon = True server.start() time.sleep(.5) if server.is_alive: break assert server.is_alive, "Unable to start null_server on INADDR_ANY" def process( p, v ): logging.normal( "process: %16s == %s", p, v ) values[p] = v process.done = False def failure( exc ): logging.normal( "failed: %s", exc ) elapsed = int(( timer() - failure.start ) * 1000 ) # ms. failed[elapsed] = str( exc ) failure.start = timer() backoff_min = 0.5 backoff_max = 4.0 backoff_multiplier = 2.0 # --> backoff == .5, 1.0, 2.0, 4.0 poller = threading.Thread( target=poll.poll, kwargs={ 'proxy_class': powerflex_750_series, 'address': ('localhost',44819), 'cycle': 1.0, 'timeout': 0.5, 'backoff_min': backoff_min, 'backoff_max': backoff_max, 'backoff_multiplier': backoff_multiplier, 'process': process, 'failure': failure, }) poller.deamon = True poller.start() try: # Polling starts immediately, but the first poll occurs after an attempt to get the # Identity string, hence two timeouts for the first poll failure. while len( failed ) < 3 and timer() - failure.start < 10.0: time.sleep(.1) finally: process.done = True control.done = True poller.join( backoff_max + 1.0 ) # allow for backoff_max before loop check assert not poller.is_alive(), "Poller Thread failed to terminate" server.join( 1.0 ) assert not server.is_alive(), "Server Thread failed to terminate" # Check that each failure is (at least) the expected backoff from the last assert len( failed ) > 0 k_last = None backoff = backoff_min for k in sorted( failed ): logging.normal( "Poll failure at %4dms (next backoff: %7.3fs): %s", k, backoff, failed[k] ) if k_last is not None: assert k - k_last >= backoff backoff = min( backoff_max, backoff * backoff_multiplier ) k_last = k assert len( values ) == 0 except Exception as exc: logging.warning( "Test terminated with exception: %s", exc ) raise
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 api_request(group, match, command, value, queries=None, environ=None, accept=None, framework=None): """Return a JSON object containing the response to the request: { data: { ... }, ..., } The data list contains objects representing all matching objects, executing the optional command. If an accept encoding is supplied, use it. Otherwise, detect it from the environ's' "HTTP_ACCEPT"; default to "application/json". group -- A device group, w/globbing; no default match -- A device id match, w/globbing; default is: '*' command -- The command to execute on the device; default is: 'get' value -- All remaining query parameters; default is: [] queries -- All HTTP queries and/or form parameters environ -- The HTTP request environment accept -- A forced MIME encoding (eg. application/json). framework -- The web framework module being used """ global options global connections global tags global srv_ctl accept = deduce_encoding( ["application/json", "text/javascript", "text/plain", "text/html"], environ=environ, accept=accept) # Deduce the device group and id match, and the optional command and value. # Values provided on the URL are overridden by those provided as query options. if "group" in queries and queries["group"]: group = queries["group"] del queries["group"] if not group: group = "*" if "match" in queries and queries["match"]: match = queries["match"] del queries["match"] if not match: match = "*" # The command/value defaults to the HTTP request, but also may be overridden by # the query option. if "command" in queries and queries["command"]: command = queries["command"] del queries["command"] if "value" in queries and queries["value"]: value = queries["value"] del queries["value"] # The "since" query option may be supplied, and is used to prevent (None) or # limit (0,...) the "alarm" responses to those that have been updated/added # since the specified time. since = None if "since" in queries and queries["since"]: since = float(queries["since"]) del queries["since"] # Collect up all the matching objects, execute any command, and then get # their attributes, adding any command { success: ..., message: ... } now = misc.timer() content = { "alarm": [], "command": None, "data": {}, "since": since, # time, 0, None (null) "until": misc.timer(), # time (default, unless we return alarms) } logging.debug( "Searching for %s/%s, since: %s (%s)" % (group, match, since, None if since is None else time.ctime(since))) # Effectively: # group.match.command = value # Look through each "group" object's dir of available attributes for "match". Then, see if # that target attribute exists, and is something we can get attributes from. for grp, obj in [('options', options), ('connections', connections), ('tags', tags), ('server', srv_ctl)]: for mch in [m for m in dir(obj) if not m.startswith('_')]: log.detail("Evaluating %s.%s: %r", grp, mch, getattr(obj, mch, None)) if not fnmatch.fnmatch(grp, group): continue if not fnmatch.fnmatch(mch, match): continue target = getattr(obj, mch, None) if not target: log.warning("Couldn't find advertised attribute %s.%s", grp, mch) continue if not hasattr(target, '__getattr__'): continue # The obj's group name 'grp' matches requested group (glob), and the entry 'mch' matches # request match (glob). /<group>/<match> matches this obj.key. result = {} if command is not None: # A request/assignment is being made. Retain the same type as the current value, # and allow indexing! We want to ensure that we don't cause failures by corrupting # the types of value. Unfortunately, this makes it tricky to support "bool", as # coercion from string is not supported. try: cur = getattr(target, command) result["message"] = "%s.%s.%s: %r" % (grp, mch, command, cur) if value is not None: typ = type(cur) if typ is bool: # Either 'true'/'yes' or 'false'/'no', otherwise it must be a number if value.lower() in ('true', 'yes'): cvt = True elif value.lower() in ('false', 'no'): cvt = False else: cvt = bool(int(value)) else: cvt = typ(value) setattr(target, command, cvt) result["message"] = "%s.%s.%s=%r (%r)" % ( grp, mch, command, value, cvt) result["success"] = True except Exception as exc: result["success"] = False result["message"] = "%s.%s.%s=%r failed: %s" % ( grp, mch, command, value, exc) logging.warning("%s.%s.%s=%s failed: %s\n%s" % (grp, mch, command, value, exc, traceback.format_exc())) # Get all of target's attributes (except _*) advertised by its dir() results attrs = [a for a in dir(target) if not a.startswith('_')] data = {a: getattr(target, a) for a in attrs} content["command"] = result content["data"].setdefault(grp, {})[mch] = data # Report the end of the time-span of alarm results returned; if none, then # the default time will be the _timer() at beginning of this function. This # ensures we don't duplicately report alarms (or miss any) if content["alarm"]: content["until"] = content["alarm"][0]["time"] # JSON response = json.dumps(content, sort_keys=True, indent=4, default=lambda obj: repr(obj)) if accept in ("text/html"): # HTML; dump any request query options, wrap JSON response in <pre> response = html_wrap( "Response:", "h2" ) \ + html_wrap( response, "pre" ) response = html_wrap( "Queries:", "h2" ) \ + html_wrap( "\n".join( ( "%(query)-16.16s %(value)r" % { "query": str( query ) + ":", "value": value, } for iterable in ( queries, [("group", group), ("match", match), ("command", command), ("value", value), ("since", since), ] ) for query, value in iterable )), tag="pre" ) \ + response response = html_head( response, title='/'.join(["api", group or '', match or '', command or ''])) elif accept and accept not in ("application/json", "text/javascript", "text/plain"): # Invalid encoding requested. Return appropriate 406 Not Acceptable message = "Invalid encoding: %s, for Accept: %s" % ( accept, environ.get("HTTP_ACCEPT", "*.*")) raise http_exception(framework, 406, message) # Return the content-type we've agreed to produce, and the result. return accept, response
def test_powerflex_poll_failure(): """No PowerFlex simulator alive; should see exponential back-off. Test that the poll.poll API can withstand gateway failures, and robustly continue polling. """ #logging.getLogger().setLevel( logging.NORMAL ) def null_server( conn, addr, server=None ): """Fake up an EtherNet/IP server that just sends a canned EtherNet/IP CIP Register and Identity string response, to fake the poll client into sending a poll request into a closed socket. Immediately does a shutdown of the incoming half of the socket, and then closes the connection after sending the fake replies, usually resulting in an excellent EPIPE/SIGPIPE on the client. Use port 44819, to avoid interference by (possibly slow-to-exit) simulators running on port 44818. """ logging.normal( "null_server on %s starting" % ( addr, )) conn.shutdown( socket.SHUT_RD ) time.sleep( 0.1 ) conn.send( b'e\x00\x04\x00\xc9wH\x81\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00' ) conn.send( b'c\x00;\x00\xd4/\x9dm\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x0c\x005\x00\x01\x00\x00\x02\xaf\x12\n\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x0e\x006\x00\x14\x0b`1\x1a\x06l\x00\x13PowerFlex/20-COMM-E\xff' ) conn.close() while server and not server.control.done: time.sleep( .1 ) logging.normal( "null_server on %s done" % ( addr, )) try: values = {} # { <parameter>: <value> } failed = {} # { <time>: <exc> } control = dotdict() control.done = False for _ in range( 3 ): server = threading.Thread( target=network.server_main, kwargs={ 'address': ('',44819), 'target': null_server, 'kwargs': { 'server': dotdict({ 'control': control }) }, 'udp': False, # no UDP server in this test }) server.daemon = True server.start() time.sleep(.5) if server.is_alive: break assert server.is_alive, "Unable to start null_server on INADDR_ANY" def process( p, v ): logging.normal( "process: %16s == %s", p, v ) values[p] = v process.done = False def failure( exc ): logging.normal( "failed: %s", exc ) elapsed = int(( timer() - failure.start ) * 1000 ) # ms. failed[elapsed] = str( exc ) failure.start = timer() backoff_min = 0.5 backoff_max = 4.0 backoff_multiplier = 2.0 # --> backoff == .5, 1.0, 2.0, 4.0 poller = threading.Thread( target=poll.poll, kwargs={ 'gateway_class':powerflex_750_series, # deprecated; use proxy_class instead 'address': ('localhost',44819), 'cycle': 1.0, 'timeout': 0.5, 'backoff_min': backoff_min, 'backoff_max': backoff_max, 'backoff_multiplier': backoff_multiplier, 'process': process, 'failure': failure, }) poller.deamon = True poller.start() try: # Polling starts immediately, but the first poll occurs after an attempt to get the # Identity string, hence two timeouts for the first poll failure. while len( failed ) < 3 and timer() - failure.start < 10.0: time.sleep(.1) finally: process.done = True control.done = True poller.join( backoff_max + 1.0 ) # allow for backoff_max before loop check assert not poller.is_alive(), "Poller Thread failed to terminate" server.join( 1.0 ) assert not server.is_alive(), "Server Thread failed to terminate" # Check that each failure is (at least) the expected backoff from the last assert len( failed ) > 0 k_last = None backoff = backoff_min for k in sorted( failed ): logging.normal( "Poll failure at %4dms (next backoff: %7.3fs): %s", k, backoff, failed[k] ) if k_last is not None: assert k - k_last >= backoff backoff = min( backoff_max, backoff * backoff_multiplier ) k_last = k assert len( values ) == 0 except Exception as exc: logging.warning( "Test terminated with exception: %s", exc ) raise
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 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 api_request( group, match, command, value, queries=None, environ=None, accept=None, framework=None ): """Return a JSON object containing the response to the request: { data: { ... }, ..., } The data list contains objects representing all matching objects, executing the optional command. If an accept encoding is supplied, use it. Otherwise, detect it from the environ's' "HTTP_ACCEPT"; default to "application/json". group -- A device group, w/globbing; no default match -- A device id match, w/globbing; default is: '*' command -- The command to execute on the device; default is: 'get' value -- All remaining query parameters; default is: [] queries -- All HTTP queries and/or form parameters environ -- The HTTP request environment accept -- A forced MIME encoding (eg. application/json). framework -- The web framework module being used """ global options global connections global tags global srv_ctl accept = deduce_encoding( [ "application/json", "text/javascript", "text/plain", "text/html" ], environ=environ, accept=accept ) # Deduce the device group and id match, and the optional command and value. # Values provided on the URL are overridden by those provided as query options. if "group" in queries and queries["group"]: group = queries["group"] del queries["group"] if not group: group = "*" if "match" in queries and queries["match"]: match = queries["match"] del queries["match"] if not match: match = "*" # The command/value defaults to the HTTP request, but also may be overridden by # the query option. if "command" in queries and queries["command"]: command = queries["command"] del queries["command"] if "value" in queries and queries["value"]: value = queries["value"] del queries["value"] # The "since" query option may be supplied, and is used to prevent (None) or # limit (0,...) the "alarm" responses to those that have been updated/added # since the specified time. since = None if "since" in queries and queries["since"]: since = float( queries["since"] ) del queries["since"] # Collect up all the matching objects, execute any command, and then get # their attributes, adding any command { success: ..., message: ... } now = misc.timer() content = { "alarm": [], "command": None, "data": {}, "since": since, # time, 0, None (null) "until": misc.timer(), # time (default, unless we return alarms) } logging.debug( "Searching for %s/%s, since: %s (%s)" % ( group, match, since, None if since is None else time.ctime( since ))) # Effectively: # group.match.command = value # Look through each "group" object's dir of available attributes for "match". Then, see if # that target attribute exists, and is something we can get attributes from. for grp, obj in [ ('options', options), ('connections', connections), ('tags', tags ), ('server', srv_ctl )]: for mch in [ m for m in dir( obj ) if not m.startswith( '_' ) ]: log.detail( "Evaluating %s.%s: %r", grp, mch, getattr( obj, mch, None )) if not fnmatch.fnmatch( grp, group ): continue if not fnmatch.fnmatch( mch, match ): continue target = getattr( obj, mch, None ) if not target: log.warning( "Couldn't find advertised attribute %s.%s", grp, mch ) continue if not hasattr( target, '__getattr__' ): continue # The obj's group name 'grp' matches requested group (glob), and the entry 'mch' matches # request match (glob). /<group>/<match> matches this obj.key. result = {} if command is not None: # A request/assignment is being made. Retain the same type as the current value, # and allow indexing! We want to ensure that we don't cause failures by corrupting # the types of value. Unfortunately, this makes it tricky to support "bool", as # coercion from string is not supported. try: cur = getattr( target, command ) result["message"] = "%s.%s.%s: %r" % ( grp, mch, command, cur ) if value is not None: typ = type( cur ) if typ is bool: # Either 'true'/'yes' or 'false'/'no', otherwise it must be a number if value.lower() in ('true', 'yes'): cvt = True elif value.lower() in ('false', 'no'): cvt = False else: cvt = bool( int( value )) else: cvt = typ( value ) setattr( target, command, cvt ) result["message"] = "%s.%s.%s=%r (%r)" % ( grp, mch, command, value, cvt ) result["success"] = True except Exception as exc: result["success"] = False result["message"] = "%s.%s.%s=%r failed: %s" % ( grp, mch, command, value, exc ) logging.warning( "%s.%s.%s=%s failed: %s\n%s" % ( grp, mch, command, value, exc, traceback.format_exc() )) # Get all of target's attributes (except _*) advertised by its dir() results attrs = [ a for a in dir( target ) if not a.startswith('_') ] data = { a: getattr( target, a ) for a in attrs } content["command"] = result content["data"].setdefault( grp, {} )[mch] = data # Report the end of the time-span of alarm results returned; if none, then # the default time will be the _timer() at beginning of this function. This # ensures we don't duplicately report alarms (or miss any) if content["alarm"]: content["until"]= content["alarm"][0]["time"] # JSON response = json.dumps( content, sort_keys=True, indent=4, default=lambda obj: repr( obj )) if accept in ("text/html"): # HTML; dump any request query options, wrap JSON response in <pre> response = html_wrap( "Response:", "h2" ) \ + html_wrap( response, "pre" ) response = html_wrap( "Queries:", "h2" ) \ + html_wrap( "\n".join( ( "%(query)-16.16s %(value)r" % { "query": str( query ) + ":", "value": value, } for iterable in ( queries, [("group", group), ("match", match), ("command", command), ("value", value), ("since", since), ] ) for query, value in iterable )), tag="pre" ) \ + response response = html_head( response, title='/'.join( ["api", group or '', match or '', command or ''] )) elif accept and accept not in ("application/json", "text/javascript", "text/plain"): # Invalid encoding requested. Return appropriate 406 Not Acceptable message = "Invalid encoding: %s, for Accept: %s" % ( accept, environ.get( "HTTP_ACCEPT", "*.*" )) raise http_exception( framework, 406, message ) # Return the content-type we've agreed to produce, and the result. return accept, response
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 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