def is_request(req): """Return True iff the given item is potentially a read/write request target: <address> -- "Tag|@<Class>/<Instance>/<Attribute>" ( <address>, <CIP-type> [, <units> ] ) ( <address>, "CIP-type-name" [, <units> ] ) ( <address>, (<CIP-type>, <CIP-type>, ...) [, <units> ]) No validation of the provided <units> is done; it is passed thru unchanged. """ log.detail("Validating request: %r", req) if isinstance(req, cpppo.type_str_base): return True if cpppo.is_listlike(req) and 2 <= len(req) <= 3: try: add, typ, _ = req except ValueError: add, typ = req if isinstance(add, cpppo.type_str_base): if isinstance(typ, (cpppo.type_str_base, type)): return True if cpppo.is_listlike(typ): if all( isinstance(t, (cpppo.type_str_base, type)) for t in typ): return True return False
def opp__att_typ_uni(i): """Generate sequence containing the enip.client operation, and the original attribute specified, its type(s) (if any), and any description. Augment produced operation with data type (if known), to allow estimation of reply sizes (and hence, Multiple Service Packet use); requires cpppo>=3.8.1. Yields: (opp,(att,typ,dsc)) """ for a in i: assert self.is_request( a ), \ "Not a valid read/write target: %r" % ( a, ) try: # The attribute description is either a plain Tag, an (address, type), or an # (address, type, description) if cpppo.is_listlike(a): att, typ, uni = a if len(a) == 3 else a + (None, ) else: att, typ, uni = a, None, None # No conversion of data type if None; use a Read Tag [Fragmented]; works only # for [S]STRING/SINT/INT/DINT/REAL/BOOL. Otherwise, conversion of data type # desired; get raw data using Get Attribute Single. parser = client.parse_operations if typ is None else attribute_operations opp, = parser( (att, ), route_path=device.parse_route_path(self.route_path), send_path=self.send_path, priority_time_tick=self.priority_time_tick, timeout_ticks=self.timeout_ticks) except Exception as exc: log.warning("Failed to parse attribute %r; %s", a, exc) raise # For read_tag.../get_attribute..., tag_type is never required; but, it is used (if # provided) to estimate data sizes for Multiple Service Packets. For # write_tag.../set_attribute..., the data has specified its data type, if not the # default (INT for write_tag, SINT for set_attribute). if typ is not None and not cpppo.is_listlike( typ) and 'tag_type' not in opp: t = typ if isinstance(typ, cpppo.type_str_base): td = self.CIP_TYPES.get(t.strip().lower()) if td is not None: t, d = td if hasattr(t, 'tag_type'): opp['tag_type'] = t.tag_type log.detail("Parsed attribute %r (type %r) into operation: %r", att, typ, opp) yield opp, (att, typ, uni)
def types_decode( types ): """Produce a sequence of type class,data-path, eg. (parser.REAL,"SSTRING.string"). If a user-supplied type (or None) is provided, data-path is None, and the type is passed. """ for t in ( types if cpppo.is_listlike( types ) else [ types ] ): d = None # No data-path, if user-supplied type if isinstance( t, int ): # a CIP type number, eg 0x00ca == 202 ==> 'REAL'. Look for CIP parsers w/ a # known tag_type and get the CIP type name string. for t_str,(t_prs,_) in self.CIP_TYPES.items(): if getattr( t_prs, 'tag_type', None ) == t: t = t_str break if isinstance( t, cpppo.type_str_base ): td = self.CIP_TYPES.get( t.strip().lower() ) assert td, "Invalid EtherNet/IP CIP type name %r specified" % ( t, ) t,d = td assert type( t ) in (type,type(None)), \ "Expected None or CIP type class, not %r" % ( t, ) yield t,d
def read_details(self, attributes): """Assumes that self.gateway has been established; does not close_gateway on Exception. If you use this interface, ensure that you maintain the gateway (eg. ): via = proxy( 'hostname' ) with via: for val,(sts,(att,typ,uni) in via.read_details( [...] ): Read the specified CIP Tags/Attributes in the string or iterable 'attributes', using Read Tag [Fragmented] (returning the native type), or Get Attribute Single/All (converting it to the specified EtherNet/IP CIP type(s)). The reason iterables are used and a generator returned, is to allow the underlying cpppo.server.enip.client connector to aggregate multiple CIP operations using Multiple Service Packet requests and/or "pipeline" multiple requests in-flight, while receiving the results of earlier requests. The 'attributes' must be either a simple string Tag name (no Type, implying the use of *Logix Read Tag [Fragmented] service), eg: "Tag" or an iterable containing 2 or 3 values; a Tag/address, a type/types (may be None, to force Tag I/O), and an optional description (eg. Units) [ "Tag", ( "Tag", None, "kWh" ), ( "@1/1/1", "INT" ) ( "@1/1/1", "INT", "Hz" ) ( "@1/1", ( "INT", "INT", "INT", "INT", "INT", "DINT", "SSTRING", "USINT" )) ( "@1/1", ( "INT", "INT", "INT", "INT", "INT", "DINT", "SSTRING", "USINT" ), "Identity" ) ] Produces a generator yielding the corresponding sequence of results and details for the supplied 'attributes' iterable. Each individual request may succeed or fail with a non-zero status code (remember: status code 0x06 indicates successful return of a partial result). Upon successful I/O, a tuple containing the result value and details about the result (a status, and the attribute's details (address, type, and units)) corresponding to each of the supplied 'attributes' elements is yielded as a sequence. Each result value is always a list of values, or None if the request failed: ( ([0],(0, ("Tag", parser.INT, None))), ([1.23],(0, "Tag", parser.REAL, "kWh"))), ([1], (0, ("@1/1/1", parser.INT, None))), ([1], (0, ("@1/1/1", parser.INT, "Hz"))), ([1, 2, 3, 4, 5 6, "Something", 255], (0, ("@1/1", [ parser.INT, parser.INT, parser.INT, parser.INT, parser.INT, parser.DINT, parser.STRING, parser.USINT ], None ))), ([1, 2, 3, 4, 5 6, "Something", 255], (0, ("@1/1", [ parser.INT, parser.INT, parser.INT, parser.INT, parser.INT, parser.DINT, parser.STRING, parser.USINT ], "Identity" ))), ) The read_details API raises exception on failure to parse request, or result data type conversion problem. The simple 'read' API also raises an Exception on attribute access error, the return of failure status code. Not all of these strictly necessitate a closure of the EtherNet/IP CIP connection, but should be sufficiently rare (once configured) that they all must signal closure of the connection gateway (which is re-established on the next call for I/O). EXAMPLES proxy = enip_proxy( '10.0.1.2' ) try: with contextlib.closing( proxy.read( [ ("@1/1/7", "SSTRING") ] )) as reader: # CIP Device Name value = next( reader ) except Exception as exc: proxy.close_gateway( exc ) # If CPython (w/ reference counting) is your only target, you can use the simpler: proxy = enip_proxy( '10.0.1.2' ) try: value, = proxy.read( [ ("@1/1/7", "SSTRING") ] ) # CIP Device Name except Exception as exc: proxy.close_gateway( exc ) """ if isinstance(attributes, cpppo.type_str_base): attributes = [attributes] def opp__att_typ_uni(i): """Generate sequence containing the enip.client operation, and the original attribute specified, its type(s) (if any), and any description. Augment produced operation with data type (if known), to allow estimation of reply sizes (and hence, Multiple Service Packet use); requires cpppo>=3.8.1. Yields: (opp,(att,typ,dsc)) """ for a in i: assert self.is_request( a ), \ "Not a valid read/write target: %r" % ( a, ) try: # The attribute description is either a plain Tag, an (address, type), or an # (address, type, description) if cpppo.is_listlike(a): att, typ, uni = a if len(a) == 3 else a + (None, ) else: att, typ, uni = a, None, None # No conversion of data type if None; use a Read Tag [Fragmented]; works only # for [S]STRING/SINT/INT/DINT/REAL/BOOL. Otherwise, conversion of data type # desired; get raw data using Get Attribute Single. parser = client.parse_operations if typ is None else attribute_operations opp, = parser( (att, ), route_path=device.parse_route_path(self.route_path), send_path=self.send_path, priority_time_tick=self.priority_time_tick, timeout_ticks=self.timeout_ticks) except Exception as exc: log.warning("Failed to parse attribute %r; %s", a, exc) raise # For read_tag.../get_attribute..., tag_type is never required; but, it is used (if # provided) to estimate data sizes for Multiple Service Packets. For # write_tag.../set_attribute..., the data has specified its data type, if not the # default (INT for write_tag, SINT for set_attribute). if typ is not None and not cpppo.is_listlike( typ) and 'tag_type' not in opp: t = typ if isinstance(typ, cpppo.type_str_base): td = self.CIP_TYPES.get(t.strip().lower()) if td is not None: t, d = td if hasattr(t, 'tag_type'): opp['tag_type'] = t.tag_type log.detail("Parsed attribute %r (type %r) into operation: %r", att, typ, opp) yield opp, (att, typ, uni) def types_decode(types): """Produce a sequence of type class,data-path, eg. (parser.REAL,"SSTRING.string"). If a user-supplied type (or None) is provided, data-path is None, and the type is passed. """ for t in (types if cpppo.is_listlike(types) else [types]): d = None # No data-path, if user-supplied type if isinstance(t, int): # a CIP type number, eg 0x00ca == 202 ==> 'REAL'. Look for CIP parsers w/ a # known tag_type and get the CIP type name string. for t_str, (t_prs, _) in self.CIP_TYPES.items(): if getattr(t_prs, 'tag_type', None) == t: t = t_str break if isinstance(t, cpppo.type_str_base): td = self.CIP_TYPES.get(t.strip().lower()) assert td, "Invalid EtherNet/IP CIP type name %r specified" % ( t, ) t, d = td assert type( t ) in (type,type(None)), \ "Expected None or CIP type class, not %r" % ( t, ) yield t, d # Get duplicate streams; one to feed the the enip.client's connector.operate, and one for # post-processing based on the declared type(s). operations, attrtypes = itertools.tee(opp__att_typ_uni(attributes)) # Process all requests w/ the specified pipeline depth, Multiple Service Packet # configuration. The 'idx' is the EtherNet/IP CIP request packet index; 'i' is the # individual I/O request index (for indexing att/typ/operations). # # This Thread may block here attempting to gain exclusive access to the cpppo.dfa used # by the cpppo.server.enip.client connector. This uses a threading.Lock, which will raise # an exception on recursive use, but block appropriately on multi-Thread contention. # # assert not self.gateway.frame.lock.locked(), \ # "Attempting recursive read on %r" % ( self.gateway.frame, ) log.info("Acquiring gateway %r connection: %s", self.gateway, "locked" if self.gateway.frame.lock.locked() else "available") blocked = cpppo.timer() with self.gateway as connection: # waits 'til any Thread's txn. completes polling = cpppo.timer() try: log.info( "Operating gateway %r connection, after blocking %7.3fs", self.gateway, polling - blocked) for i, (idx, dsc, req, rpy, sts, val) in enumerate( connection.operate((opr for opr, _ in operations), depth=self.depth, multiple=self.multiple, timeout=self.timeout)): log.detail( "%3d (pkt %3d) %16s %-12s: %r %s", i, idx, dsc, sts or "OK", val, repr(rpy) if log.isEnabledFor(logging.INFO) else '') opr, (att, typ, uni) = next(attrtypes) if typ is None or sts not in (0, 6) or val in (True, None): # No type conversion; just return whatever type produced by Read Tag # [Fragmented] (always a single CIP type parser). typ_num = rpy.get('read_tag.type') or rpy.get( 'read_frag.type') if typ_num: try: (typ_prs, _), = types_decode(typ_num) if typ_prs: typ = typ_prs except Exception as exc: log.info( "Couldn't convert CIP type {typ_num}: {exc}" .format(typ_num=typ_num, exc=exc)) # Also, if failure status (OK if no error, or if just not all # data could be returned), we can't do any more with this value... Also, if # actually a Write Tag or Set Attribute ..., then val True/None indicates # success/failure (no data returned). yield val, (sts, (att, typ, uni)) continue # Parse the raw data using the type (or list of types) desired. If one type, then # all data will be parsed using it. If a list, then the data will be sequentially # parsed using each type. Finally, the target data will be extracted from each # parsed item, and added to the result. For example, for the parsed SSTRING # # data = { "SSTRING": {"length": 3, "string": "abc"}} # # we just want to return data['SSTRING.string'] == "abc"; each recognized CIP type # has a data path which we'll use to extract just the result data. If a # user-defined type is supplied, of course we'll just return the full result. source = cpppo.peekable(bytes( bytearray(val))) # Python2/3 compat. res = [] typ_is_list = cpppo.is_listlike(typ) typ_dat = list(types_decode(typ)) for t, d in typ_dat: with t() as machine: while source.peek( ) is not None: # More data available; keep parsing. data = cpppo.dotdict() for m, s in machine.run(source=source, data=data): assert not ( s is None and source.peek() is None ), \ "Data exhausted before completing parsing a %s" % ( t.__name__, ) res.append(data[d] if d else data) # If t is the only type, keep processing it 'til out of data... if len(typ_dat) == 1: continue break typ_types = [t for t, _ in typ_dat ] if typ_is_list else typ_dat[0][0] yield res, (sts, (att, typ_types, uni)) finally: log.info( "Releasing gateway %r connection, after polling %7.3fs", self.gateway, cpppo.timer() - polling)