Ejemplo n.º 1
0
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
Ejemplo n.º 2
0
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
Ejemplo n.º 3
0
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
Ejemplo n.º 4
0
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
Ejemplo n.º 5
0
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)
Ejemplo n.º 6
0
 def failure( exc ):
     logging.normal( "failed: %s", exc )
     elapsed		= int(( timer() - failure.start ) * 1000 ) # ms.
     failed[elapsed]	= str( exc )
Ejemplo n.º 7
0
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
Ejemplo n.º 8
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()
Ejemplo n.º 9
0
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
Ejemplo n.º 10
0
 def failure( exc ):
     logging.normal( "failed: %s", exc )
     elapsed		= int(( timer() - failure.start ) * 1000 ) # ms.
     failed[elapsed]	= str( exc )
Ejemplo n.º 11
0
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
Ejemplo n.º 12
0
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
Ejemplo n.º 13
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()
Ejemplo n.º 14
0
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
Ejemplo n.º 15
0
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
Ejemplo n.º 16
0
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