Skip to content

Commit 4ebbe95

Browse files
committed
Merge branch 'feature-fragment-size'; Version 4.4.1
2 parents 7b600b8 + 7d32e42 commit 4ebbe95

File tree

8 files changed

+182
-76
lines changed

8 files changed

+182
-76
lines changed

server/enip/device.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -201,18 +201,28 @@ def resolve( path, attribute=False ):
201201
Other valid paths segments (eg. {'port':...}, {'connection':...}) are not presently usable in
202202
our Controller communication simulation.
203203
204-
Call with attribute=True to force resolving to the Attribute level; otherwise, always returns
205-
None for the attribute.
204+
Call with attribute=<Truthy> to force resolving to the Attribute level; otherwise, always returns
205+
None for the attribute. If an attribute is required, but is not supplied in the path, then we
206+
can default one. This is an (unexpected) feature of the C*Logix PLCs; you can do a Read Tag
207+
Fragmented asking for eg. @0x6b/0x0008[0] (a Class, Instance and Element but no Attribute!).
208+
Presumably there is an implied default Attribute number; we're guessing it's 1? Undocumented.
206209
207210
"""
208211

209212
result = { 'class': None, 'instance': None, 'attribute': None }
210213
tag = '' # developing symbolic tag "Symbol.Subsymbol"
211214

212215
for term in path['segment']:
213-
if ( result['class'] is not None and result['instance'] is not None
214-
and ( not attribute or result['attribute'] is not None )):
215-
break # All desired terms specified; done! (ie. ignore 'element')
216+
if ( result['class'] is not None # Got Class already
217+
and result['instance'] is not None # Got Instance already
218+
and (
219+
result['attribute'] is not None # Got Attribute already
220+
or not attribute # or no Attribute desired (must return None)
221+
or ( attribute is not True # or a default attribute is supplied
222+
and 'attribute' not in term ) # and the term didn't contain a supplied one
223+
)
224+
):
225+
break # All desired terms specified; done! (ie. ignore subsequent 'element')
216226
working = dict( term )
217227
while working:
218228
# Each term is something like {'class':5}, {'instance':1}, or (from symbol table):
@@ -240,6 +250,11 @@ def resolve( path, attribute=False ):
240250
assert not tag, \
241251
"Unrecognized symbolic name %r found in path %r" % ( tag, path['segment'] )
242252

253+
# Handle the case where a default Attribute value was provided, and none was supplied
254+
if result['attribute'] is None and attribute and attribute is not True:
255+
assert isinstance( attribute, int )
256+
result['attribute'] = attribute
257+
# Make sure we got everything we required
243258
assert ( result['class'] is not None and result['instance'] is not None
244259
and ( not attribute or result['attribute'] is not None )), \
245260
"Failed to resolve required Class (%r), Instance (%r) %s Attribute(%r) from path: %r" % (

server/enip/logix.py

Lines changed: 60 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,12 @@ class Logix( Message_Router ):
136136
137137
"""
138138

139-
# TODO: MAX_BYTES is arbitrary. We're supposed to be able to return data sufficient to fill the
139+
# MAX_BYTES is arbitrary. We're supposed to be able to return data sufficient to fill the
140140
# remaining reply package size, but how can we do that? We'd have to be informed of the
141-
# remaining packet size available, as an argument to the produce method...
142-
MAX_BYTES = 500
141+
# remaining packet size available, as an argument to the produce method... The user may alter
142+
# this limit (eg. according to the capacity of Forward Open channel). However, it is still
143+
# quite fragile, especially when producing Multiple Service Packet requests/responses.
144+
MAX_BYTES = 488 # Wild guess; leave room for ENIP headers, etc. in reply
143145

144146
RD_TAG_NAM = "Read Tag"
145147
RD_TAG_CTX = "read_tag"
@@ -192,8 +194,7 @@ def reply_elements( self, attribute, data, context ):
192194
off = 0
193195
if data.service in (self.RD_FRG_RPY, self.WR_FRG_RPY):
194196
off = data[context].get( 'offset' ) or 0 # nonexistent/None/0 --> 0
195-
assert siz and off % siz == 0, \
196-
"Requested byte offset %d is not on a %d-byte data element boundary" % ( off, siz )
197+
max_size = data[context].get( 'max_size' ) or self.MAX_BYTES
197198

198199
# Compute the extents of the full reply, given no byte offset, unlimited reply size and
199200
# complete data. If no 'elements' has been provided (only possible when hand-forming a
@@ -203,8 +204,6 @@ def reply_elements( self, attribute, data, context ):
203204
cnt = len( attribute )
204205
elm = data[context].get( 'elements', cnt - beg ) # Read/Write Tag defaults to all
205206
endactual = beg + elm
206-
assert 0 < endactual <= cnt, \
207-
"Attribute %s ending element invalid: %r" % ( attribute, (beg, endactual) )
208207

209208
# Maximum elements for read is the capacity of the reply message, for write is the number
210209
# actually provided in request. Compute this from the beginning element deduced from the
@@ -213,31 +212,42 @@ def reply_elements( self, attribute, data, context ):
213212
# incomplete number of data elements provided. The 'end' can only get smaller than the
214213
# (known valid) 'endactual'.
215214
#
216-
# TODO: This is not strictly correct; we must be able to return a response w/ an offset that
217-
# starts mid-element! Also, for STRUCT types, the size of each UDT element (eg. 600 bytes)
218-
# can be greater than the maximum connection size, preventing even one complete element from
219-
# being returned. For now, always round up to return a complete element; this may prevent
220-
# some clients from accepting the data (eg. if the elements are too big, and a Small Forward
221-
# Open is used.
222-
begadvance = off // siz
223-
log.detail( "index: {index!r} beg: {beg}, cnt: {cnt}, elm: {elm}; endactual: {endactual}, begadvance: {begadvance}".format(
215+
# This is not strictly correct; we must be able to return a response w/ an offset that
216+
# starts/ends arbitrarily mid-element! Also, for STRUCT types, the size of each UDT element
217+
# (eg. 600 bytes) can be greater than the maximum connection size, preventing even one
218+
# complete element from being returned. For this function, always round outwards to return
219+
# complete element(s); this will prevent some clients from accepting the data (eg. if the
220+
# elements are too big, and a Small Forward Open is used; it is assumed that the response
221+
# data will be trimmed elsewhere, according to the actual byte offset requested in the Read
222+
# Tag Fragmented request.
223+
begadvance = off // siz # Rounds down to the start of the element at offset
224+
offremains = off - begadvance * siz # off is how many bytes into beg element?
225+
log.info( "index: {index!r} beg: {beg}, cnt: {cnt}, elm: {elm}; endactual: {endactual}, begadvance: {begadvance}".format(
224226
index=index, beg=beg, cnt=cnt, elm=elm, endactual=endactual, begadvance=begadvance ))
225-
assert begadvance * siz == off, \
226-
"Fragment offset {off} is not on an element {siz}-byte boundary".format( off=off, siz=siz )
227227
beg += begadvance
228228
if data.service in (self.RD_TAG_RPY, self.RD_FRG_RPY):
229-
endmax = beg + max( self.MAX_BYTES // siz, 1 )
229+
# Return at least enough elements to satisfy max_size, beginning at offset 'off'. We
230+
# have a 'beg' Element that contains the first byte at offset 'off'; compute the endmax
231+
# that contains the last byte at offset off+max_siz-1. The data may specify the
232+
# (remaining) .max_size payload available.
233+
endadv = max(( offremains + max_size + siz - 1 ) // siz, 1 ) # rounds up
234+
endmax = beg + endadv
230235
else:
231-
endmax = beg + len( data[context].data )
236+
endadv = len( data[context].data )
237+
endmax = beg + endadv
232238
assert endmax <= endactual, \
233239
"Attribute %s capacity exceeded; writing %d elements beginning at index %d" % (
234240
attribute, len( data[context].data ), beg )
235241
end = min( endactual, endmax )
242+
log.info( "offset: {off:6d} siz: {siz:3d}, beg: {beg:3d}, endadv: {endadv:3d}, end: {end:3d}, endmax: {endmax:3d}, offremains: {offremains}".format(
243+
off=off, siz=siz, beg=beg, end=end, endadv=endadv, endmax=endmax, offremains=offremains ))
236244
assert 0 <= beg < cnt, \
237245
"Attribute %r initial element invalid: %r" % ( attribute, (beg, end) )
246+
assert elm <= cnt, \
247+
"Attribute %r elements requested invalid: %r" % ( attribute, elm )
238248
assert beg < end, \
239249
"Attribute %r ending element before beginning: %r" % ( attribute, (beg, end) )
240-
return (beg,end,endactual)
250+
return (beg,end,endactual,offremains,max_size)
241251

242252
def request( self, data, addr=None ):
243253
"""Any exception should result in a reply being generated with a non-zero status."""
@@ -308,7 +318,7 @@ def request( self, data, addr=None ):
308318
# We need to find the attribute for all requests, and it better be ours!
309319
data.status = 0x05 # On Failure: Request Path destination unknown
310320
data.status_ext = {'size': 1, 'data':[0x0000]}
311-
clid, inid, atid = resolve( data.path, attribute=True )
321+
clid, inid, atid = resolve( data.path, attribute=1 ) # eg. @<cls>/<ins>[<elm>] defaults to Attribute 1!
312322
attribute = lookup( clid, inid, atid )
313323
assert clid == self.class_id and inid == self.instance_id, \
314324
"Path %r processed by wrong Object %r" % ( data.path['segment'], self )
@@ -384,24 +394,45 @@ def request( self, data, addr=None ):
384394
data.status_ext = {'size': 1, 'data': [ 0x2105 ]} # Number of elements beyond end of tag
385395

386396
# Compute (beg,end] for this reply, given data.path...element, data.elements/offset.
387-
# The end element of the full request (not the size/data-limited end) is in endactual
388-
beg,end,endactual = self.reply_elements( attribute, data, context )
397+
# The end element of the original request (not the size/data-limited end) is in
398+
# endactual. If a .offset (and optionally .max_size) is provided, these must be used to
399+
# constrain the actual payload bytes returned; the [beg,end) should index elements
400+
# containing the first byte to return (at .offset), up tothe last byte
401+
# (.offset+.max_size-1). Since we might have advances 'beg', we get back the adjusted
402+
# 'offremains', as well the target 'max_size'.
403+
beg,end,endactual,offremains,max_size \
404+
= self.reply_elements( attribute, data, context )
389405
log.debug( "Replying w/ elements [%3d-%-3d/%3d] for %r", beg, end, endactual, data )
390406
if data.service in (self.RD_TAG_RPY, self.RD_FRG_RPY):
391407
# Read Tag [Fragmented]
392408
recs = attribute[beg:end]
393409
if attribute.parser.tag_type == STRUCT.tag_type:
394410
# Render the STRUCT UDTs to binary. Assume that each record has its data.input
395-
# representation
411+
# representation.
396412
input = b''
397413
for r in recs:
398414
input += octets_encode( r.data.input )
399-
recs = dict( input=input )
415+
# For STRUCTs *only*, we support arbitrary .offset and max_size; trim it
416+
# here. (For other basic data types, we'll simply return the designated
417+
# elements, which may be less than, or slightly more than the .max_size /
418+
# self.MAX_BYTES by some portion of one element size) Trim it here. If we've
419+
# returned the end element of the request, and all of its bytes, we're complete.
420+
trimmed = input[offremains:offremains+max_size]
421+
recs = dict( input=trimmed )
422+
completed = end == endactual and offremains+max_size >= len( input )
423+
else:
424+
# We don't presently support a non-zero .offset for indeterminately sized types
425+
# (eg. STRING/SSTRING, etc.), or a sub-element offset for basic data types.
426+
assert offremains == 0 or (
427+
attribute.parser.tag_type < STRING.tag_type
428+
and offremains % attribute.parser.struct_calcsize == 0 )
429+
completed = end == endactual
400430
data[context].data = recs
401-
log.detail( "%s Reading %3d elements %3d-%3d from %s: %r",
402-
self, end - beg, beg, end-1, attribute, data[context].data )
431+
log.detail( "%s Reading %3d elements %3d-%3d %s from %s: %r",
432+
self, end - beg, beg, end-1, "(done)" if completed else "(more)",
433+
attribute, data[context].data )
403434
# Final .status is 0x00 if all requested elements were shipped; 0x06 if not
404-
data.status = 0x00 if end == endactual else 0x06
435+
data.status = 0x00 if completed else 0x06
405436
data.pop( 'status_ext' ) # non-empty dotdict level; use pop instead of del
406437
else:
407438
# Write Tag [Fragmented]. We know the type is right.
@@ -668,7 +699,7 @@ def setup_tag( key, val ):
668699
# doesn't exist. Then, find the Attribute, ensuring it is consistent if it exists.
669700
cls,ins,att = 0x02,1,None # The (Logix?) Message Router, by default
670701
if 'path' in val and val['path']:
671-
cls,ins,att = resolve( val['path'], attribute=True )
702+
cls,ins,att = resolve( val['path'], attribute=True ) # No default Attribute for new Tags
672703
# See if the tag's Instance exists. If not, we'll need to create it. If the Class'
673704
# "meta" Instance exists, we'll use it to create the Instance (its always at
674705
# Instance 0). Otherwise, we'll create an Object class with the appropriate

server/enip/parser.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -684,7 +684,7 @@ def enip_format( data, sort_keys=False, indent=4 ):
684684
beg,end = 'bytearray(',')'
685685
else:
686686
beg,end = 'bytes(',')'
687-
result += "{beg}hexload('''".format( beg=beg )
687+
result += "{beg}hexload(r'''".format( beg=beg )
688688
result += ''.join( newline + prefix + row for row in misc.hexdumper( val ))
689689
result += newline + "'''){end},".format( end=end )
690690
continue

server/enip/parser_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def test_enip_format():
8282
'empty.sub': [],
8383
'empty.totally_null': None,
8484
'empty.true_false': (True, False),
85-
'long_bytes': bytes(hexload('''
85+
'long_bytes': bytes(hexload(r'''
8686
00000000: 6f 00 00 00 06 00 00 00 52 54 31 2d 31 37 00 00 |o.......RT1-17..|
8787
00000010: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
8888
00000020: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
@@ -122,7 +122,7 @@ def test_enip_format():
122122
00000240: 0c 00 00 00 07 00 00 00 0a 00 00 00 28 00 00 00 |............(...|
123123
00000250: 34 00 00 00 fe 58 0a 00 |4....X..|
124124
''')),
125-
'short_bytes': bytes(hexload('''
125+
'short_bytes': bytes(hexload(r'''
126126
00000000: 6f 00 00 00 |o...|
127127
''')),
128128
'some_ascii': 'The quick brown fox \\\\ jumped over the "lazy" dog',

server/enip/udt_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ def logix_remote_udt_pylogix( count, svraddr, kwargs ):
334334
"enip.CIP.send_data.CPF.item[1].connection_data.request.read_frag.elements": 1,
335335
"enip.CIP.send_data.CPF.item[1].connection_data.request.read_frag.type": 672,
336336
"enip.CIP.send_data.CPF.item[1].connection_data.request.read_frag.structure_tag": 36345,
337-
"enip.CIP.send_data.CPF.item[1].connection_data.request.status": 0,
337+
"enip.CIP.send_data.CPF.item[1].connection_data.request.status": 6,
338338
}, {
339339
# Then, starts with a Read Tag
340340
"enip.CIP.send_data.CPF.item[1].connection_data.request.path.segment[1].element": 0,
@@ -347,7 +347,7 @@ def logix_remote_udt_pylogix( count, svraddr, kwargs ):
347347
"enip.CIP.send_data.CPF.item[1].connection_data.request.path.segment[0].symbolic": "ExampleSensor",
348348
"enip.CIP.send_data.CPF.item[1].connection_data.request.path.segment[1].element": 0,
349349
"enip.CIP.send_data.CPF.item[1].connection_data.request.read_frag.elements": 360,
350-
"enip.CIP.send_data.CPF.item[1].connection_data.request.read_frag.offset": 215400,
350+
"enip.CIP.send_data.CPF.item[1].connection_data.request.read_frag.offset": 215696, # w/ 488 byte MAX_BYTES
351351
"enip.CIP.send_data.CPF.item[1].connection_data.request.read_frag.type": 672,
352352
"enip.CIP.send_data.CPF.item[1].connection_data.request.read_frag.structure_tag": 36345,
353353
"enip.CIP.send_data.CPF.item[1].connection_data.request.status": 0,

0 commit comments

Comments
 (0)